一个页面出现五个独立动效后,最危险的往往不是某个 shader,而是五套互不认识的循环:每套都监听 resize 和 pointermove,每套都调用 requestAnimationFrame,元素滚出视口仍计算,切到后台只依赖浏览器碰巧节流,Astro 切页后旧闭包继续引用 DOM。平均 FPS 可能暂时好看,资源所有权却已经失控。
本文面向正在搭建高动效博客、需要管理多个 DOM/Canvas/WebGL 效果的前端开发者。前置知识是 RAF、Observer 与事件监听。兼容性说明核验于 2026-07-16;五段精确 TypeScript 代码均已进入 examples/ai-web,通过类型检查,其中页面 scope 的清理行为还通过 Vitest。当前证据不含指定设备 trace,所以兼容性和实测帧率结论仍是 source-reviewed。
视觉资产记录:封面(1600 × 900)与文中控制平面图(1200 × 680)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。
RAF 是一次预约,不是永久线程
MDN requestAnimationFrame说明它请求浏览器在下一次重绘前调用回调,而且是 one-shot:需要下一帧就必须再次请求。回调频率通常跟随显示刷新率,60、75、120、144Hz 都可能出现。因此动画应使用回调的 timestamp 或由它计算的 delta,不能假设每帧固定 16.67ms。
let frame: number | null = null;
let previous: number | null = null;
function tick(now: DOMHighResTimeStamp) {
frame = null;
const dt = previous === null ? 0 : Math.min(now - previous, 50);
previous = now;
update(dt, now);
if (shouldRun()) frame = requestAnimationFrame(tick);
}
function requestFrame() {
if (frame === null && shouldRun()) frame = requestAnimationFrame(tick);
}
限制 delta 不是伪造时间,而是保护依赖积分的视觉弹簧,避免页面恢复后一步跨越数秒。需要真实墙钟的进度条可以直接用绝对 timestamp,不应套同一上限。更重要的是:没有活跃消费者时不再预约下一帧,而不是让空循环永久运行后在内部 return。
同一页面的多个轻量效果可以共享一个 scheduler,订阅者只提供 update。共享减少重复调度并统一暂停,但不能把所有工作硬塞进一个巨型回调;某个昂贵消费者仍会拖累整帧。记录每个订阅者耗时,允许按路由拆包和独立禁用。
图 1(1200 × 680):可见性与 Observer 更新控制状态,scheduler 只在许可成立时请求一帧,cleanup 统一回收所有资源。原创程序化 SVG,WEB/SUN,2026-07-16。
Page Visibility 是主动停机信号
Page Visibility API提供 document.hidden、visibilityState 与 visibilitychange。文档也说明多数浏览器会暂停隐藏标签页或隐藏 iframe 的 RAF,并节流定时器。但“浏览器通常会节流”不是应用 cleanup:音频、网络、Worker、Observer、第三方 ticker 或恢复时的时间状态仍需要产品自己处理。
页面进入 hidden 时取消已排帧、清空前一时间戳;重新 visible 时只在仍有可见消费者时恢复:
function onVisibilityChange() {
if (document.hidden) {
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
previous = null;
pauseMediaAndGpu();
} else {
requestFrame();
}
}
document.addEventListener('visibilitychange', onVisibilityChange);
MDN visibilitychange指出 hidden 转换是页面最后一个可靠可观察阶段,适合停止用户看不到的 UI 更新。不要用 window blur 代替:窗口失焦不一定被遮住,多窗口场景仍可能可见。也不要 visible 后无条件播放;用户此前主动暂停的视频或实验必须保持暂停意图。
IntersectionObserver 管是否值得运行
W3C Intersection Observer 规范的目标是异步获知元素相对 root 的可见位置,避免持续轮询布局;MDN 指南同样强调它在阈值越界时异步通知。它适合回答“动效区域是否进入或接近视口”,不适合逐像素驱动滚动位置。
const intersection = new IntersectionObserver(
([entry]) => {
inView = entry.isIntersecting;
if (inView) requestFrame();
else stopIfNoConsumers();
},
{ rootMargin: '160px 0px', threshold: 0.01 },
);
intersection.observe(host);
rootMargin 可以让动态模块在真正出现前准备,但提前距离会增加后台工作,应由资源体积和滚动测试决定。Observer 回调可能合并、延迟,也不会提供每一帧精确相交矩形;视差或滚动进度优先使用 CSS Scroll-driven Animations,确需 JS 时在活跃范围内读取滚动状态并用 RAF 批量写入。
元素离屏后可以停止时间驱动效果,却不应销毁用户状态。重新进入时从当前指针/滚动状态计算画面;若动画必须从头播放,应把“首次进入”写成显式状态,避免阈值附近抖动反复重启。
ResizeObserver 只更新几何快照
Resize Observer API用于监听元素内容盒或边框盒尺寸变化,不需要只依赖 window resize。字体加载、侧栏展开、容器查询与内容更新都可能改变 canvas 宿主尺寸,却不触发窗口变化。
Observer 回调里保存宽高并请求一次绘制,不要同步修改会再次改变同一元素尺寸的属性,避免 resize 循环。对于 canvas:按限制后的 DPR 更新 backing store,更新相机/viewport,标记下一帧需要渲染;多个尺寸变化合并到一帧执行。
const resize = new ResizeObserver(([entry]) => {
const box = entry.contentRect;
pendingSize = { width: box.width, height: box.height };
requestFrame();
});
resize.observe(host);
布局读写要分阶段。事件与 Observer 负责采集输入,RAF 开头读取缓存状态和必要几何,随后统一写 transform、CSS 变量或 uniform。写完后再调用 getBoundingClientRect() 会迫使浏览器在同一任务中结算布局,重复出现就形成 layout thrashing。
一个可组合的页面作用域
运行时不应让每个效果记住几十种清理细节。创建页面 scope,所有注册函数都返回 disposer,最终逆序执行:
function createScope() {
const disposers: Array<() => void> = [];
let disposed = false;
return {
add(dispose: () => void) {
if (disposed) dispose();
else disposers.push(dispose);
},
dispose() {
if (disposed) return;
disposed = true;
for (const dispose of disposers.reverse()) dispose();
},
};
}
RAF cancel、Observer disconnect、媒体查询和 DOM listener 移除、GSAP context revert、Three.js dispose、timeout clear 都进入同一 scope。异步 dynamic import 还需检查 disposed:用户可能在模块下载期间离开页面,Promise 完成后不能把 canvas 挂到新页面。
Astro ClientRouter 切页时,在 astro:before-swap dispose 当前 scope,在 astro:page-load 为新 DOM 建立 scope。不要全局清除别的页面或持久组件资源;scope 是所有权边界,不是“清理整个网站”的按钮。
何时按需帧,何时持续帧
不是每个交互都需要永动 RAF:
- 指针只改变 CSS 变量且没有惯性时,事件中缓存输入并请求一帧。
- 弹簧尚未收敛、视频纹理播放或 shader 时间持续推进时,保持连续帧。
- 弹簧达到阈值、元素离屏、文档 hidden 或减少动态时,停止。
- 静态图表尺寸变化时,ResizeObserver 后只画一帧。
- 滚动位置只在用户滚动期间更新时,用 scroll 事件标记 dirty、RAF 合并,而非另开轮询。
“是否需要下一帧”应由活动状态推导,而不是一个永远为 true 的全局变量。对弹簧同时检查位移与速度阈值,连续若干帧稳定后停止,避免浮点尾差让循环永不结束。
测量与诊断不能只看 RAF 数量
一个 RAF 回调也可能包含 40ms 工作,十个很短的回调也可能被浏览器在同帧调度。验收要固定设备、刷新率、窗口、路由、输入轨迹和运行时长,记录长帧、主线程任务、样式/布局、GPU、事件数量,以及离屏和 hidden 时是否仍有持续工作。
Long Animation Frames API 指南可以在支持环境中通过 PerformanceObserver 提供超过阈值的长动画帧及脚本归因,但该能力本身需要检测兼容性;不支持时使用浏览器 Performance trace。它是诊断工具,不应成为页面运行的必需依赖。
本文没有目标设备数据,因此不写“减少 30% CPU”或“稳定 60 FPS”。可自动断言的是:路由离开后 scope 数量归零;离屏和 hidden 后不再排新帧;Observer 和监听在 cleanup 后不触发;重新进入时状态正确;Reduced Motion 不加载高级动效。
把这些断言做成可观测计数器会更可靠:开发构建记录 active scope、pending RAF、连接中的 Observer 与动效实例,路由切换和可见性测试在稳定后要求回到基线。计数器只暴露资源所有权,不采集用户输入;生产环境可以移除详细标签,只保留异常采样。相比凭任务管理器目测 CPU,这种证据能精确指出是哪一个页面生命周期没有闭合。
常见失败模式
| 症状 | 根因 | 修复 |
|---|---|---|
| 高刷屏动画速度翻倍 | 每帧固定推进量 | 使用 RAF timestamp/delta |
| 切回标签页物体瞬移 | hidden 期间累计巨大 delta | 停止并重置时间基线 |
| 页面滚走仍耗电 | 永动 RAF 不看相交状态 | IntersectionObserver 控制消费者 |
| 容器变了 canvas 模糊 | 只监听 window resize | ResizeObserver 更新 backing store |
| 切页越多回调越多 | 没有页面 scope | 每次 init 返回幂等 cleanup |
| Observer 回调触发循环 | 回调同步改变被观测尺寸 | 缓存尺寸,下一帧批量写入 |
结论
高水准动效需要的不只是更复杂的 shader,而是一套知道何时运行、何时停、由谁清理的控制平面。RAF 提供帧时钟,Page Visibility 管文档状态,IntersectionObserver 管视口价值,ResizeObserver 管几何变化,页面 scope 管最终所有权。让“没有活跃消费者就没有下一帧”成为不变量,才能把作品集的动效密度提升,而不把隐藏标签页、离屏元素和反复切页变成长期成本。
Sources
- Window requestAnimationFrame method — MDN Web Docs,访问于 2026-07-16。
- Page Visibility API — MDN Web Docs,访问于 2026-07-16。
- Document visibilitychange event — MDN Web Docs,访问于 2026-07-16。
- Intersection Observer API — MDN Web Docs,访问于 2026-07-16。
- Intersection Observer Specification — W3C,访问于 2026-07-16。
- Resize Observer API — MDN Web Docs,访问于 2026-07-16。
- Long animation frame timing — MDN Web Docs,访问于 2026-07-16。