终态优先:别把正文交给动画时间线

一次浏览器验证发现,入场动画把文字的不透明度押在 requestAnimationFrame 上;后台标签页里,56 秒后标题仍然只有 0.34 的不透明度。

上一版首页有一段很常见的入场动画:把 .reveal 元素从 opacity: 0 拉到 1。它在本机看起来毫无问题,直到用真实浏览器在后台标签页里打开它。

现象

页面加载 56 秒后,读取计算样式得到这样的结果:

["hidden", false, 56056,
 ["0.87","0.76","0.59","0.34","0.00","0.00","0.00","0.00",
  "0.00","0.00","0.00","0.00","0.00","0.00","0.00","0.00"]]

十六个元素里,十二个的不透明度还是 0,标题停在 0.34。不是渲染 bug,也不是字体没加载——document.fonts.status 是 loaded,控制台没有报错。

机制

gsap.from(...) 在补间的第一帧就把初始值写进元素样式。之后补间能推进,是因为 GSAP 的 ticker 挂在 requestAnimationFrame 上。而 document.visibilityState === 'hidden' 时 rAF 不再回调,补间就永久停在开始的那一帧。

于是”渐变出现”变成了”可能永远不出现”。对真实用户来说,最常见的触发场景是:

  • 在后台标签页打开链接,隔一会儿切回来;
  • 爬虫、社交卡片预览、自动化截图;
  • 低电量模式或主线程被长任务占满时掉帧;
  • 动画脚本在写入初始值之后抛错。

三条规则

一、文字默认可见,动画只做增强。 首态由 CSS 决定为终态,JS 只在确认环境允许时才把它挪开。

二、只动 transform,不动 opacity。 本站首页的入场现在是 y: 18 → 0。即使 ticker 完全停摆,文字也只是比最终位置低 18 像素,依然可读。

import('gsap').then(({ gsap }) => {
  const tl = gsap.fromTo(els, { y: 18 }, { y: 0, duration: 0.9, stagger: 0.075 });
  // 兜底:无论 ticker 是否推进,1.6s 后落到终态
  setTimeout(() => {
    if (tl.progress() < 1) {
      tl.kill();
      els.forEach((el) => (el.style.transform = ''));
    }
  }, 1600);
});

三、页面隐藏时立刻收敛。 监听 visibilitychange,一旦变为 hidden 就结束补间、清掉内联样式。用户切回来看到的是成品,不是半成品。

验收方式

这条规则可以写成一条硬性检查,而不是审美偏好:

  1. 禁用 JavaScript,正文与标题必须 100% 不透明;
  2. 页面在 hidden 状态下加载 3 秒后,所有正文元素的 opacity 必须为 1;
  3. 首屏 LCP 元素不能是任何被动画控制的节点。

在浏览器里跑一次这三条,比在代码评审里争论”动画时长该不该 0.8 秒”有用得多。