JavaScript 动画循环优化:requestAnimationFrame 时间线与帧同步原理
JavaScript 动画循环优化requestAnimationFrame 时间线与帧同步原理一、从 30fps 的卡顿到那帧永远丢失的透明度过渡我在做一个侧边栏的展开动画。产品要求丝滑于是我写了段setInterval每 16ms 更新一次left属性。结果在低端 Android 机上动画像翻页漫画——肉眼可见的跳帧。这不是脾气问题是时钟同步问题。浏览器的渲染管线有自己严格的节奏通常 60Hz约 16.67ms 一帧而setInterval(fn, 16)是在 JS 主线程上按墙钟计时。当主线程被长任务阻塞、或显示器刷新率是 120Hz 时定时器和渲染管线之间的相位差会让某些帧被白白丢弃。requestAnimationFramerAF解决的就是这个对齐问题。但理解它需要理解浏览器的一帧里到底发生了什么。二、浏览器渲染管线与 rAF 的精确时序一个易被忽略的事实rAF 回调在样式计算之前执行。你在 rAF 里修改的任何 DOM 样式浏览器会在同一帧的样式计算阶段统一处理。这意味着rAF 里做 10 次element.style.left修改浏览器只做一次 Layout但 rAF 里做一次element.offsetWidth读取强制同步布局整个优化就废了三、帧同步动画引擎的实现/** * 基于 rAF 的动画调度引擎 * 设计意图将需要动画的值与动画如何更新解耦 * 让动画逻辑只关注当前进度百分比不关心帧率细节 */ class FrameScheduler { constructor() { // 动画注册表key → 动画描述 this._animations new Map(); // 当前帧的动画 ID0 表示没有调度中的帧 this._rafId 0; // 上一帧的时间戳用于计算 delta time this._lastTimestamp 0; } /** * 注册一个动画 * param {string} id - 唯一标识 * param {Object} config - 动画配置 * param {number} config.duration - 总时长(ms) * param {Function} config.onUpdate - 每帧回调接收进度 0~1 * param {Function} config.onComplete - 完成回调 * param {Function} [config.easing] - 缓动函数默认线性 */ register(id, { duration, onUpdate, onComplete, easing }) { this._animations.set(id, { startTime: 0, // 首次 tick 时填充 duration, onUpdate, onComplete, easing: easing || (t t), // 线性作为默认缓动 elapsed: 0 }); // 惰性启动只在有动画时才请求帧 if (this._animations.size 1) { this._tick(); } } /** * 核心帧循环——这就是 rAF 的正确使用姿态 * 每帧执行时计算所有活跃动画的进度并调用回调 */ _tick (timestamp) { // 首帧将第一个时间戳作为基准避免动画从中间开始 if (!this._lastTimestamp) { this._lastTimestamp timestamp; } // delta time 计算——这才是帧同步的关键 // 不直接用 timestamp因为浏览器可能在切标签页后追赶时间 const deltaTime Math.min(timestamp - this._lastTimestamp, 100); // 限制最大步长 100ms防止切标签页回来后所有动画一起爆炸完成 this._lastTimestamp timestamp; let hasRunning false; this._animations.forEach((anim, id) { if (anim.startTime 0) { anim.startTime timestamp; } anim.elapsed deltaTime; // 进度 已耗时 / 总时长上限 1.0 const rawProgress Math.min(anim.elapsed / anim.duration, 1); const easedProgress anim.easing(rawProgress); anim.onUpdate(easedProgress); if (rawProgress 1) { // 确保最后一帧精确达到 1.0 anim.onUpdate(anim.easing(1)); anim.onComplete?.(); this._animations.delete(id); } else { hasRunning true; } }); // 还有动画在跑继续请求下一帧 if (hasRunning) { this._rafId requestAnimationFrame(this._tick); } else { this._rafId 0; this._lastTimestamp 0; } }; /** * 取消指定动画——优雅地在帧循环外做清理 */ cancel(id) { this._animations.delete(id); if (this._animations.size 0 this._rafId) { cancelAnimationFrame(this._rafId); this._rafId 0; } } destroy() { cancelAnimationFrame(this._rafId); this._animations.clear(); } } // ---- 使用示例侧边栏展开动画 ---- const scheduler new FrameScheduler(); function animateSidebar(element, isOpen) { const startWidth element.getBoundingClientRect().width; const targetWidth isOpen ? 280 : 64; scheduler.register(sidebar-toggle, { duration: 300, // 自定义 easeOutCubic 缓动——让动画先快后慢符合物理直觉 easing: (t) 1 - Math.pow(1 - t, 3), onUpdate: (progress) { const currentWidth startWidth (targetWidth - startWidth) * progress; element.style.width ${currentWidth}px; }, onComplete: () { element.style.width ${targetWidth}px; } }); } /** * 高级用法将多个动画同步到同一帧的同一时间基线 * 适用于需要同时开始、同步结束的编排场景 * 例如列表项依次飞入但所有项共享同一个进度时钟 */ function staggeredEntrance(container, items, staggerDelay 50) { const totalItems items.length; const baseDuration 400; // 基础动画时长 items.forEach((item, index) { scheduler.register(item-${index}, { duration: baseDuration staggerDelay * index, easing: (t) 1 - Math.pow(1 - t, 2), // easeOutQuad onUpdate: (progress) { item.style.opacity progress; item.style.transform translateY(${(1 - progress) * 20}px); }, onComplete: () { item.style.opacity 1; item.style.transform translateY(0); } }); }); }四、边界陷阱与架构权衡限制最大步长是必须的。Math.min(deltaTime, 100)不是可选项——用户切到其他标签页 5 分钟后回来如果没有这个限制deltaTime可能是 300000ms所有动画会在下一帧全部瞬移完成用户体验是一场灾难。rAF 不是万能的帧同步。在 120Hz 显示器上rAF 每 8.33ms 触发一次而 60Hz 显示器是 16.67ms。如果你的动画逻辑里包含重 Layout 的操作120Hz 设备上的性能压力会翻倍。对策是把动画限制在 composite-only 属性transform和opacity。CSS Animation 与 rAF 的关系。两者走的是完全不同的路径CSS 动画在合成器线程执行完全不受 JS 主线程阻塞影响这就是 scroll-linked 动画用 CSS 比 rAF 丝滑的根本原因。但 CSS 动画缺乏暂停、恢复、在特定进度插入逻辑的能力这也是Web Animations API试图弥合的鸿沟。五、总结rAF 的核心价值不在60fps这个数字上而在让 JS 动画逻辑与浏览器的渲染节拍对齐。三个关键实践准则所有 JS 动画必须走 rAF——setTimeout/setInterval做动画是时钟不同步的根源delta time 必须 clamp——不加限制的最大步长是隐藏炸弹动画属性收敛到 transform opacity——只有这两者不触发 Layout 和 Paint是will-change和transform: translateZ(0)提权的前提FrameScheduler这个 80 行的引擎已经支撑了我过去一年 90% 的 JS 动画需求。它不是一个库而是一种范式把时间从实现细节中抽象出来让动画逻辑只关心此刻该在哪儿。