一个昵称为什么这么难: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。关键是它不能改变文本层的布局事实。
emoji 是真实文本层的责任
彩色昵称还有一个容易忽略的点:emoji。
如果为了做渐变文字,直接给整段文本加:
background: linear-gradient(...);
-webkit-background-clip: text;
color: transparent;emoji 很容易变成单色剪影,或者跟特效层发生遮挡。用户看到的是“😎”,但视觉层可能只剩一个透明洞、一个金色块,或者一段被 mask 切坏的图形。
处理方式仍然回到分层:
- 文本层要按浏览器正常文本渲染,保证 emoji 原色和 fallback。
- 如果需要彩色文字,可以只对普通字符做渐变,或使用更保守的 CSS,让 emoji 不被强制透明。
- 特效层只叠在上面或下面,不替代真实文本。
这也是为什么组件里不能只渲染一张“带特效的昵称图片”。图片看起来还原,但它没有真实文本语义,复制、搜索、无障碍、字体 fallback 和跑马灯测量都会变成新的问题。
跑马灯测量必须有稳定来源
跑马灯的核心判断通常很简单:
const shouldMarquee = textWidth > viewportWidth问题在于 textWidth 和 viewportWidth 从哪来。
如果 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 规则压缩了;第二份文本没有真正排到第一份后面,而是被挤回同一段可视区域里。看起来像动画错了,实际是布局宽度被压了。
特效宽度可以比文字宽,但不能反过来控制文字
有些昵称特效本来就会比文字宽一点。比如扫光左右有透明渐出,或 PAG 资源为了不裁切粒子,天然留了边。这个宽度可以存在,但它应该作为“视觉裁剪宽度”,而不是“文本布局宽度”。
可以把三个宽度分开理解:
- 文本宽度:真实 DOM 文本测出来的宽度。
- viewport 宽度:当前布局给昵称的可见区域宽度。
- 特效宽度:视觉资源覆盖时需要的宽度,可能等于文本宽度,也可能略宽。
跑马灯是否启动,只看 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 加载完再展示昵称。
这样做会让页面在几个状态之间跳:
- 初始没有特效,渲染普通文本。
- 特效 ready,替换成特效版节点。
- 资源失败,再退回普通文本。
如果这几个状态对应不同 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、滚动、窄屏、真机字体或资源加载时,它还是会以另一种形式坏回来。
