JavaScript执行机制

JavaScript执行机制

JavaScript为什么是单线程的呢?由于设计之初,JavaScript是用来做用户交互以及页面动态渲染,所以为了简洁和方便入手,决定了它只能是单线程,否则将会带来非常复杂的同步问题。

随着多核CPU的兴起,单线程的JavaScript不在能满足需求,HTML5标准的提出带来了Web Worker相关的一整套API,它允许JavaScript程序运行在主线程之外的另外一个线程中,但是由于worker线程是严格收到主线程控制且无法控制dom,限制重重,所以这个HTML5标准的提出并没有改变浏览器中JavaScript单线程运行的本质。

相关概念

浏览器我们都知道,是典型的多进程模型,但是JavaScript的运行时却是单线程的架构,我们来从浏览器出发看看具体JavaScript的运行时在浏览器中处于什么位置。

多进程的浏览器主要分为以下几个主要的进程:

  • Browser进程:唯一的浏览器主进程(负责协调和主控),负责各个页面的管理,创建和销毁其他进程,将渲染进程得到的内存中的Bitmap(位图)绘制到用户界面上,网络IO等等。
  • 第三方插件进程:每种类型的插件对应一个单独的进程,当使用插件时才创建。
  • GPU进程:同样唯一,用于3D绘制等等。
  • 渲染进程:此进程即我们常说的浏览器内核(Renderer进程,内部是多线程);对于浏览器不同启动模式,渲染进程不一,Chrome的默认模式是对于某个网站及其从这个网站链开的一系列网站都属于同一个渲染进程。渲染进程之间互相不影响,主要作用是做页面渲染/脚本执行以及事件处理等。

了解一下Chrome的四种启动模式:

  • Process-per-site-instance:Chrome的默认模式,打开一个网站及其子链的一系列网站都属于同一个渲染进程。
  • Process-per-site:同域名范畴的网站放在一个进程,比如www.baidu.comwww.baidu.com/fanyi就属于同一个域名范畴内,无论其是否有互相打开的关系。使用命令行参数-process-per-site开启。
  • Process-per-tab:每个tab独享一个进程,每个tab互不影响,无论其互相之间有何联系。使用命令行参数-process-per-tab开启。
  • Single Process:传统浏览器模式,没有多进程只有多线程,用-single-process命令行参数开启。

相信聪明的大佬已经感受到了,JavaScript就是在Renderer进程中运行,我们来看下Renderer进程到底有哪些线程。

GUI渲染线程

GUI渲染线程负责渲染浏览器界面,解析HTML/CSS,构建DOM树和RenderObject树,负责布局和绘制等。

  • 当我们修改了一些元素的颜色或者背景色,页面就会触发重绘(Repaint)。
  • 当我们修改元素的尺寸,页面就会触发回流(Reflow)。
  • 当页面触发Repaint或者Reflow,GUI线程执行,绘制页面。
  • Reflow比Repaint的成本要高,所以我们尽量避免页面经常出现Repaint和Reflow。
JS引擎线程

JS引擎线程,故名思义就是运行JS引擎的线程,换句话就是浏览器的内核,负责处理JavaScript脚本程序(例如Chrome的V8内核)。

JS引擎线程只做一件事情,就是等待JavaScript然后运行它,浏览器同一个Renderer进程中只能有一个JS引擎线程运行,所以我们说JS是严格单线程执行的。

注意

  1. JS引擎线程与GUI线程是严格互斥的,即JS引擎线程运行时,会挂起GUI线程。
  2. JS引擎线程执行优先级高于GUI线程,例如浏览器渲染时遇到script标签,则会停止GUI的渲染,然后JS引擎线程开始工作,执行标签内部的JS代码,等JS引擎线程执行完其中的所有JS代码则会停止工作,然后GUI线程继续渲染下面的内容。所以如果JS执行的时间太长,则会造成页面卡顿的情况,严重影响用户体验。
  3. 如果GUI线程被JS引擎执行所阻塞时,所有GUI更新将会被提交到一个队列中,直到JS引擎空闲时才会释放GUI线程。
事件触发线程

事件触发线程不属于JS引擎,属于浏览器,主要作用是用来控制事件循环并且管理着一个事件队列task queue。

当js执行碰到事件绑定和一些异步操作(如setTimeOut,也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会走事件触发线程将对应的事件添加到对应的线程中(比如定时器操作,便把定时器事件添加到定时器线程),等异步事件有了结果,便把他们的回调操作添加到事件队列,等待js引擎线程空闲时来处理。

当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理

因为JS是单线程,所以这些待处理队列中的事件都得排队等待JS引擎处理。

定时触发器线程

定时触发器线程即JS引擎提供的api:setInterval与setTimeout所在线程。

浏览器定时计数器并不是由JavaScript引擎计数的(因为JavaScript引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确)。

通过单独线程来计时并触发定时(计时完毕后,添加到事件触发线程的事件队列中,等待JS引擎空闲后执行),这个线程就是定时触发器线程,也叫定时器线程。

注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms

异步http请求线程

在XMLHttpRequest在连接后是通过浏览器新开一个线程请求。

将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行。

简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行

宏任务与微任务

了解JavaScript的执行机制前我们需要先来了解一下大家经常说的一个概念:宏任务和微任务。

在JavaScript中任务大致上分为两种任务:

  • 同步任务:可以等同与无异步逻辑的异步任务。顺序执行,与其他语言的同步任务相同。
  • 异步任务:ES6标准引入了Promise等概念,原有单一的异步任务划分出了更细致的分类:宏任务和微任务。
    • 宏任务:又称task(ES标准中,称之为Macrotasks)。 每一个宏任务会从头到尾执行完毕,不会执行其他,由于JS引擎线程和GUI渲染线程是互斥的关系,浏览器为了能够使宏任务和DOM任务有序的进行,会在一个宏任务执行结果后,在下一个宏任务执行前,GUI渲染线程开始工作,对页面进行渲染。以下几种任务都属于宏任务:
      • 主代码块
      • setTimeout
      • setInterval
      • 各种IO任务,包括网络IO
      • setImmediate(NodeJS)
    • 微任务:又称job(ES标准中,称之为Microtasks)。宏任务是js引擎进行处理的,微任务是浏览器的行为。微任务必然是由宏任务执行时创建的,在现在的各大JS主流框架(React/Vue)中,微任务又被叫做副作用(effect)。以下几种任务都属于微任务:
      • Promise.then:注意,这里不是Promise,而是Promise.then,Promise的executor属于宏任务,只有then中的任务才属于微任务。
      • MutationObserver:提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。
      • process.nextTick(NodeJS)

由于宏任务是同步执行且不会中断,在JS引擎优先级高于GUI引擎的基础上,执行机制也从【宏任务->GUI->宏任务… …】变更为【宏任务->微任务->GUI->宏任务… …】。

需要注意的是:每一个宏任务执行完毕后都会去清空微任务队列,如果清空的过程中又产生了微任务,则将不会执行下一个宏任务,而是继续清空微任务队列。

我们来看下MDN中对宏任务和微任务的说明:

宏任务

一个 任务 就是由执行诸如从头执行一段程序、执行一个事件回调或一个 interval/timeout 被触发之类的标准机制而被调度的任意 JavaScript 代码。这些都在 任务队列(task queue)上被调度。

在以下时机,任务会被添加到任务队列:

  • 一段新程序或子程序被直接执行时(比如从一个控制台,或在一个<script>元素中运行代码)。
  • 触发了一个事件,将其回调函数添加到任务队列时。
  • 执行到一个由 setTimeout()setInterval() 创建的 timeout 或 interval,以致相应的回调函数被添加到任务队列时。

事件循环驱动你的代码按照这些任务排队的顺序,一个接一个地处理它们。在当前迭代轮次中,只有那些当事件循环过程开始时 已经处于任务队列中 的任务会被执行。其余的任务不得不等待到下一次迭代。

微任务

一个 微任务(microtask)就是一个简短的函数,当创建该函数的函数执行之后,并且 只有当 Javascript 调用栈为空,而控制权尚未返还给被 user agent 用来驱动脚本执行环境的事件循环之前,该微任务才会被执行。事件循环既可能是浏览器的主事件循环也可能是被一个 web worker 所驱动的事件循环。这使得给定的函数在没有其他脚本执行干扰的情况下运行,也保证了微任务能在用户代理有机会对该微任务带来的行为做出反应之前运行。

微任务和宏任务有两个重要的区别:

  • 首先,每当一个任务存在,事件循环都会检查该任务是否正把控制权交给其他 JavaScript 代码。如若不然,事件循环就会运行微任务队列中的所有微任务。接下来微任务循环会在事件循环的每次迭代中被处理多次,包括处理完事件和其他回调之后。
  • 其次,如果一个微任务通过调用 queueMicrotask(), 向队列中加入了更多的微任务,则那些新加入的微任务 会早于下一个任务运行 。这是因为事件循环会持续调用微任务直至队列中没有留存的,即使是在有更多微任务持续被加入的情况下。

注意: 因为微任务自身可以入列更多的微任务,且事件循环会持续处理微任务直至队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增加微任务是要谨慎而行的。

如何使用微任务

就其本身而言,应该使用微任务的典型情况,要么只有在没有其他办法的时候,要么是当创建框架或库时需要使用微任务达成其功能。虽然在过去要使得入列微任务成为可能有可用的技巧(比如创建一个立即 resolve 的 promise),但新加入的 queueMicrotask() 方法增加了一种标准的方式,可以安全的引入微任务而避免使用额外的技巧。

通过引入 queueMicrotask(),由晦涩地使用 promise 去创建微任务而带来的风险就可以被避免了。举例来说,当使用 promise 创建微任务时,由回调抛出的异常被报告为 rejected promises 而不是标准异常。同时,创建和销毁 promise 带来了事件和内存方面的额外开销,这是正确入列微任务的函数应该避免的。

简单的传入一个 JavaScript Function ,以在 queueMicrotask() 方法中处理微任务时供其上下文调用即可;取决于当前执行上下文, queueMicrotask() 以定义的形式被暴露在 WindowWorker 接口上。

代码语言:javascript
复制
queueMicrotask(() => {
  /* 微任务中将运行的代码 */
});

微任务函数本身没有参数,也不返回值。

执行机制

在了解了以上概念之后,我们正式开始介绍JavaScript在浏览器中的执行机制。

JavaScript的执行机制

解释,由上图我们可以大致了解到JS引擎维护了一个任务执行栈,JavaScript是严格按照以下的顺序进行执行:

  1. 主代码块入任务执行栈,JS引擎依次遍历任务执行栈的任务并执行,判断是否同步任务,如果是同步任务就立即执行,是异步任务则分别丢入到事件处理线程的宏任务队列和微任务队列。例如setTimeout中的函数则是产出一个宏任务却不会立即执行,会被事件处理线程注册到宏任务Event Table,然后推送到宏任务队列。注意Promise中Executor不属于异步任务,而是属于同步任务,在主代码块执行时一并顺序执行,而Promise.then则是在执行过程中产生的微任务,会被事件处理线程注册到微任务的Event Table,在对应Promise中resolve或者reject之后才会推送到微任务队列。
  2. 宏任务执行完毕,检查到执行栈为空时,会去检查微任务队列,有事件回调需要执行,则入JS引擎线程继续执行(此时还未进行GUI渲染)。
  3. 一直执行直至执行栈为空,微任务队列为空,GUI渲染线程接管,进行GUI渲染。注意这个步骤,不会重复检测宏任务队列,在微任务队列为空之后,将会进入GUI渲染阶段,剩余的推送过来的宏任务会在下一个loop进行执行。
  4. GUI渲染完毕后,检查宏任务队列,是否有需要执行的宏任务,加入到JS引擎线程中的任务执行栈。
  5. 循环上述的四个步骤,直到线程退出。

当然,文字描述比不上动画更形象:loupe

了解到了上述的执行逻辑,我们对JavaScript每一行代码都在什么时间节点执行就有了大致的印象,来看下面一段代码:

代码语言:javascript
复制
console.log('start');
setTimeout(() => {
    console.log('children2');
    Promise.resolve().then(() => {
        console.log('children3');
    });
}, 0);

new Promise(function(resolve, reject) {
console.log('children4');
setTimeout(function() {
console.log('children5');
resolve('children6');
}, 0);
}).then((res) => {
console.log('children7');
setTimeout(() => {
console.log(res);
}, 0);
});

// [Log] start
// [Log] children4
// < Promise {status: "pending"}
// [Log] children2
// [Log] children3
// [Log] children5
// [Log] children7
// [Log] children6

解析:(注释是在Safari浏览器中执行的结果)

  1. 第一轮loop,首先主代码块入任务执行栈,第一行毫无疑问第一个执行,输出start。
  2. setTimeout中的函数属于宏任务,但是由于异步逻辑,加上设置的延时是0,所以会压入到下一个loop的最开始进行执行。
  3. Promise的executor会立即执行,输出children4。Promise中的setTimeout延时也是0,会作为宏任务放入到事件处理线程的宏任务队列中,注意,此时还未进入JS引擎线程,且事件处理线程已有两个宏任务正在等待。由于Promise中的唯一一个resolve是在setTimeout中,所以当前loop中Promise无法执行完,只能保持pending状态。
  4. Promise.then属于微任务,会在事件处理线程注册到Event Table中,在Promise的状态改变前不会执行,进行下一次loop。
  5. 第二轮loop,由于上一轮压入到宏认为队列中的两个宏任务都在等待执行:children2所属任务和children5所属任务,会依次按照入队列顺序进行执行,JS引擎线程会先从事件处理线程中的宏任务队列取出children2所属宏任务压入到任务执行栈,然后开始执行,输出children2,并将children3压入到微任务队列。
  6. 第二轮loop,执行完children2之后,由于会切换宏任务即进入另外一块代码,所以JS引擎会检查是否有残留微任务未执行,检查到children3所属微任务未执行,再切换到下一个宏任务之前会先清空微任务队列。
  7. 第二轮loop,清空完微任务队列之后取出宏任务队列中的children5所属宏任务进行执行,输出children5,然后将第一轮中的Promise状态置为完成态,事件处理线程会将其对应的.then的回调函数放入到对应的微任务队列中,即children7所属微任务为第二轮loop的children5所属宏任务产生的微任务。children5所属宏任务执行完毕后检查微任务队列,输出children7,然后遇到setTimeout后压入宏任务children6,同样因为没有gui渲染所以直接进入下一次loop。
  8. 第三轮loop,宏任务队列不为空,取出一个宏任务,开始执行,输出children6,执行到这里,宏任务和微任务均执行完毕,进入gui渲染且不断循环检查宏任务队列。

在继续看代码前,我们需要来简单的了解一下await/async

Async/Await

Async/Await是在ES2017标准提出的两个操作符,或者说是语法糖。

await 操作符用于等待一个Promise 对象。它只能在异步函数 async function 中使用。await 表达式会暂停当前 async function 的执行,等待 Promise 处理完成。若 Promise 正常处理(fulfilled),其回调的resolve函数参数作为 await 表达式的值,继续执行 async function。若 Promise 处理异常(rejected),await 表达式会把 Promise 的异常原因抛出。另外,如果 await 操作符后的表达式的值不是一个 Promise,则返回该值本身。

async函数是使用async关键字声明的函数。 async函数是AsyncFunction构造函数的实例, 并且其中允许使用await关键字。asyncawait关键字让我们可以用一种更简洁的方式写出基于Promise的异步行为,而无需刻意地链式调用promise

async函数可能包含0个或者多个await表达式。await表达式会暂停整个async函数的执行进程并出让其控制权,只有当其等待的基于promise的异步操作被兑现或被拒绝之后才会恢复进程。promise的解决值会被当作该await表达式的返回值。使用async / await关键字就可以在异步代码中使用普通的try / catch代码块。

注意这里await表达式会暂停整个async函数的执行并交出控制权,切换之前会将async函数的返回和await后续逻辑整个打包成微任务,这样来看就清晰了很多。

代码语言:javascript
复制
function testSomething() {
    console.log("执行testSomething"); // 步骤1
    return "testSomething";
}

async function testAsync() {
    console.log("执行testAsync"); // 步骤2
    return Promise.resolve("hello async");
}

async function test() {
    console.log("test start..."); // 步骤3
    const v1 = await testSomething();//关键点1
    console.log(v1); // 步骤4
    const v2 = await testAsync();
    console.log(v2); // 步骤5
    console.log(v1, v2); // 步骤6
}

test();
var promise = new Promise(
(resolve) => { 
console.log("promise start.."); // 步骤7
resolve("promise");
}
);//关键点2
promise.then((val)=> console.log(val)); // 步骤8
console.log("test end..."); // 步骤9

// test start...
// 执行testSomething
// promise start..
// test end...
// testSometing
// 执行testAsync
// promise
// hello async
// testSometing hello async

在了解完Async/Await之后,我们解析上面代码的具体执行过程就比较清晰了:

  1. 第一轮loop,跳过函数的定义,进入test函数执行,输出步骤3,遇到await关键词,主代码块宏任务切换到await后面的函数,执行testSomething函数输出步骤1,并在return之后挂起,注册return之后步骤为微任务并交出控制权,切换回主代码块宏任务。
  2. 第一轮loop,继续执行主代码块,进行new Promise,执行Promise的executor,输出步骤7,并修改Promise为完成态。
  3. 第一轮loop,继续执行Promise.then,注册微任务步骤8并压入微任务队列。
  4. 第一轮loop,继续执行输出步骤9。
  5. 第一轮loop,执行完步骤9之后主代码块执行完毕,开始清空微任务队列,微任务队列此时有第一个await注册的微任务和主代码块的Promise.then总计两个微任务。
  6. 第一轮loop,按照入队列的顺序,优先执行await注册的微任务,const v1 = testSomething(),此时v1拿到了testSomething函数的返回值,继续执行并输出步骤4。继续执行遇到第二个await,同理正常执行testAsync函数输出步骤2,在return之后注册微任务并挂起,交出线程控制权,此时微任务队列仍有两个任务:主代码块的Promise.then和第二个await注册的微任务。
  7. 第一轮loop,执行主代码块的Promise.then,输出步骤8,继续清空微任务队列,const v2 = testAsync(),此时v2拿到了testAsync函数的返回值,继续执行并输出步骤5,最后执行并输出步骤6。

NodeJS中的运行机制

Node 中的 Event Loop 和浏览器中的是完全不相同的东西。Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现

NodeJS的架构

nodejs底层多线程的意义是:底层的多线程实现了nodejs的异步操作,node将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度。以此node就可以实现高并发操作。

我们所看到的node.js单线程只是一个js主线程,本质上的异步操作还是由线程池完成的,node将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度,并没有进行真正的I/O操作,从而实现异步非阻塞I/O,这便是node单线程和事件驱动的精髓之处了。

我们来看一下NodeJS之中的事件循环:

NodeJS的事件循环

每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段,等等。

由于这些操作中的任何一个都可能调度 更多的 操作和由内核排列在轮询阶段被处理的新事件, 且在处理轮询中的事件时,轮询事件可以排队。因此,长时间运行的回调可以允许轮询阶段运行长于计时器的阈值时间。

NodeJS事件循环各个阶段概览:

  • timers:此阶段执行由 setTimeout 和 setInterval 设置的回调。
  • pending callbacks:执行推迟到下一个循环迭代的 I/O 回调。
  • idle, prepare, :仅在内部使用。
  • poll:取出新完成的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调,计时器调度的回调和 setImmediate 之外,几乎所有这些回调) 适当时,node 将在此处阻塞。
  • check:在这里调用 setImmediate 回调。
  • close callbacks:一些关闭回调,例如 socket.on('close', ...)。

在每次事件循环运行之间,Node.js 会检查它是否正在等待任何异步 I/Otimers,如果没有,则将其干净地关闭。

timers -- 定时器

定时器指定 可以执行所提供回调阈值,而不是用户希望其执行的确切时间。在指定的一段时间间隔后, 计时器回调将被尽可能早地运行。但是,操作系统调度或其它正在运行的回调可能会延迟它们。

注意轮询 阶段(poll) 控制何时定时器执行。

为了防止 轮询 阶段饿死事件循环,libuv(实现 Node.js 事件循环和平台的所有异步行为的 C 函数库),在停止轮询以获得更多事件之前,还有一个硬性最大值(依赖于系统)。

pending callbacks

此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 nix 的系统希望等待报告错误。这将被排队以在 挂起的回调 阶段执行。

poll -- 轮询

轮询 阶段有两个重要的功能:

  1. 计算应该阻塞和轮询 I/O 的时间。
  2. 然后,处理 轮询 队列里的事件。

当事件循环进入 轮询 阶段且 没有被调度的计时器时 ,将发生以下两种情况之一:

  • 如果 轮询 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。
  • 如果 轮询 队列 是空的* ,还有两件事发生:
    • 如果脚本被 setImmediate() 调度,则事件循环将结束 轮询 阶段,并继续 检查 阶段以执行那些被调度的脚本。
    • 如果脚本 未被 setImmediate()调度,则事件循环将等待回调被添加到队列中,然后立即执行。

一旦 轮询 队列为空,事件循环将检查 已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回计时器阶段以执行这些计时器的回调。

check

此阶段允许人员在轮询阶段完成后立即执行回调。如果轮询阶段变为空闲状态,并且脚本使用 setImmediate() 后被排列在队列中,则事件循环可能继续到 检查 阶段而不是等待。

setImmediate() 实际上是一个在事件循环的单独阶段运行的特殊计时器。它使用一个 libuv API 来安排回调在 轮询 阶段完成后执行。

通常,在执行代码时,事件循环最终会命中轮询阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且轮询阶段变为空闲状态,则它将结束此阶段,并继续到检查阶段而不是继续等待轮询事件。

close callbacks

如果套接字或处理函数突然关闭(例如 socket.destroy()),则'close' 事件将在这个阶段发出。否则它将通过 process.nextTick() 发出。

process.nextTick()

您可能已经注意到 process.nextTick() 在图示中连接了事件循环的每一个环节,这是因为 process.nextTick() 从技术上讲不是事件循环的一部分。相反,它都将在当前操作完成后处理 nextTickQueue, 而不管事件循环的当前阶段如何。这里的一个操作被视作为一个从底层 C/C++ 处理器开始过渡,并且处理需要执行的 JavaScript 代码。

任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前解析。这可能会造成一些糟糕的情况,因为它允许您通过递归 process.nextTick()调用来“饿死”您的 I/O,阻止事件循环到达 轮询 阶段。

使用process.nextTick的两个重要原因:

  1. 允许用户处理错误,清理任何不需要的资源,或者在事件循环继续之前重试请求。
  2. 有时有让回调在栈展开后,但在事件循环继续之前运行的必要。

另外,我们需要了解各种微任务的优先级问题:

优先级 process.nextTick > promise.then = queueMicrotask

显而易见,更为底层的process.nextTick优先级是高于promise.then和原生微任务apiqueueMicrotask。

参考资料

1、硬核JS:https://juejin.cn/post/6844904050543034376

2、弄懂JS执行机制:https://juejin.cn/post/6844903512845860872

3、动画演示宏微任务:https://juejin.cn/post/6861438433362247694

4、异步事件循环:https://juejin.cn/post/6844904032264257544

5、JavaScript运行机制详解:http://www.ruanyifeng.com/blog/2014/10/event-loop.html

6、NodeJS事件循环:https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/

7、NodeJS事件循环官方中译:https://learnku.com/articles/38802

8、JavaScript中的Event Loop机制:https://zhuanlan.zhihu.com/p/145383822

9、Tasks, microtasks, queues and schedules:https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/?utm_source=html5weekly

10、Chrome的多线程模型:http://www.ha97.com/2908.html

11、令人费解的async/await: https://juejin.cn/post/6844903762478235656