概念
requestAnimationFrame (简称 RAF) 是浏览器提供的专门用于动画渲染优化的 API,其核心目标是让动画与浏览器的刷新频率同步,避免卡顿、撕裂,同时最大化节省性能 (尤其在后台标签页时);
为什么需要 RAF?
-
在 RAF 出现前,前端动画主要依赖 setTimeout/setInterval,但二者存在明显缺陷:
- 时间不准:回调执行时间受主线程任务阻塞影响 (如 JS 计算、DOM 操作),无法保证与浏览器刷新同步;
- 性能浪费:即使页面隐藏 (后台标签),定时器仍会持续执行,消耗 CPU 资源;
- 撕裂风险:若动画帧频率与浏览器刷新频率 (通常 60Hz,即 16.67ms / 帧) 不匹配,会出现画面撕裂;
-
RAF 的解决思路:
- 同步刷新频率:浏览器每次刷新页面 (约 16.67ms 一次) 前,会主动调用 RAF 注册的回调函数,确保动画帧与刷新周期完全同步;
- 智能节流:当页面隐藏 (如切换标签) 时,RAF 会暂停执行,恢复显示后继续,避免无效消耗;
- 自适应帧率:若设备刷新频率不是 60Hz (如高刷屏 120Hz、低性能设备 30Hz),RAF 会自动适配,回调执行频率与设备刷新频率一致;
-
关键细节:
- 浏览器刷新频率:由设备决定 (60Hz 是主流,1 秒刷新 60 次,每帧间隔 ≈16.67ms);
- RAF 回调的执行时机:浏览器重绘 (repaint) 之前;
- 回调参数:会传入一个高精度时间戳 (DOMHighResTimeStamp),表示当前帧的开始时间 (单位:ms,精度达微秒级);
基本用法
-
语法
// 注册动画回调,返回一个唯一 ID(用于取消) const requestId = requestAnimationFrame(callback); // 取消动画(类似 clearTimeout) cancelAnimationFrame(requestId); -
回调函数:回调函数接收一个参数 timestamp (高精度时间戳),用于计算动画进度 (避免依赖 Date.now() 或 setTimeout 的时间差,更准确):
function animate(timestamp) { // timestamp:当前帧的开始时间(从页面加载后开始计时) console.log('当前帧时间戳:', timestamp); // 动画逻辑(如 DOM 样式修改、Canvas 绘制等) element.style.transform = `translateX(${x}px)`; // 递归调用,实现连续动画 requestId = requestAnimationFrame(animate); } // 启动动画 let requestId = requestAnimationFrame(animate); // 停止动画(如按钮点击时) function stopAnimation() { cancelAnimationFrame(requestId); } -
简单示例:平滑移动 DOM 元素
<div id="box" style="width: 50px; height: 50px; background: red; position: absolute; top: 100px;"></div> <button onclick="startAnimation()">开始动画</button> <button onclick="stopAnimation()">停止动画</button> <script> const box = document.getElementById('box'); let x = 0; let requestId; // 动画回调:每帧移动 2px function animate(timestamp) { x += 2; box.style.transform = `translateX(${x}px)`; // 边界判断:超过窗口宽度则重置 if (x > window.innerWidth - 50) x = 0; // 继续下一帧 requestId = requestAnimationFrame(animate); } // 启动 function startAnimation() { requestId = requestAnimationFrame(animate); } // 停止 function stopAnimation() { cancelAnimationFrame(requestId); } </script>
核心特性
-
执行频率与刷新频率同步
60Hz 设备:回调每 ≈16.67ms 执行一次 (1000ms/60);120Hz 高刷屏:每 ≈8.33ms 执行一次;低性能设备(30Hz):每 ≈33.33ms 执行一次;- 优势:动画流畅度与设备性能匹配,不会出现 “跳帧”;
-
后台标签页暂停执行:当页面切换到后台标签时,RAF 会暂停回调执行,直到页面重新激活;这是 setTimeout/setInterval 不具备的特性,能显著节省 CPU 资源 (尤其对长时间运行的动画);
-
高精度时间戳:回调参数 timestamp 是 DOMHighResTimeStamp 类型,精度远高于 Date.now() (前者精度达 1µs,后者约 1ms);用于计算动画进度时,能避免时间误差累积,确保动画匀速;
-
优先级高于定时器:浏览器会优先处理 RAF 回调 (属于 “渲染任务”),而 setTimeout/setInterval 属于 “宏任务”,执行优先级更低;因此 RAF 动画的响应速度和稳定性优于定时器;
-
兼容性:
- 支持所有现代浏览器 (Chrome、Firefox、Safari、Edge);
- IE 10+ 支持 (但存在少量兼容性问题,需 polyfill);
与 setTimeout/setInterval 的对比
| 特性 | requestAnimationFrame | setTimeout/setInterval |
|---|---|---|
| 执行时机 | 浏览器刷新前 (同步刷新频率) | 主线程空闲时 (时间不准) |
| 后台执行 | 暂停执行 | 继续执行 (浪费资源) |
| 时间精度 | 高精度时间戳 (µs 级) | 低精度 (ms 级) |
| 动画流畅度 | 与刷新频率同步 (无撕裂) | 可能跳帧、撕裂 |
| 优先级 | 渲染优先级 (高) | 宏任务优先级 (低) |
| 用途 | 视觉动画 (DOM/Canvas/SVG) | 非动画定时任务 (如接口轮询) |
常见使用场景
DOM 动画(如位移、缩放、透明度)
-
替代 setTimeout 修改 style 属性,避免布局抖动 (layout thrashing),同时保证流畅度;
-
示例:
<body> <div class="control-panel"> <button id="startBtn">启动动画</button> <button id="pauseBtn" disabled>暂停动画</button> <button id="resetBtn">重置</button> <p>说明:绿色盒子用 RAF 实现,红色盒子用 setTimeout 实现(16ms 间隔模拟 60Hz)</p> </div> <div class="animation-container"> <!-- RAF 动画盒子 --> <div class="animation-group"> <div class="box raf-box" id="rafBox">RAF</div> <div class="label">requestAnimationFrame</div> </div> <!-- setTimeout 动画盒子 --> <div class="animation-group"> <div class="box timeout-box" id="timeoutBox">Timeout</div> <div class="label">setTimeout (16ms)</div> </div> </div> <script> // 1. 获取 DOM 元素 const rafBox = document.getElementById('rafBox'); const timeoutBox = document.getElementById('timeoutBox'); const startBtn = document.getElementById('startBtn'); const pauseBtn = document.getElementById('pauseBtn'); const resetBtn = document.getElementById('resetBtn'); // 2. 动画配置(统一参数,确保对比公平) const config = { duration: 3000, // 动画总时长(3秒) startX: 0, // 初始位移 X endX: 500, // 目标位移 X startScale: 1, // 初始缩放 endScale: 1.5, // 目标缩放 startOpacity: 0.5, // 初始透明度 endOpacity: 1, // 目标透明度 }; // 3. 动画状态管理 let rafId = null; // RAF 动画 ID let timeoutId = null; // setTimeout 动画 ID let rafStartTime = null;// RAF 动画开始时间 let timeoutStartTime = null; // setTimeout 动画开始时间 let isPaused = false; // 是否暂停 // ------------------------------ // RAF 动画实现(推荐方案) // ------------------------------ function rafAnimate(timestamp) { // 暂停状态直接返回 if (isPaused) return; // 初始化开始时间(第一帧触发) if (!rafStartTime) rafStartTime = timestamp; // 计算动画进度(0 ~ 1) const elapsed = timestamp - rafStartTime; const progress = Math.min(elapsed / config.duration, 1); // 进度不超过 1 // 计算当前帧的属性值(线性插值) const currentX = config.startX + (config.endX - config.startX) * progress; const currentScale = config.startScale + (config.endScale - config.startScale) * progress; const currentOpacity = config.startOpacity + (config.endOpacity - config.startOpacity) * progress; // 应用样式(仅修改 transform 和 opacity,避免布局抖动) rafBox.style.transform = `translateX(${currentX}px) scale(${currentScale})`; rafBox.style.opacity = currentOpacity; // 动画未结束,继续请求下一帧 if (progress < 1) { rafId = requestAnimationFrame(rafAnimate); } else { // 动画结束,重置按钮状态 resetControlState(); } } // ------------------------------ // setTimeout 动画实现(对比方案) // ------------------------------ function timeoutAnimate() { // 暂停状态直接返回 if (isPaused) return; // 初始化开始时间 if (!timeoutStartTime) timeoutStartTime = Date.now(); // 计算动画进度 const elapsed = Date.now() - timeoutStartTime; const progress = Math.min(elapsed / config.duration, 1); // 计算当前帧的属性值 const currentX = config.startX + (config.endX - config.startX) * progress; const currentScale = config.startScale + (config.endScale - config.startScale) * progress; const currentOpacity = config.startOpacity + (config.endOpacity - config.startOpacity) * progress; // 应用样式(与 RAF 逻辑一致) timeoutBox.style.transform = `translateX(${currentX}px) scale(${currentScale})`; timeoutBox.style.opacity = currentOpacity; // 动画未结束,继续设置定时器(16ms 模拟 60Hz) if (progress < 1) { timeoutId = setTimeout(timeoutAnimate, 16); } else { resetControlState(); } } // ------------------------------ // 控制逻辑 // ------------------------------ // 启动动画 startBtn.addEventListener('click', () => { if (isPaused) { // 恢复暂停的动画(重新初始化开始时间,补偿暂停时间) rafStartTime = null; // RAF 会自动用当前 timestamp 计算 timeoutStartTime = Date.now() - (Date.now() - timeoutStartTime); // 保持已流逝时间 isPaused = false; rafId = requestAnimationFrame(rafAnimate); timeoutId = setTimeout(timeoutAnimate, 16); } else { // 重置状态,启动新动画 resetBoxStyle(); rafStartTime = null; timeoutStartTime = null; rafId = requestAnimationFrame(rafAnimate); timeoutId = setTimeout(timeoutAnimate, 16); } // 更新按钮状态 startBtn.disabled = true; pauseBtn.disabled = false; }); // 暂停动画 pauseBtn.addEventListener('click', () => { isPaused = true; cancelAnimationFrame(rafId); // 取消 RAF clearTimeout(timeoutId); // 取消 setTimeout startBtn.disabled = false; pauseBtn.disabled = true; startBtn.textContent = '恢复动画'; }); // 重置动画 resetBtn.addEventListener('click', () => { // 停止所有动画 cancelAnimationFrame(rafId); clearTimeout(timeoutId); // 重置样式和状态 resetBoxStyle(); resetControlState(); isPaused = false; rafStartTime = null; timeoutStartTime = null; startBtn.textContent = '启动动画'; }); // 重置盒子样式到初始状态 function resetBoxStyle() { rafBox.style.transform = `translateX(${config.startX}px) scale(${config.startScale})`; rafBox.style.opacity = config.startOpacity; timeoutBox.style.transform = `translateX(${config.startX}px) scale(${config.startScale})`; timeoutBox.style.opacity = config.startOpacity; } // 重置按钮状态 function resetControlState() { startBtn.disabled = false; pauseBtn.disabled = true; startBtn.textContent = '启动动画'; } </script> </body>
Canvas/SVG 动画
-
Canvas 游戏、数据可视化图表 (如折线图动态加载)、SVG 路径动画等,依赖帧同步的场景;
-
示例:Canvas 圆形移动动画
<canvas id="canvas" style="display: block; width: 100%; height: 250px; max-width: 100vw;"></canvas> <script> const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // 核心配置抽离(便于后续修改) const config = { circle: { y: 100, radius: 50, color: 'blue', speed: 2 }, canvasHeight: 300 // 与样式高度一致 }; // 初始化位置 let x = -config.circle.radius; // 从画布外开始,动画更流畅 let requestId; /** * 调整画布实际尺寸(关键:解决样式拉伸导致的图形变形) */ function resizeCanvas() { // 让画布实际像素尺寸匹配显示尺寸 const displayWidth = canvas.clientWidth; const displayHeight = canvas.clientHeight || config.canvasHeight; canvas.width = displayWidth; canvas.height = displayHeight; } /** * 绘制圆形(单一职责,便于复用和修改) */ function drawCircle() { ctx.beginPath(); // 使用配置项,避免硬编码 ctx.arc( x, config.circle.y, config.circle.radius, 0, Math.PI * 2, false // 明确绘制方向(默认false,显式声明更清晰) ); ctx.fillStyle = config.circle.color; ctx.fill(); ctx.closePath(); // 关闭路径,提升性能 } /** * 动画主函数 */ function animate() { // 清空画布(使用配置的高度,更灵活) ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制+更新位置 drawCircle(); x += config.circle.speed; // 边界判断优化(从画布外完全移出后重置,动画更自然) if (x > canvas.width + config.circle.radius) { x = -config.circle.radius; } requestId = requestAnimationFrame(animate); } /** * 初始化+事件监听(确保响应式) */ function init() { // 初始调整尺寸 resizeCanvas(); // 窗口大小变化时重新调整 window.addEventListener('resize', resizeCanvas); // 启动动画 animate(); } // 启动应用 init(); /** * 可选:提供停止动画的方法(增强可控性) */ function stopAnimation() { cancelAnimationFrame(requestId); } // 如需停止:stopAnimation(); </script>
滚动动画(如平滑滚动到指定位置)
-
替代 scrollTo 的瞬间滚动,实现平滑过渡:
/** * 原生平滑滚动函数:使页面从当前位置平滑滚动到指定垂直位置 * @param {number} targetTop - 目标滚动位置(单位:px,相对于文档顶部的距离) */ function smoothScroll(targetTop) { // 1. 记录滚动起始状态 // window.scrollY:获取当前页面垂直滚动距离(现代浏览器支持,单位px) const startTop = window.scrollY; // 计算需要滚动的总距离:目标位置 - 起始位置(可正可负,正=向下滚,负=向上滚) const distance = targetTop - startTop; // 动画总时长:固定 500ms(0.5秒),可根据需求调整 const duration = 500; // 存储动画开始的高精度时间戳(RAF回调会传入,后续初始化) let startTimestamp; /** * RAF 动画帧回调函数:每帧执行一次,更新滚动位置 * @param {DOMHighResTimeStamp} timestamp - RAF 传入的高精度时间戳(微秒级,从页面加载后开始计时) */ function animate(timestamp) { // 2. 初始化动画开始时间(仅第一帧执行) // 第一次调用animate时,startTimestamp为undefined,将当前帧的timestamp作为动画开始时间 if (!startTimestamp) startTimestamp = timestamp; // 3. 计算动画进度 // elapsed:动画已执行的时间(当前帧时间 - 开始时间,单位ms) const elapsed = timestamp - startTimestamp; // progress:动画进度比例(0~1) // elapsed / duration:计算已执行时间占总时长的比例 // Math.min(..., 1):确保进度不会超过1(避免动画结束后继续滚动) const progress = Math.min(elapsed / duration, 1); // 4. 缓动函数(ease-in-out 先慢后快再慢) // 核心作用:将线性进度(progress)转换为非线性进度,让滚动更自然(模拟物理运动的加速度变化) const easedProgress = progress < 0.5 ? 4 * progress * progress * progress // 前半段(progress 0~0.5):加速阶段(立方曲线,斜率逐渐增大) : 1 - Math.pow(-2 * progress + 2, 3) / 2; // 后半段(progress 0.5~1):减速阶段(反向立方曲线,斜率逐渐减小) // 5. 计算并执行当前帧的滚动位置 // startTop + distance * easedProgress:当前应滚动到的位置(起始位置 + 总距离 * 缓动后的进度) // window.scrollTo(x, y):滚动页面到指定坐标(x=0表示水平不滚动,y为垂直滚动位置) window.scrollTo(0, startTop + distance * easedProgress); // 6. 循环请求下一帧(动画未结束时继续) // 当已执行时间 < 总时长时,继续调用RAF,直到动画完成 if (elapsed < duration) { requestAnimationFrame(animate); } } // 7. 启动动画(触发第一帧执行) // 首次调用requestAnimationFrame,传入animate回调,开启滚动动画 requestAnimationFrame(animate); } // 调用函数:平滑滚动到页面顶部(targetTop=0,即文档顶部距离为0的位置) smoothScroll(0); -
ease-in-out 缓动函数解析 (怎么保证在 500 毫秒内完成指定距离的移动)
- 无论速度怎么变,必须在 500ms 时刚好滚动完 distance 距离;
- ease-in-out 缓动函数原理:非线性进度映射,逐段拆解公式(结合数值理解)
时间点 elapsed progress(线性时间进度) easedProgress(非线性运动进度) 说明(滚动节奏) 0ms 0 0 4*0³ = 0 起点,速度为 0(静止) 125ms 125 0.25(25% 时间) 4*(0.25)³ = 4*0.015625 = 0.0625
(6.25% 距离)慢加速,只走了 6.25% 距离 250ms 250 0.5(50% 时间) 4*(0.5)³ = 4*0.125 = 0.5
(50% 距离)加速结束,刚好走了 50% 距离 375ms 375 0.75(75% 时间) 1 - ((-2*0.75+2)³ )/2 = 1 - (0.5³)/2 = 1 - 0.0625 = 0.9375
(93.75% 距离)减速阶段,剩余 6.25% 距离 500ms 500 1(100% 时间) 1 - ((-2*1+2)³ )/2 = 1 - 0 = 1
(100% 距离)终点,速度回归 0
动画性能优化(如节流)
-
对于 resize、scroll 等高频事件,若需要在事件触发时执行视觉更新 (如调整 DOM 尺寸),可结合 RAF 节流,减少不必要的渲染;
-
示例:
let resizeRequestId; window.addEventListener('resize', () => { // 取消上一次未执行的回调,避免重复渲染 cancelAnimationFrame(resizeRequestId); // 注册新回调,确保只在下次刷新时执行一次 resizeRequestId = requestAnimationFrame(() => { console.log('窗口尺寸变化,执行 DOM 调整'); // 如:更新响应式布局、重新计算图表尺寸等 }); });
兼容性处理与 Polyfill
-
兼容性现状:
- 现代浏览器 (Chrome 24+、Firefox 23+、Safari 6.1+、Edge 12+) 完全支持;
- IE 10+ 支持,但存在两个问题:1. 后台标签页不会暂停;2. 时间戳精度较低 (ms 级);
- 如需兼容 IE 9 及以下,需使用 Polyfill;
-
通用 Polyfill (降级为 setTimeout,但模拟 RAF 的时间戳和执行频率);
// 检测浏览器是否原生支持 requestAnimationFrame // 若不支持(如 IE9 及以下),则手动实现兼容版本 if (!window.requestAnimationFrame) { // 1. 存储上一帧的执行时间戳(用于计算当前帧的延迟时间) // 初始值为 0,代表第一帧执行前无历史记录 let lastTime = 0; // 2. 手动挂载 requestAnimationFrame 到 window 对象,模拟原生 API window.requestAnimationFrame = function(callback) { // 3. 获取当前时间戳(毫秒级,兼容所有浏览器) // Date.now() 返回当前时间与 1970-01-01 UTC 的毫秒差 const currTime = Date.now(); // 4. 计算当前帧应该延迟多久执行(核心逻辑:模拟 60Hz 帧率) // 目标:让回调执行频率接近 60Hz(每帧约 16.67ms,取整为 16ms 简化计算) // (currTime - lastTime):上一帧到当前的时间间隔(可能因主线程阻塞变长) // 16 - 间隔时间:若上一帧执行间隔小于 16ms,补全剩余延迟;若大于 16ms,延迟设为 0(避免卡顿累积) // Math.max(0, ...):确保延迟时间非负(防止特殊情况出现负数延迟) const timeToCall = Math.max(0, 16 - (currTime - lastTime)); // 5. 用 setTimeout 模拟 RAF 的"下一帧执行"行为 // 延迟 timeToCall 毫秒后执行回调,模拟浏览器刷新周期 const id = setTimeout(() => { // 关键:传入模拟的高精度时间戳(与原生 RAF 行为对齐) // 原生 RAF 回调参数是 DOMHighResTimeStamp(微秒级),这里用 currTime + timeToCall 模拟 // 目的:让使用 RAF 的动画逻辑(依赖时间戳计算进度)无需修改,直接兼容 callback(currTime + timeToCall); }, timeToCall); // 6. 更新上一帧执行时间戳(为下一帧计算做准备) // 存储当前帧的"计划执行时间"(而非实际执行时间),确保帧率稳定 lastTime = currTime + timeToCall; // 7. 返回定时器 ID(与原生 RAF 一致) // 用于后续通过 cancelAnimationFrame 取消当前帧的回调 return id; }; // 8. 实现配套的 cancelAnimationFrame 方法(模拟原生取消逻辑) // 原生 cancelAnimationFrame 接收 RAF 返回的 ID 并取消回调,这里直接复用 clearTimeout window.cancelAnimationFrame = function(id) { clearTimeout(id); }; }
注意事项与最佳实践
避免在回调中执行 heavy 任务
-
RAF 回调执行在 “渲染前”,若回调中包含复杂 JS 计算、大量 DOM 操作 (如修改布局属性 width/height),会阻塞渲染,导致动画卡顿;
-
最佳实践:
- 复杂计算移到 Web Worker 中;
- 避免在回调中频繁读取 / 修改布局属性 (如 offsetWidth + style.width,会触发强制回流);
及时取消不需要的动画
-
动画结束后 (或组件卸载时),务必调用 cancelAnimationFrame 取消回调,否则会导致内存泄漏 (尤其在单页应用中);
-
示例代码:
<template> <div class="animated-box-container"> <!-- 控制按钮 --> <button @click="toggleAnimation" class="control-btn" > {{ isRunning ? '停止动画' : '启动动画' }} </button> <!-- 动画元素 --> <div ref="boxRef" class="box" /> </div> </template> <script setup> import { ref, onUnmounted } from 'vue'; // 1. 存储 RAF 请求 ID(关键:用 ref 保持引用,不会被重渲染重置) const requestIdRef = ref(null); // 存储盒子水平位置(用 ref 避免响应式依赖导致的不必要重渲染) const xRef = ref(0); // 动画运行状态(响应式,控制按钮文本) const isRunning = ref(false); // 绑定动画元素 DOM(通过 ref 获取,避免直接操作 document) const boxRef = ref(null); // 2. 核心动画回调函数 const animate = () => { // 更新位置:每帧移动 2px,超出窗口宽度后重置 xRef.value += 2; const maxX = window.innerWidth - 50; // 盒子宽度 50px,避免超出窗口 if (xRef.value >= maxX) xRef.value = 0; // 安全操作 DOM:判断元素是否存在(防止组件卸载后回调仍执行) if (boxRef.value) { boxRef.value.style.transform = `translateX(${xRef.value}px)`; } // 递归请求下一帧,并存入请求 ID(关键:后续用于取消) requestIdRef.value = requestAnimationFrame(animate); }; // 3. 启动/停止动画切换 const toggleAnimation = () => { if (isRunning.value) { // 停止动画:取消 RAF,重置状态 cancelAnimationFrame(requestIdRef.value); requestIdRef.value = null; // 重置 ID,避免重复取消 } else { // 启动动画:避免重复启动(防止多个 RAF 回调同时执行) if (!requestIdRef.value) { animate(); } } // 切换运行状态 isRunning.value = !isRunning.value; }; // 4. 组件卸载时取消 RAF(核心防泄漏逻辑!) onUnmounted(() => { if (requestIdRef.value) { cancelAnimationFrame(requestIdRef.value); console.log('Vue 组件卸载:已取消 RAF,避免内存泄漏'); } }); </script> <style scoped> .animated-box-container { padding: 20px; } .control-btn { padding: 8px 16px; margin-bottom: 20px; cursor: pointer; background: #42b983; color: white; border: none; border-radius: 4px; transition: background 0.2s; } .control-btn:hover { background: #359469; } .box { width: 50px; height: 50px; background: #e53e3e; position: absolute; border-radius: 8px; /* 开启硬件加速,优化动画性能 */ will-change: transform; } </style>
结合 CSS 动画的取舍
-
CSS 动画 (transition/animation) 由浏览器主线程外的合成线程处理,性能通常优于 RAF (尤其复杂动画);但 CSS 动画灵活性较低,若需要动态修改动画参数 (如根据用户输入调整速度、路径),RAF 更合适;
-
最佳实践:简单动画用 CSS,复杂 / 动态动画用 RAF;
高刷屏适配
-
高刷屏 (120Hz) 下,RAF 回调执行频率翻倍,若动画逻辑未优化,可能导致 CPU 占用过高;可通过 “帧采样” 限制实际动画更新频率;
-
示例代码:
// 1. 存储上一次执行动画逻辑的时间戳(初始值为 0,代表首次执行前无历史记录) // 作用:通过计算与当前帧时间戳的差值,判断是否达到目标执行间隔 let lastUpdateTime = 0; // 2. 动画目标执行间隔(单位:ms),对应 60Hz 帧率(1000ms/60 ≈ 16.67ms) // 核心目的:强制限制动画逻辑每 ~16.67ms 执行一次,无论设备实际刷新率是多少 const updateInterval = 16.67; /** * 动画帧回调函数(由 requestAnimationFrame 驱动) * @param {DOMHighResTimeStamp} timestamp - RAF 传入的高精度时间戳(微秒级,当前帧开始时间) */ function animate(timestamp) { // 3. 帧率限制核心判断:当前帧时间戳 - 上一次执行时间 ≥ 目标间隔(16.67ms) // 只有满足条件时,才执行动画逻辑(确保每 ~16.67ms 仅执行一次) if (timestamp - lastUpdateTime >= updateInterval) { // 4. 实际动画逻辑:每满足一次间隔,执行一次状态更新 x += 2; // 假设 x 是全局变量,存储盒子水平位移(每 16.67ms 移动 2px,对应速度 ~120px/秒) box.style.transform = `translateX(${x}px)`; // 应用位移到 DOM 元素(避免回流,性能最优) // 5. 更新上一次执行时间戳:将当前帧时间戳存入,为下一帧判断做准备 // 关键:用当前帧的 timestamp 而非 Date.now(),确保时间精度(与 RAF 原生行为对齐) lastUpdateTime = timestamp; } // 6. 递归请求下一帧:无论本次是否执行动画逻辑,都持续请求下一次 RAF 回调 // 目的:保持动画循环不中断,确保高刷屏下仍能持续监听时间戳,精准控制执行时机 requestAnimationFrame(animate); }
面试题
requestAnimationFrame 适合大数据渲染吗
-
不适合直接用于 “大规模原始数据的一次性渲染”,但适合用于 “大数据的分批增量渲染” 或 “数据可视化动画”—— 核心取决于 「数据量大小」 和 「渲染方式」;
-
RAF 有个硬性约束:回调执行时间必须控制在 16.67ms 内 (60Hz 设备)—— 如果回调内逻辑耗时过长,会阻塞渲染,导致页面卡顿、掉帧 (超过 16.67ms 未完成,浏览器会跳过当前帧);
-
大数据渲染的核心痛点:
- 一次性创建大量 DOM/Canvas 元素,JS 计算 + DOM 操作耗时远超 16.67ms (60Hz 设备);
- 频繁触发回流 (Reflow)/ 重绘(Repaint),浏览器需要重新计算布局和绘制,进一步加剧卡顿;
- 内存占用过高 (大量 DOM 节点无法被 GC 回收)。
-
RAF 不适合 “一次性大数据渲染” 的原因:
回调执行超时,引发掉帧:假设创建 1 个 DOM 元素耗时 0.1ms,10000 个就需要 1000ms (1 秒)—— 远超 16.67ms (60Hz 设备) 的帧间隔;RAF 回调超时后,浏览器会跳过当前帧,页面出现明显卡顿 (用户看到 “白屏” 或 “不动”);回流 / 重绘风暴:一次性修改大量 DOM 元素,会触发浏览器多次回流 (比如循环中频繁设置 width/height/position),即使使用 transform 等合成属性,大量元素的初始创建仍会引发批量重绘,性能开销极大;
-
RAF 适合 “大数据分批增量渲染”(正确用法):
核心思路:分批处理 + RAF 调度
- 数据分片:将 10000 条数据拆分成每批 100 条(根据实际性能调整批次大小);
- RAF 调度:每帧 (60hz 16.67ms) 渲染一批数据,避免单次耗时过长;
- 减少回流:批量创建 DOM 后一次性插入 (如用 DocumentFragment),避免循环中频繁 appendChild;
<!-- 大数据列表容器 --> <div id="bigList" style="height: 500px; overflow: auto;"></div> <script> // 模拟 10000 条大数据 const bigData = new Array(10000).fill(0).map((_, index) => ({ id: index, content: `大数据条目 ${index + 1} - 这是一条模拟的长文本数据,用于测试分批渲染性能` })); const container = document.getElementById('bigList'); const batchSize = 100; // 每批渲染 100 条(关键:控制单批耗时 < 16.67ms) let currentIndex = 0; // 当前渲染到的索引 /** * 单批渲染逻辑:渲染从 currentIndex 开始的 batchSize 条数据 */ function renderBatch() { // 创建文档片段(DocumentFragment):批量存储 DOM,避免频繁 append 触发回流 const fragment = document.createDocumentFragment(); const endIndex = Math.min(currentIndex + batchSize, bigData.length); // 渲染当前批次数据 for (let i = currentIndex; i < endIndex; i++) { const item = bigData[i]; const div = document.createElement('div'); // 样式优化:避免触发回流的属性,用 padding/margin 替代 top/left div.style.cssText = ` padding: 8px; border-bottom: 1px solid #eee; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; div.textContent = item.content; fragment.appendChild(div); // 先插入片段,不触发回流 } // 一次性插入文档片段:仅触发一次回流 container.appendChild(fragment); // 更新当前索引,准备下一批渲染 currentIndex = endIndex; // 若未渲染完,继续请求下一帧渲染下一批 if (currentIndex < bigData.length) { requestAnimationFrame(renderBatch); } else { console.log('大数据渲染完成!'); } } // 启动分批渲染(用 RAF 调度,确保每帧只渲染一批) requestAnimationFrame(renderBatch); </script> -
RAF 还适合 “大数据可视化动画”
如果大数据渲染是「动态可视化场景」(如实时更新的折线图、柱状图、粒子流),RAF 是最优选择:
- 场景示例:实时展示 1000 个传感器的数据流变化、股票大盘 5000+ 支股票的价格波动;
- 核心优势:
- 与刷新频率同步,动画流畅 (无跳帧);
- 每帧仅更新 “变化的数据” (而非全量重绘),比如只修改 Canvas 中变化的节点,或 DOM 元素的 transform/opacity,避免全量回流;
- 后台标签页暂停,节省资源 (避免后台时仍持续渲染);
<canvas id="dataCanvas" style="border: 1px solid #eee; width: 100%; height: 280px;"></canvas> <script> const canvas = document.getElementById('dataCanvas'); const ctx = canvas.getContext('2d'); const nodeCount = 1000; // 1000 个数据节点(大数据量) const nodes = []; // 初始化 1000 个随机位置的节点 for (let i = 0; i < nodeCount; i++) { nodes.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, speedX: (Math.random() - 0.5) * 2, // 随机水平速度 speedY: (Math.random() - 0.5) * 2 // 随机垂直速度 }); } /** * 每帧更新并渲染节点(RAF 驱动) */ function animateNodes(timestamp) { // 1. 清空画布(仅一次重绘) ctx.clearRect(0, 0, canvas.width, canvas.height); // 2. 更新并渲染 1000 个节点(仅修改 Canvas 像素,无 DOM 回流) nodes.forEach(node => { // 更新节点位置(大数据计算) node.x += node.speedX; node.y += node.speedY; // 边界碰撞检测 if (node.x < 0 || node.x > canvas.width) node.speedX *= -1; if (node.y < 0 || node.y > canvas.height) node.speedY *= -1; // 渲染节点 ctx.beginPath(); ctx.arc(node.x, node.y, 2, 0, Math.PI * 2); ctx.fillStyle = 'rgba(66, 185, 131, 0.7)'; ctx.fill(); }); // 3. 继续下一帧动画 requestAnimationFrame(animateNodes); } // 启动可视化动画 requestAnimationFrame(animateNodes); </script>
为什么用 requestAnimationFrame 时,不建议在回调中修改 offsetTop/offsetLeft 等属性?
-
当你在同一帧中 * “交替读取布局属性 + 修改样式” * 时,浏览器会被迫频繁重新计算元素布局 (即回流),而非批量优化,这就是 “中间触发回流” (也叫 “强制同步布局” 或 Layout Thrashing);
-
先明确两个关键概念:
- 布局属性 (Layout Properties):需要浏览器计算才能得到的属性,比如 offsetTop、offsetLeft、clientWidth、scrollHeight、getComputedStyle() 等;读取这些属性时,浏览器必须确保拿到的是 “当前最新的布局结果”;
- 样式修改 (Style Changes):修改会影响元素布局 / 尺寸的 CSS 属性,比如 width、height、margin、top、left、display 等;这些修改不会立即触发回流,浏览器会默认将其 “暂存”,等待合适时机 (如下一帧渲染前) 批量执行,避免重复计算 —— 这是浏览器的优化机制;
-
一步步拆解 “中间触发回流” 的触发过程:当在 requestAnimationFrame 回调中交替执行 “读布局属性” 和 “改样式” 时,浏览器的优化机制会失效,被迫在每次 “读” 之后立即执行 “回流”,具体步骤如下:
- 正常优化场景 (先读再写,无中间回流),浏览器会按优化逻辑执行,“读” 和 “写” 完全分离,浏览器只需要计算 1 次布局,无额外开销;
requestAnimationFrame(() => { // 第一步:批量“读”——触发1次回流,获取所有最新布局 const top = div.offsetTop; const left = div.offsetLeft; const width = div.clientWidth; // 第二步:批量“写”——仅暂存样式修改,不触发回流 div.style.top = `${top + 10}px`; div.style.left = `${left + 10}px`; div.style.width = `${width + 20}px`; }); // 最终:仅在当前帧渲染前,执行1次回流(应用所有样式修改) - 中间触发回流场景 (交替读 - 写 - 读 - 写),如果在 “读” 和 “写” 之间穿插执行,浏览器会被迫反复计算布局:
requestAnimationFrame(() => { // 第一次“读”:触发第1次回流(获取初始布局) const top = div.offsetTop; // 第一次“写”:修改样式(浏览器暂存,本应批量优化) div.style.top = `${top + 10}px`; // 第二次“读”:需要获取最新的 left 值 // 但此时浏览器发现:之前有未应用的样式修改(top 变了) // 为了返回“最新、准确”的 left 值,浏览器必须立即执行回流(应用刚才的 top 修改) const left = div.offsetLeft; // 触发第2次回流 // 第二次“写”:修改 left div.style.left = `${left + 10}px`; // 第三次“读”:需要获取最新的 width 值 // 浏览器又发现有未应用的 left 修改,再次触发回流 const width = div.clientWidth; // 触发第3次回流 // 第三次“写”:修改 width div.style.width = `${width + 20}px`; }); // 最终:同一回调中触发了3次回流,而非1次!
- 正常优化场景 (先读再写,无中间回流),浏览器会按优化逻辑执行,“读” 和 “写” 完全分离,浏览器只需要计算 1 次布局,无额外开销;
-
核心原因:浏览器的 “一致性保障”:
- 浏览器在处理 “读布局属性” 时,有一个核心原则:必须返回当前页面的 “真实、最新布局结果”,不能返回缓存的旧值;
- 当你在 “写样式” 之后立即 “读布局属性” 时:
- 浏览器已经记录了未应用的样式修改 (比如修改了 top);
- 此时读取另一个布局属性 (比如 left),浏览器无法确定 “top 的修改是否会影响 left” (虽然实际可能不影响,但浏览器不会冒险);
- 为了保证返回的 left 是 “最新的真实值”,浏览器只能立即执行回流—— 应用之前所有暂存的样式修改,重新计算布局,然后再返回 left 的值;
- 这种 “写之后立即读” 的操作,会强制浏览器打破 “批量优化” 的逻辑,每一次 “读” 都触发一次回流,这就是 “中间触发回流” 的本质;
🏷 svg
上一篇