跳到主要内容

Event Loop 事件循环

问题​

什么是 Event Loop?宏任务和微任务有什么区别?

面试速答版

什么是 Event Loop? Event Loop 是 JS 在单线程下实现并发的「调度器」:

  • 主线程跑调用栈,遇到异步 API(setTimeout、fetch、DOM 事件)就甩给浏览器/Node 的 Web API 层。
  • 完成后回调进入对应的任务队列。
  • 调用栈空了,Event Loop 就从队列里捞一个回调放上栈执行——这样无限循环。

宏任务和微任务有什么区别? 核心区别是优先级和清空时机:

  • 宏任务:setTimeout、setInterval、setImmediate(Node)、I/O、UI 渲染回调,每轮只执行一个。
  • 微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver,每轮全部清空才进入下一个宏任务。
  • 执行顺序:同步代码 → 所有微任务 → 一个宏任务 → 所有微任务 → 渲染 → 下一个宏任务。
  • 易错点:Promise.resolve().then 会插队到当前宏任务结尾立即执行,比 setTimeout(0) 早得多;Node 还有独立的 process.nextTick,比 Promise 微任务还优先。

答案​

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执行 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当前阶段结束后立即执行最高
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-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 后续代码只在上一个 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 架构中的可中断渲染也是基于类似的时间切片思想实现的。

相关链接​