​【面试必问】图解 JS 运行机制:从进程线程到 Event Loop 与 async/await

105 阅读6分钟

前端开发中,"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 与单线程的抉择

  1. 为什么是单线程?
    V8 引擎在执行 JS 的过程中,默认只开一个线程。 这是为了避免复杂的同步问题(比如多线程同时操作同一个 DOM 节点)。但也因为是单线程,意味着所有任务必须排队执行。
  2. 单线程带来的挑战:异步
    如果所有任务都同步执行,一个耗时的网络请求就会卡死整个页面。为了解决这个问题,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)。
    • 执行过程中,如果遇到异步任务(如 setTimeoutPromise.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);

3b735544d792f00db5e7d701aa3cc358.png 以上面代码为例,先执行同步代码输出 1 ;碰到异步代码 3set(第三行代码的定时器)把它放进宏任务里,再碰到异步代码 10set 同样放进宏任务里,再执行同步代码输出 5,接着执行微任务和渲染页面,没有就直接执行宏任务 3set耗时零秒,把里面的 5set 放入宏任务,由于在同一个宏任务里面,时间公共的,短的先执行,所以 5set 先执行,最后执行 10set 。


五、 async / await 的本质

async/await 是 ES7 引入的语法糖,它让异步代码看起来像同步代码,但其底层依然是基于 PromiseEvent 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

508b28b6-b669-4571-b6e3-829af0dbbe99.png

这里埋了一个小坑,第六行 async2()async 虽然给这个函数带来了一个 Promise 属性但是该函数里面的 console.log 并没有放进 Promise 里面,也就导致无法约束 async2()(如果没有 Promise 属性也是无法约束,但是再后面的同步代码依旧会被封装成一个微任务,异步代码去宏任务队列中),它还是被当作了一个异步函数处理所以最后打印出结果


六、 总结

理解 JS 运行机制,关键在于掌握这三点:

  • 互斥:JS 引擎与渲染引擎互斥,导致了 JS 可能会阻塞页面渲染。
  • 循环:Event Loop 是 宏任务 -> 清空微任务 -> 渲染 -> 下一个宏任务 的无限循环。
  • 微任务化:await 的本质是将后续代码变成了微任务,从而实现了“暂停”的效果。

希望这篇文章能帮你构建清晰的 JS 运行模型,下次面试遇到 Event Loop,直接画图给面试官看!💪