跑马灯文字为什么会重叠:一次 flex clone 布局问题的复盘
跑马灯组件看起来很普通:内容太长时,把同一段文本复制一份,整条轨道向左平移,第一份滚出视口后第二份接上来。
这次遇到的是两段文字重叠:徽章里有一段很长的家族名或身份名,滚动刚开始,第二份文字就已经盖在第一份上。调整 gap、速度和字号,都只能缓解一小部分。
排查后发现,flex 压缩了内容的布局盒,但文字仍向盒外溢出。clone 按压缩后的宽度排列,自然会和前一份文字重叠。
clone 提前出现是怎么发生的
一个最小跑马灯结构大概是这样:
<span class="marquee">
<span class="marquee__track">
<span class="marquee__content">很长很长的文字</span>
<span class="marquee__content marquee__content--clone" aria-hidden="true">很长很长的文字</span>
</span>
</span>
直觉上,第二个 content 应该排在第一个后面。但在 flex 场景里,如果父容器给了 max-width: 100%、min-width: 0,而 content 又没有明确禁止收缩,浏览器可以先把内容盒压到视口宽度附近。
如果此时文本设置了 overflow: visible,第一份文字仍会画到布局盒外;clone 却紧跟着压短后的盒子排列,起点就落在第一份文字尚未结束的位置。两份文字因此重叠,调 gap 也很难消除布局盒与可见文本宽度之间的差异。
演示保留了问题写法和修复写法两条轨道。改长文案、缩小视口或调整 clone 间距时,可以对照布局盒宽度、真实文字宽度、clone 起点和重叠风险的变化。
active 状态要改的是轨道和每一份内容
跑马灯启动前,文本当然可以省略:
.marquee__content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
进入 active 状态后,完整内容和 clone 需要组成一条可移动轨道,此时轨道和每份内容都要按内容宽度排版。
.marquee--active .marquee__track {
width: max-content;
max-width: none;
flex: none;
}
.marquee--active .marquee__content {
width: max-content;
flex-shrink: 0;
overflow: visible;
text-overflow: clip;
}
.marquee__content--clone {
margin-left: var(--marquee-gap);
user-select: none;
}
这里有两层契约:
track不能继续被父级限制成视口宽度,否则整条动画轨道会先被压缩。- 每个
content不能收缩,否则 clone 的排布位置仍然会按压短后的盒子计算。
只修其中一层都不够。轨道变宽但内容仍可收缩,clone 还是可能提前排进来;内容不收缩但轨道仍受 max-width 限制,动画距离和视觉裁切也会变得不稳定。
为什么要抽共享轨道组件
同一个项目里,昵称、徽章、标题、房间名、礼物名都可能需要跑马灯。如果每个业务组件自己写一份 track / clone CSS,就会出现一种很常见的退化:
- 昵称组件修了
flex: none。 - 徽章组件忘了写
width: max-content。 - 另一个列表组件又复制了旧版 keyframes。
- 某个组件加了
prefers-reduced-motion,另一个没有。
这些差异可能要遇到特定文案长度、字体、断点和资源加载时序才会暴露。出问题后,还得逐个组件检查 track、content、clone 的 CSS,既重复,也容易遗漏。
更稳的做法是抽一层低阶轨道组件。业务组件只做三件事:
- 测量真实内容是否溢出。
- 算出滚动距离、时长和 gap。
- 通过 slot 传入文本、徽章或昵称结构。
共享轨道组件统一处理:
- viewport 裁切。
- track 的
width: max-content、max-width: none、flex: none。 - content 和 clone 的
width: max-content、flex-shrink: 0。 - clone 间距。
- hover 暂停。
prefers-reduced-motion。
这样业务组件不需要再记住「active 后到底哪几条 CSS 必须同时出现」。它们只需要确认自己有没有溢出,以及滚动距离是多少。
Lint 很难直接判断布局语义
Lint 可以防住部分重复实现,布局是否重叠仍依赖运行时条件。
CSS 布局问题依赖真实 DOM 结构、父容器约束、字体、断点、overflow 和动画状态。ESLint 很难可靠判断「这个 clone 会不会盖住第一份文字」。如果写一个很泛的规则,误报会很多;如果写得很窄,又只能覆盖当前代码形态。
更合适的静态规则是防「重复发明」:
- 业务组件里不要新增本地 marquee keyframes。
- 业务组件不要自己维护 clone track CSS。
- 需要跑马灯时,必须使用共享轨道组件或共享 hook。
这种 Lint 规则不理解布局,但它能阻止后续代码绕开已经修好的共享实现。
防布局退化,需要组件契约检查和真实浏览器几何检查配合。
单测要守共享组件的关键 CSS
跑马灯重叠很难用普通文本断言测出来,因为测试环境没有真实字体和完整布局。但我们可以把测试对准稳定契约:
expect(source).toContain('width: max-content')
expect(source).toContain('flex: none')
expect(source).toContain('flex-shrink: 0')
expect(source).toContain('prefers-reduced-motion')
这些断言只检查关键源码片段是否存在,不能证明它们作用在正确选择器或 active 状态上。浏览器回归还应等待字体加载、暂停动画,对照原内容的完整绘制宽度与 clone 起点,确认后者排在前者和间距之后;只看容器盒宽也可能漏掉本文的溢出绘制。
业务组件的测试则更适合守边界:
- 长文本溢出时会渲染 clone。
- 短文本不渲染 clone。
- 业务组件不再包含自己的 keyframes。
- 业务组件使用共享轨道组件。
这比给每个业务组件都写一套浏览器截图更轻,也更能在 review 阶段提醒开发者:这个问题已经有共享解法,不要再局部复制。
总结
两段跑马灯文字重叠,是因为 flex 压短了布局盒,文字却仍向外绘制,clone 因而按过短的盒宽提前排列。active 状态需要同时解除轨道的宽度限制、禁止每份内容收缩,让 clone 位置和动画距离都以完整内容宽度为依据;只调 gap,或只修其中一层,都不足以稳定解决问题。
昵称、徽章、标题和房间名可以复用同一套轨道机制。共享组件维护宽度、clone 间距、暂停和 reduced-motion,业务组件负责内容、溢出测量与滚动参数,避免不同业务各自复制 CSS 后再次分化。
Lint 能约束业务使用共享实现,源码断言能提醒关键 CSS 契约丢失,最终布局仍要由真实浏览器确认。换文案、字体或断点后,应对照完整文字的绘制宽度与 clone 起点;共享实现减少重复维护,不能替代几何验证。