前端开发中,"JS 为什么是单线程的?"、"Event Loop 的执行顺序是怎样的?" 或者是 "宏任务与微任务的区别" 几乎是面试必问的金科玉律。
很多时候我们背诵了答案,却很难在脑海中构建出一个完整的运行模型。今天这篇文章,我们将从最底层的进程与线程开始,一步步拆解 V8 引擎、渲染机制以及异步编程的本质,帮你彻底打通任督二脉。
一、 进程与线程
首先,我们需要厘清 CPU 调度的两个基本概念。
核心定义
- 进程:
- 是 CPU 资源分配的最小单位。
- 本质:CPU 运行指令、加载和保存上下文所需的时间。
- 类比:一个工厂的车间(拥有独立的资源和地盘)。
- 线程:
- 是 CPU 调度的最小单位。
- 本质:CPU 执行指令需要的时间。
- 类比:车间里的流水线工人(共享车间的资源)。
二、 浏览器的“车间”:渲染进程中的线程
现代浏览器是多进程的。最直观的例子是:浏览器每多开一个 Tab 页,通常就是增加了一个进程。这样做的好处是,如果一个页面崩溃了,不会影响到其他页面(其他进程)。虽然浏览器是多进程的,但具体到我们看到的某一个 Tab 页(渲染进程),它内部包含了多个线程协同工作:
- 🎨 渲染线程
- 负责解析 HTML 生成 DOM 树。
- 负责解析 CSS 生成 CSSOM 树。
- 负责布局和绘制。
- ⚙️ JS 引擎线程
- 负责处理 JavaScript 脚本,运行代码( V8 引擎)。
- 📡 HTTP 请求线程
- 负责处理 Ajax 请求等网络交互。
- ⏱️ 定时器触发线程
- 负责 setTimeout、setInterval 等计时。
- 🖱️ 事件触发线程
- 负责处理点击、滚动等用户交互事件。
⚠️ 重点:互斥关系
JS 引擎线程和渲染线程是互斥的,不能同时执行。
- 原因:JavaScript 可以修改 DOM(HTML)和样式(CSS)。如果在渲染页面时 JS 同时去修改 DOM,浏览器就不知道该听谁的了。
- 后果:当 JS 执行时间过长时,会阻塞页面的渲染,导致页面出现“卡顿”。
三、 V8 与单线程的抉择
- 为什么是单线程?
V8 引擎在执行 JS 的过程中,默认只开一个线程。 这是为了避免复杂的同步问题(比如多线程同时操作同一个 DOM 节点)。但也因为是单线程,意味着所有任务必须排队执行。 - 单线程带来的挑战:异步
如果所有任务都同步执行,一个耗时的网络请求就会卡死整个页面。为了解决这个问题,JS 引入了异步 机制。
单线程处理代码的过程:
- 同步任务:遇到后立即在主线程执行。
- 异步任务:遇到后,将其挂起,存放到任务队列中。
- 执行时机:等待 JS 引擎线程空闲(主线程代码执行完毕),再从任务队列中取出异步任务执行。
四、 Event Loop机制
Event Loop 是 JS 实现异步的核心。它负责在主线程、微任务队列、宏任务队列之间协调工作。
1. 任务分类
| 类型 | 描述 | 常见场景 |
|---|---|---|
| 微任务 | 优先级高,在当前宏任务结束后立即执行 | Promise.then(), process.nextTick()(Node), MutationObserver |
| 宏任务 | 粒度大,每次循环执行一个 | script(整体代码), setTimeout, setInterval, Ajax, I/O(用户交互), UI-rendering(页面渲染) |
注:setTimeout() 是执行一次,setInterval() 是隔一段时间重复执行,但它们本质都属于宏任务。
2. ♻️ 详细执行顺序(闭环)
- 执行同步代码:
- 这本身属于第一个宏任务(
Script)。 - 执行过程中,如果遇到异步任务(如
setTimeout和Promise.then()),分别放入对应的宏任务队列或微任务队列。
- 这本身属于第一个宏任务(
- 清空微任务队列:
- 同步代码执行完后,立即检查微任务队列。
- 执行微任务队列中的所有代码,直到队列为空。
- 页面渲染 (UI Rendering):
- 微任务全部结束后,浏览器判断是否需要更新页面(DOM/CSS 变动),如果有则进行渲染。
- 执行下一个宏任务:
- 从宏任务队列中取出一个任务执行。(在同一个宏任务里面,时间公共的,短的先执行)
- 回到步骤 1,开始下一次循环。
console.log(1);
setTimeout(() => {
console.log(2);
setTimeout(() => {
console.log(3)
}, 1000)
}, 0)
setTimeout(() => {
console.log(4)
}, 2000)
console.log(5);
以上面代码为例,先执行同步代码输出 1 ;碰到异步代码 3set(第三行代码的定时器)把它放进宏任务里,再碰到异步代码 10set 同样放进宏任务里,再执行同步代码输出 5,接着执行微任务和渲染页面,没有就直接执行宏任务 3set耗时零秒,把里面的 5set 放入宏任务,由于在同一个宏任务里面,时间公共的,短的先执行,所以 5set 先执行,最后执行 10set 。
五、 async / await 的本质
async/await 是 ES7 引入的语法糖,它让异步代码看起来像同步代码,但其底层依然是基于 Promise 和 Event Loop 的。
1. async
函数前面加一个 async,等同于该函数内部返回了一个 Promise 实例对象。
2. await 的约束力
- await 必须跟 async 配合使用。
- await 后面通常接一个 Promise 对象。如果不接 Promise,await 也就无法发挥“等待”的作用,代码会直接继续执行。
3. ⭐ await 的执行逻辑(关键)
很多同学会疑惑:“为什么 await 后面的代码像同步一样?”
其实,await fn() 把 fn() 当成同步看待,是因为 await 会把它后续的同步代码“挤”到微任务队列中,异步代码去到宏任务队列中。
具体流程:
- 执行 await 同一行的代码(通常是一个 Promise)。
- 暂停 async 函数的执行。
- 将 await 下面的所有同步代码封装成一个微任务,放入微任务队列,异步代码去宏任务队列中。
- JS 引擎跳出当前 async 函数,继续执行外面的同步代码。
- 等外面的同步代码执行完,Event Loop 回来处理微任务队列时,才会执行 await 后面的代码。
4、 自我检验
console.log('script start'); // 1
async function async1() {
await async2()
console.log('async1 end'); // 4
}
async function async2() {
setTimeout(() => {
console.log('async2 end'); // 8
}, 1000)
}
async1()
setTimeout(() => {
console.log('setTimeout'); //7
}, 0)
new Promise((resolve, reject) => {
console.log('promise'); // 2
resolve()
})
.then(() => {
console.log('then1'); //5
})
.then(() => {
console.log('then2'); //6
});
console.log('script end'); // 3
这里埋了一个小坑,第六行 async2() ,async 虽然给这个函数带来了一个 Promise 属性但是该函数里面的 console.log 并没有放进 Promise 里面,也就导致无法约束 async2()(如果没有 Promise 属性也是无法约束,但是再后面的同步代码依旧会被封装成一个微任务,异步代码去宏任务队列中),它还是被当作了一个异步函数处理所以最后打印出结果
六、 总结
理解 JS 运行机制,关键在于掌握这三点:
- 互斥:JS 引擎与渲染引擎互斥,导致了 JS 可能会阻塞页面渲染。
- 循环:Event Loop 是 宏任务 -> 清空微任务 -> 渲染 -> 下一个宏任务 的无限循环。
- 微任务化:await 的本质是将后续代码变成了微任务,从而实现了“暂停”的效果。
希望这篇文章能帮你构建清晰的 JS 运行模型,下次面试遇到 Event Loop,直接画图给面试官看!💪