前言
本文重点是要掌握 JavaScript 事件循环(Event Loop)的处理步骤 , 它是异步任务执行机制的核心 。
本文分为两部分讲解 :
- 第一 : 回顾 JavaScript 中的任务类型 , 理解它们是如何调度的 ; 通过一个案例分析出任务的执行顺序和时机 ;
- 第二 : 我们手搓一段简化版的代码来 模拟浏览器中的事件循环机制 , 更深入地理解事件循环的工作原理
一、JavaScript 任务类型有哪些,它们是如何调度的 ?
JavaScript 是一门单线程的语言 , 它通过区分同步任务和异步任务来管理执行流程 ; JavaScript 引入异步任务是为了避免阻塞 , 提高用户体验, 优化性能 ;
任务类型
同步任务 : 在主线程上按顺序执行, 必须等待前一个任务完成才能开始下一个。
异步任务 : 不直接在主线程上执行, 而是先放入"任务队列"。只有当主线程空闲时, 才会从任务队列中取出异步任务放到主线程中去执行 , 异步任务可以简单的分为宏任务
和微任务
【具体的每个浏览器实现都不一致,任务队列会很多】。
宏任务
包含整个脚本的初始执行、事件(如点击、键盘输入)、setTimeout
、setInterval
、I/O、UI 渲染等。这些任务在事件循环的不同阶段执行。
微任务 是更为轻量级的任务, 它们在当前宏任务结束后, 但在下一个宏任务开始之前执行。典型的微任务包括:
Promise
的 .then
和.catch
等 、async/await
的后续操作 , Ajax请求
、MutationObserver
、 process.nextTick
( Node.js 环境特有)
执行步骤
- 主任务执行
- 初始化执行上下文:
- 首次执行全局上下文中的代码 , 这是一个同步过程
- 在这个阶段 , 变量声明、函数声明等被处理
- 队列准备:
- 在执行过程中 , 如果遇到异步操作(如 setTimeout、Promise 等) , 相关任务会被添加到宏任务队列或微任务队列。
- 宏任务队列示例 : Macro = [] , (此处用数组模拟队列做展示)
- 微任务队列示例 : Micro = []
- 同步代码执行结束后 , 当主线程空闲时 , 优先处理微任务
- 事件循环开始
-
微任务调度 :
- 宏任务执行完毕 , 立即 清空微任务队列
- 执行过程中新产生的宏任务和微任务 , 分别加入各自的队列
- 注意 :本轮执行过程中新添加的微任务也会在本轮清空过程中执行
-
宏任务调度 :
- 微任务执行完毕后 , 从宏任务队列中 取出一个宏任务执行
- 执行过程中新产生的宏任务和微任务 , 分别加入各自的队列
- 每执行完一个宏任务 , 就需要去清空微任务队列
-
循环迭代
- 重复【微任务调度 - 宏任务调度】的过程 , 直到两个队列均为空 , 循环结束
任务调度案例分析
console.log("--主任务--");
Promise.resolve().then(() => {
console.log("--微任务1--");
});
setTimeout(() => {
console.log("--宏任务1--");
Promise.resolve()
.then(() => {
console.log("--微任务3--");
})
.then(() => {
console.log("--微任务5--");
});
setTimeout(() => {
console.log("--宏任务2--");
}, 0);
Promise.resolve().then(() => {
console.log("--微任务4--");
});
setTimeout(() => {
console.log("--宏任务3--");
}, 0);
}, 0);
Promise.resolve().then(() => {
console.log("--微任务2--");
});
console.log("--主任务1--");
分析过程 :
- 执行全局上下文中的同步代码
- 输出 "--主任务--"
- Micro: [微任务1]
- Macro: [宏任务1]
- Micro: [微任务1,微任务2]
- 输出 "--主任务1--"
- ----- 同步代码执行结束 , 开启事件循环去处理异步任务 -----
- 清空微任务队列
- 输出 "--微任务1--"
- 输出 "--微任务2--"
- 取出一条宏任务执行
- 输出 "--宏任务1--"
- Micro: [微任务3]
- Macro: [宏任务2]
- Micro: [微任务3,微任务4]
- Macro: [宏任务2,宏任务3]
- 清空微任务队列
- 输出 "--微任务3--"
- Micro:[微任务4,微任务5]
- 输出 "--微任务4--"
- 输出 "--微任务5--" (本轮执行过程中新添加的微任务也会在本轮清空过程中执行)
- 取出一条宏任务执行
- 输出 "--宏任务2--"
- 取出一条宏任务执行
- 输出 "--宏任务3--"
二、手搓 JavaScript 代码模拟事件循环
我们手搓一段简化版的代码来模拟浏览器中的事件循环机制 , 包括宏任务 Macrotask
和微任务Microtask
的处理流程。通过此代码 , 我们可以更深入地理解事件循环的工作原理 , 并学习如何手动实现一个简化版的事件循环模型。
代码结构与功能
定义任务队列
// macros 和 micros 数组分别用于存储宏任务和微任务的回调函数。
const macros: Array<() => void> = [],
micros: Array<() => void> = [];
- 宏任务队列
(macros)
:存储所有宏任务的回调函数 - 微任务队列
(micros)
:存储所有微任务的回调函数
使用数组简单模拟队列,保证先进先出规则
定义执行宏微任务的函数
function runMicro() {
let func = micros.shift();
do {
func?.();
func = micros.shift();
} while (func);
}
function runMacro() {
let func = macros.shift();
do {
func?.();
runMicro();
func = macros.shift();
} while (func);
}
微任务执行的时候,我们只需要清空队列即可,但是到了宏任务执行的时候,我们就需要执行一个宏任务就清空一次微任务队列
这里用的
shift
, 从头部取出一个数据,然后添加的时候我们只用push
,这就简单实现了队列的先进先出原则
执行队列的代码稍微有点重复,我们可以进行简单优化一下
添加一个执行任务的 run
函数
- 功能:遍历并执行给定任务数组中的所有函数。可选地 , 在每个任务执行后调用一个回调函数,处理宏任务的特殊性
- 参数:
tasks
- 待执行的任务数组 ;callback
- 可选的每次执行后调用的回调
// run 函数执行给定的任务数组。它会一直执行数组中的任务, 直到数组为空。如果提供了回调函数, 它会在每个任务执行后调用。
function run(tasks: Array<() => void>, callback?: () => void) {
let func = tasks.shift();
do {
func?.();
callback?.();
func = tasks.shift();
} while (func);
}
function runMicro() {
run(micros);
}
function runMacro() {
run(macros, runMicro);
}
事件循环核心逻辑 eventLoop
function eventLoop() {
runMicro();
runMacro();
requestIdleCallback(eventLoop);
}
- 先执行微任务
- 接着执行宏任务 【单个宏任务执行结束后就会清空微任务队列,这个操作我们在
runMacro
中做了】 - 使用
requestIdleCallback
注册eventLoop
, 确保在浏览器空闲时重复执行该方法 , 模拟持续的事件循环
添加任务到宏微任务的队列中
// pushMacroTask 和 pushMicroTask 函数分别用于向宏任务和微任务队列中添加新的任务。
function pushMacroTask(...tasks: Array<() => void>) {
macros.push(...tasks);
}
function pushMicroTask(...tasks: Array<() => void>) {
micros.push(...tasks);
}
pushMacroTask
:向宏任务队列添加任务pushMicroTask
:向微任务队列添加任务
这里一定要添加到数组的尾部,所以用的
push
, 至于添加多个,可以要可不要
启动事件循环
通过 requestIdleCallback(eventLoop)
开启首次循环
eventLoop.ts
const macros: Array<() => void> = [],
micros: Array<() => void> = [];
function run(tasks: Array<() => void>, callback?: () => void) {
let func = tasks.shift();
do {
func?.();
callback?.();
func = tasks.shift();
} while (func);
}
function runMicro() {
run(micros);
}
function runMacro() {
run(macros, runMicro);
}
function eventLoop() {
runMicro();
runMacro();
requestIdleCallback(eventLoop);
}
function pushMacroTask(...tasks: Array<() => void>) {
macros.push(...tasks);
}
function pushMicroTask(...tasks: Array<() => void>) {
micros.push(...tasks);
}
requestIdleCallback(eventLoop);
export { pushMacroTask, pushMicroTask };
eventLoop.ts 在案例中使用
引入 eventLoop.js
后 , 就可以直接使用 pushMacroTask
和 pushMicroTask
方法向任务队列中添加宏任务和微任务了 ; 下面这个案例的执行步骤和最终输出结果同上文中的案例一样 。
import { pushMacroTask, pushMicroTask } from "./eventLoop.js";
console.log("--主任务--");
pushMicroTask(function () {
console.log("--微任务1--");
});
pushMacroTask(function () {
console.log("--宏任务1--");
pushMicroTask(function () {
console.log("--微任务3--");
pushMicroTask(function () {
console.log("--微任务5--");
});
});
pushMacroTask(function () {
console.log("--宏任务2--");
});
pushMicroTask(function () {
console.log("--微任务4--");
});
pushMacroTask(function () {
console.log("--宏任务3--");
});
});
pushMicroTask(function () {
console.log("--微任务2--");
});
console.log("--主任务1--");