JavaScript事件循环(Event Loop)和浏览器渲染帧(Browser Rendering Frame)是前端开发中理解浏览器工作原理和优化性能的关键概念。它们之间存在着密切的关系,共同决定了页面何时更新以及用户交互的响应速度。
1. JavaScript事件循环 (Event Loop)
JavaScript是单线程的,这意味着它一次只能执行一个任务。为了避免阻塞(例如,当执行耗时的网络请求时),JavaScript引入了事件循环机制。
核心组件:
- 调用栈 (Call Stack): 同步任务执行的地方。当函数被调用时,它被推入栈中;当函数执行完毕时,它被弹出栈。
- Web APIs (或宿主环境提供的API): 浏览器(或Node.js等环境)提供的一些异步功能,如
setTimeout、setInterval、XMLHttpRequest、DOM事件监听等。当调用这些API时,它们会被移交给Web APIs处理,不会阻塞主线程。 - 任务队列 (Task Queue / Macrotask Queue): 当Web APIs完成异步任务后,相关的回调函数(如
setTimeout的回调、DOM事件回调、网络请求回调等)会被放入这个队列中排队。 - 微任务队列 (Microtask Queue): 优先级更高的任务队列。主要包含
Promise的then/catch/finally回调、MutationObserver回调等。 - 事件循环 (Event Loop): 持续不断地检查调用栈是否为空。如果为空,它会首先检查微任务队列,将所有微任务按顺序推到调用栈中执行,直到微任务队列清空。然后,它会检查任务队列,将队列中的第一个宏任务推到调用栈中执行。这个过程循环往复。
事件循环的执行顺序概括:
- 执行当前宏任务(通常是整个脚本的执行)。
- 执行过程中遇到的同步代码立即执行,异步任务(如
setTimeout)被推送到Web APIs。 - 当前宏任务执行完毕后,检查微任务队列。
- 执行所有可用的微任务,直到微任务队列清空。
- 如果浏览器有渲染更新的需求,会在此时进行渲染。
- 从任务队列中取出一个宏任务,重复步骤2。
2. 浏览器渲染帧 (Browser Rendering Frame)
浏览器为了呈现页面内容,会以一定的频率(通常是每秒60帧,即每16.6毫秒一帧)进行重绘和回流(reflow/layout & repaint/paint)。这一个周期性的视觉更新过程就称为一个“渲染帧”。
一个渲染帧通常包含以下步骤:
- JavaScript: 执行JavaScript代码,可能导致DOM/CSSOM的改变。
- 样式计算 (Style): 根据CSS规则计算每个元素的最终样式。
- 布局 (Layout / Reflow): 根据计算出的样式和DOM结构,计算每个元素在屏幕上的位置和大小。
- 绘制 (Paint / Repaint): 将元素的可见部分绘制到屏幕上(例如,文本、颜色、图像、边框、阴影等)。
- 合成 (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>
讲解:
-
初始渲染: 页面加载后,
myBox.style.backgroundColor = 'lightgreen';会立即执行,并期望浏览器尽快渲染这个变化。由于没有其他阻塞代码,这个变化会很快显示。 -
点击“改变盒子颜色”: 这个按钮的点击事件回调是一个轻量级的任务。当点击时,它会迅速执行,改变盒子的类名,浏览器会立即安排一次重绘来显示新的颜色。
-
点击“点击我,然后等待”:
-
当你点击这个按钮时,它的点击事件回调(一个宏任务)被推入任务队列。
-
事件循环将这个宏任务推到调用栈执行。
-
while (Date.now() - startTime < 5000)这个循环会持续执行大约 5 秒钟。 -
在此期间,主线程被完全占用。
-
后果:
- 页面冻结: 你会发现页面上的任何交互(包括点击“改变盒子颜色”按钮)都无法响应。因为点击事件的回调也需要等待主线程空闲才能被推入调用栈执行。
- 渲染停止: 如果在阻塞期间有任何DOM或CSS变化(例如,你快速点击了“改变盒子颜色”按钮),这些变化也无法被浏览器渲染出来。因为浏览器没有机会执行其渲染步骤(布局、绘制等)。只有当 5 秒的阻塞任务完成后,主线程空闲下来,事件循环才能继续处理其他任务,浏览器才有机会进行渲染。
-
这个例子清晰地展示了长时间的同步JS执行如何“饿死”事件循环,从而阻止了用户交互和浏览器渲染。
示例 2:setTimeout 与 requestAnimationFrame 进行动画比较
这个例子展示了使用 setTimeout 和 requestAnimationFrame 实现动画的区别,以及它们与渲染帧的关系。
<!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>
讲解:
-
setTimeout(animateWithSetTimeout, 16):setTimeout的回调是一个宏任务。它被推入任务队列,等待主线程空闲。- 即使你设置了
16ms的延迟(近似于 60fps),但由于事件循环的调度机制,以及可能存在的其他宏任务或浏览器内部的开销,这个回调并不能保证在精确的 16ms 后执行,也无法保证与浏览器的渲染帧同步。 - 结果: 动画可能会显得不流畅,有“跳动”或“卡顿”的感觉,尤其是在浏览器繁忙或有其他JS任务时。这是因为它的更新可能与浏览器的实际刷新率不同步,导致有些帧被跳过或重复。
-
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>
讲解:
-
点击“启动测试”按钮: 按钮的点击事件回调是一个宏任务。
-
同步代码执行:
logMessage('1. 同步代码开始执行');myBox.style.backgroundColor = 'orange';:盒子颜色变为橙色。此时,这个DOM变化已经发生,但还没有被浏览器渲染到屏幕上。logMessage('2. 同步代码执行完毕,准备进入微任务阶段');
-
微任务队列清空:
- 当前宏任务(点击事件回调)执行完毕后,事件循环会立即检查微任务队列。
Promise.resolve().then(...)的回调被执行:logMessage('3. Promise.then (微任务) 执行');myBox.style.backgroundColor = 'green';:盒子颜色变为绿色。同样,这个变化也没有立即渲染。- 重要: 在这个阶段,所有微任务都会被执行完毕。如果在微任务中又产生了新的微任务,它们也会被立即执行。
-
渲染阶段(潜在的):
- 在所有微任务执行完毕后,浏览器会检查是否有需要渲染的更新。此时,你会看到盒子从紫色(初始)直接变为绿色。 橙色状态可能不会被肉眼观察到,因为它在微任务执行前就发生了,但在微任务执行完且渲染前,又被微任务中的绿色覆盖了。
-
requestAnimationFrame执行:requestAnimationFrame的回调会在浏览器下一次重绘之前被调用。logMessage('5. requestAnimationFrame (渲染前) 执行');myBox.style.borderRadius = '50%';:盒子变为圆形。这个变化会紧接着绿色盒子的渲染之后,在同一个渲染帧或紧随其后的渲染帧中显示。
-
下一个宏任务执行:
- 浏览器完成渲染后,事件循环会从任务队列中取出下一个宏任务。
setTimeout(() => { ... }, 0)的回调被执行:logMessage('4. setTimeout (宏任务) 执行');myBox.style.backgroundColor = 'blue';:盒子颜色变为蓝色。这个变化会在圆形盒子渲染之后,作为一个新的渲染周期的一部分显示。
观察现象:
- 你可能会看到盒子从紫色直接变为绿色,然后变为圆形,最后变为蓝色。橙色状态可能一闪而过甚至不显示,因为在渲染前它就被绿色覆盖了。
- 日志输出顺序会是:同步 -> 微任务 ->
requestAnimationFrame->setTimeout。这印证了事件循环的优先级:同步 > 微任务 > 渲染 > 宏任务。
这个例子清晰地展示了微任务的优先级高于宏任务,并且浏览器渲染发生在微任务清空之后、下一个宏任务开始之前,以及 requestAnimationFrame 是在渲染前执行的。