◈ 夜宵吧

动效的分寸:五次尝试和两个坑

五项动效里,两个真有用、三个只是锦上添花——但前提是先让数字滚动别显示负数。

一个几乎没有动效的站点要加动画,第一件事不是挑效果,而是决定「不加什么」。本文把候选拆成三类(值得做 / 可考虑 / 劝退),再写清落地时两个必须知道的坑:requestAnimationFrame 的第一帧时间戳可能早于你记下的起点,而切到后台标签页时它根本不跑。

读完你能拿到一套不踩坑的数字滚动实现,和一条判断标准:什么样的动效能做。

数字滚动里的负数

统计卡的数字从 0 跳到目标值,这个动效最直观。写完一测,四张卡最后显示:

-6133  -26233  -11436  -974

全是负数。但接口返回的数据是正的:2375, 1035, 302, 88。

逐帧采样才看清第一帧:

-9, 171, 171, 369, 460, …, 600

第一帧是负的。

根因在时间基准:

const t0 = performance.now();
const k = Math.min(1, (t - t0) / dur);

t 来自 requestAnimationFrame 的回调参数,它是这一帧开始的时间;t0 是「我开始执行这段代码的时间」。后者可能比前者晚几毫秒。

于是 t - t0 是负数,k 是负数,缓动函数 1 - (1-k)³ 也是负数,目标值乘以负数就是负的。

修法就一个 Math.max:

const k = Math.max(0, Math.min(1, (t - t0) / dur));
if (k >= 1) { el.textContent = fmt(target); return; }

顺便加了句收尾:k 到 1 就直接赋最终值,不再依赖最后一帧的浮点计算。

后台标签页里的僵尸动画

第二个问题更隐蔽:切到别的标签页再切回来,四张卡显示 0 | 0 | 0 | 0。

因为后台标签页里 requestAnimationFrame 会被暂停。页面在后台时那几帧根本不跑,动画停在第一帧,而第一帧是 0。

补一个定时器兜底,不管帧跑不跑,到了时间就把最终值写上去:

setTimeout(() => {
  if (el.__cuToken === token) el.textContent = fmt(target);
}, dur + 90);

__cuToken 是为了防止竞态:同一元素短时间内被两次动画接管时,旧的那个自己退出。

跨页面的标题

路由切换想做得更好的地方是让卡片标题一路长成文章标题。

这件事用浏览器原生的 View Transitions 就能做,不需要手写补间:

const vt = document.startViewTransition;

// 出发页:给要变形的元素起个名
cardTitle.style.viewTransitionName = 'post-morph';

await vt.call(document, async () => {
  await render();            // 换掉 DOM
  newTitle.style.viewTransitionName = 'post-morph';  // 新页面同名
});

浏览器会发现「旧页面里叫这个名字的元素」和「新页面里叫这个名字的元素」,然后自动补出中间帧。标题从卡片位置移到文章顶部,字号一起变。

收尾要记得把名字清掉,否则下次切换会认错元素:

t.finished.then(cleanup, cleanup);

几个坑:

名字必须两边都有。 只有一边有名字,那边会淡出,另一边直接出现,像是坏了。

名字必须唯一。 同一时刻只能有一个元素叫这个名字,列表里循环赋值要小心。

降级要写。 document.startViewTransition 不存在时(老浏览器)直接走普通渲染:

if (!vt) { await render(); return; }

reduced-motion 不是可选项

系统里有个「减少动态效果」开关,开了之后用户希望少动。这个必须尊重:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    transition-duration: .01ms !important;
  }
}

注意是 .01ms 不是 0——有些动画结束时要靠 transitionend 触发回调,时长设成 0 在某些浏览器里事件不触发。

JS 那边也要判:

if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;

分寸

五项做完,最有用的其实是两个不起眼的:

卡片整片可点,鼠标移上去有微弱的高光跟着走。 它在持续回答「我点的是哪一张」。

返回上一页时滚动位置能恢复。 这个不算动画,但带来的顺滑感超过前面所有。

反过来,最花哨的路由转场只用了 0.3 秒,而且只在「卡片点进文章」这一条路径上触发——从导航栏进同一个页面时不做,因为那里没有「同一个元素从 A 到 B」的故事可讲。

动画的价值在于让两个状态之间的关系变得可读。没有关系的两帧之间插补,只是延迟。

代价的上限

动效的成本最难量,因为它分散在每一帧里。所以这里只记几个能算出来的上限,不是实测平均值:

停留成本是 0 ~ 30 次/秒。 聚光灯跟着鼠标走,pointermove 的触发频率跟着设备走:鼠标 60125 Hz,触摸屏 60120 Hz,而浏览器的合成帧率最多 60。也就是说即使事件来得更密,几何计算也被压在 30 次/秒——不会因为换了一块高刷屏就翻倍烧 CPU。

进场动画的延迟上限是 330 ms。 每张卡最多排到第 6 档,每档 55 ms,6 × 55 = 330。第 7 张之后的卡片不再累加,所以进入长列表时,所有卡的动画都在 330 毫秒内开始,不会出现划到第 20 张时它才开始进场。

转场时长 0.3 秒,只在一个路径上付。 从卡片进文章。从导航栏进同一篇不做,所以「浏览」这个动作的绝大部分不走 View Transition。

数字滚动 1.2 秒左右,只付一次。 每次渲染统计卡时跑一轮,和用户停留时长无关。

这几个数合起来的意思是:动效的开销有硬上限,和列表长度、屏幕刷新率、停留时间都不成正比。这是设计时唯一值得争取的性质——平均值会骗人,上限不会。

这些动效的代价

两处常驻监听。 聚光灯要在 pointermove 上读坐标,虽然合了帧,但每次移动都有一条事件进来。用 pointerType === 'touch' 排除触摸设备——手机上本来就没有「悬停」这回事。

一次观察器注册。 滚动进场把每张卡片交给 IntersectionObserver,长列表下回调会排队;所以给每张卡设了最大 6 档、每档 55 毫秒的延迟,再多的卡片同时进场。

转场只在一条路径上触发。 从导航栏进文章页时不做,因为那里没有「同一个元素从 A 到 B」的故事,硬做只能整页淡入——那还不如不做。

还有一个限制写在规范里:view-transition-name 同一时刻只能给一个元素。列表里若两篇文章的标题同时挂上同一个名字,浏览器会直接取消整次转场。