作者:Ivan本文从JavaScript规范入手,阐述了考虑时效性和效率权衡的JS执行过程的演变,分析了事件队列的基本机制对JS代码运行的不同JS任务类型(macrotasks、microtasks)的区别进行了解释,并通过这些区别详细给出了不同任务嵌套的复杂JS代码执行的分析过程。1、事件队列和回调在使用JavaScript编程时,需要用到很多回调编程。回调,简单理解,其实就是设置状态通知。当一个语句模块被执行时,会通知相应的方法执行动作。最常见的定时器如setTimeout、AJAX请求等。这是JavaScript的单线程设计造成的。作为一种脚本语言,语言设计者在运行时需要考虑的两个重要的事情是实时执行和效率。实时性是指代码执行过程中代码执行的有效性,当前执行的语句任务是否在当前有效性下发挥作用。效率,这里指的是代码执行过程中每条语句的执行对后续执行造成的延迟率。由于JavaScript的单线程特性,如果想在不阻塞后续执行的情况下完成复杂的逻辑执行,即保证效率,回调似乎是必然的选择。早期浏览器的实现可能与今天的有所不同,但不影响我们对回调过程的理解。早期的浏览器在设计的时候,比如IE6,一般页面中相关的内容,比如渲染、事件监听、网络请求、文件处理等,都是在一个单独的线程中运行的。这时候如果要引入JavaScript控制文件,JavaScript也会和页面运行在同一个线程上。当一个事件被触发时,有一个单线程线性执行。这时候不仅有可能线程中正在执行其他任务,导致当前事件不能立即执行,更有可能是直接执行当前事件造成的线程阻塞,会影响到执行效率。原因。这时由事件触发的执行过程,比如一个函数,就会进入回调处理过程,为了实现不同回调的实现,浏览器提供了一个消息队列。当主线上下文内容执行完毕后,会把消息队列中的回调逻辑一个一个取出来执行。这是最简单的事件机制模型。浏览器的事件回调实际上是一种异步回调机制。常见的异步进程有两个典型代表。一种是以setTimeout定时器为代表,被触发后直接进入事件队列等待执行;另一种用XMLHTTPRequest表示,被触发后需要调用到另一个线程执行,执行完成后封装返回值进入事件队列等待。这里不深入讨论。由此,我们得到了JavaScript设计的基本线程框架。在此基础上衍生出macrotasks和microtasks的差异化实现,以解决具体问题。在没有微任务的时代,JavaScript的执行没有所谓的异步执行概念。异步执行是在浏览器提供的宿主环境中实现的。在实现微任务之前,JavaScript代码执行可以被认为是一个异步过程。(因为广泛使用的JavaScript引擎是V8,这里我们以V8作为讲解对象)二、(宏)任务和微任务我们在文章中经常看到,macroTask(宏任务)和microTask(微任务)。但实际上,在MDN[链接]中查看时,术语macroTask(宏任务)对应于microTask(微任务),与microTask的统一区分其实就是一个普通的Task任务。这里我们可以粗略的认为,普通的Task任务其实就是macroTasks。任务定义:任务是任何JavaScript代码,它被安排由标准机制运行,例如最初开始运行程序,正在运行的事件回调,或者触发间隔或超时。这些都被安排在任务队列中。(任何被标准机制调度执行的JavaScript代码都是一个任务,比如执行程序,执行事件回调,或者触发间隔/超时,这些都是在任务队列上调度的。)微任务之间区别的定义:首先,每次任务退出时,事件循环检查任务是否正在将控制权返回给其他JavaScript代码。如果没有,它将运行微任务队列中的所有微任务。然后,微任务队列在事件循环的每次迭代中被处理多次,包括在处理事件和其他回调之后。其次,如果微任务通过调用queueMicrotask()将更多微任务添加到队列中,这些新添加的微任务会在下一个任务运行之前执行。(当一个任务存在时,事件循环会检查该任务是否正在将控制权移交给其他JavaScript代码。如果没有,事件循环会运行微任务队列中的所有微任务。接下来,微任务循环会在每个任务中处理多次事件循环的迭代,包括处理事件和其他回调之后。其次,如果一个微任务通过调用queueMicrotask()向队列中添加更多的微任务,那些新添加的将比下一个任务更早运行。)根据定义,下面可以简单地理解。(宏)任务其实就是标准JavaScript机制下的常规任务,或者简单的说,就是消息队列中等待主线程执行的事件。在执行宏任务的过程中,v8引擎会新建一个栈来存放任务。宏任务中执行不同的函数调用,栈随着执行而变化。当宏任务执行结束时,会清空当前栈,然后主线程继续执行宏任务。microtasks和(macro)tasks在定义上的区别其实比较复杂,但是根据定义就可以知道了。其中最重要的一点就是microtask必须是异步执行任务,执行时间需要在main函数执行之后,即microtask建立的函数执行之后,但结束之前当前的宏任务。由此可见,微任务的出现其实是语言设计上实时性和效率的权衡。当宏任务执行时间过长时,会影响后续任务的执行。这时,由于某些需求,程序员需要让某些任务在宿主环境(如浏览器)提供的事件循环下一轮执行之前执行。完了,提高实时性,这就是微任务存在的意义。宏任务常用的创建方法有setTimeout定时器,微任务扩展的常用技术有Promise、Generator、async/await等。无论是宏任务还是微任务,都依赖于基本的执行栈和消息队列机制运行。根据定义,macrotasks和microtasks存在于不同的taskqueue中,在macrotasks执行栈完成之前,应该清空microtasks的任务队列。这是分析和编写如下复杂逻辑代码,充分利用事件循环的基本原则。3.根据定义functiontaskOne(){console.log('taskone...')setTimeout(()=>{Promise.resolve().then(()=>{console.log('宏中的任务一微...')})setTimeout(()=>{console.log('任务一宏...')},0)},0)taskTwo()}functiontaskTwo(){console.log('tasktwo...')Promise.resolve().then(()=>{setTimeout(()=>{console.log('tasktwomacroinmicro...')},0)})setTimeout(()=>{console.log('任务二宏...')},0)}setTimeout(()=>{console.log('运行宏...')},0)taskOne()Promise.resolve().then(()=>{console.log('runningmicro...')})根据宏任务、微任务定义、调用栈执行和消息队列,console可以分析出.log的输出顺序就是代表的执行顺序。首先,在执行的第一步,全局上下文进入调用栈,这也是一个例行任务。可以简单的认为,这次执行也是一个执行中的宏任务。在全局上下文中,setTimeout触发设置宏任务直接进入消息队列,而Promise.resolve().then()中的内容在当前宏任务执行状态下进入微任务队列。taskOne被压入调用堆栈。当然,因为microtask队列的存储位置也应用到环境对象上,所以可以认为microtask有一个单独的queue。此时当前宏任务还没有结束,还需要执行taskOne函数上下文。函数内部的console.log()立即执行,setTimeout触发宏task,进入消息队列,taskTwo被压入调用栈。此时当前宏任务还没有结束,需要执行调用栈中的taskTwo。函数里面的console.log()立即执行,里面的promise进入microtask队列,setTimeout进入消息队列。taskTwo从堆栈中弹出并执行。此时主逻辑当前没有执行任何代码,会执行当前宏任务,在当前宏任务完成之前先执行微任务,所以微任务队列会依次执行,直到微任务队列被清空。先执行runningmicro,输出打印,然后执行taskTwo中的promise,setTimeout触发宏任务进入消息队列。此时microtask队列已经清空,当前macrotask结束,主线程会去消息队列消费。先执行runningmacro宏任务,直接打印,没有对应的微任务,当前结束,继续执行taskOnesetTimeout宏任务,内部执行同理。由于微任务队列中有任务,微任务队列中的任务需要在上一个宏任务taskOnesetTimeout执行结束前执行完毕。所有后续的宏任务都按顺序执行。得到最终的输出。我们可以在Chrome中验证。看起来不是问题。4.Nodejs环境的差异这是浏览器搭载v8引擎的时候。我们验证了宏任务和微任务的执行机制。在Nodejs中运行JavaScript代码会有什么不同吗?使用命令行直接执行JavaScript脚本文件,得到如下结果。它不同于浏览器的执行输出。这里宏中的一微,一开始并没有实现。为什么是这样?Nodejs的eventloop虽然和浏览器的eventloop有六个不同的stage,但是根据定义规范,这里宏任务和微任务的执行显然没有遵循微任务区别的第二点,即微任务必须在宏任务结束前执行。其实这个问题在之前的业务开发中就遇到过。因为microtask执行的时机与定义不符,所以数据略有差异。这与Nodejs版本迭代中的实现有关。通过命令可以看到当前执行的Nodejs版本为10.16.0。我们使用nvm切换到较新的版本以查看执行情况。然后再次使用Nodejs执行上面的脚本代码。在版本11上,我们得到了与浏览器相同的结果。从一开始,浏览器端就严格按照微任务和宏任务的定义来执行。也就是说当一个macrotask执行时,会检查microtask队列中是否有需要执行的task,即使microtask是嵌套的。Microtasks也会在执行下一个macrotask之前完成microtasks的执行。通过查看Nodejs版本日志,发现在Nodejs环境中,11版本之前,同源任务是一起执行的,即宏任务队列和微任务队列清零后才会执行。即使涉及同源宏任务的嵌套代码,宏任务还是会一起执行,但内部任务会在下一个循环执行。11版本之后,Nodejs被修改为遵循浏览器定义的执行方式。对于Nodejs11之前版本的实现,可能是由于嵌套任务的可能。Microtask嵌套microtasks可能会导致线程处于microtask队列的当前执行状态,无法继续执行。但是macrotasks的嵌套循环执行不会造成内存溢出的问题,因为每一个macrotasks的执行都是新创建的栈。这就是为什么下面的代码会导致栈溢出,加上setTimeout后不会报错的原因。既然如此,或许开发者在考虑这样的场景时,最好先执行同源任务,以免微任务饿死线程时宏任务未完成。但是,这不符合规范,显然也不太合理。这样的操作甚至错误都应该交给JavaScript开发人员。functionrun(){run()}run()functionrun(){setTimeout(run,0)}run()这可能是Nodejs11之前版本实现的考虑,但这不符合规范,所以我更倾向于认为Nodejs团队在11版本之前的实现有bug,11版本之后修复了这个bug。毕竟如果使用同源执行策略,nest中的microtasks会失去它们的作用时效性,宏任务执行后的微任务与宏任务没有区别。当然,目前大多数浏览器都倾向于符合该规范的实现,但还是存在一些差异。在使用过程中,如果需要兼容不兼容的浏览器,还是需要多了解这些执行过程,以免出现难以检测和发现的问题。在不同版本的IE、FireFox和Safari中,实现会有些不同。如果你有兴趣,你可以自己尝试一下,看看为什么不一样。
