JS事件循环和浏览器一帧的关系是什么样

737 阅读12分钟

JavaScript事件循环(Event Loop)和浏览器渲染帧(Browser Rendering Frame)是前端开发中理解浏览器工作原理和优化性能的关键概念。它们之间存在着密切的关系,共同决定了页面何时更新以及用户交互的响应速度。

1. JavaScript事件循环 (Event Loop)

JavaScript是单线程的,这意味着它一次只能执行一个任务。为了避免阻塞(例如,当执行耗时的网络请求时),JavaScript引入了事件循环机制。

核心组件:

  • 调用栈 (Call Stack): 同步任务执行的地方。当函数被调用时,它被推入栈中;当函数执行完毕时,它被弹出栈。
  • Web APIs (或宿主环境提供的API): 浏览器(或Node.js等环境)提供的一些异步功能,如 setTimeoutsetIntervalXMLHttpRequest、DOM事件监听等。当调用这些API时,它们会被移交给Web APIs处理,不会阻塞主线程。
  • 任务队列 (Task Queue / Macrotask Queue): 当Web APIs完成异步任务后,相关的回调函数(如setTimeout的回调、DOM事件回调、网络请求回调等)会被放入这个队列中排队。
  • 微任务队列 (Microtask Queue): 优先级更高的任务队列。主要包含 Promisethen/catch/finally 回调、MutationObserver 回调等。
  • 事件循环 (Event Loop): 持续不断地检查调用栈是否为空。如果为空,它会首先检查微任务队列,将所有微任务按顺序推到调用栈中执行,直到微任务队列清空。然后,它会检查任务队列,将队列中的第一个宏任务推到调用栈中执行。这个过程循环往复。

事件循环的执行顺序概括:

  1. 执行当前宏任务(通常是整个脚本的执行)。
  2. 执行过程中遇到的同步代码立即执行,异步任务(如setTimeout)被推送到Web APIs。
  3. 当前宏任务执行完毕后,检查微任务队列。
  4. 执行所有可用的微任务,直到微任务队列清空。
  5. 如果浏览器有渲染更新的需求,会在此时进行渲染。
  6. 从任务队列中取出一个宏任务,重复步骤2。

2. 浏览器渲染帧 (Browser Rendering Frame)

浏览器为了呈现页面内容,会以一定的频率(通常是每秒60帧,即每16.6毫秒一帧)进行重绘和回流(reflow/layout & repaint/paint)。这一个周期性的视觉更新过程就称为一个“渲染帧”。

一个渲染帧通常包含以下步骤:

  1. JavaScript: 执行JavaScript代码,可能导致DOM/CSSOM的改变。
  2. 样式计算 (Style): 根据CSS规则计算每个元素的最终样式。
  3. 布局 (Layout / Reflow): 根据计算出的样式和DOM结构,计算每个元素在屏幕上的位置和大小。
  4. 绘制 (Paint / Repaint): 将元素的可见部分绘制到屏幕上(例如,文本、颜色、图像、边框、阴影等)。
  5. 合成 (Composite): 将多个层(layers)合并成最终的图像,并显示在屏幕上。

requestAnimationFrame (rAF)

requestAnimationFrame 是一个专门用于动画的API。它的回调函数会在浏览器下一次重绘之前执行。这意味着 rAF 的回调与浏览器的渲染周期同步,能够确保动画流畅,避免丢帧。

3. JS事件循环与浏览器渲染帧的关系

关键点:浏览器渲染发生在事件循环的特定阶段。

通常,浏览器会在执行完一个宏任务,并且清空了所有微任务队列之后,下一个宏任务开始之前,进行一次渲染更新。

  • 阻塞渲染: 如果一个宏任务(或一系列微任务)执行时间过长,它会霸占主线程,导致浏览器无法及时进行渲染更新。这就会造成页面卡顿、动画不流畅(“掉帧”或“卡顿”)。
  • requestAnimationFrame 的时机: requestAnimationFrame 的回调函数通常在浏览器准备进行下一次重绘时执行。它被安排在微任务队列清空之后,但又在浏览器实际绘制之前。这使得它成为执行视觉更新的最佳时机,因为它能够确保在浏览器更新屏幕之前,你的所有DOM操作都已完成,从而避免不必要的重绘和布局抖动。
  • setTimeout 的问题: setTimeout(callback, 0)setTimeout(callback, 16) 并不保证回调在下一个渲染帧之前执行。它只是将回调放入任务队列,等待主线程空闲后才执行。如果在其之前有其他宏任务或微任务,或者浏览器正忙于其他事情,那么回调的执行可能会延迟,导致动画不流畅。

总结关系:

事件循环是JS代码执行的调度器,它决定了JS任务的执行顺序。浏览器渲染是浏览器将DOM/CSSOM树转换为屏幕像素的过程。这两者通过主线程紧密关联:

  • 主线程空闲时,事件循环才会调度任务。
  • 主线程空闲且没有待处理的微任务时,浏览器才有机会进行渲染。
  • requestAnimationFrame 是浏览器提供的一种机制,允许开发者将视觉更新任务精确地安排在渲染周期的最佳时机。

4. 代码示例与详细讲解

示例 1:阻塞主线程导致页面卡顿和渲染延迟

这个例子展示了长时间运行的同步JavaScript代码如何阻塞主线程,导致点击事件无法立即响应,并且页面无法及时渲染。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>阻塞主线程示例</title>
    <style>
        body { font-family: sans-serif; text-align: center; margin-top: 50px; }
        .box {
            width: 100px;
            height: 100px;
            background-color: lightblue;
            margin: 20px auto;
            transition: background-color 0.3s; /* 添加过渡效果 */
        }
        .box.active {
            background-color: salmon;
        }
        button {
            padding: 10px 20px;
            font-size: 16px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>阻塞主线程与渲染</h1>
    <div class="box" id="myBox"></div>
    <button id="blockButton">点击我,然后等待</button>
    <button id="changeColorButton">改变盒子颜色</button>

    <script>
        const blockButton = document.getElementById('blockButton');
        const changeColorButton = document.getElementById('changeColorButton');
        const myBox = document.getElementById('myBox');

        // 监听改变颜色按钮的点击事件
        changeColorButton.addEventListener('click', () => {
            console.log('改变盒子颜色按钮被点击了!');
            myBox.classList.toggle('active'); // 切换盒子的颜色类
        });

        // 监听阻塞按钮的点击事件
        blockButton.addEventListener('click', () => {
            console.log('阻塞按钮被点击了!');
            // 模拟一个非常耗时的同步操作
            const startTime = Date.now();
            while (Date.now() - startTime < 5000) { // 阻塞主线程 5 秒
                // 这是一个空循环,模拟CPU密集型计算
            }
            console.log('阻塞任务完成!');
        });

        // 页面加载后立即改变盒子颜色,观察是否能立即看到变化
        // 理论上,如果JS代码执行很快,这个变化会立即渲染
        myBox.style.backgroundColor = 'lightgreen';
        console.log('盒子初始颜色已设置为 lightgreen');

    </script>
</body>
</html>

讲解:

  1. 初始渲染: 页面加载后,myBox.style.backgroundColor = 'lightgreen'; 会立即执行,并期望浏览器尽快渲染这个变化。由于没有其他阻塞代码,这个变化会很快显示。

  2. 点击“改变盒子颜色”: 这个按钮的点击事件回调是一个轻量级的任务。当点击时,它会迅速执行,改变盒子的类名,浏览器会立即安排一次重绘来显示新的颜色。

  3. 点击“点击我,然后等待”:

    • 当你点击这个按钮时,它的点击事件回调(一个宏任务)被推入任务队列。

    • 事件循环将这个宏任务推到调用栈执行。

    • while (Date.now() - startTime < 5000) 这个循环会持续执行大约 5 秒钟。

    • 在此期间,主线程被完全占用。

    • 后果:

      • 页面冻结: 你会发现页面上的任何交互(包括点击“改变盒子颜色”按钮)都无法响应。因为点击事件的回调也需要等待主线程空闲才能被推入调用栈执行。
      • 渲染停止: 如果在阻塞期间有任何DOM或CSS变化(例如,你快速点击了“改变盒子颜色”按钮),这些变化也无法被浏览器渲染出来。因为浏览器没有机会执行其渲染步骤(布局、绘制等)。只有当 5 秒的阻塞任务完成后,主线程空闲下来,事件循环才能继续处理其他任务,浏览器才有机会进行渲染。

这个例子清晰地展示了长时间的同步JS执行如何“饿死”事件循环,从而阻止了用户交互和浏览器渲染。

示例 2:setTimeoutrequestAnimationFrame 进行动画比较

这个例子展示了使用 setTimeoutrequestAnimationFrame 实现动画的区别,以及它们与渲染帧的关系。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>setTimeout vs requestAnimationFrame</title>
    <style>
        body { font-family: sans-serif; text-align: center; margin-top: 50px; }
        .animated-box {
            width: 50px;
            height: 50px;
            background-color: dodgerblue;
            position: relative;
            left: 0;
            margin: 20px auto;
        }
        button {
            padding: 10px 20px;
            font-size: 16px;
            margin: 10px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>setTimeout vs requestAnimationFrame 动画</h1>
    <div class="animated-box" id="box1">setTimeout 动画</div>
    <div class="animated-box" id="box2">requestAnimationFrame 动画</div>
    <button id="startSetTimeout">启动 setTimeout 动画</button>
    <button id="startRAF">启动 requestAnimationFrame 动画</button>
    <button id="stopAll">停止所有动画</button>

    <script>
        const box1 = document.getElementById('box1');
        const box2 = document.getElementById('box2');
        const startSetTimeoutBtn = document.getElementById('startSetTimeout');
        const startRAFBtn = document.getElementById('startRAF');
        const stopAllBtn = document.getElementById('stopAll');

        let timeoutId = null;
        let rafId = null;
        let position1 = 0;
        let position2 = 0;
        const speed = 2; // 移动速度

        // setTimeout 动画函数
        function animateWithSetTimeout() {
            position1 += speed;
            if (position1 > window.innerWidth - 50) {
                position1 = 0; // 循环
            }
            box1.style.left = position1 + 'px';
            timeoutId = setTimeout(animateWithSetTimeout, 16); // 理论上每16ms执行一次
        }

        // requestAnimationFrame 动画函数
        function animateWithRAF() {
            position2 += speed;
            if (position2 > window.innerWidth - 50) {
                position2 = 0; // 循环
            }
            box2.style.left = position2 + 'px';
            rafId = requestAnimationFrame(animateWithRAF); // 浏览器下一次重绘前调用
        }

        // 停止所有动画
        function stopAllAnimations() {
            if (timeoutId) {
                clearTimeout(timeoutId);
                timeoutId = null;
            }
            if (rafId) {
                cancelAnimationFrame(rafId);
                rafId = null;
            }
            position1 = 0;
            position2 = 0;
            box1.style.left = '0px';
            box2.style.left = '0px';
        }

        startSetTimeoutBtn.addEventListener('click', () => {
            stopAllAnimations(); // 确保只运行一个动画
            animateWithSetTimeout();
        });

        startRAFBtn.addEventListener('click', () => {
            stopAllAnimations(); // 确保只运行一个动画
            animateWithRAF();
        });

        stopAllBtn.addEventListener('click', stopAllAnimations);

    </script>
</body>
</html>

讲解:

  1. setTimeout(animateWithSetTimeout, 16)

    • setTimeout 的回调是一个宏任务。它被推入任务队列,等待主线程空闲。
    • 即使你设置了 16ms 的延迟(近似于 60fps),但由于事件循环的调度机制,以及可能存在的其他宏任务或浏览器内部的开销,这个回调并不能保证在精确的 16ms 后执行,也无法保证与浏览器的渲染帧同步。
    • 结果: 动画可能会显得不流畅,有“跳动”或“卡顿”的感觉,尤其是在浏览器繁忙或有其他JS任务时。这是因为它的更新可能与浏览器的实际刷新率不同步,导致有些帧被跳过或重复。
  2. requestAnimationFrame(animateWithRAF)

    • requestAnimationFrame 的回调函数会在浏览器下一次重绘之前执行。
    • 浏览器会根据自身的刷新率(例如 60Hz)来调度 rAF 的回调。
    • 结果: 动画会非常流畅。因为 rAF 确保了你的DOM更新操作在浏览器进行布局和绘制之前完成,并且只在真正需要更新屏幕时才执行,从而避免了不必要的计算和重绘,并与显示器的刷新率保持同步。

示例 3:微任务、宏任务与渲染的顺序

这个例子展示了 Promise.then (微任务) 和 setTimeout (宏任务) 如何影响渲染的时机。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>微任务、宏任务与渲染</title>
    <style>
        body { font-family: sans-serif; text-align: center; margin-top: 50px; }
        .log-area {
            border: 1px solid #ccc;
            padding: 10px;
            min-height: 150px;
            text-align: left;
            margin: 20px auto;
            width: 80%;
            overflow-y: auto;
        }
        .box {
            width: 80px;
            height: 80px;
            background-color: purple;
            margin: 20px auto;
            transition: background-color 0.5s;
        }
    </style>
</head>
<body>
    <h1>微任务、宏任务与渲染顺序</h1>
    <div class="box" id="myBox"></div>
    <button id="startTest">启动测试</button>
    <div class="log-area" id="log"></div>

    <script>
        const myBox = document.getElementById('myBox');
        const startTestBtn = document.getElementById('startTest');
        const logArea = document.getElementById('log');

        function logMessage(msg) {
            const p = document.createElement('p');
            p.textContent = `${Date.now() % 10000}ms: ${msg}`; // 显示时间戳后几位方便观察
            logArea.appendChild(p);
            logArea.scrollTop = logArea.scrollHeight; // 滚动到底部
        }

        startTestBtn.addEventListener('click', () => {
            logArea.innerHTML = ''; // 清空日志
            myBox.style.backgroundColor = 'purple'; // 重置颜色

            logMessage('--- 测试开始 ---');

            // 1. 同步代码立即执行
            logMessage('1. 同步代码开始执行');
            myBox.style.backgroundColor = 'orange'; // 改变颜色

            // 2. Promise.resolve().then() 是微任务
            Promise.resolve().then(() => {
                logMessage('3. Promise.then (微任务) 执行');
                myBox.style.backgroundColor = 'green'; // 再次改变颜色
            });

            // 3. setTimeout 是宏任务
            setTimeout(() => {
                logMessage('4. setTimeout (宏任务) 执行');
                myBox.style.backgroundColor = 'blue'; // 再次改变颜色
            }, 0);

            // 4. requestAnimationFrame
            requestAnimationFrame(() => {
                logMessage('5. requestAnimationFrame (渲染前) 执行');
                myBox.style.borderRadius = '50%'; // 改变形状
            });

            logMessage('2. 同步代码执行完毕,准备进入微任务阶段');
        });
    </script>
</body>
</html>

讲解:

  1. 点击“启动测试”按钮: 按钮的点击事件回调是一个宏任务。

  2. 同步代码执行:

    • logMessage('1. 同步代码开始执行');
    • myBox.style.backgroundColor = 'orange';:盒子颜色变为橙色。此时,这个DOM变化已经发生,但还没有被浏览器渲染到屏幕上。
    • logMessage('2. 同步代码执行完毕,准备进入微任务阶段');
  3. 微任务队列清空:

    • 当前宏任务(点击事件回调)执行完毕后,事件循环会立即检查微任务队列。
    • Promise.resolve().then(...) 的回调被执行:logMessage('3. Promise.then (微任务) 执行');
    • myBox.style.backgroundColor = 'green';:盒子颜色变为绿色。同样,这个变化也没有立即渲染。
    • 重要: 在这个阶段,所有微任务都会被执行完毕。如果在微任务中又产生了新的微任务,它们也会被立即执行。
  4. 渲染阶段(潜在的):

    • 在所有微任务执行完毕后,浏览器会检查是否有需要渲染的更新。此时,你会看到盒子从紫色(初始)直接变为绿色。 橙色状态可能不会被肉眼观察到,因为它在微任务执行前就发生了,但在微任务执行完且渲染前,又被微任务中的绿色覆盖了。
  5. requestAnimationFrame 执行:

    • requestAnimationFrame 的回调会在浏览器下一次重绘之前被调用。
    • logMessage('5. requestAnimationFrame (渲染前) 执行');
    • myBox.style.borderRadius = '50%';:盒子变为圆形。这个变化会紧接着绿色盒子的渲染之后,在同一个渲染帧或紧随其后的渲染帧中显示。
  6. 下一个宏任务执行:

    • 浏览器完成渲染后,事件循环会从任务队列中取出下一个宏任务。
    • setTimeout(() => { ... }, 0) 的回调被执行:logMessage('4. setTimeout (宏任务) 执行');
    • myBox.style.backgroundColor = 'blue';:盒子颜色变为蓝色。这个变化会在圆形盒子渲染之后,作为一个新的渲染周期的一部分显示。

观察现象:

  • 你可能会看到盒子从紫色直接变为绿色,然后变为圆形,最后变为蓝色。橙色状态可能一闪而过甚至不显示,因为在渲染前它就被绿色覆盖了。
  • 日志输出顺序会是:同步 -> 微任务 -> requestAnimationFrame -> setTimeout。这印证了事件循环的优先级:同步 > 微任务 > 渲染 > 宏任务。

这个例子清晰地展示了微任务的优先级高于宏任务,并且浏览器渲染发生在微任务清空之后、下一个宏任务开始之前,以及 requestAnimationFrame 是在渲染前执行的。