跳到主要内容

窗口自己没动,那人却说它过渡卡——这头牛非钻穿不可

钻牛角
钻牛角

· 阅读约 6 分钟

嘿,我本来只是想刷个 DEV 摸摸鱼,结果撞见 Anna Villarreal 的冰沙餐车,纯 CSS 堆的——渐变、盒阴影、图层叠加,背景模拟 LED 灯条,桌布有褶皱,后部车窗镂空透过去看星星。评论区有个人说:窗口的 CSS 过渡有点卡顿。

Anna 回了一句:窗口是静态的,没有过渡。你用什么浏览器和设备?

你说气不气。一个完全不动的元素,他硬说看到过渡,还说它卡。这不就跟说“我看到鬼了”一个意思嘛。这头牛我当场就起劲了——不是钻 Anna 的餐车,是要钻这桩“静态元素被感知成动画/过渡”的视觉错觉案。今天非钻穿不可。

那餐车的结构我瞄了一眼,车身车窗是叠出来的层,后窗镂空透过去能看见星星——那些星星是几行 JS 随机撒的,会闪。整个页面里,只有星星在动。Anna 说窗口是静态的,我信她。那么,那家伙说的“卡顿过渡”到底是什么?我第一反应:他把星星穿过镂空窗口时的观感,记到窗口头上了。窗口自己不闪,但它是个掏了洞的层,盖在星星上,星星一明一灭,洞口里的亮度就在跳。这个跳的节奏不连续——JS 随机 interval,加上闪烁是硬切换不是渐变——所以透过窗口看进去,就像有层东西在“卡着切”。说白了,他把窗口后面那层动的东西的动态,错按到窗口自己的 transition 上。

先别猜了,我搭个最小 demo。一个 .window 层掏个洞——其实不是真挖洞,是四块不透明面板围出透明区域——后面放一层闪烁的星星。就这么个结构:

<div class="scene">
  <div class="stars"></div>
  <div class="vehicle">
    <div class="window"></div>
  </div>
</div>
.stars {
  position: absolute;
  animation: twinkle 0.3s steps(1) infinite;
}
@keyframes twinkle {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}
.window {
  position: absolute;
  background: transparent; /* 真正的窗口区域是透明的 */
}

星星用 steps(1) 做硬闪——瞬间明灭,没有中间帧。你把视线放在窗口边缘上,眼睛就会不自觉地把洞里的亮度跳变归到窗口头上,因为窗口是那个区域里最明显、有明确边界的东西。然后“过渡卡卡的”这个判断就冒出来了。哎,人脑这套物体归因机制,跟 CSS 没关系,但踩的就是 CSS。

等一下,这不对劲。我前面信誓旦旦说“窗口是静态的”,但我凭什么?就凭 Anna 回了一句?我从头到尾没摸过她的源码。不钻到底不舒服,我这就去翻她 GitHub 仓库。她公开了链接,我也没逐行读,就扫到车窗那块的定位逻辑。结论是:窗口本身确实没有任何 transitionanimation,静态表述没错。那个镂空效果是用一层带透明区域的元素实现的,星星的闪烁是独立的 JS 在驱动。

那“卡顿”这个感觉,不止是归因错误这么简单。还有一层更阴间的东西:星星这种 steps(1) 的硬闪,和窗口边缘的锐利边界搭在一起,会产生一种类似“逐帧动画接不上”的质感。你盯得越认真,越觉得是窗口在跳。

我就在想,这是不是跟闪烁的刷新和显示器刷新频率之间的 beat 有关系。假如星星的闪烁是 0.3 秒的 steps 切换,显示器的刷新是 60Hz,那帧与帧之间的错位就会产生一种不那么均匀的闪烁节奏——肉眼可见的“卡”。这个卡真实存在,但它不属于窗口,属于星星驱动的帧节奏。评论者看到了真实的东西,只是认错了对象。活见鬼。

我接着钻。第二个 demo,我刻意制造闪烁和静态镂空窗口的叠加,把闪烁周期从 0.3 秒改成 0.28 秒:

.stars {
  animation: twinkleHard 0.28s steps(1) infinite;
}

改完,闪烁和 60Hz 刷新率的 beat pattern 就变了,肉眼感觉“更卡”了。这里头有意思的是,卡顿感的来源根本不是什么性能问题,是闪烁的周期与刷新周期互质之后产生的相位漂移。你如果把这些星星放在一个完全开放的空间里(没有窗口边界),它看起来就只是“闪得不均匀”;但一旦加上一个镂空窗口的边界,人的注意力被边界框住,就会把框内节奏的抖动安到框上。

这让我想起上次钻 line-height 撞的那个墙——视觉节奏这东西,单看元素本身没用,得把它放进它所在的边界上下文里看。边界会把节奏归因到自己头上,就像你敲击桌面,大家会觉得是桌子在响而不是你的手指在响。

这里头还有个更偏浏览器实现的层面:steps() 在不同引擎上的时序处理并不完全同步。别的不说,闪烁这种离散动画里,Chrome 通常会在合成线程上把 opacity 切换做得非常利落,Safari 在部分版本上会延迟半帧才把新 opacity 提交给 GPU。当闪烁藏在窗口后面时,这种半帧的迟滞会变成一种“窗口好像抽了一下”的体感。我捋了张表:

浏览器表现备注
Chrome利落合成线程上的 opacity 切换很干脆,不容易错觉
Firefox基本利落离散闪烁的时序与 Chrome 有微妙差异但不明显
Edge同 ChromeChromium 内核一致
Safari 17 及以下半帧迟滞steps() 在合成提交上偶发慢半拍,闪烁藏在窗口后更容易被误判成窗口卡顿

拉胯的依旧是 Safari——你永远能相信它在合成时机上给你整点细微到难以复现的阴间活儿。别跟人抬杠说“我测了没问题”,你用肉眼扫一眼的“没问题”和藏在镂空边界后的半帧迟滞根本不在一个量级上。

这桩事最让我起劲的不是“视觉归因”这四个字,而是它直接戳了一件事:人对 CSS 效果的判断,经常是在他还没分清“谁在动”之前就下了结论。那个评论者就是个活例子——他去说一个静态元素有过渡,并且还说它卡了。这不是蠢,是边界上下文太强了。Anna 反手问浏览器和设备,等于把这个锅暂时轻轻放回了一个可以排查的流程里,这个反应其实相当到位。

但我想补一手:如果 Anna 当时没说是静态的,底下再来一个人附和“对对我也觉得卡”,这个误会就能一路滚雪球变成“这个 CSS 有性能 bug”。然后 GitHub issue 区就会多出一条莫名其妙的优化诉求。这就是为什么我在意“亲手试一遍”,因为只有自己去摸那个 demo,把窗口和星星分开来,找出卡顿感的真正来源,你才能在那条评论出现的时候说一句:那是星星在卡,不是窗口。

最后,说件和这八竿子打得着的事儿——其实我最近在做一个带闪烁边框的组件,调试了半天总觉得边框过渡很怪,后来发现是我把闪烁层包在边框层里,视线一直落在边框上,结果把闪烁层的不均匀全记在边框账上了。我为此停了一下午,最后只改了一行:把闪烁层从边框层里挪出来,放在后面一个独立的位子上。嘿,心里那叫一个舒坦。不是 CSS 不听话,是我自己没把板子打对地方。

这样想来,人眼和这个合成线程一样,也是有点倔的——它看到的“卡”,不一定是元素真的卡,也可能是你找错了那个会动的东西。扯远了,反正——嘿嘿,下次见。

钻牛角
钻牛角

逮住一个前端小知识点挖到底:现象→实验→兼容性表格→闲聊,配 demo 玩梗。

查看主页 →