单线程的 Javascript 为什么可以异步

# 前言

看下面一段代码:

console.log("start");

setTimeout(() => {
    console.log("children2");

    Promise.resolve().then(() => {
        console.log("children3");
    });
}, 0);

new Promise((resolve, reject) => {
    console.log("children4");

    setTimeout(() => {
        console.log("children5");

        resolve("children6");
    }, 0);
}).then(result => {
    console.log("children7");

    setTimeout(() => {
        console.log(result);
    }, 0);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

打印结果:

start
chidlren4
chidlren2
chidlren3
chidlren5
chidlren7
chidlren6
1
2
3
4
5
6
7

疑问:既然 Javascript 是单线程的,为什么不是从上到下的打印结果呢?

# 浏览器内核是多线程的

虽然 Javascript 是单线程的,但是浏览器却不是,在内核控制下,各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

  • 浏览器
    • 浏览器进程
    • 渲染进程
      • Javascript 引擎线程
      • GUI 渲染线程
      • 定时触发器线程
      • 事件触发线程
      • 异步 HTTP 请求线程
    • GPU 进程
    • 网络进程
    • 插件进程

Javascript 引擎线程称为主线程,其他可以称为辅助线程,这些辅助线程便是 Javascript 实现异步的关键

# Javascript 引擎线程

Javascript 引擎,也叫 Javascript 内核,主要负责处理 Javascript 脚本程序,解析 Javascript 脚本,运行代码,例如 V8 引擎

# GUI 渲染线程

GUI 渲染线程,负责渲染浏览器界面,解析 HTML、CSS,构建 DOM 树和 RenderObject 树,布局和绘制等

当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行

Javascript 引擎线程运行脚本期间,GUI 渲染线程都是处于挂起状态的,也就是说被“冻结”了

GUI 渲染线程Javascript 引擎线程互斥

由于 Javascript 是可操纵 DOM 的,如果在修改这些元素属性同时渲染界面(即Javascript 引擎线程GUI 渲染线程同时运行),那么GUI 渲染前后获得的元素数据就可能不一致了;因此,为了防止渲染出现不可预期的结果,浏览器设置GUI 渲染线程Javascript 引擎线程为互斥的关系,当Javascript 引擎线程执行时,GUI 渲染线程会被挂起,GUI 更新会被保存在一个队列中,等到Javascript 引擎线程空闲时立即被执行。

Javascript 阻塞页面加载

由于GUI 渲染线程Javascript 引擎线程是互斥的关系,当浏览器在执行 Javascript 程序的时候,GUI 渲染线程会被保存在一个队列中,直到 Javascript 程序执行完成,才会接着执行;因此,如果 Javascript 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。

# 定时触发器线程

setIntervalsetTimeout所在线程,避免Javascript 引擎线程处于阻塞线程状态影响计时的准确

# 事件触发线程

将满足触发条件的事件放入任务队列

当一个事件被触发时,事件触发线程会把事件添加到待处理队列的队尾,等待Javascript 引擎线程的处理;这些事件可以是当前执行的代码块,如定时任务,也可来自浏览器内核的其他线程,如鼠标点击、Ajax 异步请求等,但由于 Javascript 的单线程关系,所有这些事件都得排队,等待Javascript 引擎线程处理。

# 异步 HTTP 请求线程

XMLHttpRequest 在连接后,通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到Javascript 引擎线程的处理队列中等待处理。

# 任务队列

任务队列,即事件循环(Event Loop),Javascript 管理事件执行的一个流程

当任务加入到任务队列后并不会立即执行,而是处于等候状态,等主线程处理完了自己的事情后,才来执行任务队列中任务

任务又分为两种:

  • 宏任务(MacroTask 或者 Task)
    • script
    • setInterval/setTimeout
    • setImmediate(NodeJS)
    • requestAnimationFrame
    • I/O
    • ajax
    • 事件绑定
    • MessageChannel
    • postMessage
  • 微任务(MicroTask)
    • UI rendering
    • Promise
    • process.nextTick(NodeJS)
    • Object.observe
    • MutationObserve

微任务拥有更高的优先级,可以插队,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务

  1. 一个事件循环可以有一个或多个事件队列,但是只有一个微任务队列。
  2. 微任务队列全部执行完会重新渲染一次
  3. 每个宏任务执行完都会重新渲染一次
  4. requestAnimationFrame 处于渲染阶段,不在微任务队列,也不在宏任务队列

# Web Worker

Web Worker 能够同时执行两段 Javascript,不代表 Javascript 实现了多线程,Web Worker 是向浏览器申请一个子线程,该子线程服务于主线程,完全受主线程控制

  • 同源限制
  • DOM 限制
  • 通信限制
  • 脚本限制
  • 文件限制

# 同源限制

分配给 Worker 线程运行的脚本文件,必须和主线程的脚本文件同源

# DOM 限制

Worker 线程所在的全局对象与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用 document、window、parent 这些对象,但是,Worker 线程可以使用 navigator 对象和 location 对象

# 通信限制

Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成

# 脚本限制

Worker 线程不能执行 alter 和 confirm 方法,但可以使用 XMLHttpRequest 对象发出 Ajax 请求

# 文件限制

Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本必须来自网络

# 最后

回到前言,分析执行流程:

  1. 先执行宏任务 script 脚本
  2. 执行 console.log("start")
  3. 遇到定时器,交由定时触发器线程,等待时间到了加入队列
  4. 遇到 Promise 直接执行 executor,打印 console.log("children4");遇到第二定时器,又交由定时触发器线程管理,等待加入队列;Promise.then等 resolve 之后加入微队列;此时,第一轮任务执行完毕
  5. 第一定时器先进入队列,取出任务执行 console.log("children2"),此时遇到 Promise 执行,并将 Promise.then 放入当前宏任务队列中的微任务队列,当前任务执行完毕;执行 then,打印 console.log("children3")
  6. 取出第二定时器,打印 console.log("children5"),并将 then 放入微任务中,当前宏任务执行完毕,取出 then 执行打印 console.log("children7")
  7. 又遇到定时器,由定时触发器线程等待时间到了添加到宏任务中
  8. 取出定时器任务,打印 console.log("children6")