概念

requestAnimationFrame (简称 RAF) 是浏览器提供的专门用于动画渲染优化的 API,其核心目标是让动画与浏览器的刷新频率同步,避免卡顿、撕裂,同时最大化节省性能 (尤其在后台标签页时)

为什么需要 RAF?

  1. RAF 出现前,前端动画主要依赖 setTimeout/setInterval,但二者存在明显缺陷:

    1. 时间不准:回调执行时间受主线程任务阻塞影响 (如 JS 计算、DOM 操作),无法保证与浏览器刷新同步;
    2. 性能浪费:即使页面隐藏 (后台标签),定时器仍会持续执行,消耗 CPU 资源;
    3. 撕裂风险:若动画帧频率与浏览器刷新频率 (通常 60Hz,即 16.67ms / 帧) 不匹配,会出现画面撕裂;
  2. RAF 的解决思路:

    1. 同步刷新频率:浏览器每次刷新页面 (约 16.67ms 一次) 前,会主动调用 RAF 注册的回调函数,确保动画帧与刷新周期完全同步;
    2. 智能节流:当页面隐藏 (如切换标签) 时,RAF 会暂停执行,恢复显示后继续,避免无效消耗;
    3. 自适应帧率:若设备刷新频率不是 60Hz (如高刷屏 120Hz、低性能设备 30Hz)RAF 会自动适配,回调执行频率与设备刷新频率一致;
  3. 关键细节:

    1. 浏览器刷新频率:由设备决定 (60Hz 是主流,1 秒刷新 60 次,每帧间隔 ≈16.67ms)
    2. RAF 回调的执行时机:浏览器重绘 (repaint) 之前;
    3. 回调参数:会传入一个高精度时间戳 (DOMHighResTimeStamp),表示当前帧的开始时间 (单位:ms,精度达微秒级)

基本用法

  1. 语法

    // 注册动画回调,返回一个唯一 ID(用于取消)
    const requestId = requestAnimationFrame(callback);
    
    // 取消动画(类似 clearTimeout)
    cancelAnimationFrame(requestId);
    
  2. 回调函数:回调函数接收一个参数 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);
    }
    
  3. 简单示例:平滑移动 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>
    

核心特性

  1. 执行频率与刷新频率同步

    1. 60Hz 设备:回调每 ≈16.67ms 执行一次 (1000ms/60)120Hz 高刷屏:每 ≈8.33ms 执行一次;低性能设备(30Hz):每 ≈33.33ms 执行一次;
    2. 优势:动画流畅度与设备性能匹配,不会出现 “跳帧”;
  2. 后台标签页暂停执行:当页面切换到后台标签时,RAF 会暂停回调执行,直到页面重新激活;这是 setTimeout/setInterval 不具备的特性,能显著节省 CPU 资源 (尤其对长时间运行的动画)

  3. 高精度时间戳:回调参数 timestampDOMHighResTimeStamp 类型,精度远高于 Date.now() (前者精度达 1µs,后者约 1ms);用于计算动画进度时,能避免时间误差累积,确保动画匀速;

  4. 优先级高于定时器:浏览器会优先处理 RAF 回调 (属于 “渲染任务”),而 setTimeout/setInterval 属于 “宏任务”,执行优先级更低;因此 RAF 动画的响应速度和稳定性优于定时器;

  5. 兼容性:

    1. 支持所有现代浏览器 (Chrome、Firefox、Safari、Edge)
    2. IE 10+ 支持 (但存在少量兼容性问题,需 polyfill)

与 setTimeout/setInterval 的对比

特性 requestAnimationFrame setTimeout/setInterval
执行时机 浏览器刷新前 (同步刷新频率) 主线程空闲时 (时间不准)
后台执行 暂停执行 继续执行 (浪费资源)
时间精度 高精度时间戳 (µs 级) 低精度 (ms 级)
动画流畅度 与刷新频率同步 (无撕裂) 可能跳帧、撕裂
优先级 渲染优先级 (高) 宏任务优先级 (低)
用途 视觉动画 (DOM/Canvas/SVG) 非动画定时任务 (如接口轮询)

常见使用场景

DOM 动画(如位移、缩放、透明度)

  1. 替代 setTimeout 修改 style 属性,避免布局抖动 (layout thrashing),同时保证流畅度;

  2. 示例:

    <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 动画

  1. Canvas 游戏、数据可视化图表 (如折线图动态加载)SVG 路径动画等,依赖帧同步的场景;

  2. 示例: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>
    

滚动动画(如平滑滚动到指定位置)

  1. 替代 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);
    
  2. ease-in-out 缓动函数解析 (怎么保证在 500 毫秒内完成指定距离的移动)

    1. 无论速度怎么变,必须在 500ms 时刚好滚动完 distance 距离;
    2. 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

动画性能优化(如节流)

  1. 对于 resizescroll 等高频事件,若需要在事件触发时执行视觉更新 (如调整 DOM 尺寸),可结合 RAF 节流,减少不必要的渲染;

  2. 示例:

    let resizeRequestId;
    window.addEventListener('resize', () => {
      // 取消上一次未执行的回调,避免重复渲染
      cancelAnimationFrame(resizeRequestId);
    
      // 注册新回调,确保只在下次刷新时执行一次
      resizeRequestId = requestAnimationFrame(() => {
        console.log('窗口尺寸变化,执行 DOM 调整');
        // 如:更新响应式布局、重新计算图表尺寸等
      });
    });
    

兼容性处理与 Polyfill

  1. 兼容性现状:

    1. 现代浏览器 (Chrome 24+、Firefox 23+、Safari 6.1+、Edge 12+) 完全支持;
    2. IE 10+ 支持,但存在两个问题:1. 后台标签页不会暂停;2. 时间戳精度较低 (ms 级)
    3. 如需兼容 IE 9 及以下,需使用 Polyfill
  2. 通用 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 任务

  1. RAF 回调执行在 “渲染前”,若回调中包含复杂 JS 计算、大量 DOM 操作 (如修改布局属性 width/height),会阻塞渲染,导致动画卡顿;

  2. 最佳实践:

    1. 复杂计算移到 Web Worker 中;
    2. 避免在回调中频繁读取 / 修改布局属性 (如 offsetWidth + style.width,会触发强制回流)

及时取消不需要的动画

  1. 动画结束后 (或组件卸载时),务必调用 cancelAnimationFrame 取消回调,否则会导致内存泄漏 (尤其在单页应用中)

  2. 示例代码:

    <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 动画的取舍

  1. CSS 动画 (transition/animation) 由浏览器主线程外的合成线程处理,性能通常优于 RAF (尤其复杂动画);但 CSS 动画灵活性较低,若需要动态修改动画参数 (如根据用户输入调整速度、路径)RAF 更合适;

  2. 最佳实践:简单动画用 CSS,复杂 / 动态动画用 RAF

高刷屏适配

  1. 高刷屏 (120Hz) 下,RAF 回调执行频率翻倍,若动画逻辑未优化,可能导致 CPU 占用过高;可通过 “帧采样” 限制实际动画更新频率;

  2. 示例代码:

    // 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 适合大数据渲染吗

  1. 不适合直接用于 “大规模原始数据的一次性渲染”,但适合用于 “大数据的分批增量渲染”“数据可视化动画”—— 核心取决于 「数据量大小」「渲染方式」

  2. RAF 有个硬性约束:回调执行时间必须控制在 16.67ms(60Hz 设备)—— 如果回调内逻辑耗时过长,会阻塞渲染,导致页面卡顿、掉帧 (超过 16.67ms 未完成,浏览器会跳过当前帧)

  3. 大数据渲染的核心痛点:

    1. 一次性创建大量 DOM/Canvas 元素,JS 计算 + DOM 操作耗时远超 16.67ms (60Hz 设备)
    2. 频繁触发回流 (Reflow)/ 重绘(Repaint),浏览器需要重新计算布局和绘制,进一步加剧卡顿;
    3. 内存占用过高 (大量 DOM 节点无法被 GC 回收)
  4. RAF 不适合 “一次性大数据渲染” 的原因:

    1. 回调执行超时,引发掉帧:假设创建 1DOM 元素耗时 0.1ms10000 个就需要 1000ms (1 秒)—— 远超 16.67ms (60Hz 设备) 的帧间隔;RAF 回调超时后,浏览器会跳过当前帧,页面出现明显卡顿 (用户看到 “白屏” 或 “不动”)
    2. 回流 / 重绘风暴:一次性修改大量 DOM 元素,会触发浏览器多次回流 (比如循环中频繁设置 width/height/position),即使使用 transform 等合成属性,大量元素的初始创建仍会引发批量重绘,性能开销极大;
  5. RAF 适合 “大数据分批增量渲染”(正确用法):

    核心思路:分批处理 + RAF 调度

    1. 数据分片:将 10000 条数据拆分成每批 100 条(根据实际性能调整批次大小);
    2. RAF 调度:每帧 (60hz 16.67ms) 渲染一批数据,避免单次耗时过长;
    3. 减少回流:批量创建 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>
    
  6. RAF 还适合 “大数据可视化动画”

    如果大数据渲染是「动态可视化场景」(如实时更新的折线图、柱状图、粒子流)RAF 是最优选择:

    1. 场景示例:实时展示 1000 个传感器的数据流变化、股票大盘 5000+ 支股票的价格波动;
    2. 核心优势:
      1. 与刷新频率同步,动画流畅 (无跳帧)
      2. 每帧仅更新 “变化的数据” (而非全量重绘),比如只修改 Canvas 中变化的节点,或 DOM 元素的 transform/opacity,避免全量回流;
      3. 后台标签页暂停,节省资源 (避免后台时仍持续渲染)
    <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 等属性?

  1. 当你在同一帧中 * “交替读取布局属性 + 修改样式” * 时,浏览器会被迫频繁重新计算元素布局 (即回流),而非批量优化,这就是 “中间触发回流” (也叫 “强制同步布局” 或 Layout Thrashing)

  2. 先明确两个关键概念:

    1. 布局属性 (Layout Properties):需要浏览器计算才能得到的属性,比如 offsetTop、offsetLeft、clientWidth、scrollHeight、getComputedStyle() 等;读取这些属性时,浏览器必须确保拿到的是 “当前最新的布局结果”
    2. 样式修改 (Style Changes):修改会影响元素布局 / 尺寸的 CSS 属性,比如 width、height、margin、top、left、display 等;这些修改不会立即触发回流,浏览器会默认将其 “暂存”,等待合适时机 (如下一帧渲染前) 批量执行,避免重复计算 —— 这是浏览器的优化机制;
  3. 一步步拆解 “中间触发回流” 的触发过程:当在 requestAnimationFrame 回调中交替执行 “读布局属性”“改样式” 时,浏览器的优化机制会失效,被迫在每次 “读” 之后立即执行 “回流”,具体步骤如下:

    1. 正常优化场景 (先读再写,无中间回流),浏览器会按优化逻辑执行,“读”“写” 完全分离,浏览器只需要计算 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次回流(应用所有样式修改)
      
    2. 中间触发回流场景 (交替读 - 写 - 读 - 写),如果在 “读”“写” 之间穿插执行,浏览器会被迫反复计算布局:
      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次!
      
  4. 核心原因:浏览器的 “一致性保障”

    1. 浏览器在处理 “读布局属性” 时,有一个核心原则:必须返回当前页面的 “真实、最新布局结果”,不能返回缓存的旧值;
    2. 当你在 “写样式” 之后立即 “读布局属性” 时:
      1. 浏览器已经记录了未应用的样式修改 (比如修改了 top)
      2. 此时读取另一个布局属性 (比如 left),浏览器无法确定 “top 的修改是否会影响 left” (虽然实际可能不影响,但浏览器不会冒险)
      3. 为了保证返回的 left“最新的真实值”,浏览器只能立即执行回流—— 应用之前所有暂存的样式修改,重新计算布局,然后再返回 left 的值;
    3. 这种 “写之后立即读” 的操作,会强制浏览器打破 “批量优化” 的逻辑,每一次 “读” 都触发一次回流,这就是 “中间触发回流” 的本质;
打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

你好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. 概念
  2. 2. 为什么需要 RAF?
  3. 3. 基本用法
  4. 4. 核心特性
  5. 5. 与 setTimeout/setInterval 的对比
  6. 6. 常见使用场景
    1. 6.1. DOM 动画(如位移、缩放、透明度)
    2. 6.2. Canvas/SVG 动画
    3. 6.3. 滚动动画(如平滑滚动到指定位置)
    4. 6.4. 动画性能优化(如节流)
  7. 7. 兼容性处理与 Polyfill
  8. 8. 注意事项与最佳实践
    1. 8.1. 避免在回调中执行 heavy 任务
    2. 8.2. 及时取消不需要的动画
    3. 8.3. 结合 CSS 动画的取舍
    4. 8.4. 高刷屏适配
  9. 9. 面试题
    1. 9.1. requestAnimationFrame 适合大数据渲染吗
    2. 9.2. 为什么用 requestAnimationFrame 时,不建议在回调中修改 offsetTop/offsetLeft 等属性?