JavaScript事件循环
1. 为什么需要事件循环
1.1 JavaScript 的单线程特性
JavaScript 最初被设计为浏览器脚本语言,核心用途是操作 DOM。为了避免多线程同时修改 DOM 带来的复杂同步问题,JavaScript 被设计为单线程,即同一时间只能做一件事。
1.2 阻塞与非阻塞
如果某段代码执行时间很长(例如循环、网络请求、文件读取),就会阻塞后续代码,导致页面“卡死”。为了解决这个问题,JavaScript 引入了异步机制:将耗时的任务交给浏览器或 Node.js 的其他线程处理,等结果返回后再执行回调。
那么问题来了:单线程的 JS 如何知道异步任务何时完成、如何安排回调的执行?答案就是 事件循环。
2. 事件循环的核心组件
为了更好地理解事件循环,需要先了解几个重要的概念:
2.1 调用栈(Call Stack)
调用栈是一个后进先出(LIFO)的数据结构,用来记录当前代码的执行位置。每个函数调用都会创建一个栈帧(frame)压入栈顶,函数执行完毕后出栈。
function foo() {
console.log('foo');
}
function bar() {
foo();
}
bar(); // 调用栈: bar -> foo -> console.log -> 依次出栈2.2 任务队列(Task Queue / Callback Queue)
当异步任务(如 setTimeout、点击事件、网络请求)完成后,其回调函数并不会立即执行,而是被放入一个先进先出(FIFO)的任务队列中,等待主线程腾出空来执行。
2.3 事件循环(Event Loop)
事件循环是一个不断运行的过程:它监视着调用栈和任务队列。当调用栈为空时,事件循环会从任务队列中取出第一个任务,推入调用栈执行。
// 伪代码
while (true) {
if (callStack.isEmpty()) {
const nextTask = taskQueue.dequeue();
if (nextTask) {
callStack.push(nextTask);
}
}
}3. 宏任务(MacroTask)与微任务(MicroTask)
任务队列实际上并不是单一的。ES6 之后,Promise 的出现引入了微任务的概念,从而将任务分为两大类:
| 类型 | 常见 API / 场景 | 执行时机 |
|---|---|---|
| 宏任务 | setTimeout, setInterval, setImmediate (Node), I/O, UI rendering, MessageChannel | 每个事件循环周期执行一个宏任务 |
| 微任务 | Promise.then/catch/finally, MutationObserver, queueMicrotask, process.nextTick (Node) | 当前宏任务执行完后、下一个宏任务之前执行 全部 微任务 |
执行顺序总结:
- 执行一个宏任务(从任务队列中取出)
- 执行过程中产生的所有微任务(清空微任务队列,包括微任务产生的新微任务)
- 必要时进行 UI 渲染(浏览器)
- 开始下一个宏任务(回到步骤 1)
注意:
Promise本身是同步的,它的then回调才是微任务。async/await底层也是基于 Promise,因此await后面的代码相当于微任务。
4. 事件循环的完整流程(浏览器环境)
下图(ASCII 描述)展示了事件循环的简化过程:
~
┌─────────┐
│ 开始 │
└────┬────┘
▼
┌────────────────┐
│ 执行一个宏任务 │ ←─ 来自宏任务队列
└────────┬───────┘
▼
┌────────────────┐
│ 清空微任务队列 │ ←─ 所有微任务(包括微任务中产生的新的微任务)
└────────┬───────┘
▼
┌────────────────┐
│ 必要时渲染UI │
└────────┬───────┘
▼
(循环回到开始)5. 其它版本
5.1 基本概念
事件循环(event loop)又叫做消息循环(message loop),是浏览器渲染主线程的工作方式,是浏览器处理异步的一种调度策略。
在 Chrome 的源码中,它开启一个不会结束的 for 循环,每次循环从消息队列中取出第一个任务执行,而其他线程只需要在合适的时候将任务加入到队列末尾即可。
过去把消息队列简单分为宏队列和微队列,这种说法目前已无法满足复杂的浏览器环境,取而代之的是一种更加灵活多变的处理方式。
根据 W3C 官方的解释,每个任务有不同的类型,同类型的任务必须在同一个队列,不同的任务可以属于不同的队列。不同任务队列有不同的优先级,在一次事件循环中,由浏览器自行决定取哪一个队列的任务。但浏览器必须有一个微队列,微队列的任务一定具有最高的优先级,必须优先调度执行。
5.2 浏览器的进程与线程
进程是计算机操作系统资源分配的最小单位。现代浏览器,以 Chrome 为例,广泛采用的是多进程架构。为了减少连环崩溃的几率,启动浏览器后,会将各个任务拆分为多个进程。
- 浏览器进程(Browser process):我负责界面显示、用户交互、子进程管理等业务。
- 网络进程(Network process):我负责加载网络资源业务。
- 渲染进程(Rendering process):我是渲染的主角,负责执行 HTML、CSS、JS 代码。默认情况,浏览器会为每一个标签页签开启一个渲染进程,以确保相互不影响。
- 。。。
频繁开启进程会造成内存占用过多的情况,Chrome 后续会进行改进,考虑相同站点(相同顶级域名和二级域名)共用一个渲染进程。(官方说明文档)
渲染进程会开启一个渲染主线程,用于无阻塞渲染任务。其余线程协助主线程完成任务。
渲染主线程(Main thread) 负责执行代码、样式与布局(通过优先级、百分比换算、宽高等几何信息高动态计算、相对位置等计算最终样式表)、处理图层、控制 60 帧刷新、执行各种回调函数等,是最忙的一个,一刻也没有停歇。
此时就有一个问题,主渲染任务都交给一个线程,效率会高吗?为什么不多线程一起渲染?
这是因为浏览器渲染和 JavaScript 执行共用一个线程,而默认情况下, JavaScript 执行又会影响 CSSOM/DOM 树的渲染和结构,所以必须阻塞渲染,他们也必须是单线程操作。在构建 DOM 时,HTML 解析器若遇到了 JavaScript,它会暂停构建DOM,将控制权移交给 JavaScript 引擎,等 JavaScript 引擎运行完毕,浏览器再从中断的地方恢复 DOM 构建。如果使用多线程处理可能会导致渲染DOM的冲突,复杂度会大幅度提升。
5.3 消息循环
使用单线程,使得在编写代码时无需考虑复杂的线程同步和互斥问题,从而降低了开发的复杂性,但是终归效率是上不去的。为了解决这个问题,这里巧妙地引入了消息循环来统一解决这个问题。
工作原理
核心思想:渲染主线程永久轮询执行任务;单独开辟一个消息队列(Message queue)空间用于存放各个线程加入的任务;渲染主线程只是简单的拿取任务执行,而不用操心这个任务是谁送过来的。在 Chrome 的源码中,使用的是一个死循环来完成的:
for(;;) {}消息队列的无限轮询就叫消息循环,消息循环就发生在渲染主线程里。
- 最开始,渲染主线程开始轮询
- 每一次循环检查消息队列是否有任务,有则取出第一个任务执行。
- 其他所有线程都可以追加任务进消息队列的末尾。
注意事项:
- 绘制任务出现在循环的每一次迭代之后。
- 最需要注意的细节是,事件循环每一次只执行一个“宏”任务。
优先级
任务本身没有优先级,渲染主线程依次从对头拿取任务执行。有优先级的是消息队列。
在目前 Chrome 的实现中,消息队列其实不是一个队列,而是多个,他们有优先级,这个优先级决定了谁先进入渲染主线程被执行。至少包含了下面的队列:
- 延时队列(Delay queue):用于存放计时器到达后的回调任务,优先级「中」。
- 交互队列(Interactive queue):用于存放用户操作后产生的事件处理任务,优先级「高」。
- 微队列(Micro queue):用户存放需要最快执行的任务,优先级「最高」。
随着浏览器的复杂度急剧提升,W3C 不再使用宏队列的说法。
5.4 总结
- 事件循环又叫消息循环,是浏览器渲染主线程的工作方式,是浏览器处理异步的一种调度策略。
- 渲染主线程会反复轮询(for 循环),不断拿取消息队列的任务来执行;其他线程获取到任务后,在合适的时机将其追加在消息队列末尾。
- 任务队列分为延时队列、交互队列、微队列;不同任务队列有不同的优先级。
测试
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// 新版输出(新版的chrome浏览器优化了,await变得更快了,输出为)
// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
// 注意一个点await async2() 执行完后面的任务才会注册到微任务中
// 旧版输出如下,但是请继续看完本文下面的注意那里,新版有改动
// script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout//第一个宏任务
setTimeout(() => {
console.log(1); //宏任务中的同步任务
Promise.resolve().then(() => { console.log(7) }) //宏任务中的微任务
}, 0); //异步任务 - 宏任务
console.log(2); //同步任务
Promise.resolve().then(() => { console.log(3) }) //异步任务 - 微任务
//第二个宏任务
setTimeout(() => {
console.log(8); //宏任务中的同步任务
setTimeout(() => { console.log(5) }, 0) //宏任务中的宏任务 第四个宏任务
}, 0);
//第三个宏任务
setTimeout(() => {
Promise.resolve().then(() => { console.log(4) }) //宏任务中的微任务
}, 0);
console.log(6); //同步任务
// 执行结果:2 6 3 1 7 8 4 5