Event Loop 事件循环
问题
什么是 Event Loop?宏任务和微任务有什么区别?
答案
Event Loop(事件循环) 是 JavaScript 实现异步编程的核心机制。由于 JavaScript 是单线程语言,Event Loop 使得 JS 能够在不阻塞主线程的情况下执行异步操作。
为什么需要 Event Loop?
JavaScript 是单线程的,这意味着同一时间只能执行一个任务。如果没有 Event Loop:
// 假设没有 Event Loop,这段代码会发生什么?
console.log('开始');
// 假设这是一个耗时 5 秒的网络请求
fetch('/api/data'); // 会阻塞 5 秒
console.log('结束'); // 5 秒后才能执行
有了 Event Loop,异步操作不会阻塞主线程:
console.log('开始'); // 1. 立即执行
fetch('/api/data') // 2. 发起请求,不阻塞
.then(data => {
console.log('数据到达'); // 4. 数据到达后执行
});
console.log('结束'); // 3. 立即执行
JavaScript 运行时模型
核心组成部分
| 组件 | 说明 | 示例 |
|---|---|---|
| 调用栈(Call Stack) | 执行同步代码的栈结构 | 函数调用、变量声明 |
| 堆(Heap) | 存储对象的内存区域 | 对象、数组 |
| Web APIs | 浏览器提供的异步 API | setTimeout、fetch、DOM |
| 宏任务队列(Task Queue) | 存储宏任务的队列 | setTimeout 回调 |
| 微任务队列(Microtask Queue) | 存储微任务的队列 | Promise.then 回调 |
Event Loop 执行机制
执行顺序
- 执行同步代码(当前宏任务)
- 清空微任务队列(执行所有微任务)
- 检查是否需要渲染
- 执行一个宏任务
- 重复步骤 2-4
- 每次执行一个宏任务后,会清空所有微任务
- 微任务优先级高于宏任务
- 渲染发生在微任务之后、下一个宏任务之前
宏任务与微任务
什么是宏任务(Macro Task / Task)?
宏任务是由宿主环境(浏览器或 Node.js)发起的任务,代表一个独立的、完整的工作单元。
宏任务可以理解为"一整块需要执行的代码"。每个宏任务执行完毕后,浏览器有机会进行渲染、响应用户交互等操作。
宏任务的特点:
- 独立性:每个宏任务是一个独立的执行单元
- 由宿主环境触发:由浏览器/Node.js 的 API 产生
- 执行间隙可渲染:两个宏任务之间,浏览器可以进行页面渲染
- 每次只执行一个:Event Loop 每轮只从宏任务队列取出一个任务执行
// 宏任务示例
// 1. script 标签中的整体代码就是第一个宏任务
console.log('这是第一个宏任务');
// 2. setTimeout 回调是一个新的宏任务
setTimeout(() => {
console.log('这是一个新的宏任务');
}, 0);
// 3. setInterval 的每次回调也是独立的宏任务
setInterval(() => {
console.log('每次回调都是一个宏任务');
}, 1000);
// 4. 用户点击事件的回调也是宏任务
button.addEventListener('click', () => {
console.log('点击事件回调是宏任务');
});
常见的宏任务:
| 宏任务 | 来源 | 说明 |
|---|---|---|
script(整体代码) | 浏览器 | 最初的宏任务,页面加载时执行 |
setTimeout | 浏览器/Node.js | 延时执行,最小延时约 4ms |
setInterval | 浏览器/Node.js | 循环定时器 |
setImmediate | Node.js | 在当前轮 poll 阶段后执行 |
I/O 回调 | 浏览器/Node.js | 文件读写、网络请求完成后的回调 |
UI 事件 | 浏览器 | click、scroll、keydown 等 |
MessageChannel | 浏览器 | 消息通道的 message 事件 |
requestAnimationFrame | 浏览器 | 动画帧回调(较特殊,在渲染前执行) |
什么是微任务(Micro Task)?
微任务是由 JavaScript 引擎发起的任务,用于处理当前宏任务执行过程中产生的异步操作。
微任务可以理解为"当前任务的后续处理"。它们在当前宏任务结束后、下一个宏任务开始前立即执行,且会一次性清空整个微任务队列。
微任务的特点:
- 依附性:微任务依附于当前宏任务,是对当前任务的"善后"工作
- 由 JS 引擎触发:主要由 Promise 机制产生
- 优先级高:在下一个宏任务之前执行,在渲染之前执行
- 全部执行:一次性执行完所有微任务,包括执行过程中新产生的微任务
// 微任务示例
// 1. Promise.then 回调是微任务
Promise.resolve().then(() => {
console.log('Promise.then 是微任务');
});
// 2. async/await 中 await 之后的代码是微任务
async function example(): Promise<void> {
console.log('同步执行');
await Promise.resolve();
console.log('这里是微任务'); // await 之后是微任务
}
// 3. queueMicrotask 显式创建微任务
queueMicrotask(() => {
console.log('显式创建的微任务');
});
// 4. MutationObserver 回调是微任务
const observer = new MutationObserver(() => {
console.log('DOM 变化回调是微任务');
});
常见的微任务:
| 微任务 | 来源 | 说明 |
|---|---|---|
Promise.then/catch/finally | JS 引擎 | Promise 状态变化后的回调 |
async/await | JS 引擎 | await 之后的代码(本质是 Promise) |
queueMicrotask() | JS 引擎 | 显式添加微任务的 API |
MutationObserver | 浏览器 | DOM 变化观察回调 |
process.nextTick | Node.js | 优先级最高的微任务 |
为什么要区分宏任务和微任务?
区分的原因:
| 目的 | 宏任务的作用 | 微任务的作用 |
|---|---|---|
| 时机控制 | 允许浏览器在任务间隙渲染 | 在渲染前快速处理后续逻辑 |
| 优先级 | 处理独立的异步操作 | 处理需要立即响应的异步操作 |
| 批量更新 | - | 允许在渲染前合并多次 DOM 更新 |
| 避免卡顿 | 将大任务拆分,让出主线程 | 快速完成 Promise 链 |
实际应用示例:
// Vue 的 nextTick 利用微任务实现批量 DOM 更新
// 多次数据变化只触发一次渲染
this.message = 'Hello';
this.count = 1;
this.visible = true;
// Vue 会把这三次更新合并到一个微任务中
// 只在微任务执行时进行一次 DOM 更新
this.$nextTick(() => {
// DOM 已更新
});
宏任务 vs 微任务 完整对比
| 特性 | 宏任务(Macro Task) | 微任务(Micro Task) |
|---|---|---|
| 发起者 | 宿主环境(浏览器/Node.js) | JavaScript 引擎 |
| 代表 API | setTimeout、setInterval、I/O | Promise.then、queueMicrotask |
| 执行时机 | 每轮 Event Loop 执行一个 | 每轮 Event Loop 执行全部 |
| 优先级 | 低 | 高 |
| 与渲染关系 | 两个宏任务之间可能渲染 | 所有微任务执行完才渲染 |
| 队列数量 | 可以有多个(不同类型) | 只有一个 |
| 新任务处理 | 新宏任务排到队尾,下轮执行 | 新微任务立即加入队列,本轮执行 |
执行顺序可视化
微任务会"插队"的特性
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
// 在微任务中产生新的微任务,会在本轮执行
Promise.resolve().then(() => {
console.log('4');
});
});
Promise.resolve().then(() => {
console.log('5');
});
console.log('6');
// 输出:1 6 3 5 4 2
// 注意:4 在 5 之后,因为它是在 3 执行时才加入队列的
// 但 4 在 2 之前,因为所有微任务都要在宏任务之前执行
如果微任务不断产生新的微任务,会导致微任务队列永远无法清空,宏任务和渲染都无法执行,页面会卡死。
// ❌ 危险:无限微任务
function danger(): void {
Promise.resolve().then(() => {
danger(); // 递归产生微任务,页面卡死
});
}
代码执行分析
示例 1:基础示例
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 输出顺序:1 4 3 2
执行过程分析:
| 步骤 | 调用栈 | 微任务队列 | 宏任务队列 | 输出 |
|---|---|---|---|---|
| 1 | console.log('1') | - | - | 1 |
| 2 | - | - | setTimeout 回调 | - |
| 3 | - | Promise.then | setTimeout 回调 | - |
| 4 | console.log('4') | Promise.then | setTimeout 回调 | 4 |
| 5 | console.log('3') | - | setTimeout 回调 | 3 |
| 6 | console.log('2') | - | - | 2 |
示例 2:嵌套任务
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
});
console.log('6');
// 输出顺序:1 6 4 2 3 5
执行过程:
| 阶段 | 执行内容 | 输出 |
|---|---|---|
| 同步代码 | console.log('1') | 1 |
| 同步代码 | setTimeout 回调入宏队列 | - |
| 同步代码 | Promise.then 入微队列 | - |
| 同步代码 | console.log('6') | 6 |
| 微任务 | 执行 Promise.then → console.log('4') | 4 |
| 微任务 | setTimeout 回调入宏队列 | - |
| 宏任务 | 执行第一个 setTimeout | 2 |
| 微任务 | 执行 Promise.then → console.log('3') | 3 |
| 宏任务 | 执行第二个 setTimeout | 5 |
示例 3:async/await
async function async1(): Promise<void> {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2(): Promise<void> {
console.log('async2');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise<void>((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
});
console.log('script end');
// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
// 等价于
function async1() {
console.log('async1 start');
return Promise.resolve(async2()).then(() => {
console.log('async1 end');
});
}
await 之后的代码相当于 Promise.then 的回调,是微任务。
示例 4:Promise 构造函数
new Promise<void>((resolve, reject) => {
console.log('1'); // Promise 构造函数的执行器是同步的
resolve();
console.log('2'); // resolve 后的代码仍会执行
}).then(() => {
console.log('3'); // then 回调是微任务
});
console.log('4');
// 输出顺序:1 2 4 3
- Promise 构造函数的执行器函数是同步执行的
resolve()或reject()调用后,后面的代码仍会继续执行- 只有
.then()的回调才是微任务
示例 5:复杂嵌套
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise<void>((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
return new Promise<void>((resolve) => {
console.log('5');
resolve();
});
}).then(() => {
console.log('6');
});
new Promise<void>((resolve) => {
console.log('7');
resolve();
}).then(() => {
console.log('8');
});
console.log('9');
// 输出顺序:1 3 7 9 4 5 8 6 2
分析:
| 步骤 | 类型 | 输出 | 微任务队列变化 |
|---|---|---|---|
| 1 | 同步 | 1 | - |
| 2 | 同步 | 3 | [then1] |
| 3 | 同步 | 7 | [then1, then2] |
| 4 | 同步 | 9 | - |
| 5 | 微任务 then1 | 4, 5 | [then2, then3] |
| 6 | 微任务 then2 | 8 | [then3] |
| 7 | 微任务 then3 | 6 | - |
| 8 | 宏任务 | 2 | - |
Node.js 中的 Event Loop
Node.js 的 Event Loop 与浏览器有所不同,分为多个阶段:
Node.js 阶段详解
| 阶段 | 说明 |
|---|---|
| timers | 执行 setTimeout、setInterval 回调 |
| pending callbacks | 执行延迟的 I/O 回调 |
| poll | 获取新的 I/O 事件,执行 I/O 回调 |
| check | 执行 setImmediate 回调 |
| close callbacks | 执行 close 事件回调 |
process.nextTick vs setImmediate
setImmediate(() => {
console.log('setImmediate');
});
process.nextTick(() => {
console.log('nextTick');
});
// 输出顺序:nextTick setImmediate
| 方法 | 执行时机 | 优先级 |
|---|---|---|
process.nextTick | 当前阶段结束后立即执行 | 最高 |
setImmediate | check 阶段执行 | 低于 nextTick |
Node.js 示例
console.log('1');
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
process.nextTick(() => {
console.log('nextTick');
});
Promise.resolve().then(() => {
console.log('promise');
});
console.log('2');
// 输出顺序:1 2 nextTick promise setTimeout setImmediate
// 注意:setTimeout 和 setImmediate 的顺序在某些情况下可能不确定
requestAnimationFrame
requestAnimationFrame(RAF)比较特殊,它既不是宏任务也不是微任务,而是在渲染之前执行:
console.log('1');
setTimeout(() => {
console.log('setTimeout');
}, 0);
requestAnimationFrame(() => {
console.log('RAF');
});
Promise.resolve().then(() => {
console.log('promise');
});
console.log('2');
// 典型输出:1 2 promise RAF setTimeout
// 但 RAF 和 setTimeout 的顺序取决于浏览器实现和时机
实际应用场景
1. 避免阻塞 UI
// ❌ 长任务阻塞 UI
function processLargeArray(items: number[]): void {
items.forEach(item => {
heavyComputation(item); // 可能阻塞 UI
});
}
// ✅ 分批处理,让出主线程
async function processLargeArrayAsync(items: number[]): Promise<void> {
const chunkSize = 100;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(item => heavyComputation(item));
// 让出主线程,允许处理其他任务和渲染
await new Promise(resolve => setTimeout(resolve, 0));
}
}
2. 批量 DOM 更新
// ❌ 多次触发重排
function updateDOM(): void {
element.style.width = '100px'; // 重排
element.style.height = '100px'; // 重排
element.style.margin = '10px'; // 重排
}
// ✅ 使用微任务批量更新
const updates: Array<() => void> = [];
let pending = false;
function queueUpdate(fn: () => void): void {
updates.push(fn);
if (!pending) {
pending = true;
queueMicrotask(() => {
const fns = updates.slice();
updates.length = 0;
pending = false;
fns.forEach(f => f()); // 批量执行,只触发一次重排
});
}
}
3. Vue 的 nextTick 实现原理
// Vue nextTick 简化实现
const callbacks: Array<() => void> = [];
let pending = false;
function nextTick(callback?: () => void): Promise<void> {
return new Promise(resolve => {
callbacks.push(() => {
callback?.();
resolve();
});
if (!pending) {
pending = true;
// 优先使用微任务
Promise.resolve().then(flushCallbacks);
}
});
}
function flushCallbacks(): void {
pending = false;
const copies = callbacks.slice();
callbacks.length = 0;
copies.forEach(cb => cb());
}
4. 防止递归微任务导致卡死
// ❌ 无限微任务,页面卡死
function infiniteMicrotask(): void {
Promise.resolve().then(() => {
console.log('微任务');
infiniteMicrotask(); // 无限递归微任务,永远不会执行宏任务
});
}
// ✅ 使用宏任务让出控制权
function safeRecursion(): void {
setTimeout(() => {
console.log('任务');
safeRecursion(); // 每次都让出控制权,不会卡死
}, 0);
}
常见面试问题
Q1: 什么是 Event Loop?为什么需要它?
答案:
Event Loop(事件循环) 是 JavaScript 实现异步编程的核心机制。
为什么需要:
- JavaScript 是单线程语言,同一时间只能执行一个任务
- 如果没有 Event Loop,耗时操作(网络请求、定时器)会阻塞主线程
- Event Loop 使得 JS 能够非阻塞地执行异步操作
工作原理:
- 执行同步代码(调用栈)
- 调用栈为空时,检查微任务队列,执行所有微任务
- 执行一个宏任务
- 重复步骤 2-3
Q2: 宏任务和微任务有什么区别?
答案:
| 特性 | 宏任务 | 微任务 |
|---|---|---|
| 常见 API | setTimeout、setInterval、I/O | Promise.then、async/await、queueMicrotask |
| 执行时机 | 每轮循环执行一个 | 每轮循环执行全部 |
| 优先级 | 低 | 高 |
| 与渲染关系 | 之间可能发生渲染 | 全部执行完才渲染 |
执行顺序:同步代码 → 微任务 → 渲染 → 宏任务
console.log('1'); // 同步
setTimeout(() => console.log('2'), 0); // 宏任务
Promise.resolve().then(() => console.log('3')); // 微任务
console.log('4'); // 同步
// 输出:1 4 3 2
Q3: 分析以下代码的输出顺序
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise(resolve => {
console.log('promise1');
resolve(undefined);
}).then(() => console.log('promise2'));
console.log('script end');
答案:
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
分析:
| 步骤 | 类型 | 输出 |
|---|---|---|
| 1 | 同步 | script start |
| 2 | 同步(async1 调用) | async1 start |
| 3 | 同步(async2 调用) | async2 |
| 4 | 同步(Promise 构造器) | promise1 |
| 5 | 同步 | script end |
| 6 | 微任务(await 后续) | async1 end |
| 7 | 微任务(then 回调) | promise2 |
| 8 | 宏任务(setTimeout) | setTimeout |
Q4: 为什么 Promise.then 是微任务而 setTimeout 是宏任务?
答案:
设计原因:
-
Promise 需要更高优先级:
- Promise 用于处理异步操作的结果
- 通常需要在当前任务完成后立即处理
- 避免等待其他宏任务导致延迟
-
setTimeout 是定时器:
- 本质上是"在指定时间后执行"
- 不需要立即执行
- 允许其他任务(包括渲染)插入
-
避免阻塞渲染:
- 如果 Promise 是宏任务,每个 Promise 之间都可能触发渲染
- 微任务在渲染前全部执行,更高效
// 微任务的好处:批量更新
element.style.width = '100px';
Promise.resolve().then(() => {
element.style.height = '100px';
});
// 两次更新在同一次渲染中完成
Q5: Node.js 和浏览器的 Event Loop 有什么区别?
答案:
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 阶段 | 简单的宏任务/微任务 | 6 个阶段 |
| 特有 API | - | setImmediate、process.nextTick |
| 微任务时机 | 每个宏任务后 | 每个阶段切换时 |
| nextTick | 无 | 所有微任务中优先级最高 |
Node.js 特有:
// process.nextTick 优先级最高
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
// 输出:nextTick promise
// setImmediate 在 check 阶段执行
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出顺序不确定(依赖于事件循环启动时机)
Q6: 如何利用 Event Loop 优化性能?
答案:
1. 拆分长任务:
// 使用 setTimeout 拆分
function processChunk(items: unknown[], index: number): void {
const chunk = items.slice(index, index + 100);
chunk.forEach(processItem);
if (index + 100 < items.length) {
setTimeout(() => processChunk(items, index + 100), 0);
}
}
2. 使用 requestIdleCallback:
function doBackgroundWork(deadline: IdleDeadline): void {
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
const task = tasks.shift();
task?.();
}
if (tasks.length > 0) {
requestIdleCallback(doBackgroundWork);
}
}
requestIdleCallback(doBackgroundWork);
3. 使用 Web Worker:
// 主线程
const worker = new Worker('heavy-task.js');
worker.postMessage(data);
worker.onmessage = (e) => {
console.log('结果:', e.data);
};
4. 批量 DOM 更新:
// 使用 queueMicrotask 批量更新
queueMicrotask(() => {
// 所有更新在一次微任务中完成
elements.forEach(el => el.style.color = 'red');
});
Q7: async/await 在事件循环中的执行顺序是怎样的?
答案:
async/await 本质上是 Promise 的语法糖,理解它在事件循环中的行为关键在于:await 会将后续代码包装为微任务。
async函数中await之前的代码是同步执行的await表达式本身(右侧)也是同步执行的await之后的所有代码相当于被放入Promise.then()回调中,作为微任务执行
经典输出题 1:基础 async/await
async function foo(): Promise<void> {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar(): Promise<void> {
console.log('bar');
}
console.log('script start');
foo();
console.log('script end');
// 输出顺序:
// script start
// foo start
// bar
// script end
// foo end
分析:await bar() 会先同步执行 bar(),打印 bar。然后 await 将后续代码(console.log('foo end'))注册为微任务。同步代码继续执行 script end,最后执行微任务打印 foo end。
经典输出题 2:async/await 与 Promise 混合
async function async1(): Promise<void> {
console.log('async1 start');
await async2();
console.log('async1 end'); // 微任务 1
await async3();
console.log('async1 final'); // 微任务 3(在微任务 1 执行后才注册)
}
async function async2(): Promise<void> {
console.log('async2');
}
async function async3(): Promise<void> {
console.log('async3');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise<void>((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2'); // 微任务 2
}).then(() => {
console.log('promise3'); // 微任务 4
});
console.log('script end');
// 输出顺序:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// async3
// promise3
// async1 final
// setTimeout
逐步分析:
| 步骤 | 类型 | 输出 | 微任务队列 |
|---|---|---|---|
| 1 | 同步 | script start | [] |
| 2 | 同步(async1 内部) | async1 start | [] |
| 3 | 同步(async2 调用) | async2 | [async1 end] |
| 4 | 同步(Promise 构造器) | promise1 | [async1 end, promise2] |
| 5 | 同步 | script end | [async1 end, promise2] |
| 6 | 微任务 | async1 end | [promise2, async1 final] |
| 7 | 微任务 | promise2 | [async3→async1 final, promise3] |
| 8 | 微任务 | async3 | [promise3, async1 final] |
| 9 | 微任务 | promise3 | [async1 final] |
| 10 | 微任务 | async1 final | [] |
| 11 | 宏任务 | setTimeout | [] |
每个 await 后续代码只在上一个 await 完成后才注册为微任务,而不是一开始就全部注册。这就是为什么 async1 final 不会紧跟 async1 end 输出——中间还有其他微任务插入。
经典输出题 3:await 后面跟非 Promise 值
async function test(): Promise<void> {
console.log('test start');
const result = await 123; // 非 Promise 值会被包装为 Promise.resolve(123)
console.log('result:', result);
}
console.log('start');
test();
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// 输出顺序:
// start
// test start
// end
// result: 123
// promise
// await 非 Promise 值
await 123;
// 等价于
await Promise.resolve(123);
// await 一个已 resolve 的 Promise
await Promise.resolve('done');
// 后续代码会在下一个微任务执行
// await 一个 pending 的 Promise
await fetch('/api');
// 后续代码会在 Promise resolve 后的微任务中执行
Q8: requestAnimationFrame 和 requestIdleCallback 在事件循环中的位置是什么?
答案:
requestAnimationFrame(RAF)和 requestIdleCallback(RIC)都不属于宏任务,也不属于微任务,它们有各自独立的执行时机。
执行时机对比:
| API | 执行时机 | 触发频率 | 主要用途 |
|---|---|---|---|
| requestAnimationFrame | 微任务之后,渲染之前 | 每帧一次(约 16.67ms) | 动画、DOM 读写 |
| requestIdleCallback | 渲染之后,下一帧开始之前 | 浏览器空闲时 | 低优先级任务 |
| setTimeout(fn, 0) | 下一个宏任务 | 最快约 4ms | 延迟执行 |
| queueMicrotask | 当前宏任务之后立即 | 每个宏任务后 | 高优先级异步 |
验证代码:
console.log('1. 同步代码');
setTimeout(() => {
console.log('5. setTimeout(宏任务)');
}, 0);
requestAnimationFrame(() => {
console.log('4. requestAnimationFrame(渲染前)');
});
requestIdleCallback(() => {
console.log('6. requestIdleCallback(空闲时)');
});
Promise.resolve().then(() => {
console.log('2. Promise.then(微任务)');
});
queueMicrotask(() => {
console.log('3. queueMicrotask(微任务)');
});
// 典型输出顺序:
// 1. 同步代码
// 2. Promise.then(微任务)
// 3. queueMicrotask(微任务)
// 4. requestAnimationFrame(渲染前)
// 5. setTimeout(宏任务)
// 6. requestIdleCallback(空闲时)
RAF 和 setTimeout 的先后顺序取决于浏览器当前帧的时机。如果当前帧还不需要渲染,RAF 可能延迟到下一帧,此时 setTimeout 可能先执行。RIC 更是完全取决于浏览器是否有空闲时间。
requestAnimationFrame 深入理解:
// RAF 在同一帧内只执行一次,多次调用会排队到下一帧
requestAnimationFrame(() => {
console.log('RAF 1');
// 在 RAF 回调中再调用 RAF,会排到下一帧
requestAnimationFrame(() => {
console.log('RAF 2(下一帧)');
});
});
// RAF 内产生的微任务会在当前 RAF 执行后立即执行,在渲染之前
requestAnimationFrame(() => {
console.log('RAF');
Promise.resolve().then(() => {
console.log('RAF 中的微任务(渲染前执行)');
});
});
requestIdleCallback 深入理解:
// RIC 提供 deadline 对象,告诉你还有多少空闲时间
requestIdleCallback((deadline: IdleDeadline) => {
// deadline.timeRemaining() 返回当前帧剩余空闲时间(ms)
console.log(`空闲时间: ${deadline.timeRemaining()}ms`);
// deadline.didTimeout 表示是否因超时强制执行
console.log(`是否超时: ${deadline.didTimeout}`);
});
// 可以设置超时时间,防止任务被无限延迟
requestIdleCallback(callback, { timeout: 1000 });
- 不要在 RIC 中修改 DOM:因为 RIC 在渲染之后执行,修改 DOM 会导致下一帧重新布局
- Safari 不支持:需要 polyfill(通常用
setTimeout(fn, 0)模拟) - 不保证执行:如果主线程一直繁忙,RIC 可能永远不执行(除非设置了 timeout)
Q9: Vue 的 nextTick 和 React 的 setState 批量更新与事件循环有什么关系?
答案:
Vue 的 nextTick 和 React 的 setState 批量更新都巧妙利用了事件循环的微任务机制来实现性能优化——将多次状态变更合并为一次 DOM 更新。
Vue nextTick 与微任务:
Vue 的响应式系统在数据变化时不会立即更新 DOM,而是将更新操作推入微任务队列,等到同步代码执行完毕后统一更新:
// Vue 3 nextTick 核心实现(简化)
const queue: Array<() => void> = [];
let isFlushing = false;
const resolvedPromise = Promise.resolve();
function queueJob(job: () => void): void {
if (!queue.includes(job)) {
queue.push(job);
}
if (!isFlushing) {
isFlushing = true;
resolvedPromise.then(flushJobs); // 利用微任务来调度更新
}
}
function flushJobs(): void {
isFlushing = false;
// 排序确保父组件先于子组件更新
queue.sort((a, b) => getId(a) - getId(b));
for (const job of queue) {
job();
}
queue.length = 0;
}
// 实际使用示例
import { ref, nextTick } from 'vue';
const count = ref(0);
function handleClick(): void {
count.value = 1;
count.value = 2;
count.value = 3;
// 三次赋值只触发一次 DOM 更新(同一个微任务中批量处理)
// nextTick 确保在 DOM 更新后执行
nextTick(() => {
// 此时 DOM 已更新为 count = 3
console.log(document.getElementById('count')?.textContent); // "3"
});
}
执行流程:
React setState 批量更新:
React 18 之前的批量更新依赖同步执行上下文(React 事件处理函数内自动批量)。React 18 引入了自动批处理(Automatic Batching),基于微任务机制实现所有场景下的批量更新:
import { useState } from 'react';
function Counter(): JSX.Element {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick(): void {
// React 18:所有场景都自动批量更新
setCount(c => c + 1);
setFlag(f => !f);
// 只触发一次重渲染
}
async function handleAsync(): Promise<void> {
const data = await fetch('/api');
// React 18 之前:setTimeout、Promise 中不会批量更新,触发两次渲染
// React 18:自动批处理,依然只触发一次渲染
setCount(c => c + 1);
setFlag(f => !f);
}
return <button onClick={handleClick}>{count}</button>;
}
React 18 与事件循环的关系:
import { useState } from 'react';
import { flushSync } from 'react-dom';
function Example(): JSX.Element {
const [count, setCount] = useState(0);
function handleClick(): void {
// 自动批处理(默认行为):两次 setState 合并为一次渲染
setCount(1);
setCount(2);
// React 使用类似微任务的调度机制,在同步代码执行完毕后统一更新
// 如果需要强制同步更新,使用 flushSync
flushSync(() => {
setCount(3); // 立即触发渲染
});
// 此时 DOM 已经更新
}
return <div>{count}</div>;
}
Vue vs React 批量更新对比:
| 特性 | Vue 3 nextTick | React 18 Automatic Batching |
|---|---|---|
| 实现机制 | Promise.resolve().then() 微任务 | 内部调度器(类似微任务) |
| 批量范围 | 所有同步代码中的数据变更 | 所有场景(事件、setTimeout、Promise) |
| 强制同步更新 | 无(设计上不需要) | flushSync() |
| 获取更新后 DOM | nextTick() | flushSync() 或 useEffect |
| 去重策略 | 同一 watcher 不重复入队 | 同一 setState 合并 |
两者的核心思路一致:利用事件循环机制,将同步代码中的多次状态变更收集起来,延迟到微任务阶段统一处理,从而减少不必要的 DOM 操作和渲染次数,提升性能。
Q10: 如何用事件循环知识优化长任务(时间切片)?
答案:
长任务(Long Task) 指执行时间超过 50ms 的任务。长任务会阻塞主线程,导致页面卡顿、交互无响应。利用事件循环机制进行时间切片(Time Slicing),可以将长任务拆分为多个小任务,让浏览器在间隙处理渲染和用户交互。
问题演示:
// ❌ 长任务:处理 100000 条数据,主线程阻塞数秒
function processAll(data: number[]): number[] {
return data.map(item => {
// 模拟耗时计算
let result = item;
for (let i = 0; i < 10000; i++) {
result = Math.sqrt(result * result + i);
}
return result;
});
}
方案 1:使用 setTimeout 进行时间切片
function timeSlice(
tasks: Array<() => void>,
chunkSize: number = 5
): Promise<void> {
return new Promise((resolve) => {
let index = 0;
function runChunk(): void {
const end = Math.min(index + chunkSize, tasks.length);
for (; index < end; index++) {
tasks[index]();
}
if (index < tasks.length) {
setTimeout(runChunk, 0); // 让出主线程,允许渲染和交互
} else {
resolve();
}
}
runChunk();
});
}
// 使用
const tasks = data.map(item => () => processItem(item));
await timeSlice(tasks, 100);
setTimeout(fn, 0) 实际最小延迟约为 4ms(嵌套超过 5 层后)。如果每个 chunk 很小,4ms 的开销占比过大会影响整体性能。
方案 2:使用 requestAnimationFrame 对齐帧调度
function frameSlice<T>(
items: T[],
process: (item: T) => void,
framebudget: number = 12 // 每帧预算 12ms(留 4ms 给浏览器渲染)
): Promise<void> {
return new Promise((resolve) => {
let index = 0;
function processFrame(): void {
const startTime = performance.now();
// 在时间预算内尽可能多处理
while (index < items.length && performance.now() - startTime < framebudget) {
process(items[index]);
index++;
}
if (index < items.length) {
requestAnimationFrame(processFrame); // 下一帧继续
} else {
resolve();
}
}
requestAnimationFrame(processFrame);
});
}
// 使用:处理 10 万条数据,页面不卡顿
await frameSlice(hugeArray, (item) => {
expensiveComputation(item);
});
方案 3:使用 requestIdleCallback 利用空闲时间
function idleSlice<T>(
items: T[],
process: (item: T) => void,
options: { timeout?: number } = {}
): Promise<void> {
return new Promise((resolve) => {
let index = 0;
function processIdle(deadline: IdleDeadline): void {
// 只在空闲时间内执行,不影响关键渲染和交互
while (
index < items.length &&
(deadline.timeRemaining() > 1 || deadline.didTimeout)
) {
process(items[index]);
index++;
}
if (index < items.length) {
requestIdleCallback(processIdle, { timeout: options.timeout });
} else {
resolve();
}
}
requestIdleCallback(processIdle, { timeout: options.timeout });
});
}
// 使用:低优先级任务,不影响用户操作
await idleSlice(analyticsData, sendToServer, { timeout: 5000 });
方案 4:使用 scheduler.yield()(现代 API)
// scheduler.yield() 是新的浏览器 API,让出主线程后会以高优先级恢复
async function modernSlice<T>(
items: T[],
process: (item: T) => void,
chunkSize: number = 100
): Promise<void> {
for (let i = 0; i < items.length; i += chunkSize) {
const end = Math.min(i + chunkSize, items.length);
for (let j = i; j < end; j++) {
process(items[j]);
}
// 让出主线程,浏览器可以处理渲染和交互
if ('scheduler' in globalThis && 'yield' in (globalThis as any).scheduler) {
await (globalThis as any).scheduler.yield();
} else {
// 降级方案
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
四种方案对比:
| 方案 | 让出时机 | 恢复优先级 | 适用场景 |
|---|---|---|---|
setTimeout | 固定延迟(≥4ms) | 低(宏任务) | 通用场景 |
requestAnimationFrame | 每帧(~16ms) | 渲染前 | 与视觉更新相关的任务 |
requestIdleCallback | 浏览器空闲时 | 最低 | 低优先级后台任务 |
scheduler.yield() | 立即让出 | 高 | 高优先级但需让出的任务 |
完整的时间切片工具类:
type ScheduleStrategy = 'timeout' | 'raf' | 'idle';
interface TimeSliceOptions {
strategy?: ScheduleStrategy;
chunkSize?: number;
frameBudget?: number;
onProgress?: (progress: number) => void;
}
async function timeSliceProcess<T, R>(
items: T[],
process: (item: T) => R,
options: TimeSliceOptions = {}
): Promise<R[]> {
const {
strategy = 'raf',
chunkSize = 100,
frameBudget = 12,
onProgress,
} = options;
const results: R[] = [];
let index = 0;
function yieldToMain(): Promise<void> {
switch (strategy) {
case 'timeout':
return new Promise(resolve => setTimeout(resolve, 0));
case 'raf':
return new Promise(resolve => requestAnimationFrame(() => resolve()));
case 'idle':
return new Promise(resolve =>
requestIdleCallback(() => resolve(), { timeout: 1000 })
);
}
}
while (index < items.length) {
const startTime = performance.now();
const end = strategy === 'raf'
? items.length // RAF 按时间预算控制
: Math.min(index + chunkSize, items.length);
while (index < end) {
if (strategy === 'raf' && performance.now() - startTime >= frameBudget) break;
results.push(process(items[index]));
index++;
}
onProgress?.(index / items.length);
if (index < items.length) {
await yieldToMain(); // 让出主线程
}
}
return results;
}
// 使用示例
const results = await timeSliceProcess(
largeDataset,
(item) => heavyComputation(item),
{
strategy: 'raf',
frameBudget: 10,
onProgress: (p) => console.log(`进度: ${(p * 100).toFixed(1)}%`),
}
);
时间切片的核心思想:利用事件循环的调度机制,主动让出主线程控制权,让浏览器有机会执行渲染、处理用户交互,避免页面卡顿。选择哪种方案取决于任务的优先级和对用户体验的影响程度。React 的 Fiber 架构中的可中断渲染也是基于类似的时间切片思想实现的。