一、背景:JavaScript 单线程和异步编程
JavaScript 的设计本质上是单线程的,这意味着在同一时刻只能执行一个任务。在浏览器环境中,这个线程同时需要管理 UI 渲染、事件监听、网络请求等多个任务,如何在单线程的限制下高效地处理这些任务,是 JavaScript 需要解决的核心问题。
为了解决这个问题,JavaScript 引擎引入了 事件循环(Event Loop) 机制,使得程序能够通过异步任务、回调函数等方式实现并发性。JavaScript 通过事件循环来管理宏任务(Macro Tasks)和微任务(Micro Tasks)的执行,从而避免了“阻塞”现象,使得界面保持响应。
二、JavaScript 执行模型:从执行栈到任务队列
要理解事件循环机制,必须了解 JavaScript 执行模型的组成:
- 执行栈(Call Stack):执行栈用于存放当前正在执行的函数。当一个函数被调用时,它会被压入栈顶,执行完成后从栈顶弹出。执行栈遵循后进先出(LIFO)规则。
- 任务队列(Task Queue):
-
-
宏任务队列(Macro Task Queue):存放较为宏观的任务,如
setTimeout
、setInterval
、I/O 操作等。 -
微任务队列(Micro Task Queue):存放较细小的任务,通常是更高优先级的任务。微任务队列包含
Promise
的回调、MutationObserver
、queueMicrotask
等。
-
宏任务队列(Macro Task Queue):存放较为宏观的任务,如
- 事件循环(Event Loop):事件循环不断轮询,查看是否可以从宏任务队列或微任务队列中获取任务来执行。
三、宏任务与微任务的调度机制
理解宏任务和微任务的调度机制至关重要。其核心逻辑可以通过下面的步骤来描述:
- 执行同步代码:首先,执行栈会执行主线程中的同步代码(即在当前执行上下文中顺序执行的代码)。
- 执行微任务:同步代码执行完后,事件循环会检查微任务队列,依次执行其中所有的微任务。如果在执行微任务的过程中有新的微任务被添加,它们会立即执行,直到微任务队列为空。
- 执行宏任务:事件循环接下来会从宏任务队列中取出一个任务并执行。每次从宏任务队列中取出一个任务后,事件循环会继续执行步骤 2,即处理微任务队列中的任务。
- 渲染更新:事件循环执行完当前宏任务并清空所有微任务后,浏览器会进行页面渲染更新,确保 UI 显示是最新的。
四、宏任务与微任务的优先级
4.1 微任务的高优先级
微任务队列在事件循环中的优先级高于宏任务队列。这意味着,即使有宏任务处于待执行状态,如果微任务队列中有任务,它们会被先执行。这种设计保证了微任务(如 Promise
回调)能够尽早完成,避免异步任务的延迟。
例如,Promise
是微任务,它的 .then
回调总是会在当前宏任务执行结束后、下一个宏任务开始之前执行。这种机制使得我们可以保证异步代码的执行顺序,并且减少延迟。
4.2 宏任务的执行与调度
宏任务的调度相对较为“粗糙”,它们通常在主线程上执行后立即进入任务队列等待被调度。每次事件循环都会从宏任务队列中取出一个任务进行执行。常见的宏任务有:
-
setTimeout
和setInterval
- DOM 事件(如
click
、load
等) - 网络请求(如 AJAX 请求、
fetch
) - UI 渲染操作
在宏任务中执行过程中,如果生成了新的微任务,它们会立即被执行。
五、深入剖析:微任务与宏任务的实际执行顺序
我们通过一个复杂的示例来深入分析宏任务与微任务的执行顺序及其背后的机制:
console.log('Start');
setTimeout(() => {
console.log('Macro Task 1');
Promise.resolve().then(() => console.log('Micro Task 1.1'));
}, 0);
Promise.resolve().then(() => {
console.log('Micro Task 1');
setTimeout(() => {
console.log('Macro Task 2');
}, 0);
});
console.log('End');
执行步骤分析:
- 同步代码执行:
-
-
console.log('Start')
被执行,输出:Start
-
setTimeout
和Promise.resolve().then
在同步代码中注册,但并不会立即执行。
-
- 执行微任务:
-
-
console.log('End')
被执行,输出:End
- 事件循环发现微任务队列中有一个微任务(由
Promise.resolve().then
创建),于是立刻执行:
-
-
-
- 输出:
Micro Task 1
- 在此微任务中,调用了一个
setTimeout
,它会被放入宏任务队列。
- 输出:
-
- 执行宏任务:
-
- 事件循环继续执行宏任务队列中的第一个任务:
-
-
-
setTimeout
回调Macro Task 1
被执行,输出:Macro Task 1
- 在
Macro Task 1
中创建了一个新的微任务(Promise.resolve().then
),它会被立即执行:
-
-
-
-
-
- 输出:
Micro Task 1.1
- 输出:
-
-
- 执行下一个宏任务:
-
- 接着,事件循环执行第二个
setTimeout
,它会被放入宏任务队列。
- 接着,事件循环执行第二个
-
-
- 输出:
Macro Task 2
- 输出:
-
最终输出顺序:
Start
End
Micro Task 1
Macro Task 1
Micro Task 1.1
Macro Task 2
关键点:
-
微任务队列的强优先级:即使是一个宏任务中的回调,也会在微任务执行后才被调度执行。这就解释了为什么
Micro Task 1.1
在Macro Task 1
之后被执行。 - 事件循环的精细调度:每次事件循环都会先清空微任务队列,然后再执行宏任务。即使新的宏任务会在微任务队列执行期间被加入,它们也会在当前微任务队列完全清空后才会执行。
六、事件循环的底层实现
JavaScript 的事件循环并非仅仅是一个简单的队列操作。在浏览器中,JavaScript 引擎(如 V8)通过多线程、异步 I/O 和任务调度来提高性能。
-
Web API 线程:浏览器通过 Web API(如
setTimeout
、XMLHttpRequest、fetch
等)来处理异步任务。它们由单独的线程处理,处理完成后将结果返回主线程,主线程再将它们放入相应的任务队列。 - 宏任务与微任务队列的分离:宏任务和微任务在浏览器内部通常由不同的队列管理。宏任务通常由浏览器的事件循环调度,而微任务则在宏任务执行后、UI 渲染之前执行。
- 任务分发与优化:V8 引擎会对任务的调度进行优化。例如,微任务队列的任务通常被尽量集中在一起执行,以减少上下文切换的开销。
七、总结
JavaScript 的事件循环机制通过精细的任务调度,确保了单线程环境下高效的异步处理。宏任务和微任务队列的优先级、执行顺序,以及事件循环的细致调度机制,我们可以更好地设计异步代码,避免不必要的延迟和性能瓶颈。
技术沟通交流,VX:1010368236