宏任务:由setTimeout,setInterval,setImmediate调用而进入执行队列的回调函数(【译注】宏任务队列) 微任务:由process.nextTick,Promises,MutationObserver调用而进入执行队列的回调函数(【译注】微任务队列)
看一段代码:
// example.js
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
运行结果:
script start
script end
promise1
promise2
setTimeout
看下结果,script start先输出,然后是script end, promise1, promise2 and setTimeout。虽然setTimeout的延时设置的是0,但是为什么在最后打印呢?
在一个event loop循环里,宏任务在微任务之前运行。
你可能要说了,那这个setTimeout应该先打印呀,不是说宏任务在微任务之前运行么,而且从代码上看,在setTimeout之前也没有排队的宏任务。 嗯,你说的对。但是,(在这之前)要先有一个事件发生,不然JS(引擎)是不会运行任何代码的。这个事件会进入宏任务队列。
运行任何JS文件的时候,JS引擎会把文件内容包在一个函数里并且把这个函数和一个事件(start或launch)相关联。JS引擎发出这个start事件,这个事件会被进入队列(宏任务队列)。 JS引擎在初始化的时候会从宏任务队列里拉出第一个任务并执行相关回调函数,这时,我们的代码运行了。
- 读取文件内容
- 把内容包在函数里
- 把这个函数作为一个event handler,和程序的“start”或“launch”事件相关联
- 执行其他初始化
- 发出程序的start事件
- 这个事件被放进队列
- JS引擎从队列里拉出那个事件并执行与其注册过的handler
- 我们的程序运行了!
所以我们看到,运行脚本是第一个进入排队的宏任务。然后回调函数执行我们的代码。接下来,script start被console.log打印出来。然后,setTimeout函数调用后把回调函数放进了宏任务队列。接着,Promise 调用把一个微任务放进队列,接着console.log打印script end。然后初始回调(宏任务)结束。
上面那个是宏任务,(完了之后),微任务开始被运行。Promise的(then)回调函数打印了promise1,返回后并且又通过then函数加了个新的微任务。再执行这个新的微任务打印promise2(记住,在一个周期内微任务能够添加额外的微任务,但在下一次宏任务执行前,这些微任务都会被执行)。没有新加的微任务了,微任务队列也空了。这个初始的宏任务(从宏任务队列里)清除,(宏任务队列里)还剩那个setTimeout的回调函数。
此时,UI渲染开始运行(如果有的话)。接着,下一个宏任务也就是setTimeout的回调开始执行。打印了setTimeout并从宏队列里清掉。接着由于没有更多任务,调用堆栈也空了,JS引擎交出控制权。
接下来,让我们用我们的js代码来模拟一下event loop的过程。
// js_engine.js
1.➥ let macrotask = []
2.➥ let microtask = []
3.➥ let js_stack = [] // microtask
4.➥ function setMicro(fn) {
microtask.push(fn)
} // macrotask
5.➥ function setMacro(fn) {
macrotask.push(fn)
} // macrotask
6.➥ function runScript(fn) {
macrotask.push(fn)
}
7.➥ global.setTimeout = function setTimeout(fn, milli) {
macrotask.push(fn)
}
// your script here
8.➥ function runScriptHandler() {
8I.➥for (var index = 0; index < js_stack.length; index++) {
8II.➥eval(js_stack[index])
}
}
// start the script execution
9.➥runScript(runScriptHandler) // run macrotask
10.➥for (let ii = 0; ii < macrotask.length; ii++) {
11.➥ eval(macrotask[ii])()
if (microtask.length != 0) {
// process microtasks
12.➥ for (let __i = 0; __i < microtask.length; __i++) {
eval(microtask[__i])()
}
// empty microtask
microtask = []
}
}
首先,我们定义了macrotask(1.)和microtask(2.)两个队列。每当有一个宏任务(比如setTimeout的回调)就会被加到macrotask队列(1.)里去,同样,微任务队列(2.)里也会放进微任务函数。
js_stack(3.)这里保存的是我们需要执行的代码。其实,就是(模拟)我们在js文件里写的代码。为了执行他们,我们循环这个stack并且用eval函数调用他们。
接着,我们定义宏/微任务设置函数:setMicro(4.),setMacro(5.),runScript(6.) 和 setTimeout(7.)。这些函数接受一个回调函数作为参数并把这个函数放进宏/微任务队列。
那些函数(4. 5. 6.)在调用时会去设置微/宏任务。我们这里,只是把这些回调函数放进相应的队列里去。setMicro是微任务(设置)函数,所以它的回调就被加进microtask。我们把setTimeout给hook重新定义了。所以当我们调用(setTimeout),其实进的是我们定义的这个函数。
因为它(setTimeout)是一个宏任务函数,所以我们把回调函数放进宏任务队列里。我们还有一个runScript函数,这个函数模拟了JS引擎在初始化时的全局“start”事件。因为全局事件(“start”)是一个宏任务,所以我们把(runScript的)回调函数放进宏任务队列。runScriptHandler(8.)会引导执行js_stack里的所有脚本(也就是模拟了我们js文件里的代码)。
(【译注】这一小段,没有很理解原作者的意思,我按照自己的思路写了一下,有问题还希望大家提给我,我来改正。)一开始,我们执行runScript函数,把作为初始化运行的函数作为宏任务放进macrotask(并未执行),然后开始运行到(10.)。每执行一个宏任务后(11.),(当前)所有的微任务都会被执行完(12.)。
我们for-loop整个macrotask数组,并执行当前索引里的函数,并仍在当前的索引内,再通过一个子for-loop去循环完和执行完microtask数组里的函数,虽然,某些微任务里还会再加进新的微任务,子for-loop会循环运行完直到microtask为空。等microtask全部运行完后,再开始下一个macrotask的执行。
实践一下,我们来运行一下如下js代码:
console.log('start')
console.log(`Hi, I'm running in a custom JS engine`)
console.log('end')
我们需要把每一行作为string放进js_stack数组:
...
// your script here
js_stack.push(`console.log('start')`)
js_stack.push("console.log(`Hi, I'm running in a custom JS engine`)")
js_stack.push(`console.log('end')`)
...
这个js_stack就像是我们的js文件里的代码。JS引擎读取它并执行每一条。这其实就是我们在(8.)这一步做的事情。我们for-loop(8I.)js_stack并用eval执行每条语句(8II.)。
如果我们运行node js_engine.js,我们能够看到:
start
Hi, I'm running in a custom JS engine
end
OK,让我们来点别的。让我们用一下一开始的那个例子(example.js),不过需要一点小修改:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
setMicro(()=> {
console.log('micro1')
setMicro(()=> {
console.log('micro2')
})
})
console.log('script end');
我们去掉了Promises并且用setMicro函数来替代。这是相同的,都是记录一个微任务。我们可以看到从微任务队列执行setMicromicro1回调的时候,它又加了一个新的微任务micro2,这和我们再example.js的Promise做的一样。
所以,我们希望执行如下:
script start
script end
micro1
micro2
setTimeout
为了在我们自定义的JS engine里执行,我们再转换一下(下面这段替换掉//your script here):
// js_engine.js
...
js_stack.push(`console.log('script start');`)
js_stack.push(`setTimeout(function() {
console.log('setTimeout');
}, 0);`)
js_stack.push(`setMicro(()=> {
console.log('micro1')
setMicro(()=> {
console.log('micro2')
})
})`)
js_stack.push(`console.log('script end');`)
...
然后,运行node js_engine.js,我们得到:
$ node js_engine
script start
script end
micro1
micro2
setTimeout
和真正的JS引擎一样。我们正确的模拟了真实的JS引擎。
runScript把我们的代码注册成一个宏任务然后退出,它的宏任务回调运行了我们的代码打印了script start,setTimeout设置了一个宏任务,setMicro设置了一个微任务micro1。script end最后被打印。当每个macrotask运行后,所有在microtask队列里的微任务都会被执行完。micro1这个回调运行打印micro1而且还加了另外一个微任务micro2。micro1这个微任务运行完后,这个micro2微任务运行打印了micro2。再一次退出后,已经没有其它的微任务了,所以接着宏任务执行。setTimeout回调运行打印了setTimeout。当队列里没有更多的宏任务,循环退出,我们的JS引擎也退出了。
要点如下:
- 任务来自任务队列。
- 我们手写的代码是被包成一个宏任务,而不是微任务。(【译注】这里是我自己改写的,没太理解原文这里的解释。)
- 微任务是在当前(宏)任务完成后再开始运行,并在执行下一个宏任务周期前会执行完所有的微任务。
- 微任务(执行时)可以加入新的微任务。所有的(微任务)都会在下一个宏任务前执行完。
- UI渲染会在所有微任务执行后运行
总结
这篇文章里我们模拟了JS引擎的任务队列,看到了队列里的任务是如何被执行的。而且,我们还了解到了比任务队列更多的东西:微任务和宏任务。所有的微任务都在一个宏任务执行周期内全部执行。
你可以随便使用这个自定义的js engine来学习和理解真正的JS引擎。
我只是翻译了文章内宏/微任务这部分,不对之处欢迎大家指正。