跳到主要内容

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浏览器提供的异步 APIsetTimeout、fetch、DOM
宏任务队列(Task Queue)存储宏任务的队列setTimeout 回调
微任务队列(Microtask Queue)存储微任务的队列Promise.then 回调

Event Loop 执行机制

执行顺序

  1. 执行同步代码(当前宏任务)
  2. 清空微任务队列(执行所有微任务)
  3. 检查是否需要渲染
  4. 执行一个宏任务
  5. 重复步骤 2-4
关键点
  • 每次执行一个宏任务后,会清空所有微任务
  • 微任务优先级高于宏任务
  • 渲染发生在微任务之后、下一个宏任务之前

宏任务与微任务

什么是宏任务(Macro Task / Task)?

宏任务是由宿主环境(浏览器或 Node.js)发起的任务,代表一个独立的、完整的工作单元

宏任务的本质

宏任务可以理解为"一整块需要执行的代码"。每个宏任务执行完毕后,浏览器有机会进行渲染、响应用户交互等操作。

宏任务的特点

  1. 独立性:每个宏任务是一个独立的执行单元
  2. 由宿主环境触发:由浏览器/Node.js 的 API 产生
  3. 执行间隙可渲染:两个宏任务之间,浏览器可以进行页面渲染
  4. 每次只执行一个: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循环定时器
setImmediateNode.js在当前轮 poll 阶段后执行
I/O 回调浏览器/Node.js文件读写、网络请求完成后的回调
UI 事件浏览器click、scroll、keydown 等
MessageChannel浏览器消息通道的 message 事件
requestAnimationFrame浏览器动画帧回调(较特殊,在渲染前执行)

什么是微任务(Micro Task)?

微任务是由 JavaScript 引擎发起的任务,用于处理当前宏任务执行过程中产生的异步操作

微任务的本质

微任务可以理解为"当前任务的后续处理"。它们在当前宏任务结束后、下一个宏任务开始前立即执行,且会一次性清空整个微任务队列。

微任务的特点

  1. 依附性:微任务依附于当前宏任务,是对当前任务的"善后"工作
  2. 由 JS 引擎触发:主要由 Promise 机制产生
  3. 优先级高:在下一个宏任务之前执行,在渲染之前执行
  4. 全部执行:一次性执行完所有微任务,包括执行过程中新产生的微任务
// 微任务示例
// 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/finallyJS 引擎Promise 状态变化后的回调
async/awaitJS 引擎await 之后的代码(本质是 Promise)
queueMicrotask()JS 引擎显式添加微任务的 API
MutationObserver浏览器DOM 变化观察回调
process.nextTickNode.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 引擎
代表 APIsetTimeout、setInterval、I/OPromise.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

执行过程分析

步骤调用栈微任务队列宏任务队列输出
1console.log('1')--1
2--setTimeout 回调-
3-Promise.thensetTimeout 回调-
4console.log('4')Promise.thensetTimeout 回调4
5console.log('3')-setTimeout 回调3
6console.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 回调入宏队列-
宏任务执行第一个 setTimeout2
微任务执行 Promise.then → console.log('3')3
宏任务执行第二个 setTimeout5

示例 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/await 的本质
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微任务 then14, 5[then2, then3]
6微任务 then28[then3]
7微任务 then36-
8宏任务2-

Node.js 中的 Event Loop

Node.js 的 Event Loop 与浏览器有所不同,分为多个阶段:

Node.js 阶段详解

阶段说明
timers执行 setTimeoutsetInterval 回调
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当前阶段结束后立即执行最高
setImmediatecheck 阶段执行低于 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 能够非阻塞地执行异步操作

工作原理

  1. 执行同步代码(调用栈)
  2. 调用栈为空时,检查微任务队列,执行所有微任务
  3. 执行一个宏任务
  4. 重复步骤 2-3

Q2: 宏任务和微任务有什么区别?

答案

特性宏任务微任务
常见 APIsetTimeout、setInterval、I/OPromise.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 是宏任务?

答案

设计原因

  1. Promise 需要更高优先级

    • Promise 用于处理异步操作的结果
    • 通常需要在当前任务完成后立即处理
    • 避免等待其他宏任务导致延迟
  2. setTimeout 是定时器

    • 本质上是"在指定时间后执行"
    • 不需要立即执行
    • 允许其他任务(包括渲染)插入
  3. 避免阻塞渲染

    • 如果 Promise 是宏任务,每个 Promise 之间都可能触发渲染
    • 微任务在渲染前全部执行,更高效
// 微任务的好处:批量更新
element.style.width = '100px';
Promise.resolve().then(() => {
element.style.height = '100px';
});
// 两次更新在同一次渲染中完成

Q5: Node.js 和浏览器的 Event Loop 有什么区别?

答案

特性浏览器Node.js
阶段简单的宏任务/微任务6 个阶段
特有 API-setImmediateprocess.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 后续代码只在上一个 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 的转换规则
// 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 });
requestIdleCallback 的限制
  • 不要在 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 nextTickReact 18 Automatic Batching
实现机制Promise.resolve().then() 微任务内部调度器(类似微任务)
批量范围所有同步代码中的数据变更所有场景(事件、setTimeout、Promise)
强制同步更新无(设计上不需要)flushSync()
获取更新后 DOMnextTick()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 的局限

setTimeout(fn, 0) 实际最小延迟约为 4ms(嵌套超过 5 层后)。如果每个 chunk 很小,4ms 的开销占比过大会影响整体性能。

方案 2:使用 requestAnimationFrame 对齐帧调度

RAF 时间切片 —— 按帧预算分配
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 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()立即让出高优先级但需让出的任务

完整的时间切片工具类

utils/time-slice.ts
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 架构中的可中断渲染也是基于类似的时间切片思想实现的。

相关链接