原先,我们有一篇文章,简单描述了 JS (Event Loop)事件循环 和 (Call Stack) 调用堆栈。从宏观角度,分析浏览器中事件循环的运行机制。
理论终归是理论,我们在知晓原理之后,是为了更好的解决实际问题。「纸上得来终觉浅,绝知此事要躬行」。所以,今天借助一个可视化工具来详细介绍一下,JS事件循环的各种运行细节。

Promise调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。事件循环是一个不停的从 宏任务队列/微任务队列中取出对应任务的「循环函数」。在一定条件下,你可以将其类比成一个永不停歇的「永动机」。它从宏/微任务队列中「取出」任务并将其「推送」到「调用栈」中被执行。
事件循环包含了四个重要的步骤:
用伪代码来描述一下Event Loop的运行机制
// 在第一步执行Script时,开启“永动机”
while (EventLoop.waitForTask()) {
// 第二步:从宏任务队列中挑选最老任务
const taskQueue = EventLoop.selectTaskQueue();
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask();
}
// 第三步:从微任务队列中挑选最老任务
const microtaskQueue = EventLoop.microTaskQueue;
while (microtaskQueue.hasNextMicrotask()) {
microtaskQueue.processNextMicrotask();
}
// 第四步:UI渲染
rerender();
}
❝事件循环(Event Loop)用于处理宏任务和微任务。并将它们推入到调用栈中,并且满足「同一时间」只能执行一种任务(单线程特性)。同时还能控制页面何时被渲染。 ❞
调用栈(Call Stack)是JS的基本组成部分。它是一个「记录保存结构」(record-keeping structure)允许我们能够执行函数调用的操作。在调用栈中,每一个函数调用被一种叫做「栈帧」(frame)的数据结构所替代。该结构能够帮助JS引擎(V8)保持函数之间的调用顺序和关系。并且能够在某个函数结束后,利用存储在栈帧中的信息,执行剩余的代码。使得JS应用拥有记忆。
当JS代码第一次被执行时,此时的调用栈是「空的」。只有在第一个函数被调用时候,才会向调用栈的栈顶「推入」(push)该函数对应的栈帧。当函数执行完成(执行到return语句),对应的栈帧会从调用栈中「抛出」(pop)。
❝「调用栈」(Call Stack)用于「追踪」函数调用。是「LIFO」(后进先出)的栈结构。每个「栈帧」代表一次函数调用。 ❞
也可以称为回调队列(Callback queue)。
调用栈是用于跟踪「正在被执行」函数的机制,而宏任务队列是用于跟踪「将要被执行」函数的机制。
宏任务队列是一个「FIFO」(先进先出)的队列结构。结构中存储的宏任务会被事件循环「探查」到。并且,这些任务是「同步阻塞」的。你可以将这些任务类比成一个函数对象。
事件循环「不知疲倦」的运行着,并且按照一定的规则(后面会讲)从宏任务队列中不停的取出任务对象。事件循环的单次迭代过程被称为「tick」。
「题外话」:看到tick是不是会想到Vue.nextTick(callback)。在下次 「DOM 更新循环结束」之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
Vue.nextTick(callback) 使用原理:Vue 是异步执行(会被推入到宏任务队列中)dom更新的,一旦观察到数据变化,Vue就会开启一个队列,然后把在同一个事件循环 (event loop) 当中观察到数据变化的 watcher 推送进这个队列。
如果这个watcher被触发多次,只会被推送到队列一次。这种缓冲行为可以有效的去掉重复数据造成的不必要的计算和DOm操作。而在下一个事件循环时,Vue会「清空队列」,并进行必要的DOM更新。
当你设置 vm.someData = 'new value',DOM 并不会马上更新,而是在异步队列被清除,也就是下一个事件循环开始时执行更新时才会进行必要的DOM更新。如果此时你想要根据更新的 DOM 状态去做某些事情,就会出现问题。为了在数据变化之后等待 Vue 完成更新 DOM ,可以在数据变化之后立即使用 Vue.nextTick(callback) 。这样回调函数在 DOM 更新完成后就会调用。
为了处理宏任务,事件循环会调用与该宏任务「一一对应」的函数。当宏任务被执行时,它「霸占」整个调用栈,并且是「排他性」的。也就是说,此时被执行的宏任务(函数)的优先级最高。而事件循环只能等到该任务执行完,「并且,并且,并且」调用栈为空时,才会从宏任务队列中挑选「最老」的任务继续上述步骤。
❝「最老任务」:这里有两层含义 1:如果每个宏任务的「约定」被执行的时间都相同的话(参考
setTimeout中的第二个参数),那么最老的任务就是按照入队顺序来定,越早入队,越早被执行 2: 如果时间不一致,那就按「约定执行时间」来挑选,时间越小,越早被执行 ❞
在任务(函数)执行期间,如果触发了新的宏任务,它也会将新任务「提交」到宏任务队列中,按照队列排队顺序,将任务进行合理安置。有很多方式能够触发新的宏任务,最简单的方式就是在代码中触发了setTimeout(newTaskFn,0)。当然,《在JS (Event Loop)事件循环 和 (Call Stack) 调用堆栈》 一文中我们也介绍过能够触发宏任务的函数被称为Web APIS。这里,我就直接拿来主义了。
Web APIs
❝宏任务队列是一个「FIFO」(先进先出)的队列结构。结构中存储的宏任务会被事件循环「探查」到。并且,这些任务是「同步阻塞」的。当一个任务被执行,其他任务是被挂起的(按顺序排队)。 ❞
微任务队列也是一个「FIFO」(先进先出)的队列结构。并且,结构中存储的微任务也会被时间循环「探查」到。「微任务队列和宏任务队列很像」。作为ES6的一部分,它被添加到JS的执行模型中,以处理Promise回调。
微任务和宏任务也很像。它也是一个「同步阻塞代码」,运行时也会「霸占」调用栈。像宏任务一样,在运行期间,也会触发「新的」微任务,并且将新任务「提交」到微任务队列中,按照队列排队顺序,将任务进行合理安置。
布莱希特说:「世界上没有两片相同的叶子」。虽然,宏任务和微任务在很多地方相似,但是在「存储」和「处理过程」上还是有差异的。
❝微任务队列是ES6新增的专门用于处理
Promise调用的数据结构。它和宏任务队列很像,它们最大的不同就是微任务队列是专门处理微任务的相关处理逻辑的。 ❞
假设,我们是V8引擎,在接收到一段JS代码后,按照「既定」的套路,来输出用户想要的结果。
function seventh() { }
function sixth() { seventh() }
function fifth() { sixth() }
function fourth() { fifth() }
function third() { fourth() }
function second() { third() }
function first() { second() }
first();
作为一个合格的JS引擎,你需要了解要执行这段代码需要用到何种工具(调用栈、任务队列等)。
简单的分析一波:从代码角度看,这里都是一些函数的定义和调用。没有任何「副作用」(产生异步)。所以,处理它们,仅用常规的调用栈就足够了。上文说到,调用栈中存储的是与函数一一对应的栈帧。它能够记住函数之间的调用关系。所以在一个函数返回后,还能通过栈帧中存储的信息恢复之前的函数信息。

Call Stack运行机制
setTimeout(function a() {}, 0);
setTimeout(function b() {}, 0);
setTimeout(function c() {}, 0);
function d() {}
d();
在执行该段代码时,JS引擎会「从上到下」对代码进行分析。首先映入眼帘的是三个setTimeout,依次执行setTimeout代码。将setTimeout中回调函数按照「调用顺序」依次入队。(a=>b=>c)
随后,执行同步代码d(),由于是同步代码,它会被「推入」到调用栈内,执行对应的代码逻辑。
在调用栈为空后(d()执行完),事件循环会从宏任务队列中「提取」满足要求的任务。由于,三个宏任务的预订运行时间都相等,会按照他们入队的顺序依次被「推入」调用栈内。

预订运行时间相同
这段代码和上面例子中有一点不同,在执行同步代码的逻辑是一样的。
从上到下执行代码,对应的setTimeout的回调函数依次入队。(a=>b=>c)。随后,执行同步函数d()。
「但是,但是,但是」(转折又来了),在执行完d()后,时间循环从宏任务队列中「提取」满足条件的回调函数时。由于三个回调函数的预订执行时间不一致,此时不会按照入队顺序提取。而是根据「预订运行时间的大小」来进行函数的提取。也就是我们上面提到的。「时间越小,越靠前」。
setTimeout(function a() {}, 1000);
setTimeout(function b() {}, 500);
setTimeout(function c() {}, 0);
function d() {}
d();

预订运行时间不同
这里有一些前置知识点,需要简单说一下。在Promise中有一个概念叫做 「非重入」
❝「非重入」:Promise进入落定(「解决/拒绝」)状态时,与该状态相关的处理程序「不会立即执行」,处理程序后的同步代码会在其之前先执行 ❞
在一个解决promise上调用 then()会把 onResolved 处理程序「推进消息队列」
// 创建解决的promise
let p = Promise.resolve();
// 添加解决处理程序
p.then(() => console.log('新增处理程序'));
// 同步输出
console.log('同步代码');
// 实际的输出:
// 同步代码
// 新增处理程序
fetch('https://www.google.com')
.then(function a() {});
Promise.resolve()
.then(function b() {});
Promise.reject()
.catch(function c() {});
该段代码是刻意为之的。三个语句都会产生promise。根据前面所知,promise产生微任务。
继续分析代码,从上而下,fetch()进行一个异步接口请求,在接口没完成时(成功/失败),此时的promise的状态是pending状态。是不会触发对应的回调函数。所以,fetch()会优先触发,但是不会进入微任务队列或者调用栈。
而Promise.resolve()/Promise.reject()却不同,它们返回的Promise直接会进入落定(「解决/拒绝」)状态。所以,在遍历到它们的时候,会按照代码顺序,将其产生的微任务依次入队。
当同步代码执行完后,时间循环就会从宏任务队列/微任务队列中「检索」需要处理的任务。而此时宏任务队列为空。所以,就会从微任务队列中提取任务,并将其推入(push)到调用栈内执行。
等当前微任务队列中的任务被全部处理完后,此时fetch()的异步接口也会发生变化,会触发对应promise的then方法,此时就会产生新的微任务,该微任务会被入队。继续上述的步骤。

setTimeout(function a() {}, 0);
Promise.resolve().then(function b() {});
继续分析代码。
从上到下,执行代码。在执行过程中setTimeout首先被执行,与之对应的回调函数,被「请入」 宏任务队列。(相信,这步已经轻车熟路了哇)
继续,执行Promise.resolve(),此步直接返回了一个进入落定(「解决」)状态promise。那对于的微任务被「区别对待」被请入了 微任务队列。
到这里,貌似就有点犯难了。前文讲过,宏任务队列和微任务队列其实很像,但是在事件循环是一个喜新厌旧的主。微任务队列是新人,它的优先级比宏任务队列高。毕竟,「年轻就是资本」。
其实,这里有一个很重要的点:
❝在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行 ❞
到这里可能会有疑惑,这里的代码,也不是在函数内部啊。其实哇,在script被解析执行时候,在全局范围内会存在一个anonymous函数。如果大家在debugger的时候,在控制台-Call Stack中最底部,就是一个代表当前script的匿名函数。
好了,收。拐的有点远,说了这么多,其实就是为说一句。「在同一函数作用域下,微任务比宏任务先执行」
再多说一嘴,其实哇,在JS中。宏任务和微任务是 「1对N」的关系。
❝V8 会为每个宏任务维护一个微任务队列 ❞
同时,微任务被执行的时机,是在V8要销毁全局代码的环境对象,此时会调用环境对象的「析构函数」 (这是C++的一个概念),此时,V8 会检查微任务队列,如果微任务队列中存在微任务,那么 V8 会依次取出微任务,并按照顺行执行。

接下来的代码解析,会简单的一带而过,其中涉及到知识点,其实都是针对promise的运行机制。后期,打算会有一篇专门针对promise的文章。
const GOOGLE = 'https://www.google.com';
const NEWS = 'https://www.news.google.com';
Promise.all([
fetch(GOOGLE).then(function b() {}),
fetch(GOOGLE).then(function c() {}),
]).then(function after() {});
我们直接说结论了。Promise.all中产生的微任务,是内部所有的任务进入落定(「解决/拒绝」)状态,才会执行后续的处理。
上述代码的运行结果就是 b=>c=> after 或者 c=>b=>after 具体是哪一个,需要看b/c哪一个先进入落定状态。

const GOOGLE = 'https://www.google.com';
const NEWS = 'https://www.news.google.com';
Promise.race([
fetch(NEWS).then(function b() {}),
fetch(GOOGLE).then(function c() {}),
]).then(function after() {});
而Promise.race()却是,内部哪一个任务先落定,就会执行后续的代码(then)。在then的回调处理后,才会继续处理race中未被处理的任务。
上述代码的运行结果是 c=>after=>b 这里的b/c的运行顺序也是看哪一个先落定。

Promise.resolve()
.then(function a() {
Promise.resolve().then(function d() {})
Promise.resolve().then(function e() {})
throw new Error('Error')
Promise.resolve().then(function f() {})
})
.catch(function b() {})
.then(function c() {})
这里简单说一下,在Promise中throw一个错误,会将后续的代码「截断」。这里的后续代码指的是,throw后面的代码。也就是Promise.resolve().then(function f() {})不会被执行。
在promise中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令。
Promise.reject(Error('foo'));
console.log('bar');
// bar
// Uncaught (in promise) Error: foo
Promise.prototype.catch()调用它就相当于调用 Promise.prototype.then(null, onRejected),并且返回了一个新的promise实例。

Promise 中产生Error
Promise.resolve()
.then(function a() {
Promise.resolve().then(function c() {});
})
.then(function b() {
Promise.resolve().then(function d() {});
});
在微任务执行期间,如果又产生新的微任务,此时新产生的微任务会入队到微任务队列中。在新的微任务被执行完后,才会执行后续的操作。
此时,就会存在一些不知名的bug。
function foo() {
return Promise.resolve().then(foo)
}
foo()
当执行 foo 函数时,foo 函数中调用了 Promise.resolve(),触发一个微任务。V8 会将该微任务添加进微任务队列中,退出当前 foo 函数的执行。
V8 在准备退出当前的宏任务之前,「检查微任务队列」,微任务队列中有一个微任务,先执行微任务。由于这个微任务就是调用 foo 函数本身,执行微任务的过程中,需要继续调用 foo 函数,在执行 foo 函数的过程中,又会触发了同样的微任务。
这个循环就会一直持续下去,「当前的宏任务无法退出」,消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件,事件会「一直保存在消息队列中」,页面无法响应这些事件,「页面卡死」。

Promise 链式操作
分享是一种态度,这篇文章,主要是根据一个可视化工具进行代码分析的。如果,自己想要实践一下,可以自行验证。
参考资料: