5
作者:Ivan

本文根据 JavaScript 规范入手,阐述了JS执行过程在考虑时效性和效率权衡中的演变,并通过从JS代码运行的基础机制事件队列入手,分析了JS不同任务类型(宏任务、微任务)的差别,通过这些差别给出了详细分析不同任务嵌套的复杂 JS 代码执行的分析流程。

一、事件队列与回调

在使用JavaScript编程时,需要用到大量的回调编程。回调,单纯的理解,其实就是一种设置的状态通知,当某个语句模块执行完后,进行一个通知到对应方法执行的动作。

最常见的setTimeout等定时器,AJAX请求等。这是由于JavaScript单线程设计导致的,作为脚本语言,在运行的时候,语言设计人员需要考虑的两件重要的事情,就是执行的实时性和效率。

实时性,就是指在代码执行过程中,代码执行的实效性,当前执行语句任务是否在当前的实效下发挥作用。效率,在这里指的是代码执行过程中,每个语句执行的造成后续执行的延迟率。

由于JavaScript单线程特性,想要在完成复杂的逻辑执行情况下而不阻塞后续执行,也就是保证效率,回调看似是不可避免的选择。

早期浏览器的实现和现在可能有许多不同,但是并不会影响我们用其来理解回调过程。

早期浏览器设计时,比如IE6,一般都让页面内相关内容,比如渲染、事件监听、网络请求、文件处理等,都运行于一个单独的线程。此时要引入JavaScript控制文件,那JavaScript也会运行在于页面相同的线程上。

当触发某个事件时,有单线程线性执行,这时不仅仅可能是线程中正在执行其他任务,使得当前事件不能立即执行,更可能是考虑到直接执行当前事件导致的线程阻塞影响执行效率的原因。这时事件触发的执行流程,比如函数等,将会进入回调的处理过程,而为了实现不同回调的实现,浏览器提供了一个消息队列。

当主线上下文内容都程执行完成后,会将消息队列中的回调逻辑一一取出,将其执行。这就是一个最简单的事件机制模型。

浏览器的事件回调,其实就是一种异步的回调机制。常见的异步过程有两种典型代表。一种是setTimeout定时器作为代表的,触发后直接进入事件队列等待执行;一种是XMLHTTPRequest代表的,触发后需要调用去另一个线程执行,执行完成后封装返回值进入事件队列等待。在这里并不进行深入讨论。

由此,我们得到了JavaScript设计的基础线程框架。而宏任务和微任务的差异实现正是为了解决特定问题而在此基础上衍生出来的。而在没有微任务的时代,JavaScript的执行中并没有所谓异步执行的概念,异步执行是在宿主环境中实现的,也就是浏览器提供了。直至实现了微任务,才可以认为JavaScript的代码执行存在了异步过程。

(由于目前广泛使用的JavaScript引擎是V8,在此我们已V8作为解释对象)

二、(宏)任务和微任务

我们常在文章中看到,macroTask(宏任务)和microTask(微任务)的说法。但其实在MDN[链接]中查看的时候,macroTask(宏任务)这一说法对应于microTask(微任务)而言的,而统一区分于microTask其实就是普通的Task任务。在此我们可以粗略的认为普通的Task任务其实都是macroTask。

任务的定义:

A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired. These all get scheduled on the task queue.

(任何按标准机制调度进行执行的JavaScript代码,都是任务,比如执行一段程序、执行一个事件回调或interval/timeout触发,这些都在任务队列上被调度。)

微任务存在的区别定义:

First, each time a task exits, the event loop checks to see if the task is returning control to other JavaScript code. If not, it runs all of the microtasks in the microtask queue. The microtask queue is, then, processed multiple times per iteration of the event loop, including after handling events and other callbacks.

Second, if a microtask adds more microtasks to the queue by calling queueMicrotask(), those newly-added microtasks execute before the next task is run. 

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

根据定义,可以简单地作出以下理解。

(宏)任务,其实就是标准JavaScript机制下的常规任务,或者简单的说,就是指消息队列中的等待被主线程执行的事件。在宏任务执行过程中,v8引擎都会建立新栈存储任务,宏任务中执行不同的函数调用,栈随执行变化,当该宏任务执行结束时,会清空当前的栈,接着主线程继续执行下一个宏任务。

微任务,看定义中与(宏)任务的区别其实比较复杂,但是根据定义就可以知道,其中很重要的一点是,微任务必须是一个异步的执行的任务,这个执行的时间需要在主函数执行之后,也就是微任务建立的函数执行后,而又需要在当前宏任务结束之前。

由此可以看出,微任务的出现其实就是语言设计中的一种实时性和效率的权衡体现。当宏任务执行时间太久,就会影响到后续任务的执行,而此时因为某些需求,编程人员需要让某些任务在宿主环境(比如浏览器)提供的事件循环下一轮执行前执行完毕,提高实时性,这就是微任务存在的意义。

常见的创建宏任务的方法有setTimeout定时器,而常见的属于微任务延伸出的技术有Promise、Generator、async/await等。而无论是宏任务还是微任务依赖的都是基础的执行栈和消息队列的机制而运行。根据定义,宏任务和微任务存在于不同的任务队列,而微任务的任务队列应该在宏任务执行栈完成前清空。

这正是分析和编写类似以下复杂逻辑代码所根据的基本原理,并且做到对事件循环的充分利用。

三、根据定义得出的分析实例

function taskOne() {
    console.log('task one ...')
    setTimeout(() => {
        Promise.resolve().then(() => {
            console.log('task one micro in macro ...')
        })
        setTimeout(() => {
            console.log('task one macro ...')
        }, 0)
    }, 0)
    taskTwo()
}
 
 
function taskTwo() {
    console.log('task two ...')
    Promise.resolve().then(() => {
        setTimeout(() => {
            console.log('task two macro in micro...')
        }, 0)
    })
 
    setTimeout(() => {
        console.log('task two macro ...')
    }, 0)
}
 
setTimeout(() => {
    console.log('running macro ...')
}, 0)
 
taskOne()
 
Promise.resolve().then(() => {
    console.log('running micro ...')
})

根据宏任务、微任务定义和调用栈执行以及消息队列就可以分析出console.log的输出顺序,即所代表的执行顺序。

首先,在执行的第一步,全局上下文进入调用栈,也属于常规任务,可以简单认为此执行也是执行中的一个宏任务。

在全局上下文中,setTimeout触发设置宏任务,直接进入消息队列,而Promise.resolve().then()中的内容进入当前宏任务执行状态下的微任务队列。taskOne被压入调用栈。当然,因为微任务队列的存放位置,也是申请于环境对象中,可以认为微任务拥有一个单独的队列。

此时当前宏任务并没有结束,taskOne函数上下文需要被执行。函数内部的console.log()立即执行,其中的setTimeout触发宏任务,进入消息队列,taskTwo被压入调用栈。

此时当前宏任务还没有结束,调用栈中taskTwo需要被执行。函数内部的console.log()立即执行,其中的promise进入微任务的队列,setTimeout进入消息队列。taskTwo出栈执行完毕。

此时当前已没有主逻辑执行的代码,而当前宏任务将执行结束,微任务会在当前宏任务完成前执行,所以微任务队列会依次执行,直到微任务队列清空。首先执行running micro,输出打印,然后执行taskTwo中的promise,setTimeout触发宏任务进入消息队列。

此时已经清空微任务队列,当前宏任务结束,主线程会到消息队列进行消费。先执行running macro 宏任务,直接进行打印,没有对应微任务,当前结束,继续执行taskOne setTimeout宏任务,内部执行同理。

由于微任务队列存在任务,在上一个宏任务taskOne setTimeout执行结束前,需要执行微任务队列中任务。

接下来所有的宏任务依次执行。得到最终的输出结果。

我们可以在Chrome里面进行验证。看起来并没有问题。

四、Nodejs环境中的区别

这是在浏览器搭载v8引擎的情况下,我们验证了宏任务和微任务的执行机理,那在Nodejs中运行JavaScript代码会有什么不同吗?

使用命令行直接执行JavaScript脚本文件,得到了以下结果。

与浏览器的执行输出结果有所不同。这里的one micro in macro 并没有在一开始执行。这是为什么呢?

虽然Nodejs的事件循环有不同于浏览器的六个阶段,但是按照定义规范,这里的宏任务和微任务执行,明显没有遵循微任务区分差别的第二点,也就是微任务必须在宏任务执行结束前执行。

其实这个问题在之前的业务开发中遇到过。由于微任务执行的时序与定义不符,导致数据出现了微小的差异。这里与Nodejs版本迭代中的实现有关。

通过命令可以看到当前执行的Nodejs版本为10.16.0。

我们使用nvm切换到更新一些的版本看看执行结果如何。

然后再次使用Nodejs执行上述脚本代码。在11版本之上我们得到了和浏览器一致的结果。

从一开始浏览器端就是严格遵循了微任务和宏任务定义进行执行,也就是说,一个宏任务执行完成过程中,就会去检测微任务队列是否有需要执行的任务,即使是微任务嵌套微任务,也会将微任务执行完成,再去执行下一个宏任务。

而通过查看Nodejs版本日志发现,在Nodejs环境中,在11版本之前,同源的任务放在一起进行执行,也就是宏任务队列和微任务队列只有清空一个后才会执行另一个。

就算涉及到同源宏任务的嵌套代码,任然会将宏任务一起执行,但是内部的任务则会放到下一个循环中去执行。而在11版本后,Nodejs修改成了与浏览器一样的遵循定义的执行方式。

对于早于11版本的Nodejs的实现,可能是由于嵌套任务存在的可能性。微任务嵌套微任务可能造成线程中一直处于当前微任务队列执行状态而走不下去,而宏任务的嵌套循环执行,并不会造成内存溢出的问题,因为每个宏任务的执行都是新建的栈。这就是为什么下方的代码会导致栈溢出,而加入setTimeout后就不会报错的原因。

既然如此,可能开发人员考虑这样情景的时候,不如先把同源任务执行完毕,以免在微任务饿死线程的时候,还有未执行完成的宏任务。然而这不符合规范,也显然不是很合理,这样的操作甚至是失误应该交给JavaScript的开发者。

function run() {
    run()
}
run()
 
 
function run() {
    setTimeout(run, 0)
}
run()

这也许是早于11版本,Nodejs实现的一个考虑。但是这样并不符合规范,所以我更愿意倾向于相信Nodejs团队在11版本之前的实现存在错误,而在11版本后修复了这个错误。毕竟如果使用同源执行策略,嵌套中的微任务就已经失去了时效性,在宏任务都执行完成后的微任务,与宏任务并没有区别。

当然了,目前大部分浏览器都倾向于去符合规范的实现方式,但是任然有一些区别。在使用的过程中,如果需要兼容不容的浏览器还是要更了解这些执行过程,以免出现难以察觉和查找的问题。在IE高版本、FireFox和Safari不同版本中,执行会有些不同,有兴趣的可以动手试试,并找出为何不同。


vivo互联网技术
3.3k 声望10.2k 粉丝