一个昵称为什么这么难:emoji、PAG 特效和跑马灯

昵称组件很容易被低估。

它看起来只是一个 <span>:把用户昵称渲染出来,超长时滚一下,有特权时叠个彩色特效。真正做起来才发现,这个小组件同时踩中了几个前端最容易互相打架的点:

  • emoji 要保持原色,不能被渐变文本或 mask 搞成单色。
  • 昵称要有真实 DOM 文本,方便布局、复制、无障碍和兜底。
  • 高等级昵称要叠 PAG / WebP / mask 这类视觉特效。
  • 超长昵称要跑马灯滚动。
  • 跑马灯通常还需要 clone 一份文本做无缝循环。

这些需求单独看都不复杂,叠在一起就很容易出怪问题。我们这次实际遇到过几种症状:文字和特效轻微错位,看起来像两个字叠在一起;有一个字没展示出来;改完后跑马灯消失,旁边多出一份文本不停闪;小图标缩得特别小;屏幕变窄后,跑马灯一启动两份昵称又重叠了。

最后的结论不是“再调几个 CSS 数值”,而是把组件边界重新拆清楚:真实文本层负责内容和布局,视觉特效层只负责装饰。

先别让特效参与布局

带特效的昵称最容易写成这样:

<div class="display-name">
  <img v-if="effectReady" class="display-name__effect" :src="effectSrc" />
  <span class="display-name__text">{{ name }}</span>
</div>

或者更隐蔽一点:特效层和文本层都在同一个 flex 轨道里,测量跑马灯时直接拿最外层 scrollWidth。看起来很自然,但这里已经埋了雷。

特效资源的宽度不等于昵称文本宽度。PAG / WebP / mask 可能为了扫光、多帧动画或透明边缘,比真实文字宽一点;也可能因为加载时机、缩放策略或裁剪方式,在 ready 前后尺寸变化。如果拿这个容器去判断“昵称是否溢出”,跑马灯会被特效资源误导。

更麻烦的是,特效通常是视觉增强,不是内容本身。它不应该影响用户能复制到什么,不应该决定文本占多少布局宽度,也不应该决定无障碍树里读出什么。

所以第一条规则是:

真实 DOM 文本层永远存在,负责布局、测量、复制、无障碍和 fallback;特效层 aria-hidden,绝对定位,只按文本层的位置和宽度做视觉覆盖。

一个更稳的结构长这样:

<span class="display-name">
  <span ref="viewportRef" class="display-name__viewport">
    <span ref="textRef" class="display-name__text">
      {{ name }}
    </span>

    <span
      v-if="effectSrc"
      class="display-name__effect"
      aria-hidden="true"
    />
  </span>
</span>

真实文本层可以是渐变色,也可以是普通颜色;特效层可以是 PAG、WebP、canvas 或 CSS mask。关键是它不能改变文本层的布局事实。

昵称组件里真实文本层负责布局和测量,视觉特效层只做 aria-hidden 的装饰覆盖

emoji 是真实文本层的责任

彩色昵称还有一个容易忽略的点:emoji。

如果为了做渐变文字,直接给整段文本加:

background: linear-gradient(...);
-webkit-background-clip: text;
color: transparent;

emoji 很容易变成单色剪影,或者跟特效层发生遮挡。用户看到的是“😎”,但视觉层可能只剩一个透明洞、一个金色块,或者一段被 mask 切坏的图形。

处理方式仍然回到分层:

  • 文本层要按浏览器正常文本渲染,保证 emoji 原色和 fallback。
  • 如果需要彩色文字,可以只对普通字符做渐变,或使用更保守的 CSS,让 emoji 不被强制透明。
  • 特效层只叠在上面或下面,不替代真实文本。

这也是为什么组件里不能只渲染一张“带特效的昵称图片”。图片看起来还原,但它没有真实文本语义,复制、搜索、无障碍、字体 fallback 和跑马灯测量都会变成新的问题。

跑马灯测量必须有稳定来源

跑马灯的核心判断通常很简单:

const shouldMarquee = textWidth > viewportWidth

问题在于 textWidthviewportWidth 从哪来。

如果 textWidth 来自包含特效层和 clone 的外层节点,那么它会被特效资源、动画节点、第二份文本一起污染。结果就是短文本也被判断成溢出,或者长文本反而因为 flex 收缩被判断成不溢出。

更稳的做法是准备一个纯文本测量源:

<span ref="measureRef" class="display-name__measure">
  {{ name }}
</span>

这个节点可以视觉隐藏,但要保留字体、字号、字重、letter-spacing 等文本样式。测出来的宽度只代表真实昵称,不包含 PAG、WebP、clone、光效边缘。

跑马灯轨道则只消费测量结果:

<span class="display-name__marquee-track">
  <span class="display-name__marquee-text">{{ name }}</span>
  <span class="display-name__marquee-text" aria-hidden="true">{{ name }}</span>
</span>

对应 CSS 里,轨道和文本要防止被 flex 压缩:

.display-name__viewport {
  overflow: hidden;
}

.display-name__marquee-track {
  display: flex;
  width: max-content;
}

.display-name__marquee-text {
  flex: none;
  width: max-content;
  white-space: nowrap;
}

我们这次遇到“屏幕很小,一加跑马灯就重叠”,根因就是这里。clone 被放进了同一条轨道,但轨道或文本本身被父级 flex 规则压缩了;第二份文本没有真正排到第一份后面,而是被挤回同一段可视区域里。看起来像动画错了,实际是布局宽度被压了。

跑马灯先用纯文本节点测量,再把 viewport 和滚动轨道分开渲染

特效宽度可以比文字宽,但不能反过来控制文字

有些昵称特效本来就会比文字宽一点。比如扫光左右有透明渐出,或 PAG 资源为了不裁切粒子,天然留了边。这个宽度可以存在,但它应该作为“视觉裁剪宽度”,而不是“文本布局宽度”。

可以把三个宽度分开理解:

  1. 文本宽度:真实 DOM 文本测出来的宽度。
  2. viewport 宽度:当前布局给昵称的可见区域宽度。
  3. 特效宽度:视觉资源覆盖时需要的宽度,可能等于文本宽度,也可能略宽。

跑马灯是否启动,只看 1 和 2。特效层如何裁剪,看 1 和 3。不要让 3 回头改变 1。

如果特效层一定要稍微溢出文本边缘,可以让它绝对定位:

.display-name__effect {
  position: absolute;
  inset-block: 0;
  left: 50%;
  width: var(--effect-width);
  transform: translateX(-50%);
  pointer-events: none;
}

它可以比文本宽,但不应该撑开 .display-name__viewport

ready 状态不能决定真实文本是否存在

这类组件还有一个常见误区:等 PAG / WebP 加载完再展示昵称。

这样做会让页面在几个状态之间跳:

  1. 初始没有特效,渲染普通文本。
  2. 特效 ready,替换成特效版节点。
  3. 资源失败,再退回普通文本。

如果这几个状态对应不同 DOM 结构,跑马灯测量、SSR hydration、布局宽度都会跟着抖。用户看到的就是闪一下、丢一个字、或旁边出现第二份文本。

更稳的是:真实文本层从一开始就渲染。特效 ready 只决定装饰层是否出现。

<span class="display-name__text">{{ name }}</span>
<span v-if="effectReady" class="display-name__effect" aria-hidden="true" />

资源失败也只是少了视觉增强,内容本身不受影响。

组件拆分:让名字暴露边界

这类组件如果都写在一个文件里,很快会混成一团:测量、动画、资源加载、fallback、DOM 结构、emoji、跑马灯、清理计时器都挤在一起。后面修 bug 时,最容易在不知不觉中让特效层重新参与布局。

比较清晰的拆法是:

  • DisplayNameText:只负责真实文本、测量节点、跑马灯结构。
  • DisplayNameColorfulEffect:只负责视觉特效层,不拥有真实文案。
  • useDisplayNameMarquee:负责测量、ResizeObserver、动画距离和是否启用 marquee。
  • useDisplayNameEffect:负责 PAG / WebP source、ready、error、cleanup。

命名不一定一模一样,但职责要能从名字看出来。尤其不要让 Effect 组件再通过 slot 包住真实文本;如果只有一个调用方,slot 反而会隐藏边界,让读者以为它可以承载任意内容。

测试要守边界,而不是只测能渲染

这种 bug 很难靠“组件 mount 后 text 存在”发现。测试要对准真正容易回退的边界。

单测可以覆盖:

  • 真实文本节点始终存在,特效 ready / error 不影响文本渲染。
  • 特效节点 aria-hidden="true",不出现在文本测量源里。
  • marquee 判断只依赖纯文本宽度和 viewport 宽度。
  • clone 节点是 aria-hidden,且 class 保证 flex: none / width: max-content
  • 资源失败时只关闭特效层,不清空昵称。

E2E 或浏览器验收可以覆盖:

  • 长昵称在小宽度下滚动,不出现两份文本重叠。
  • 短昵称不启动 marquee,也不会被特效资源撑宽。
  • emoji 保持原色。
  • 特效资源加载前后,昵称所在行 DOMRect 不跳变。

这些测试不需要做整页截图金丝雀。用 DOMRect、computed style、关键 class 和局部截图,通常更容易定位根因。

最后沉淀成一条原则

这次最值得记住的不是某个 CSS 值,而是一条分层原则:

用户真正要读、复制、搜索、无障碍访问和参与布局的内容,必须有稳定的 DOM 内容层;动画、PAG、WebP、mask、光效和渐变都只是视觉增强层。

文本层不依赖特效 ready,特效层不参与文本测量。

只要这条边界守住,彩色昵称、徽章、标题、会员名牌、跑马灯和将来的更多视觉效果,都能在同一个模型里迭代。反过来,如果为了“看起来像”把内容和特效揉成一个节点,下一次遇到 emoji、滚动、窄屏、真机字体或资源加载时,它还是会以另一种形式坏回来。