通常,JavaScript会通过使用事件机制或timer的方式以达到在特定事件或时间调度执行某段代码(块),这种类型的异步通常在JavaScript中称为Event loop(事件循环)机制。本文,我们将探讨事件循环机制的工作原理,并演示其任务队列的执行过程。
JavaScript中的Event Loop(事件循环)与Call Stack(调用栈)
由于是单线程的,JS使用事件循环的概念来创建异步运行多个任务(我们的JS代码只运行在一个线程中,而JS使用事件循环异步运行代码)。我们将listener(监听器)附加到事件上,因此无论何时触发事件,附加到该事件的回调代码都会被执行。在进一步深入之前,让我们先了解一下JavaScript引擎是如何工作的。
JavaScript引擎由Stack(栈),Heap(堆)及Task Queue(任务队列)组成。
- Stack
Stack是一个类似数组的结构,用于跟踪当前正在执行的函数。
function m() {
a()
b()
}
m()
我们有一个m函数,该函数在函数体中调用了a函数和b函数。在开始执行时,内存中m函数的地址将被压入call stack(调用栈)。同样,JavaScript引擎在执行a函数和b函数前也会将它们的地址压入调用栈。
先等等,你是否会有这样的疑问:为什么JavaScript引擎要存储函数地址及其参数到调用栈呢?
在底层(以x86汇编语言为例),CPU会利用EAX,EBX,ECX,ESP,EIP等registers(寄存器)来临时存储变量并运行我们的程序(前提是已经加载到内存中)。其中EAX和EBX用于计算,ECX用于counter job(计数器作业,如存储for循环的次数)。ESP(堆栈指针)保存堆栈的当前地址,EIP(指令指针)保存要执行的程序的当前地址。
RAM EIP = 10
0 | | ESP = 21
1 |a(){}|
2 | | Call Stack
3 |b(){}| 14| |
4 | | 15| |
5 | | 16| |
6 |m(){ | 17| |
7 | a() | 18| |
8 | b() | 19| |
9 |} | 20| |
10|m() | 21| |
以上是我们的程序运行时在内存中表示的草图,我们看到程序被加载,然后是调用堆栈、ESP和EIP。程序的入口是m(),这就是为什么EIP是10(语句在内存中的位置)。在执行期间,CPU通过查看EIP来知道从哪里开始执行。
每当调用函数时,执行都会跳转到内存中的函数并从那里执行。 然后,在函数运行完成时,必须从其跳转的位置继续上一个函数,因此返回地址必须保存,调用栈就是用于解决这个问题的。 在每个函数调用中,EIP中的当前值都被推送到调用堆栈中。 在我们的示例中,当调用a()时,我们的调用堆栈如下所示:
RAM EIP = 1
0 | | ESP = 19
➥ 1 |a(){}|
2 | | Call Stack
3 |b(){}| 14| |
4 | | 15| |
5 | | 16| |
6 |m(){ | 17| |
7 | a() | 18| |
8 | b() | 19| |
9 |} | 20| 7 |
10|m() | 21| 10|
当a()执行完成后,调用栈会将栈顶的7弹出并赋值给EIP,来达到告知CPU继续执行内存地址为7之后的指令。
为什么函数执行参数也要推入调用栈?其实在执行带参数的函数时,该函数使用EBP寄存器从堆栈中获取值,这些值就是它的参数。因此,在调用方函数调用一个函数之前,它必须首先推送被调用方函数要访问的参数,然后推送EIP和ESP地址。
- Heap
通常在new一个Objects(对象)时,会将新创建的对象分配到堆中。
const apple = new Fruit('apple', 'very_tasty');
上述代码将在堆上创建一个Fruit对象,并将地址返回给apple变量。由于堆本质上不是有序的,因此操作系统必须找到一种方法来实现内存管理,以防止内存泄漏。
- Task Queue
后续由JavaScript引擎处理的任务会进入任务队列。事件循环机制会不断检查调用栈是否为空,为空的话会继续执行任务队列中排队的所有回调。
Microtask(微任务)与Macrotask(宏任务)
上面我们探讨了JavaScript引擎的工作方式以及任务队列的基本作用。当我们进一步探讨任务队列时,发现 任务进一步被细分为微任务和宏任务。
事件循环的每次循环:
while (eventLoop.waitForTask()) {
eventLoop.processNextTask()
}
每次循环中只处理一个宏任务(任务队列是宏任务队列), 完成此操作后,将在同一循环内处理微任务队列中入队的所有微任务。 这些微任务可以入队其他微任务,这些微任务将一直运行直到微任务队列为空。
while (eventLoop.waitForTask()) {
const taskQueue = eventLoop.selectTaskQueue()
if (taskQueue.hasNextTask()) {
taskQueue.processNextTask()
}
const microtaskQueue = eventLoop.microTaskQueue
while (microtaskQueue.hasNextMicrotask()) {
microtaskQueue.processNextMicrotask()
}
}
举个实际栗子以更好帮助我们了解微任务及宏任务的处理机制:
// 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
PS:宏任务可通过setTimeout、setInterval、setImmediate、event等方式入队;微任务通过process.nextTick、Promises、MutationObserver等方式入队。
上面提到每次事件循环都会先处理一个宏任务,之后再处理微任务队列中的所有微任务。因此你也许会觉得setTimeout应该先于promise1与promise2打印,毕竟似乎没有其他宏任务先于setTimeout产生的宏任务入队?
JavaScript是事件驱动的,因此除非产生了事件,没有代码会在JavaScript引擎中运行。其实在执行任何JS文件前,JavaScript引擎都会将其中的内容包装进一个函数,并且将该函数绑定了start或launch事件。然后JavaScript引擎会主动触发start事件,该事件的回调函数(其实就是包装代码的函数)将会被加入到宏任务队列中。
总结
- 任务从任务队列中获取
- 从任务队列中获取的任务是宏任务,而不是微任务
- 微任务在当前任务结束时进行处理,在下一个宏任务处理前会处理完微任务队列中所以微任务。
- 微任务可以入队其他微任务,但微任务队列中所有的微任务都会在下一轮宏任务开始前执行完。
- UI渲染运行在所有微任务执行完成后。
《JavaScript中的TaskQueue,Macrotask 与 Microtask》上有1条评论