JS 事件循环分析
JS 是单线程的。
不论是在浏览器还是在 NodeJS 中,JS 的执行流程都是基于事件循环的。
理解事件循环对于进行代码优化和架构设计是很有帮助的。
事件循环流程顺序如下:
- 执行同步任务。
- 同步任务执行完毕,开始事件循环。
- 宏任务队列队首出队,执行宏任务。
- 宏任务执行完毕,准备清空微任务队列。
- 微任务队列队首出队,不断执行微任务。
- 微任务队列清空完毕,回到第 3 步。
其中,同步任务是立即在当前执行栈执行的。不经过事件循环调度。
在 JS 引擎启动时,会创建全局执行上下文,随后开始执行如全局脚本,模块引入,顶层函数调用等同步代码。
如果不执行完毕同步任务,那么程序会被阻塞,是不会进入到事件循环的。
换句话说,同步任务通常是在初始化时进行的,随后的代码全部都在事件循环中被调度。
微任务与宏任务
事件循环中,任务被分为微任务(Microtask)和宏任务(Macrotask),字面意思分别是“微小的任务”和“宏大的任务”。
它们在事件循环中的调度方式不同:
- 宏任务是大任务,因此每次事件循环只会从宏任务队列中取出一个宏任务执行。常见宏任务包括
setTimeout
、setInterval
、I/O 回调等 - 微任务是小任务,因此每次事件循环都会将微任务队列清空。以确保当前所有的微任务都在下一轮宏任务之前被优先执行。常见微任务包括
Promise.then
、queueMicrotask
等
宏任务执行时将会进入同步任务,事件循环将被堵塞,不会再执行任何宏任务和微任务。期间可能会新增微任务,它们将会按顺序在微任务队列队尾入队。
宏任务是事件循环的调度单位,每次事件循环都基于宏任务,因此看起来是“宏任务先执行”而不是,但实际上微任务是优先于下一轮宏任务。
可以将事件循环看作是办理业务:
- 业务大厅开门前需要做一些准备,什么机器开机,准备打印纸这些。准备完毕后开门,可以办理业务。这一步对应的是同步任务步骤。
- 业务窗口每次只能接待一名客户办理业务,如果当前客户业务没有办理完毕,不能接待其他客户。这一步对应宏任务处理。
- 当前客户在办理业务中途可能会提出一些额外的业务请求,诸如查看某个信息,查询某个状态等等。服务人员将在接待下一位客户前将当前客户的所有额外请求处理完毕。这一步对应微任务处理。
- 额外请求处理完毕后,就可以接待下一位客户了。
阻塞
因为 JS 是单线程的。
同步任务是在当前执行栈直接执行,不经过事件循环。
因此若在同步任务中执行过久,会导致后续的宏任务,微任务全部无法执行。使得应用卡顿,卡死。
所以在实际开发中,通常会将大任务拆分为一个个子任务,或是开一个 Worker 等。避免同步任务执行过久。
举个例子,倘若我们需要在页面中创建 10000 个元素,如果是一次性创建,那么页面会变得非常卡顿,乃至卡死。知道所有元素创建完毕。
于是我们可以将这个大任务拆分,变成一个个子任务。
例如首页顶多渲染 100 个元素,那么我们就以 100 个元素为单位进行拆分,逐步创建,直到10000 个元素全部UC哈UN感觉。
实际应用
console.log('1. 同步任务开始');
// 宏任务
setTimeout(() => {
console.log('4. 宏任务执行');
// 在宏任务中产生的微任务
Promise.resolve().then(() => {
console.log('5. 宏任务中的微任务');
});
}, 0);
// 微任务
Promise.resolve().then(() => {
console.log('3. 微任务执行');
});
console.log('2. 同步任务结束');
// 输出顺序:
// 1. 同步任务开始
// 2. 同步任务结束
// 3. 微任务执行
// 4. 宏任务执行
// 5. 宏任务中的微任务
一般正常业务开发是不会写以上这种代码的。
通常是库和框架开发,以及在特定场景优化时才需要考虑借助事件循环。
但学习事件循环原理是可以在一定程度上避免一些潜在问题的。特别是在借助现代化框架开发项目时。