跑马灯文字为什么会重叠:一次 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,成本很高,也很容易漏。

更稳的做法是抽一层低阶轨道组件。业务组件只做三件事:

  1. 测量真实内容是否溢出。
  2. 算出滚动距离、时长和 gap。
  3. 把自己的文本、徽章或昵称结构塞进 slot。

共享轨道组件统一处理:

  • viewport 裁切。
  • track 的 width: max-contentmax-width: noneflex: none
  • content 和 clone 的 width: max-contentflex-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: 0max-widthoverflow 会一起影响盒子。
  • 动画层:clone 要根据完整内容宽度排在第一份后面,动画距离也要按完整宽度计算。
  • 业务层:不同业务组件会塞入不同内容,有的只是文字,有的还有徽章背景、特效、图标、emoji。

如果每个业务组件都独立实现,它们迟早会在某一层少写一点。第一次少写 flex-none,第二次少写 w-max,第三次忘了 reduced-motion。问题看起来不一样,底层原因很接近。

所以这类经验适合沉淀成 Skill:以后遇到“内容 + clone + 滚动轨道”时,先问有没有共享轨道组件;没有的话,先抽共享层,再让业务组件接入。不要每次都从某个截图开始局部调 CSS。

总结

  • 跑马灯文字重叠通常来自 flex 布局盒被压缩后,视觉文本仍然溢出绘制。
  • active 状态下,轨道和每份内容都要按完整内容宽度排版,核心是 max-contentflex: noneflex-shrink: 0
  • 多个业务组件需要跑马灯时,优先抽共享轨道组件,业务组件只负责测量和内容。
  • Lint 适合防止绕开共享实现,不适合直接推断所有布局重叠风险。
  • 单测和架构测试要守共享组件的关键 CSS 契约,以及业务组件不再复制本地 keyframes。

UI 问题最容易被当成“调样式”,但很多时候真正要修的是结构。只要 clone 的布局规则仍然散在每个业务组件里,同样的问题就会换一个文案、换一个断点、换一个字体再回来一次。把轨道抽出来,才算真的把坑填上。