最近在看关于 js 的事件循环机制,(很多公司必问的面试题)看了几篇文章后准备总结出来分享给大家

众所周知,JavaScript 是一门单线程语言,虽然在 html5 中提出了 Web-Worker ,但这并未改变 JavaScript 是单线程这一核心,,可是浏览器又能很好的处理异步请求,那么到底是为什么呢?

浏览器执行线程

在解释事件循环之前首先先解释一下浏览器的执行线程:
浏览器是多进程的,浏览器每一个 tab 标签都代表一个独立的进程,其中浏览器渲染进程(浏览器内核)属于浏览器多进程中的一种,主要负责页面渲染,脚本执行,事件处理等
其包含的线程有:GUI 渲染线程(负责渲染页面,解析 HTML,CSS 构成 DOM 树)、JS 引擎线程、事件触发线程、定时器触发线程、http 请求线程等主要线程

关于执行中的线程:

主线程:也就是 js 引擎执行的线程,这个线程只有一个,页面渲染、函数处理都在这个主线程上执行。
工作线程:也称幕后线程,这个线程可能存在于浏览器或 js 引擎内,与主线程是分开的,处理文件读取、网络请求等异步事件。

任务队列( Event Queue )

所有的任务可以分为同步任务和异步任务,同步任务,顾名思义,就是立即执行的任务,同步任务一般会直接进入到主线程中执行;而异步任务,就是异步执行的任务,比如 ajax 网络请求,setTimeout 定时函数等都属于异步任务,异步任务会通过任务队列的机制(先进先出的机制)来进行协调。具体的可以用下面的图来大致说明一下:

img

同步和异步任务分别进入不同的执行环境,同步的进入主线程,即主执行栈,异步的进入任务队列。主线程内的任务执行完毕为空,会去任务队列读取对应的任务,推入主线程执行。 上述过程的不断重复就是我们说的 Event Loop (事件循环)。

在事件循环中,每进行一次循环操作称为 tick,通过阅读规范可知,每一次 tick 的任务处理模型是比较复杂的,其关键的步骤可以总结如下:

1.在此次 tick 中选择最先进入队列的任务( oldest task ),如果有则执行(一次)

2.检查是否存在 Microtasks ,如果存在则不停地执行,直至清空 Microtask Queue

3.更新 render

4.主线程重复执行上述步骤

可以用一张图来说明下流程:

img

这里相信有人会想问,什么是 microtasks ?规范中规定,task 分为两大类, 分别是 Macro Task (宏任务)和 Micro Task(微任务), 并且每个宏任务结束后, 都要清空所有的微任务,这里的 Macro Task 也是我们常说的 task ,有些文章并没有对其做区分,后面文章中所提及的 task 皆看做宏任务( macro task)。

宏任务主要包含:script( 整体代码)、setTimeout、setInterval、I/O、UI 交互事件、setImmediate(Node.js 环境)

微任务主要包含:Promise、MutationObserver、queueMicroTasks、process.nextTick(Node.js 环境)

setTimeout/Promise 等 API 便是任务源,而进入任务队列的是由他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中 setTimeout 与 setInterval 是同源的。

举例

掌握概念之后,我们来做一个例子强化一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('script start')

setTimeout(function () {
console.log('setTimeout')
}, 0)

Promise.resolve()
.then(function () {
console.log('promise1')
})
.then(function () {
console.log('promise2')
})

console.log('script end')

整体 script 作为第一个宏任务进入主线程,遇到 console.log,输出 script start
遇到 setTimeout,其回调函数被分发到宏任务 Event Queue 中
遇到 Promise,其 then 函数被分到到微任务 Event Queue 中,记为 then1,之后又遇到了 then 函数,将其分到微任务 Event Queue 中,记为 then2
遇到 console.log,输出 script end

至此,Event Queue 中存在三个任务:宏任务:setTimeout 微任务:then1、then2

执行微任务,首先执行 then1,输出 promise1, 然后执行 then2,输出 promise2,这样就清空了所有微任务
执行 setTimeout 任务,输出 setTimeout 至此,输出的顺序是:script start, script end, promise1, promise2, setTimeout

再来一个题目,来做个练习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log('script start')

setTimeout(function () {
console.log('timeout1')
}, 10)

new Promise((resolve) => {
console.log('promise1')
resolve()
setTimeout(() => console.log('timeout2'), 10)
}).then(function () {
console.log('then1')
})

console.log('script end')

这个题目就稍微有点复杂了,我们再分析下:

首先,事件循环从宏任务 (macrotask) 队列开始,最初始,宏任务队列中,只有一个 scrip t(整体代码)任务;当遇到任务源 (task source) 时,则会先分发任务到对应的任务队列中去。所以,就和上面例子类似,首先遇到了 console.log,输出 script start; 接着往下走,遇到 setTimeout 任务源,将其分发到任务队列中去,记为 timeout1; 接着遇到 promise,new promise 中的代码立即执行,输出 promise1, 然后执行 resolve ,遇到 setTimeout ,将其分发到任务队列中去,记为 timemout2, 将其 then 分发到微任务队列中去,记为 then1; 接着遇到 console.log 代码,直接输出 script end 接着检查微任务队列,发现有个 then1 微任务,执行,输出 then1 再检查微任务队列,发现已经清空,则开始检查宏任务队列,执行 timeout1,输出 timeout1; 接着执行 timeout2,输出 timeout2 至此,所有的都队列都已清空,执行完毕。其输出的顺序依次是:script start, promise1, script end, then1, timeout1, timeout2

用流程图看更清晰:

img

总结

有个小 tip:从规范来看,microtask 优先于 task 执行,所以如果有需要优先执行的逻辑,放入 microtask 队列会比 task 更早的被执行。

最后的最后,记住,JavaScript 是一门单线程语言,异步操作都是放到事件循环队列里面,等待主执行栈来执行的,并没有专门的异步执行线程。

转载自:https://zhuanlan.zhihu.com/p/87684858