一个页面出现五个独立动效后,最危险的往往不是某个 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.hiddenvisibilityStatevisibilitychange。文档也说明多数浏览器会暂停隐藏标签页或隐藏 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