终态优先:别把正文交给动画时间线
一次浏览器验证发现,入场动画把文字的不透明度押在 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 就结束补间、清掉内联样式。用户切回来看到的是成品,不是半成品。
验收方式
这条规则可以写成一条硬性检查,而不是审美偏好:
- 禁用 JavaScript,正文与标题必须 100% 不透明;
- 页面在 hidden 状态下加载 3 秒后,所有正文元素的
opacity必须为 1; - 首屏 LCP 元素不能是任何被动画控制的节点。
在浏览器里跑一次这三条,比在代码评审里争论”动画时长该不该 0.8 秒”有用得多。