单线程的 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);
});
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
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 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞的感觉。
# 定时触发器线程
setInterval和setTimeout所在线程,避免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
微任务拥有更高的优先级,可以插队,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务
- 一个事件循环可以有一个或多个事件队列,但是只有一个
微任务队列。 微任务队列全部执行完会重新渲染一次- 每个
宏任务执行完都会重新渲染一次 - 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://),它所加载的脚本必须来自网络
# 最后
回到前言,分析执行流程:
- 先执行
宏任务script 脚本 - 执行
console.log("start") - 遇到定时器,交由
定时触发器线程,等待时间到了加入队列 - 遇到 Promise 直接执行 executor,打印
console.log("children4");遇到第二定时器,又交由定时触发器线程管理,等待加入队列;Promise.then等 resolve 之后加入微队列;此时,第一轮任务执行完毕 - 第一定时器先进入队列,取出任务执行
console.log("children2"),此时遇到 Promise 执行,并将Promise.then放入当前宏任务队列中的微任务队列,当前任务执行完毕;执行 then,打印console.log("children3") - 取出第二定时器,打印
console.log("children5"),并将 then 放入微任务中,当前宏任务执行完毕,取出 then 执行打印console.log("children7") - 又遇到定时器,由
定时触发器线程等待时间到了添加到宏任务中 - 取出定时器任务,打印
console.log("children6")