JS-EventLoop

javascript事件循环

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务

EventLoop

导图要表达的内容用文字来表述的话:

  • 同步和异步任务分别进入不同的执行”场所”,所有同步任务进入主线程执行,形成一个执行栈(execution context stack)。异步的进入Event Table并注册函数。
  • 异步任务指定的事情完成时,Event Table会将这个函数移入Event Queue。
  • 主线程(执行栈)内的所有同步任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程(执行栈)执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

例如:

1
2
3
4
5
6
7
8
9
let data = [];
$.ajax({
url: www.javascript.com,
data: data,
success: () => {
console.log('发送成功!');
}
})
console.log('代码执行结束');

上面是一段简易的ajax请求代码:

  • ajax进入Event Table,注册回调函数success
  • 执行console.log('代码执行结束')
  • ajax事件完成,回调函数success进入Event Queue。
  • 主线程从Event Queue读取回调函数success并执行。

定时器

setTimeout

1
2
3
4
5
6
7
8
9
10
11
12
function task() {
console.log("执行");
}

function sleep(num) {
for (let i = 0; i < num; i++) {}
}
setTimeout(() => {
task()
}, 3000)

sleep(10000000000)

把这段代码在chrome执行一下,却发现控制台执行task()需要的时间远远超过3秒

这时候我们需要重新理解setTimeout的定义。我们先说上述代码是怎么执行的:

  • task()进入Event Table并注册,计时开始。
  • 执行sleep函数,很慢,非常慢,计时仍在继续。
  • 3秒到了,计时事件timeout完成,task()进入Event Queue,但是sleep也太慢了吧,还没执行完,只好等着。
  • sleep终于执行完了,task()终于从Event Queue进入了主线程执行。

上述的流程走完,我们知道setTimeout这个函数,是经过指定时间后,把要执行的任务(本例中为task())加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。

我们还经常遇到setTimeout(fn,0)这样的代码,0秒后执行又是什么意思呢?是不是可以立即执行呢?

答案是不会的,setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。

setInterval

对于执行顺序来说,setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。

唯一需要注意的一点是,对于setInterval(fn,ms)来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。

任务

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

setTimeout(() => {
console.log(2)
}, 0)

Promise.resolve().then(() => {
console.log(3)
}).then(() => {
console.log(4)
})

console.log(5)
// 1、5、3、4、2

当遇到上面这种问题时,仅仅依靠上面的知识还是不够的。因为 setTimeoutPromise.then 都是异步的,怎么判断他们的执行顺序呢?

这就引出了宏任务与微任务。

这两个概念属于对异步任务的分类,不同的API注册的异步任务会依次进入自身对应的队列(Event Queue)中,然后等待Event Loop将它们依次压入执行栈中执行。比如setTimeoutsetInterval会进入相同的Event Queue。

特别的:script代码执行到底部就相当于一次宏任务。第一次执行栈执行的代码就是script代码执行到底部。这样就能理解下面的“每次执行栈执行的代码就是一个宏任务”这个说法了。

EventLoop

宏任务

(macro)task(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)

浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:

1
(macro)task->渲染->(macro)task->...

(macro)task主要包含:setTimeout、setInterval、I/O、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)

微任务

(micro)task(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。

所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个(macro)task执行完后,就会将在它执行期间产生的所有(micro)task都执行完毕(在渲染前)。

(micro)task主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 环境)

事件循环的顺序,决定js代码的执行顺序。进入整体代码后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
setTimeout(function () {
console.log('setTimeout');
})

new Promise(function (resolve) {
console.log('promise');
resolve();
}).then(function () {
console.log('then');
})

console.log('console');

这段代码作为宏任务,进入主线程。

先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)

接下来遇到了Promisenew Promise立即执行,then函数分发到微任务Event Queue。

遇到console.log(),立即执行。

好啦,整体代码执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。

ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。

结束。

下图是它们的关系:

EventLoop

测试题

实例1

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
26
27
28
29
30
31
32
33
34
35
36
37
console.log('1');

setTimeout(function () {
console.log('2');
process.nextTick(function () {
console.log('3');
})
new Promise(function (resolve) {
console.log('4');
resolve();
}).then(function () {
console.log('5')
})
})
process.nextTick(function () {
console.log('6');
})
new Promise(function (resolve) {
console.log('7');
resolve();
}).then(function () {
console.log('8')
})

setTimeout(function () {
console.log('9');
process.nextTick(function () {
console.log('10');
})
new Promise(function (resolve) {
console.log('11');
resolve();
}).then(function () {
console.log('12')
})
})
// 1,7,6,8,2,4,3,5,9,11,10,12

第一轮事件循环流程分析如下:

  • 整体代码作为进入主线程,遇到console.log,输出1。
  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1
  • 遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为process1
  • 遇到Promisenew Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1
  • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2
宏任务Event Queue 微任务Event Queue
setTimeout1 process1
setTimeout2 then1
  • 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。
  • 我们发现了process1then1两个微任务。
  • 执行process1,输出6。
  • 执行then1,输出8。

好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:

  • 首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2
宏任务Event Queue 微任务Event Queue
setTimeout2 process2
then2
  • 第二轮事件循环宏任务结束,我们发现有process2then2两个微任务可以执行。
  • 输出3。
  • 输出5。
  • 第二轮事件循环结束,第二轮输出2,4,3,5。
  • 第三轮事件循环开始,此时只剩setTimeout2了,执行。
  • 直接输出9。
  • process.nextTick()分发到微任务Event Queue中。记为process3
  • 直接执行new Promise,输出11。
  • then分发到微任务Event Queue中,记为then3
宏任务Event Queue 微任务Event Queue
process3
then3
  • 第三轮事件循环宏任务执行结束,执行两个微任务process3then3
  • 输出10。
  • 输出12。
  • 第三轮事件循环结束,第三轮输出9,11,10,12。

整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)

实例2

1
2
3
4
5
6
7
8
9
10
11
12
13
new Promise(resolve => {
resolve(1);

Promise.resolve().then(() => {
// t2
console.log(2)
});
console.log(4)
}).then(t => {
// t1
console.log(t)
});
console.log(3);

这段代码的流程大致如下:

  1. 首先遇到 Promise 实例,构造函数首先执行,所以首先输出了 4。此时 microtask 的任务有 t2t1
  2. 继续运行,输出 3。
  3. 执行所有的微任务,先后取出 t2t1,分别输出 2 和 1
  4. 代码执行完毕

综上,上述代码的输出是:4321

为什么 t2 会先执行呢?理由如下:

对于下面的解释还是不太理解。。。

实践中要确保 onFulfilled 和 onRejected 方法异步执行,且应该在 then 方法被调用的那一轮事件循环之后的新执行栈中执行

  • Promise.resolve 方法允许调用时不带参数,直接返回一个resolved 状态的 Promise 对象。立即 resolvedPromise 对象,是在本轮“事件循环”(event loop)的结束时执行,而不是在下一轮“事件循环”的开始时。

http://es6.ruanyifeng.com/#docs/promise#Promise-resolve

所以,t2t1 会先进入 microtask 的 Promise 队列。

总结

javascript是一门单线程语言,不管是什么新框架新语法糖实现的所谓异步,其实都是用同步的方法去模拟的,牢牢把握住单线程这点非常重要。

Event Loop(事件循环)是js实现异步的一种方法,也是js的执行机制。

参考

这一次,彻底弄懂 JavaScript 执行机制

从一道题浅说 JavaScript 的事件循环