跑马灯文字为什么会重叠:一次 flex clone 布局问题的复盘
跑马灯组件看起来很普通:内容太长时,把同一段文本复制一份,整条轨道向左平移,第一份滚出视口后第二份接上来。
真正出问题时,症状往往不像“动画坏了”,而像两段文字盖在一起。比如徽章里有一段很长的家族名或身份名,滚动刚开始时,后半段文字像提前钻进了第一段文字里;再调 gap、速度、字号,都只能缓解一小部分。
这类问题的根因通常不在动画,而在 flex 布局盒。
clone 提前出现是怎么发生的
一个最小跑马灯结构大概是这样:
<span class="marquee">
<span class="marquee__track">
<span class="marquee__content">很长很长的文字</span>
<span class="marquee__content marquee__content--clone">很长很长的文字</span>
</span>
</span>直觉上,第二个 content 应该排在第一个后面。但在 flex 场景里,如果父容器给了 max-width: 100%、min-width: 0,而 content 又没有明确禁止收缩,浏览器可以先把内容盒压到视口宽度附近。
接着,如果文本本身又被设置成 overflow: visible,就会出现一个错位状态:
- 布局盒被压短了。
- 真实文字仍然画在盒子外面。
- clone 按压短后的盒子位置排布。
- 视觉上,两份文字就重叠了。
换句话说,浏览器排版时看到的是「一个被压短的盒子 + 另一个盒子」,用户看到的是「第一段文字溢出到盒子外 + 第二段文字已经排进来了」。
所以这类现象的关键不在 gap,而在布局盒和视觉文本宽度没有对齐。
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);
}这里有两层契约:
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 防住?可以防一部分,但不适合把所有希望都压在 Lint 上。
CSS 布局问题依赖真实 DOM 结构、父容器约束、字体、断点、overflow 和动画状态。ESLint 很难可靠判断“这个 clone 会不会盖住第一份文字”。如果写一个很泛的规则,误报会很多;如果写得很窄,又只能覆盖当前代码形态。
更合适的静态规则是防“重复发明”:
- 业务组件里不要新增本地 marquee keyframes。
- 业务组件不要自己维护 clone track CSS。
- 需要跑马灯时,必须使用共享轨道组件或共享 hook。
这种 Lint 规则不理解布局,但它能阻止后续代码绕开已经修好的共享实现。
真正防布局退化,还是要靠组件单测或 source-level 架构测试。
单测要守共享组件的关键 CSS
跑马灯重叠很难用普通文本断言测出来,因为测试环境没有真实字体和完整布局。但我们可以把测试对准稳定契约:
expect(source).toContain('width: max-content')
expect(source).toContain('flex: none')
expect(source).toContain('flex-shrink: 0')
expect(source).toContain('prefers-reduced-motion')这种测试看起来有点“白盒”,但它守的是根因,不是某一行无关实现细节。只要共享轨道组件还承担 clone 布局,就必须保留这些 CSS 契约。
业务组件的测试则更适合守边界:
- 长文本溢出时会渲染 clone。
- 短文本不渲染 clone。
- 业务组件不再包含自己的 keyframes。
- 业务组件使用共享轨道组件。
这比给每个业务组件都写一套浏览器截图更轻,也更能在 review 阶段提醒开发者:这个问题已经有共享解法,不要再局部复制。
为什么这类问题总会反复出现
跑马灯重叠反复出现,通常是因为这个功能天然跨了三层知识:
- 布局层:flex item 默认可以收缩,
min-width: 0、max-width、overflow会一起影响盒子。 - 动画层:clone 要根据完整内容宽度排在第一份后面,动画距离也要按完整宽度计算。
- 业务层:不同业务组件会塞入不同内容,有的只是文字,有的还有徽章背景、特效、图标、emoji。
如果每个业务组件都独立实现,它们迟早会在某一层少写一点。第一次少写 flex-none,第二次少写 w-max,第三次忘了 reduced-motion。问题看起来不一样,底层原因很接近。
所以这类经验适合沉淀成 Skill:以后遇到“内容 + clone + 滚动轨道”时,先问有没有共享轨道组件;没有的话,先抽共享层,再让业务组件接入。不要每次都从某个截图开始局部调 CSS。
总结
- 跑马灯文字重叠通常来自 flex 布局盒被压缩后,视觉文本仍然溢出绘制。
- active 状态下,轨道和每份内容都要按完整内容宽度排版,核心是
max-content、flex: none和flex-shrink: 0。 - 多个业务组件需要跑马灯时,优先抽共享轨道组件,业务组件只负责测量和内容。
- Lint 适合防止绕开共享实现,不适合直接推断所有布局重叠风险。
- 单测和架构测试要守共享组件的关键 CSS 契约,以及业务组件不再复制本地 keyframes。
UI 问题最容易被当成“调样式”,但很多时候真正要修的是结构。只要 clone 的布局规则仍然散在每个业务组件里,同样的问题就会换一个文案、换一个断点、换一个字体再回来一次。把轨道抽出来,才算真的把坑填上。