前言
当我们在浏览器中打开一个页面时,背后发生了什么?为什么 JavaScript 是单线程却能处理异步任务?本文将从浏览器渲染进程的底层机制出发,深入剖析事件循环(Event Loop)的工作原理。
渲染主线程的职责
每个页面都有一个独立的渲染进程,其中最核心的是渲染主线程。这个主线程负责的任务非常繁重:
1. DOM 解析
渲染主线程接收 HTML 文件后,会解析标记语言生成 DOM 树(Document Object Model Tree),这是页面结构的抽象表示。
2. 样式计算
主线程需要合并所有 CSS 规则(包括外部样式、内联样式和元素默认样式),确定每个 DOM 节点的最终可视化样式属性值,生成 CSSOM 树(CSS Object Model Tree)。
3. 构建渲染树
将 DOM Tree 和 CSSOM Tree 结合,生成 Render Tree(渲染树) ,这棵树只包含需要显示的节点。
4. 布局计算
渲染引擎会根据盒模型、BFC(块级格式化上下文)、弹性布局、浮动、定位等规则,计算每个 DOM 节点在屏幕上的精确位置和尺寸,生成 Layout Tree。
5. 图层合成与绘制
最后,渲染引擎会合并图层并执行绘制操作,将内容显示到屏幕上。
6. JavaScript 执行
主线程还要负责执行 JavaScript 代码。从第一个 <script> 标签开始,主线程会执行同步代码,并将异步任务(如 setTimeout、Promise、事件监听器等)分发到相应的任务队列中。
html
<script src="app.js" type="module"></script>
这么多任务都在一个线程中执行,如何保证不会相互阻塞?这就需要消息机制和事件循环。
从单线程模型到事件循环
单线程的局限性
最简单的程序运行模型是主线程模型:代码顺序执行,执行完毕后线程自动退出。这种模型简单高效,但存在致命问题——阻塞。
看一个 C++ 的例子:
cpp
// 等待用户从键盘输入一个数字
int GetInput(){
int input_number = 0;
cout << "请输入一个数:";
cin >> input_number; // 主线程会一直阻塞在这里
return input_number;
}
// 主线程
void MainThread(){
for(;;){
int first_num = GetInput();
int second_num = GetInput();
int result_num = first_num + second_num;
printf("最终计算的值为:%d", result_num);
}
}
在这个程序中,cin >> input_number 会让主线程一直阻塞,无法处理其他任务。如果在等待输入期间,用户点击了按钮或网络数据到达,程序都无法响应。
引入事件循环机制
为了解决阻塞问题,浏览器引入了**事件循环(Event Loop)**机制。相比简单的单线程模型,事件循环有两个关键改变:
- 循环机制:主线程会持续运行,不断检测是否有新任务需要处理
- 引入事件:通过事件系统接收来自其他线程或用户的消息
Event + Loop = Event Loop,这让主线程"活"了起来,可以持续响应各种任务。
消息队列:任务的缓冲区
渲染主线程会频繁接收来自其他线程的任务:
- 网络进程:资源加载完成的通知
- IO 线程:用户点击、键盘输入等事件
- 定时器线程:
setTimeout/setInterval到期的通知
这些任务不能立即执行,需要先放入消息队列等待处理。浏览器通过两种队列来管理这些任务:
宏任务队列(Macro Task Queue)
宏任务包括:
<script>标签中的代码(第一个宏任务)setTimeout/setInterval- UI 渲染
postMessageMessageChannel
关键特性:每次事件循环只会从宏任务队列中取出一个任务执行。
微任务队列(Micro Task Queue)
微任务包括:
Promise.then/Promise.catch/Promise.finallyasync/await(本质是 Promise 的语法糖)MutationObserverqueueMicrotask
关键特性:每次事件循环会一次性清空微任务队列中的所有任务(先进先出)。
事件循环的执行流程
完整的事件循环执行步骤如下:
1. 执行第一个宏任务(通常是 <script> 中的同步代码)
2. 执行过程中:
- 同步代码立即执行
- 遇到宏任务(如 setTimeout)放入宏任务队列
- 遇到微任务(如 Promise)放入微任务队列
3. 当前宏任务执行完毕后,检查微任务队列
4. 一次性执行所有微任务(直到微任务队列清空)
5. 执行 UI 渲染(如果需要)
6. 从宏任务队列取出下一个宏任务,重复步骤 2-6
代码实例解析
让我们通过一个经典例子来理解这个流程:
javascript
console.log('1: script start'); // 同步代码
setTimeout(() => {
console.log('2: setTimeout'); // 宏任务
}, 0);
Promise.resolve()
.then(() => {
console.log('3: promise1'); // 微任务
})
.then(() => {
console.log('4: promise2'); // 微任务
});
console.log('5: script end'); // 同步代码
执行流程分析:
-
第一轮事件循环(宏任务:script)
- 执行同步代码:打印
1: script start - 遇到
setTimeout,将回调放入宏任务队列 - 遇到
Promise.then,将第一个.then放入微任务队列 - 执行同步代码:打印
5: script end
- 执行同步代码:打印
-
清空微任务队列
- 执行第一个微任务:打印
3: promise1 - 第一个
.then返回新 Promise,第二个.then进入微任务队列 - 执行第二个微任务:打印
4: promise2
- 执行第一个微任务:打印
-
第二轮事件循环(宏任务:setTimeout)
- 执行
setTimeout回调:打印2: setTimeout
- 执行
最终输出顺序:
1: script start
5: script end
3: promise1
4: promise2
2: setTimeout
为什么微任务优先级更高?
你可能会问:为什么要区分宏任务和微任务?
这是因为微任务是为了在当前任务执行完后、下一个宏任务开始前,立即处理一些紧急的异步操作。典型场景包括:
- Promise 链式调用:需要在同一轮事件循环中连续执行多个
.then - 响应式数据变更:Vue 3 使用微任务批量更新 DOM
- MutationObserver:需要在 DOM 变更后立即响应
如果微任务也是一次只执行一个(像宏任务那样),就无法实现这些场景的连续性处理。
实战场景:async/await 的执行顺序
async/await 是 Promise 的语法糖,理解它的执行顺序需要知道:
javascript
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end'); // 这行代码相当于在 Promise.then 中
}
async function async2() {
console.log('async2');
}
console.log('script start');
async1();
console.log('script end');
关键点:await 后面的代码会被包装成微任务。上述代码等价于:
javascript
function async1() {
console.log('async1 start');
return async2().then(() => {
console.log('async1 end');
});
}
```
**输出顺序**:
```
script start
async1 start
async2
script end
async1 end
总结
事件循环机制是浏览器渲染主线程处理多任务的核心设计:
- 单线程 + 事件循环:通过循环机制让主线程持续运行,响应各种任务
- 消息队列:通过宏任务队列和微任务队列管理异步任务的优先级
- 执行规则:宏任务一次执行一个,微任务一次清空队列
- 优先级:同步代码 > 微任务 > 宏任务
理解事件循环不仅能帮助我们写出更高效的异步代码,还能在遇到性能问题时快速定位原因。掌握这个机制,是成为高级前端工程师的必经之路。