JS 动画性能:为什么 requestAnimationFrame 比 setInterval 更加流畅?

各位同仁,各位对前端性能与用户体验充满热情的开发者们,下午好!

今天,我们将深入探讨一个在前端动画领域经常被提及,但其背后原理往往被低估的话题:为什么在 JavaScript 动画中,requestAnimationFrame 会比 setInterval 更加流畅?这不仅仅是一个最佳实践的建议,更是一扇窗口,让我们得以窥见浏览器内部复杂的渲染机制与事件循环的精妙协同。

作为一名编程专家,我的目标是不仅告诉大家“是什么”,更要剖析“为什么”,从底层机制、到实际代码,再到性能考量,一步步揭示这两种动画调度方式的本质差异。

1. 动画的本质与流畅度的追求

在数字世界中,动画是赋予静态内容生命力的魔法。它能吸引用户的注意力,引导用户操作,甚至传达品牌情感。然而,如果动画卡顿、跳帧,不仅会破坏用户体验,更会给用户留下粗糙、不专业的印象。因此,追求动画的“流畅度”是前端性能优化的核心目标之一。

在浏览器中,动画的本质无非是在极短的时间间隔内,连续地改变元素的样式或属性,从而在视觉上产生运动的错觉。实现这一目标,我们有两个主要的 JavaScript 工具:setIntervalrequestAnimationFrame。长久以来,业界普遍推崇后者,那么,这其中的奥秘究竟何在?要理解这一点,我们首先需要构建对浏览器运行机制的基本认知。

2. 深入理解浏览器:渲染流水线与事件循环

浏览器并非一个简单的执行 JavaScript 代码的机器。它是一个高度复杂的系统,其核心任务是解析、渲染网页,并响应用户交互。为了实现流畅的视觉更新,浏览器内部有一套严谨的“渲染流水线”和“事件循环”机制。

2.1 浏览器渲染流水线 (The Critical Rendering Path)

当浏览器接收到 HTML、CSS 和 JavaScript 文件后,它会经历一系列步骤才能将网页呈现在屏幕上。这些步骤通常被称为“渲染流水线”:

  1. DOM (Document Object Model) 构建: 浏览器解析 HTML,构建 DOM 树。DOM 树代表了网页的结构。
  2. CSSOM (CSS Object Model) 构建: 浏览器解析 CSS,构建 CSSOM 树。CSSOM 树代表了网页的样式。
  3. Render Tree (渲染树) 构建: 将 DOM 树和 CSSOM 树合并,构建渲染树。渲染树只包含可见元素及其计算后的样式。display: none 的元素不会进入渲染树。
  4. Layout (布局/回流/重排): 根据渲染树,计算每个可见元素在视口中的精确位置和大小。这个过程通常是自上而下、自左向右进行的。一旦元素的位置或大小发生改变,就需要重新布局。
  5. Paint (绘制/重绘): 将布局好的元素绘制到屏幕上。这涉及到将元素的视觉属性(如颜色、边框、阴影、背景等)转换为屏幕上的像素。
  6. Composite (合成): 将绘制好的图层按照正确的顺序和位置进行合成,最终呈现在用户屏幕上。现代浏览器通常会将页面分解为多个图层,这些图层可以独立地进行绘制和变换,最后由 GPU 进行合成。这大大提高了渲染效率,特别是对于 CSS transformopacity 等属性的动画。

V-sync (垂直同步):理解渲染流水线至关重要的一点是,浏览器渲染并不是随时随地进行的。为了避免“画面撕裂”(即屏幕上同时显示来自不同帧的画面),显示器会以固定的频率(通常是 60Hz,即每秒刷新 60 次)刷新画面。浏览器会尽量将其渲染操作与显示器的 V-sync 信号同步,确保在每个 V-sync 间隔内完成一帧的渲染和合成。这意味着,理想情况下,浏览器每 16.6 毫秒(1000ms / 60fps)会生成一个新帧。

2.2 JavaScript 事件循环 (Event Loop)

浏览器中的 JavaScript 引擎是单线程的,这意味着在任何给定的时间点,它只能执行一个任务。为了处理异步操作(如用户输入、网络请求、定时器等),浏览器引入了“事件循环”机制。

事件循环的核心概念包括:

  • 调用栈 (Call Stack): 存储正在执行的函数。
  • Web APIs (浏览器提供的接口): 浏览器提供的一系列 API,用于处理异步操作,如 setTimeout, setInterval, fetch, DOM 事件等。当调用这些 API 时,它们会将相应的回调函数注册到Web API 环境中,而不是立即执行。
  • 回调队列 (Callback Queue / Task Queue / Macrotask Queue): 当 Web API 完成其异步操作后,它会将对应的回调函数放入回调队列中等待执行。
  • 微任务队列 (Microtask Queue): 在每个宏任务(来自回调队列的任务)执行完毕后,事件循环会检查并清空微任务队列(例如 Promise.then() 的回调)。
  • 事件循环 (Event Loop): 持续地检查调用栈是否为空。如果为空,它会从回调队列中取出一个任务(宏任务),将其推入调用栈执行。在执行完每个宏任务后,它会先清空微任务队列,然后再检查下一个宏任务。

事件循环与渲染的关系: 渲染操作(布局、绘制、合成)本身也是浏览器中的一个重要任务。这些渲染任务通常被视为“宏任务”的一部分,或者更准确地说,它们与宏任务的执行是交错进行的。通常,在一次事件循环迭代中,当所有同步 JavaScript 代码和所有可用的微任务都执行完毕后,浏览器会检查是否有渲染更新的需求。如果有,它就会执行渲染流水线,然后才从宏任务队列中取出下一个任务。

关键点: JavaScript 的执行和渲染操作共享同一个主线程。这意味着,长时间运行的 JavaScript 任务会“阻塞”渲染,导致页面卡顿。

3. setInterval:一个看似简单却充满陷阱的计时器

setInterval 是 JavaScript 中最基础的定时器之一,用于周期性地执行某个函数。

3.1 setInterval 的工作方式

setInterval(callback, delay) 会告诉浏览器:“请在 delay 毫秒后执行 callback 函数,然后每隔 delay 毫秒再次执行。”

代码示例 1:使用 setInterval 进行动画

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>setInterval 动画示例</title>
    <style>
        #box {
            width: 50px;
            height: 50px;
            background-color: dodgerblue;
            position: absolute;
            top: 50px;
            left: 0;
        }
    </style>
</head>
<body>
    <div id="box"></div>

    <script>
        const box = document.getElementById('box');
        let position = 0;
        const speed = 5; // 像素/次

        function animateWithSetInterval() {
            position += speed;
            box.style.left = position + 'px';

            if (position > window.innerWidth - box.offsetWidth) {
                position = 0; // 重置位置
            }
        }

        // 尝试以 16ms 的间隔(接近 60fps)进行动画
        // 实际上,这并不意味着它真的会每 16ms 触发一次视觉更新
        const intervalId = setInterval(animateWithSetInterval, 16);

        // 为了演示,我们可以在某个时候停止它
        // setTimeout(() => {
        //     clearInterval(intervalId);
        //     console.log('setInterval 动画停止');
        // }, 5000);
    </script>
</body>
</html>

在这个例子中,我们试图通过每 16 毫秒更新一次元素的位置来模拟 60 帧每秒的动画效果。

3.2 setInterval 为什么会导致不流畅的动画?

尽管 setInterval 看起来很直观,但在动画场景下,它却充满了缺陷,导致动画卡顿、不流畅,甚至出现“跳帧”现象。

3.2.1 与浏览器渲染周期的脱节

这是 setInterval 导致不流畅的最根本原因。浏览器渲染是与显示器 V-sync 信号同步的,通常是 60Hz。这意味着浏览器每 16.6 毫秒会尝试绘制一个新帧。setInterval 只是简单地将一个任务放入回调队列,它并不关心浏览器何时进行下一次渲染。

  • 情况一:setIntervaldelay 小于 16.6ms (例如 10ms)

    • 在一次 16.6ms 的渲染周期内,setInterval 可能会触发多次回调(例如 16.6ms / 10ms ≈ 1.66 次)。
    • 这意味着在浏览器完成一次渲染之前,JavaScript 可能会多次更新 DOM。
    • 浏览器在渲染时,只会绘制 DOM 的最新状态。之前多次更新的中间状态会被完全丢弃,造成了大量的无效计算和资源浪费。例如,在一个帧内,元素可能从 left: 0px 更新到 left: 5px,然后又更新到 left: 10px。但用户只会在屏幕上看到 left: 10px 的结果,中间的 5px 状态从未被渲染。
    • 这不会使动画更快,反而可能因为 JavaScript 频繁操作 DOM 导致浏览器布局/绘制任务增多,反而拖慢渲染速度,甚至导致掉帧。
  • 情况二:setIntervaldelay 大于 16.6ms (例如 30ms)

    • 在 16.6ms 的渲染周期内,setInterval 可能根本不会触发回调。
    • 这意味着浏览器可能会绘制多个帧,但 DOM 元素的位置却没有更新。
    • 结果就是动画看起来一顿一顿的,因为每隔一帧或几帧才会有一次视觉上的跳动。例如,元素从 left: 0px 直接跳到 left: 10px,然后停顿一个帧,再跳到 left: 20px
  • 情况三:setIntervaldelay 恰好是 16.6ms

    • 即使你尝试将 delay 精确设置为 16.6ms,由于 JavaScript 事件循环的非精确性,以及其他任务可能阻塞主线程,setInterval 的实际触发时间往往会偏离这个值。
    • 轻微的偏离就足以导致与渲染周期的不同步,从而引发上述两种情况的混合问题。
3.2.2 不精确的执行时间与任务堆积

setIntervaldelay 参数表示的是“至少等待这么长时间”,而不是“精确地在这么长时间后执行”。

  • 事件循环的阻塞: 如果在 setInterval 的回调函数执行期间,或者在两次回调之间,有其他耗时的 JavaScript 任务(例如复杂的计算、网络请求回调、用户输入事件处理等)正在执行,那么 setInterval 的下一次回调就会被推迟。它必须等待主线程空闲后才能被推入调用栈并执行。
  • 任务堆积: 如果 setInterval 的回调函数本身执行时间很长,或者 delay 设置得很小,那么在主线程忙碌时,可能会有多个 setInterval 的回调函数被放入回调队列中等待。当主线程空闲时,这些回调函数会一个接一个地被快速执行,而不是按照预期的 delay 间隔执行。这会导致动画在一段时间内看起来静止,然后突然“跳跃”一大段距离,因为一次性应用了多次位置更新。

代码示例 2:演示 setInterval 的不精确性与任务堆积

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>setInterval 不精确性演示</title>
    <style>
        #box {
            width: 50px;
            height: 50px;
            background-color: tomato;
            position: absolute;
            top: 50px;
            left: 0;
            transition: background-color 0.5s; /* 添加一个 CSS 过渡,便于观察 */
        }
    </style>
</head>
<body>
    <div id="box"></div>
    <button id="blockBtn">阻塞主线程 (500ms)</button>
    <p>上一次 `setInterval` 触发时间: <span id="lastIntervalTime"></span> ms</p>
    <p>与预期间隔的误差: <span id="intervalError"></span> ms</p>

    <script>
        const box = document.getElementById('box');
        const blockBtn = document.getElementById('blockBtn');
        const lastIntervalTimeSpan = document.getElementById('lastIntervalTime');
        const intervalErrorSpan = document.getElementById('intervalError');

        let position = 0;
        const speed = 2; // 像素/次
        const desiredInterval = 16; // 期望间隔 16ms
        let lastExecutionTime = performance.now();

        function animateAndLog() {
            const currentTime = performance.now();
            const actualInterval = currentTime - lastExecutionTime;
            const error = actualInterval - desiredInterval;

            lastIntervalTimeSpan.textContent = actualInterval.toFixed(2);
            intervalErrorSpan.textContent = error.toFixed(2);

            // 模拟动画更新
            position += speed;
            box.style.left = position + 'px';
            if (position > window.innerWidth - box.offsetWidth) {
                position = 0; // 重置位置
            }

            // 随机改变颜色,增加视觉反馈
            box.style.backgroundColor = `hsl(${Math.random() * 360}, 70%, 50%)`;

            lastExecutionTime = currentTime;
        }

        const intervalId = setInterval(animateAndLog, desiredInterval);

        blockBtn.addEventListener('click', () => {
            console.log('开始阻塞主线程...');
            const startTime = performance.now();
            // 模拟一个耗时任务
            while (performance.now() - startTime < 500) {
                // 忙等待
            }
            console.log('阻塞结束。');
        });

        // setTimeout(() => {
        //     clearInterval(intervalId);
        //     console.log('setInterval 动画停止');
        // }, 10000);
    </script>
</body>
</html>

点击“阻塞主线程”按钮后,你会发现动画在阻塞期间停滞,并且 intervalError 会显著增大,显示出 setInterval 在主线程繁忙时的不精确性。当阻塞结束后,动画可能会快速跳动一段距离,以弥补丢失的帧。

3.2.3 耗电与后台性能问题

当页面处于后台标签页或最小化时,浏览器通常会对 setInterval 进行节流。例如,Chrome 浏览器对后台标签页的 setInterval 最小间隔可能会增加到 1000ms。虽然这节省了资源,但如果你的动画仍然在后台运行,它就会变得非常慢且不连贯。更重要的是,在某些浏览器或特定条件下,setInterval 仍然可能在后台以较小的间隔运行,白白消耗 CPU 和电池资源,而用户却看不到任何效果。

4. requestAnimationFrame:浏览器为动画而生

requestAnimationFrame (简称 rAF) 是 W3C 专门为动画而设计的 API。它的核心思想是:将动画的调度权交给浏览器。

4.1 requestAnimationFrame 的工作方式

requestAnimationFrame(callback) 会告诉浏览器:“我希望在浏览器下一次重绘之前执行 callback 函数。”

关键特性:

  • 浏览器调度: 浏览器会智能地决定何时执行 callback。它会在渲染流水线的“布局”和“绘制”阶段之间,也就是在浏览器准备更新屏幕内容之前,调用你的回调函数。
  • 同步 V-sync: requestAnimationFrame 的回调执行与浏览器的渲染周期(V-sync)高度同步。这意味着你的 DOM 更新会恰好在浏览器生成新帧之前应用。
  • 时间戳参数: callback 函数会接收一个 DOMHighResTimeStamp 参数,表示当前回调函数被触发的时间。这是一个高精度的时间戳,通常以毫秒为单位,从页面加载开始计算。这个时间戳对于实现时间驱动的动画至关重要。
  • 单次执行: requestAnimationFrame 每次只调度一次。如果需要连续动画,你必须在 callback 函数内部再次调用 requestAnimationFrame,形成一个递归循环。
  • 后台优化: 当页面处于后台标签页、最小化、或者被滚动到屏幕外时,requestAnimationFrame 会自动暂停,从而节省 CPU 和电池资源。当页面再次变为可见时,动画会自动恢复。

代码示例 3:使用 requestAnimationFrame 进行动画

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width=initial-scale=1.0">
    <title>requestAnimationFrame 动画示例</title>
    <style>
        #box {
            width: 50px;
            height: 50px;
            background-color: forestgreen;
            position: absolute;
            top: 50px;
            left: 0;
        }
    </style>
</head>
<body>
    <div id="box"></div>

    <script>
        const box = document.getElementById('box');
        let position = 0;
        const speed = 5; // 像素/次 (这是一个帧速率依赖的速度)
        let animationId; // 用于取消动画

        function animateWithRAF() {
            position += speed;
            box.style.left = position + 'px';

            if (position > window.innerWidth - box.offsetWidth) {
                position = 0; // 重置位置
            }

            animationId = requestAnimationFrame(animateWithRAF); // 递归调用,形成动画循环
        }

        // 启动动画
        animationId = requestAnimationFrame(animateWithRAF);

        // 为了演示,我们可以在某个时候停止它
        // setTimeout(() => {
        //     cancelAnimationFrame(animationId);
        //     console.log('requestAnimationFrame 动画停止');
        // }, 5000);
    </script>
</body>
</html>

4.2 requestAnimationFrame 为什么能带来流畅的动画?

requestAnimationFrame 之所以能提供显著更流畅的动画体验,正是因为它完美地契合了浏览器渲染周期的节奏。

4.2.1 与浏览器渲染周期的完美同步
  • 在正确的时间做正确的事: requestAnimationFrame 的回调函数总是在浏览器准备好渲染下一帧时执行。这意味着你的 DOM 操作只会在浏览器真正需要更新屏幕内容之前进行。
  • 一次更新,一帧显示: 无论你的动画逻辑有多复杂,只要它能在一次 requestAnimationFrame 回调中完成,浏览器就会将其最新状态渲染成一帧。不会出现 setInterval 中多次无效更新或者错过渲染周期的情况。
  • 消除画面撕裂: 由于与 V-sync 同步,requestAnimationFrame 确保了每次 DOM 更新都发生在屏幕刷新周期的开始,避免了画面撕裂,保证了每一帧都是完整的。
4.2.2 自动适应帧率与节省资源
  • 动态帧率: 不同的显示器有不同的刷新率(60Hz, 75Hz, 120Hz, 144Hz 等)。requestAnimationFrame 会自动适应当前设备的刷新率,以最高效的方式驱动动画。如果显示器是 120Hz,你的动画理论上就能以 120fps 运行,提供更加丝滑的体验;如果是 60Hz,则以 60fps 运行。
  • 智能暂停: 当页面不可见时(例如用户切换到其他标签页,或者最小化浏览器),requestAnimationFrame 会自动暂停。这意味着它不会在用户看不到动画时白白消耗 CPU 和电池资源。当页面再次可见时,动画会自动恢复。这对于移动设备尤其重要,可以显著提升电池续航。
4.2.3 时间戳实现时间驱动动画

requestAnimationFrame 回调函数提供的 timestamp 参数是其强大之处。它允许我们实现“时间驱动”的动画,而不是“帧驱动”的动画。

  • 帧驱动动画的缺陷: 在之前的 setIntervalrequestAnimationFrame 基础示例中,我们简单地将 position += speed。这意味着每当回调函数执行一次,元素就移动 speed 像素。如果帧率不稳定(例如,由于主线程被阻塞导致某些帧被跳过),动画的速度就会变得不均匀。
    • 例如,如果 speed = 5px/frame,在 60fps 时,物体每秒移动 300px。
    • 但如果由于卡顿,帧率降到 30fps,那么物体每秒只移动 150px,动画会明显变慢。
  • 时间驱动动画的优势: 通过 timestamp,我们可以计算从上一帧到当前帧经过了多少时间(deltaTime)。然后,我们可以根据这个 deltaTime 来计算元素应该移动的距离。这样,无论帧率如何波动,动画的实际物理速度都保持一致。

代码示例 4:时间驱动动画与 requestAnimationFrame

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>时间驱动动画示例</title>
    <style>
        #box {
            width: 50px;
            height: 50px;
            background-color: purple;
            position: absolute;
            top: 50px;
            left: 0;
        }
    </style>
</head>
<body>
    <div id="box"></div>
    <button id="blockBtn">阻塞主线程 (500ms)</button>
    <p>当前 FPS: <span id="fpsDisplay">--</span></p>

    <script>
        const box = document.getElementById('box');
        const blockBtn = document.getElementById('blockBtn');
        const fpsDisplay = document.getElementById('fpsDisplay');

        let position = 0;
        const speedPerSecond = 200; // 像素/秒
        let lastTimestamp = 0;
        let animationId;

        // FPS 计算辅助变量
        let frameCount = 0;
        let fpsLastUpdateTime = 0;

        function animate(timestamp) {
            if (!lastTimestamp) {
                lastTimestamp = timestamp;
            }

            const deltaTime = timestamp - lastTimestamp; // 距离上一帧经过的时间 (ms)
            lastTimestamp = timestamp;

            // 根据时间计算移动距离
            // (speedPerSecond 像素/秒) * (deltaTime 毫秒 / 1000 毫秒/秒)
            const distanceToMove = speedPerSecond * (deltaTime / 1000);

            position += distanceToMove;
            box.style.left = position + 'px';

            if (position > window.innerWidth - box.offsetWidth) {
                position = 0; // 重置位置
            }

            // FPS 计算
            frameCount++;
            if (timestamp - fpsLastUpdateTime >= 1000) { // 每秒更新一次 FPS
                fpsDisplay.textContent = frameCount;
                frameCount = 0;
                fpsLastUpdateTime = timestamp;
            }

            animationId = requestAnimationFrame(animate);
        }

        animationId = requestAnimationFrame(animate);

        blockBtn.addEventListener('click', () => {
            console.log('开始阻塞主线程...');
            const startTime = performance.now();
            while (performance.now() - startTime < 500) {
                // 忙等待
            }
            console.log('阻塞结束。');
        });

        // 停止动画
        // setTimeout(() => {
        //     cancelAnimationFrame(animationId);
        //     console.log('requestAnimationFrame 动画停止');
        // }, 10000);
    </script>
</body>
</html>

在这个时间驱动的动画中,即使你点击“阻塞主线程”按钮,动画在阻塞结束后也会以正确的速度继续,而不是像 setInterval 那样看起来跳动一大段距离。这是因为我们根据经过的实际时间来计算位移,而不是简单地每帧固定位移。当阻塞发生时,deltaTime 会变大,从而在恢复时一次性移动更大的距离,确保动画的总速度保持一致。

4.2.4 浏览器内部优化

浏览器可以对 requestAnimationFrame 进行更深层次的优化。例如,它可以将多个 requestAnimationFrame 回调合并到同一个渲染周期中,或者在多个动画同时运行时进行协调,以最大限度地减少布局和绘制的开销。

5. 高级考量与最佳实践

理解了 requestAnimationFrame 的优势,我们还需要掌握一些高级考量和最佳实践,以确保动画性能达到极致。

5.1 动画的取消

requestAnimationFrame 返回一个请求 ID,类似于 setTimeoutsetInterval。你可以使用 cancelAnimationFrame(requestID) 来取消一个待处理的动画帧请求。这在动画结束或页面卸载时非常重要,可以防止内存泄漏和不必要的计算。

let animationId;
function startAnimation() {
    // ... 动画逻辑 ...
    animationId = requestAnimationFrame(animate);
}

function stopAnimation() {
    if (animationId) {
        cancelAnimationFrame(animationId);
        animationId = null; // 清除引用
    }
}

5.2 避免强制同步布局 (Forced Synchronous Layouts / Layout Thrashing)

即使使用了 requestAnimationFrame,如果你的回调函数中执行了不当的 DOM 操作,仍然可能导致性能问题。最常见的问题是“强制同步布局”。

当 JavaScript 修改了 DOM 样式(例如 element.style.width = '100px'),然后立即读取会触发布局的属性(例如 element.offsetWidthelement.getBoundingClientRect()),浏览器为了提供最新的正确值,会不得不立即执行布局计算,即使它还没有到渲染周期的布局阶段。这种强制的、额外的布局计算会显著拖慢性能。

示例:导致强制同步布局的代码

function problematicAnimation() {
    const box = document.getElementById('box');
    box.style.width = (Math.random() * 100) + 'px'; // 写入样式
    console.log(box.offsetWidth); // 立即读取布局属性,强制布局
    requestAnimationFrame(problematicAnimation);
}
requestAnimationFrame(problematicAnimation);

最佳实践: 尽量将所有“写入”(修改样式/DOM)操作集中在一起,然后将所有“读取”(查询布局属性)操作集中在一起,避免交替进行。或者,尽可能使用 CSS transformopacity 进行动画,因为它们通常可以由 GPU 处理,不会触发布局或绘制,只在合成阶段发生变化。

5.3 离线计算与 Web Workers

对于非常复杂的动画计算,或者需要处理大量数据的场景,即使在 requestAnimationFrame 回调中执行,也可能因为计算量过大而阻塞主线程,导致掉帧。

在这种情况下,可以考虑将耗时的计算任务转移到 Web Workers 中。Web Workers 允许 JavaScript 在后台线程中运行,而不会阻塞主线程。计算完成后,Web Worker 可以将结果发送回主线程,主线程再在 requestAnimationFrame 回调中将结果应用到 DOM 上。

这虽然增加了实现的复杂性,但对于保持主线程响应性和动画流畅度至关重要。

5.4 will-change CSS 属性

will-change 是一个 CSS 属性,它允许开发者提前告知浏览器,某个元素将来会发生哪些变化。这使得浏览器可以提前进行一些优化,例如为该元素创建独立的合成层。

#animated-element {
    will-change: transform, opacity; /* 告诉浏览器这个元素会发生 transform 和 opacity 的变化 */
}

注意: will-change 不应该滥用。只应用于那些确实会进行动画或频繁变化的元素。过度使用反而可能导致资源浪费。

5.5 什么时候 setInterval 可能是可以接受的?

尽管 setInterval 不适合视觉动画,但在某些非视觉的场景下,它仍然有其用武之地:

  • 非视觉的数据更新: 例如,每隔一段时间从服务器拉取最新数据(例如聊天消息、股票报价),或者更新一个不直接显示在屏幕上的内部计时器。
  • 非关键性的后台任务: 例如,定期保存用户在表单中的草稿。
  • 需要精确的固定间隔: 某些场景下,你可能需要一个任务在严格的固定间隔后触发,而不在乎它是否与渲染同步。但在这种情况下,setTimeout 的递归调用通常比 setInterval 更加健壮,因为它可以确保前一个任务完成后才调度下一个任务,避免任务堆积。

使用 setTimeout 递归替代 setInterval 的模式:

let timerId;
function recursiveTimer() {
    // 执行任务
    console.log('执行任务', performance.now());

    // 调度下一次执行
    timerId = setTimeout(recursiveTimer, 1000);
}

timerId = setTimeout(recursiveTimer, 1000); // 首次启动
// clearTimeout(timerId); // 停止计时器

这种模式确保了每次回调之间至少有一个 delay 的间隔,即使回调函数本身耗时较长,也不会导致任务堆积,而是简单地将下一次执行推迟。这比 setInterval 更安全。

6. 两种机制的对比总结

让我们用一张表格来直观地对比 setIntervalrequestAnimationFrame 的关键特性:

特性 setInterval requestAnimationFrame
调度方式 开发者指定固定间隔 (最小间隔),由事件循环调度。 浏览器自动调度,在下一次重绘之前执行回调。
与 V-sync 同步 无同步,独立运行。 高度同步,在浏览器渲染周期的最佳时机触发。
帧率适应 固定的执行间隔,不适应显示器刷新率。 自动适应显示器刷新率,以最佳帧率运行。
时间戳 不提供。 提供 DOMHighResTimeStamp,用于时间驱动动画。
资源消耗 即使页面不可见也可能运行,消耗 CPU 和电池(可能被节流)。 页面不可见时自动暂停,显著节省 CPU 和电池资源。
动画流畅度 容易出现卡顿、跳帧,不流畅。 提供最流畅的动画体验。
任务堆积 主线程繁忙时,容易导致任务堆积。 不会堆积任务,只在合适的时机执行一次。
取消机制 clearInterval(id) cancelAnimationFrame(id)
典型应用场景 非视觉的、低频率的数据轮询,或特定后台任务(建议用 setTimeout 递归)。 任何需要视觉更新的动画、过渡、滚动效果等。

7. 动画性能的未来趋势

随着 Web 技术的发展,浏览器对动画的优化也在不断深入。除了 requestAnimationFrame,还有一些现代 API 和技术可以进一步提升动画性能:

  • Web Animations API (WAAPI): 这是一个更高级的 API,允许开发者通过 JavaScript 定义和控制动画,而浏览器可以在内部进行更深层次的优化,例如将动画完全 offload 到合成线程,实现主线程零开销。它提供了类似 CSS 动画的声明式语法,但拥有 JavaScript 的灵活控制。
  • CSS Animations/Transitions: 对于简单的动画,纯 CSS 动画和过渡仍然是首选,因为它们默认由浏览器在合成线程上执行,性能极高。
  • GPU 加速: 充分利用 CSS transformopacity 属性进行动画,因为这些属性通常可以由 GPU 进行处理,避免触发布局和绘制,从而实现高性能的合成动画。

总结

至此,我们已经全面剖析了 requestAnimationFrame 优于 setInterval 进行 JavaScript 动画的核心原因。这不仅仅是 API 设计上的差异,更是对浏览器底层渲染机制和事件循环的深刻理解。

requestAnimationFrame 的流畅性来源于其与浏览器 V-sync 渲染周期的完美同步,以及浏览器对其智能的调度和优化。它让我们的动画更新与屏幕刷新步调一致,避免了不必要的计算和资源浪费,同时通过时间戳机制,使得动画在不同设备和负载下都能保持一致的速度。

因此,在任何需要视觉更新的场景中,无论是复杂的交互动画,还是简单的元素位移,requestAnimationFrame 都应该是你构建高性能、流畅用户体验的首选工具。掌握并善用它,将是每一位追求卓越的前端开发者必备的技能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注