JS中的事件循环原理以及异步执行过程这些知识点对新手来说可能有点难,但是是必须迈过的坎,逃避是解决不了问题的,本篇文章旨在帮你彻底搞懂它们。
说明:本篇文章主要是基于浏览器环境,Node环境没有研究过暂时不讨论。文章的内容也是博采众长以及结合自己的理解完成的,相关参考文献文章末尾也会给出,若有侵权请告知整改,或有描述不正确的也欢迎提醒。文章会有点长,耐心看完哦~
不废话,让我们从简单到复杂,步步深入,去享受知识的盛宴~
我们都知道JS是单线程执行的(原因:我们不想并行地操作DOM,DOM树不是线程安全的,如果多线程,那会造成冲突),one thread --> one call stack --> one thing at a time
,也就是说,它只有一个执行栈(call stack),在同一时刻,JS引擎只能做一件事(JS在执行时有一个非常重要的特性:run to complete
,只要运行就直到完成)。看到 “JS是单线程的”这句话的时候,不知道你有没有这样的疑惑:既然JS是单线程的,那么我在网页向后端请求数据的时候,我怎么还可以操作页面:我可以滚动页面,我也可以点击按钮,这不是跟JS是单线程的冲突吗?这个问题困扰了我好久,很大的一个原因是:我以为浏览器单单只是由一个JS引擎构成的。如下图(我以为的浏览器构造,这里以谷歌浏览器chrome为例):
这里小说明一下:V8是谷歌浏览器的JS执行引擎,在运行JS代码的时候,是以函数作为一个个帧(保存当前函数的执行环境)按代码的执行顺序压入执行栈(call stack)中,栈顶的函数先执行,执行完毕后弹出再执行下一个函数。其中堆是用来存放各种JS对象的。
假设浏览器就是上图的这种结构的话,执行同步代码是没什么问题的,如下代码1:
function foo() {
bar()
console.log('foo')
}
function bar() {
baz()
console.log('bar')
}
function baz() {
console.log('baz')
}
foo()
我们定义了foo、bar、baz三个函数,然后调用foo函数,控制台输出的结果为:
baz
bar
foo
执行过程如下:
console.log('baz')
,执行,在控制台打印:baz,然后baz函数执行完毕弹出执行栈。baz()
语句已经执行完,接着执行下一条语句(console.log('bar')
),在控制台打印:bar,然后bar函数执行完毕弹出执行栈。bar()
语句已经执行完,接着执行下一条语句(console.log('foo')
),在控制台打印:foo,然后foo函数执行完毕弹出执行栈。还是图直观点,以上步骤对应的执行流程图如下:
非动图:
但是,如果我们代码中有异步事件该怎么办?
我们改变一下代码1,代码2如下:
function foo() {
bar()
console.log('foo')
}
function bar() {
baz()
console.log('bar')
}
function baz() {
setTimeout(() => {
console.log('setTimeout: 2s')
}, 2000)
console.log('baz')
}
foo()
其他都不变,就在baz函数中增加了一个setTimeout函数。根据1中的假设,浏览器只由一个JS引擎构成的话,那么所有的代码必然同步执行(因为JS执行是单线程的,所以当前栈顶函数不管执行时间需要多久,执行栈中该函数下面的其他函数必须等它执行完弹出后才能执行(这就是代码被阻塞的意思)),执行到baz函数体中的setTimeout时应该等2秒,在控制台中输出setTimeout: 2s
,然后再输出:baz
。所以我们期望的输出顺序应该是:setTimeout: 2s -> baz -> bar -> foo(这是错的)。
浏览器如果真这样设计的话,肯定是有问题的!!! 遇到AJAX请求、setTimeout等比较耗时的操作时,我们页面需要长时间等待,就被阻塞住啥也干不了,出现了页面“假死”,这样绝对不是我们想要的结果。
实际当然并非我以为的那样,这里先重点提醒一下:JS是单线程的,这一点也没错,但是浏览器中并不仅仅只是由一个JS引擎构成,它还包括其他的一些线程来处理别的事情。如下图(此图参考了Philip Roberts的演讲:《Help, I’m stuck in an event-loop》(YouTube版),被墙的可以看这:《Help, I’m stuck in an event-loop》(bilibili版),这视频推荐大家观看):
浏览器除了JS引擎(JS执行线程,后面我们只关注JS引擎中的执行栈)以外,还有Web APIs(浏览器提供的接口,这是在JS引擎以外的)线程、GUI渲染线程等(如下表)。JS引擎在执行过程中,如果遇到相关的事件(DOM操作、鼠标点击事件、滚轮事件、AJAX请求、setTimeout等),并不会因此阻塞,它会将这些事件移交给Web APIs线程处理,而自己则接着往下执行。Web APIs(这里其实有一个event table,用于记录各种事件)则会按照一定的规则将这些事件放入一个任务队列(callback queue,也叫 task queue,HTML标准定义中,任务队列的数据结构其实不是队列,而是Set(集合),比如,当前执行栈正在执行,即使有一个定时器回调已经在任务队列中等待,此时发生了一个鼠标点击事件,那么该点击事件回调也会添加到任务队列中,此后执行栈变为空,JS引擎是会先取鼠标点击事件的回调执行,而不是先添加到任务队列中的定时器回调。即任务队列中是由一个个集构成的,各个集的执行先后是确定好的,按集的优先级取回调执行,集內是同一类型的回调才是按照先进先出的队列模式)中,当JS执行栈中的代码执行完毕以后,它就会去任务队列中获取一个事件回调放入执行栈中执行,然后如此往复,这就是所谓的事件循环机制。
线程名 | 作用 |
---|---|
JS引擎线程 | 也称为JS内核,负责处理JavaScript脚本。(例如V8引擎) ①JS引擎线程负责解析JS脚本,运行代码。 ②JS引擎一直等待着任务队列中的任务的到来,然后加以处理。 ③一个Tab页(renderer进程)中无论什么时候都只有一个JS线程运行JS程序。 |
事件触发线程 | 归属于渲染进程而不是JS引擎,用来控制事件循环 ①当JS引擎执行代码块如setTimeout时(也可来自浏览器内核的其他线程,如鼠标点击、Ajax异步请求等),会将对应任务添加到事件线程中。 ②当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。 注意:由于JS的单线程关系,所以这些待处理队列中的事件都是排队等待JS引擎处理,JS引擎空闲时才会执行。 |
定时触发器线程 | setInterval和setTimeout所在的线程 ①浏览器定时计数器并不是由JS引擎计数的。 ②JS引擎时单线程的,如果处于阻塞线程状态就会影响计时的准确,因此,通过单独的线程来计时并触发定时。 ③计时完毕后,添加到事件队列中,等待JS引擎空闲后执行。 注意:W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。 |
异步http请求线程 | XMLHttpRequest在连接后通过浏览器新开一个线程请求 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调放入事件队列中,再由JS引擎执行。 |
GUI渲染线程 | 负责渲染浏览器界面,包括: ①解析HTML、CSS,构建DOM树和RenderObject树,布局和绘制等。 ②重绘(Repaint)以及回流(Reflow)处理。 |
这里让我们对事件循环先来做个小总结:
让我们来看看真正的浏览器中执行代码1是什么个流程吧!
TIP:这里的流程示例网站也是Philip Roberts的演讲中提到的(是他本人写的),可以自己去尝试尝试:传送门
在代码1中并没有出现异步事件,也就不会调用到Web API线程,所以动图中的Web API和任务队列一直为空。
这次,让我们运行一下有异步事件的代码2看看什么效果:
可以看到,当JS执行栈执行到baz中的setTimeout时,执行栈将该事件推送给Web API处理(Web API开始计时,而不是JS引擎来计时),自己则不被阻塞继续执行,当JS执行栈为空时再去任务队列中获取事件执行。所以代码2的正确运行结果打印出来的顺序应该是:baz -> bar -> foo -> setTimeout: 2s。
细心的小伙伴可能有发现Web API在计时器时间到达后将匿名回调函数添加到任务队列中了,虽然定时器时间已到,但它目前并不能执行!!!因为JS的执行栈此时并非空,必须要等到当前执行栈为空后才有机会被召回到执行栈执行。由此,我们可以得出一个结论:setTimeout设置的时间其实只是最小延迟时间,而并不是确切的等待时间。(当主线程的任务耗时比较长的时候,等待时间将会变得更长)
相信有了以上的铺垫之后,你对浏览器中JS的执行流程有点感觉了,让我们趁热打铁,进一步探讨事件循环和异步吧~
现在让我们试试0秒延时的setTimeout执行会如何,按道理来说0秒延迟就是立即执行,那么控制台打印结果应该为:setTimeout: 0s -> foo,事实如此吗?
function foo() {
console.log('foo');
}
setTimeout(function() {
console.log('setTimeout: 0s');
}, 0);
foo();
实际控制台打印结果的顺序为:foo -> setTimeout: 0s,来看看实际代码执行的过程:
可以看到,即使setTimeout的延时设置为0(实际上最小延时 >=4ms,参考这),JS执行栈也将该延时事件发放给Web API处理,Web API再将事件添加到任务队列中,等JS执行栈为空时,该延时事件再压入执行栈中执行。由此我们可以得出一个结论:JS执行栈只要遇到异步函数,则无脑推给Web APIs处理。与许多其他语言不同,JS永不阻塞(也存在一些遗留的意外:如 alert 或者同步 XHR)。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。
现在是时候再深入一步了,我们ES6中新增的promise已经迫不及待地亮相了!(本篇文章不讨论Promise相关的知识点,如果你对Promise不了解的话,建议先去看看相关知识点)。
其实以上的浏览器模型是ES5标准的,ES6+标准中的任务队列在此基础上新增了一种,变成了如下两种:
setTimeout(func)
即可将func
函数添加到宏任务队列中(使用场景:将计算耗时长的任务切分成小块,以便于浏览器有空处理用户事件,以及显示耗时进度)。queueMicrotask(func)
可以将func
函数添加到微任务队列中。那么,现在的事件循环模型就变成了如下的样子:
事件循环的处理流程变成了如下:
一图胜千言,画个流程图更加清晰,帮助记忆:
排一下先后顺序: 执行栈 --> 微任务 --> 渲染 --> 下一个宏任务。
先来个只有Promise的例子热热身:
function foo() {
console.log('foo')
}
console.log('global start')
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('promise then')
})
foo()
console.log('global end')
控制台输出的结果为:
//前面的序号不用管,是给接下来的描述用的
global start
promise
foo
global end
promise then
代码执行的解释:
console.log('global start')
语句,打印出:global start。new Promise(....)
,执行之(这里说明一点:在使用new关键字来创建Promise对象时,传递给Promise的函数称为executor,当promise被创建的时候executor函数会自动执行,而then里面的东西才是异步执行的部分),Promise参数中的匿名函数与主线程同步执行,执行console.log('promise')
打印出:promise。在执行resolve()
之后Promise状态变为resolved,再继续执行then(...)
,遇到then则将其提交给Web API处理,Web API将其添加到微任务队列(注意:此时微任务队列中已有一个Promise事件待处理)。foo()
,执行foo函数,打印出:foo。console.log('global end')
,执行后打印出:global end。至此,本轮事件循环已结束,执行栈为空。console.log('promise then')
,打印出:promise then。至此,新的一轮事件循环(Promise事件)已结束,执行栈为空。(注意:此时微任务队列为空)用动图来展示一下执行的流程(备注:该demo网站并未画出微任务队列,我们需自己脑补一下microtask queue):
我们已经对单独的宏任务和微任务的执行流程分别做了分析,现在让我们混合这两种任务的事件来看看结果如何,来个代码示例小试牛刀:
function foo() {
console.log('foo')
}
console.log('global start')
setTimeout(() => {
console.log('setTimeout: 0s')
}, 0)
new Promise((resolve) => {
console.log('promise')
resolve()
}).then(() => {
console.log('promise then')
})
foo()
console.log('global end')
控制台输出的结果为:
global start
promise
foo
global end
promise then
setTimeout: 0S
代码执行的解释(相比3.2.1中的代码,黄色背景为改变的部分):
console.log('global start')
语句,打印出:global start。new Promise(....)
,执行之,Promise参数中的匿名函数同步执行,执行console.log('promise')
打印出:promise。在执行resolve()
之后Promise状态变为resolved,再继续执行then(...)
,遇到then则将其提交给Web API处理,Web API将其添加到微任务队列(注意:此时微任务队列中已有一个Promise事件待处理)。foo()
,执行foo函数,打印出foo。console.log('global end')
,执行后打印出:global end。至此,本轮事件循环已结束,执行栈为空。console.log('promise then')
,打印出:promise then。至此,新的一轮事件循环(Promise事件)已结束,执行栈为空。(注意:此时微任务队列为空)console.log('setTimeout: 0s')
语句,打印出:setTimeout: 0s。至此,新的一轮事件循环(setTimeout事件)已结束,执行栈为空。(注意:此时微任务队列为空,宏任务队列也为空)这个例子比较详细地解释了一遍,一共发生了三次事件循环。同理,还是用个动图来直观地展示代码执行过程吧!
相信耐心看到这的你已经对事件循环机制以及宏任务和微任务的执行顺序有个清晰的了解了吧!不过,还没结束哦,我们async/await(不了解的人建议先去补习一下:async_function)还没讲呢!
这里简单介绍下async函数:
async
关键字的作用就2点:①这个函数总是返回一个promise。②允许函数内使用await
关键字。await
使async函数一直等待(执行栈当然不可能停下来等待的,await将其后面的内容包装成promise交给Web APIs后,执行栈会跳出async函数继续执行),直到promise执行完并返回结果。await
只在async函数函数里面奏效。像上面一样,我们先单独拎出async函数来看看是怎么样个执行流程吧~
function foo() {
console.log('foo')
}
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
console.log('global start')
async1()
foo()
console.log('global end')
这里就增加了两个async函数:async1、async2。执行的结果如下:
global start
async1 start
async2
foo
global end
async1 end
我们再来逐条解析一下代码的执行过程吧(前面那些我们已经懂的就不重复了):
console.log('global start')
,打印出:global start。async1()
,进入到async1函数体内,执行console.log('async1 start')
,打印出:async1 start。接着执行await async2()
,这里await
关键字的作用就是await下面的代码只有当await后面的promise返回结果后才可以执行(此时,微任务队列有一事件,其实就是Promise事件),而await async2()
语句就像执行普通函数一样执行async2()
,进入到async2函数体中;执行console.log('async2')
,打印出:async2。async2函数执行结束弹出执行栈。await
关键字之后的语句已经被暂停,那么async1函数执行结束,弹出执行栈。JS主线程继续向下执行,执行foo()
函数打印出:foo。console.log('global end')
,打印出:global end。该语句之后再无其他需执行的代码,执行栈为空,则本轮事件执行结束。await async2()
语句,async2函数执行完毕后,promise状态变为settled,之后的代码就可以继续执行了(可以这么理解:用一个匿名函数包裹await
语句之后的代码作为一个微任务事件),执行console.log('async1 end')
语句,打印出:async1 end。执行栈又为空,本轮事件也执行结束。至此,单一事件类型我们都掌握了,下面我们综合演练一下!
这里来几道常见的题目来考察自己的掌握程度以及进一步巩固吧!这里不再逐步分析了,有困惑的可以留言再解答。
//请写出输出内容
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出结果:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
//async2做出如下更改:
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise3');
resolve();
}).then(function() {
console.log('promise4');
});
console.log('script end');
输出的结果:
script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout
async function async1() {
console.log('async1 start');
await async2();
//更改如下:
setTimeout(function() {
console.log('setTimeout1')
},0)
}
async function async2() {
//更改如下:
setTimeout(function() {
console.log('setTimeout2')
},0)
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout3');
}, 0)
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出的结果:
script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1
async function a1 () {
console.log('a1 start')
await a2()
console.log('a1 end')
}
async function a2 () {
console.log('a2')
}
console.log('script start')
setTimeout(() => {
console.log('setTimeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise1')
})
a1()
let promise2 = new Promise((resolve) => {
resolve('promise2.then')
console.log('promise2')
})
promise2.then((res) => {
console.log(res)
Promise.resolve().then(() => {
console.log('promise3')
})
})
console.log('script end')
输出的结果:
script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout
怎么样,都做对了吗?其实就是将这几个异步事件糅合在一起罢了,只要我们分别掌握了它们的执行过程,一步步拆开分析,一点都不难,这都是纸老虎而已!
呼~长吁一口气,相信看到这的你已经疲惫了,不过恭喜:你应该完全掌握了事件循环以及异步执行机制了吧!最后,让我们再总结一下本文涉及的要点吧!
JS是单线程执行的,同一时间只能处理一件事。但是浏览器是有多个线程的,JS引擎通过分发这些耗时的异步事件(AJAX请求、DOM操作等)给Wep APIs线程处理,因此避免了单线程被耗时的异步事件阻塞的问题。
Web APIs线程会将接收到的所有事件中已完成的事件根据类别分别将它们添加到相应的任务队列中。其中任务队列分以下两种:
task queue
,也即本文图中的callback queue
,macrotask是我们给它的别名,原因只是为了与ES6新增的microtask队列作区分而这样称呼,HTML标准中并没有macrotask这种说法。它存放的是DOM事件、AJAX事件、setTimeout事件等。事件循环(event loop) 机制是为了协调事件(events)、用户交互(user interaction)、JS脚本(scripts)、页面渲染(rendering)、网络请求(networking)等等事件的有序执行而设置(定义由HTML标准给出,实现方式是靠各个浏览器厂商自己实现)。事件循环的过程如下:
打个比方帮助理解:宏任务事件就像是普通用户,而微任务事件就像是VIP用户,执行栈要先把所有在等待的VIP用户服务好了以后才能给在等待的普通用户服务,而且每次服务完一个普通用户以后都要先看看有没有VIP用户在等待,若有,则VIP用户优先(PS:人民币玩家真的可以为所欲为,hah…)。当然,执行栈正在给一个普通用户服务的时候,这时即使来了VIP用户,他也是需要等待执行栈服务完该普通用户后才能轮到他。
setTimeout设置的时间其实只是最小延迟时间,并不是确切的等待时间。实际上最小延时 >=4ms,小于4ms的会被当做4ms。
promise 对象是由关键字 new 及Promise构造函数来创建的。该构造函数会把一个叫做“处理器函数”(executor function)的函数作为它的参数(即 new Promise(...)
中的...
的内容)。这个“处理器函数”是在promise创建时是自动执行的,.then
之后的内容才是异步内容,会交给Web APIs处理,然后被添加到微任务队列。
async/await:async函数其实是Generator函数的语法糖(解释一下“语法糖”:就是添加标准以外的语法以方便开发人员使用,本质上还是基于已有标准提供的语法进行封装来实现的),async function 声明用于定义一个返回 AsyncFunction 对象的异步函数。执行async函数时,遇到await
关键字时,await 语句产生一个promise,await 语句之后的代码被暂停执行,等promise有结果(状态变为settled)以后再接着执行。
呼~ 结束了,休息一下~
若对你有帮助,可以支持一下作者创作更多好文章哦~
一分钱也是爱~
参考文献: