事件循环(Event Loop)详解

事件循环(Event Loop)是 JavaScript 异步编程的核心机制,理解它是成为一名优秀前端工程师的必备技能。本文将从基础概念到浏览器和 Node.js 的实现,全面讲解事件循环。


一、为什么需要事件循环?

1.1 JavaScript 是单线程的

JavaScript 从诞生之日起就是单线程的。为什么?

原因:DOM 操作的互斥性

假设 JavaScript 是多线程的:

  • 线程 A 把 DOM 元素的颜色改成红色
  • 线程 B 把同一个 DOM 元素的颜色改成蓝色
  • 此时浏览器该听谁的?

为了避免这种复杂的同步问题,JavaScript 选择了单线程。

但单线程会带来问题:

1
2
3
4
5
6
7
8
9
10
11
12
// 假设这是一个耗时 3 秒的计算
function heavyTask() {
let sum = 0;
for (let i = 0; i < 1000000000; i++) {
sum += i;
}
return sum;
}

console.log('开始');
heavyTask(); // 这里会阻塞 3 秒!
console.log('结束'); // 3 秒后才会执行

heavyTask 执行期间:

  • 页面无法响应点击
  • 动画会卡顿
  • 用户体验很差

这就是事件循环要解决的问题:让单线程的 JavaScript 也能高效处理异步操作。


二、核心概念

2.1 执行栈(Call Stack)

执行栈是一个后进先出(LIFO)的数据结构,用来存储当前正在执行的函数调用帧。

执行栈的工作原理

  1. 函数调用时:创建一个调用帧(包含函数的参数、局部变量等信息)并压入栈顶
  2. 函数执行时:执行栈顶的调用帧
  3. 函数执行完毕:调用帧从栈顶弹出
  4. 栈为空时:执行引擎会检查任务队列
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function a() {
console.log('a');
}

function b() {
a();
console.log('b');
}

function c() {
b();
console.log('c');
}

c();

执行过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1. c() 入栈
[c]

2. c 调用 b(),b 入栈
[c, b]

3. b 调用 a(),a 入栈
[c, b, a]

4. a 执行完,出栈
[c, b]

5. b 执行完,出栈
[c]

6. c 执行完,出栈
[]

执行栈的重要特性:

  • 同步代码执行时,执行栈会一直被占用,直到所有同步代码执行完毕
  • 当执行栈为空时,事件循环才会从任务队列中取出任务执行
  • 如果执行栈中有大量同步代码,会阻塞事件循环,导致页面卡顿

2.2 任务队列(Task Queue)

当异步任务完成时,它们不会直接进入执行栈,而是先进入任务队列。任务队列是一个先进先出(FIFO)的数据结构。

任务队列的分类

任务队列分为两类:

  1. 宏任务(Macro Task / Task):较大的任务,如定时器、I/O 操作等
  2. 微任务(Micro Task / Job):较小的任务,如 Promise 回调、MutationObserver 等

任务队列的执行顺序

  • 每次事件循环只处理一个宏任务
  • 一个宏任务执行完毕后,会清空所有微任务
  • 微任务执行过程中产生的新微任务,也会在当前这一轮执行

2.3 宏任务 vs 微任务

类型包含说明执行时机
宏任务setTimeout / setInterval定时器每个事件循环周期执行一个
setImmediate(Node.js)立即执行在 poll 阶段结束后执行
I/O 操作文件读写、网络请求等完成后进入任务队列
UI 渲染(浏览器)页面重绘微任务执行完毕后
requestAnimationFrame(浏览器)动画帧浏览器渲染前执行
脚本执行整体脚本代码初始执行
微任务Promise.then/catch/finallyPromise 回调每个宏任务执行完毕后
async/await底层是 Promise每个宏任务执行完毕后
process.nextTick(Node.js)Node 特有微任务中优先级最高
MutationObserver(浏览器)DOM 变化监听每个宏任务执行完毕后
queueMicrotask()手动创建微任务每个宏任务执行完毕后

微任务的优先级

在微任务队列中,不同类型的微任务也有优先级:

  • Node.jsprocess.nextTick > Promise.then
  • 浏览器Promise.then > MutationObserver

为什么需要微任务?

微任务的设计目的是为了处理那些需要在当前宏任务执行完毕后立即执行的操作,比如:

  1. Promise 的回调
  2. DOM 变化的处理
  3. 需要在下一次 UI 渲染前完成的操作

微任务的执行时机比宏任务更早,这使得它们可以在 UI 渲染前完成,从而避免视觉上的不一致。


三、浏览器中的事件循环

3.1 事件循环的执行流程

一句话总结:一个宏任务,一批微任务。

详细执行流程

  1. 初始执行:执行整体脚本代码(第一个宏任务)
  2. 执行同步代码:执行当前宏任务中的同步代码
  3. 处理微任务:执行完宏任务后,清空所有微任务队列
  4. UI 渲染:如果需要,执行 UI 渲染
  5. 下一个宏任务:从宏任务队列中取出下一个任务执行
  6. 循环:重复步骤 2-5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────────────────────┐
│ 事件循环 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 从宏任务队列中取出一个任务执行 │
│ │ │
│ ↓ │
│ 2. 执行完这个宏任务后,清空微任务队列 │
│ │ (微任务执行过程中产生的新微任务,也会在本轮执行) │
│ ↓ │
│ 3. 如果需要,执行 UI 渲染 │
│ │ │
│ ↓ │
│ 4. 回到第 1 步,继续下一个宏任务 │
│ │
└─────────────────────────────────────────────────────────────┘

关键点:

  • 每次只执行一个宏任务
  • 执行完这个宏任务后,要把所有微任务都执行完
  • 微任务执行过程中产生的新微任务,也会在当前这一轮执行
  • UI 渲染发生在微任务执行完毕后,下一个宏任务开始前

3.2 经典例题解析

例题 1:基础版

1
2
3
4
5
6
7
8
9
10
11
console.log('1');

setTimeout(() => {
console.log('2');
}, 0);

Promise.resolve().then(() => {
console.log('3');
});

console.log('4');

输出结果:

1
2
3
4
1
4
3
2

执行过程详解:

步骤执行栈宏任务队列微任务队列输出说明
1console.log('1')[][]1同步代码执行
2setTimeout[setTimeout][]-宏任务加入队列
3Promise.resolve().then[setTimeout][promise]-微任务加入队列
4console.log('4')[setTimeout][promise]4同步代码执行
5清空微任务队列[setTimeout][]3执行微任务
6执行宏任务[][]2执行下一个宏任务

例题 2:async/await 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}

async function async2() {
console.log('async2');
}

console.log('script start');

async1();

setTimeout(() => {
console.log('setTimeout');
}, 0);

new Promise((resolve) => {
console.log('promise');
resolve();
}).then(() => {
console.log('promise1');
}).then(() => {
console.log('promise2');
});

console.log('script end');

输出结果(现代浏览器):

1
2
3
4
5
6
7
8
9
script start
async1 start
async2
promise
script end
async1 end
promise1
promise2
setTimeout

执行过程详解:

步骤执行栈宏任务队列微任务队列输出说明
1console.log('script start')[][]script start同步代码执行
2async1()[][]async1 start执行 async1,输出 start
3async2()[][]async2执行 async2,输出 async2
4暂停 async1[][async1 end]-await 让出线程,async1 end 进入微任务
5setTimeout[setTimeout][async1 end]-宏任务加入队列
6new Promise[setTimeout][async1 end]promise执行 Promise 构造函数,输出 promise
7Promise.then[setTimeout][async1 end, promise1]-微任务加入队列
8console.log('script end')[setTimeout][async1 end, promise1]script end同步代码执行
9清空微任务队列[setTimeout][promise2]async1 end执行 async1 end
10继续清空微任务队列[setTimeout][]promise1, promise2执行 promise1,然后 promise2
11执行宏任务[][]setTimeout执行下一个宏任务

关键点理解:

  • async 函数的返回值是 Promise
  • await 会暂停执行,让出线程
  • await 后面的代码会被包装成微任务
  • 多个微任务会按顺序执行

例题 3:微任务中产生微任务

1
2
3
4
5
6
7
8
Promise.resolve().then(() => {
console.log('1');
Promise.resolve().then(() => {
console.log('2');
});
}).then(() => {
console.log('3');
});

输出结果:

1
2
3
1
2
3

执行过程详解:

步骤执行栈微任务队列输出说明
1同步代码执行[Promise1]-初始微任务加入队列
2执行 Promise1[Promise2]1输出 1,新微任务加入队列
3执行 Promise2[Promise3]2输出 2,新微任务加入队列
4执行 Promise3[]3输出 3

解释:
微任务队列中的任务会按顺序执行,即使在执行过程中产生新的微任务,也会在当前这一轮全部执行完。这是因为微任务队列会一直被处理,直到队列为空。


例题 4:复杂嵌套版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
2
3
4
5
6
1
6
4
2
3
5

执行过程详解:

步骤执行栈宏任务队列微任务队列输出说明
1console.log('1')[][]1同步代码执行
2setTimeout[setTimeout1][]-宏任务加入队列
3Promise.resolve().then[setTimeout1][Promise1]-微任务加入队列
4console.log('6')[setTimeout1][Promise1]6同步代码执行
5执行微任务[setTimeout1, setTimeout2][]4执行 Promise1,输出 4,新宏任务加入队列
6执行宏任务[setTimeout2][Promise2]2执行 setTimeout1,输出 2,新微任务加入队列
7执行微任务[setTimeout2][]3执行 Promise2,输出 3
8执行宏任务[][]5执行 setTimeout2,输出 5

3.3 事件循环与 UI 渲染

UI 渲染的时机

在浏览器中,UI 渲染发生在微任务队列清空后,下一个宏任务开始前。这是因为:

  1. 微任务执行完毕:确保所有需要在渲染前完成的操作都已执行
  2. DOM 变化已处理:微任务通常用于处理 DOM 变化(如 MutationObserver)
  3. 渲染效率:批量处理渲染,减少重绘次数
1
宏任务 → 微任务 → [UI 渲染] → 宏任务 → ...

requestAnimationFrame 的执行时机

requestAnimationFrame 是一个特殊的 API,它的执行时机是在浏览器渲染前,具体来说:

  • 不属于宏任务也不属于微任务:它有自己独立的执行时机
  • 执行时机:在每次 UI 渲染前执行
  • 用途:用于创建流畅的动画效果
  • 优先级:高于宏任务,但在微任务之后

详细执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('开始');

setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0);

Promise.resolve().then(() => {
console.log('promise'); // 微任务
});

requestAnimationFrame(() => {
console.log('requestAnimationFrame'); // 渲染前执行
});

输出顺序(确定):

1
2
3
4
开始
promise
requestAnimationFrame
setTimeout

执行过程:

  1. 执行同步代码:console.log('开始')
  2. setTimeout 回调加入宏任务队列
  3. Promise.then 回调加入微任务队列
  4. requestAnimationFrame 回调加入渲染队列
  5. 同步代码执行完毕,执行微任务:console.log('promise')
  6. 微任务执行完毕,执行渲染前的 requestAnimationFrameconsole.log('requestAnimationFrame')
  7. 执行 UI 渲染
  8. 执行下一个宏任务:console.log('setTimeout')

实际应用:避免布局抖动

布局抖动(Layout Thrashing) 是指频繁地读写 DOM 导致浏览器频繁重排(reflow)的现象。通过合理使用事件循环,可以避免布局抖动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ❌ 不好的做法:频繁读写 DOM,导致布局抖动
function badExample() {
for (let i = 0; i < 1000; i++) {
const width = element.offsetWidth; // 读取
element.style.width = width + 1 + 'px'; // 写入
}
}

// ✅ 好的做法:批量读取,批量写入
function goodExample() {
// 批量读取(在同一个宏任务中)
const widths = [];
for (let i = 0; i < 1000; i++) {
widths.push(element.offsetWidth);
}

// 批量写入(在下一个微任务中)
Promise.resolve().then(() => {
for (let i = 0; i < 1000; i++) {
element.style.width = widths[i] + 1 + 'px';
}
});
}

解释:

  • 读取 DOM 属性会触发浏览器的重排计算
  • 写入 DOM 属性会标记需要重排
  • 通过在微任务中执行写入操作,可以确保所有读取操作都已完成,避免频繁重排

四、Node.js 中的事件循环

Node.js 的事件循环比浏览器更复杂,它基于 libuv 库实现,包含 6 个阶段。

4.1 Node.js 事件循环的 6 个阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout/setInterval 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ 执行系统回调(如 TCP 错误)
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ 内部使用
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │ 轮询 I/O 事件
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │ 执行 setImmediate 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ 执行关闭回调,如 socket.on('close')
└───────────────────────────┘

详细解释每个阶段

  1. timers 阶段

    • 功能:执行 setTimeoutsetInterval 回调
    • 执行时机:当定时器的时间到达时
    • 实际应用:定时任务、延迟执行
  2. pending callbacks 阶段

    • 功能:执行系统级别的回调,如 TCP 连接错误
    • 执行时机:上一个事件循环周期中被延迟的 I/O 回调
    • 实际应用:处理网络错误、系统级回调
  3. idle, prepare 阶段

    • 功能:内部使用,用于 Node.js 内部的准备工作
    • 执行时机:在 poll 阶段之前
    • 实际应用:Node.js 内部使用,开发者一般不直接使用
  4. poll 阶段(最复杂的阶段)

    • 功能
      • 执行 I/O 回调
      • 轮询新的 I/O 事件
      • 计算阻塞时间
    • 执行时机:当有 I/O 事件时
    • 实际应用:处理文件读写、网络请求等 I/O 操作
  5. check 阶段

    • 功能:执行 setImmediate 回调
    • 执行时机:poll 阶段结束后
    • 实际应用:需要立即执行的异步操作
  6. close callbacks 阶段

    • 功能:执行关闭相关的回调,如 socket.on('close')
    • 执行时机:当 socket 或其他资源关闭时
    • 实际应用:资源清理、连接关闭处理

4.2 Node.js 事件循环的执行流程

Node.js 11+ 的行为(与浏览器一致):

每个宏任务执行完后,立即执行对应的微任务队列。

完整执行流程

  1. 进入 timers 阶段

    • 检查是否有到期的定时器
    • 执行定时器回调
    • 执行所有微任务(process.nextTickPromise.then
  2. 进入 pending callbacks 阶段

    • 执行系统级回调
    • 执行所有微任务
  3. 进入 idle, prepare 阶段

    • Node.js 内部使用
  4. 进入 poll 阶段

    • 执行 I/O 回调
    • 检查是否有新的 I/O 事件
    • 如果没有定时器,可能会阻塞在这里
    • 执行所有微任务
  5. 进入 check 阶段

    • 执行 setImmediate 回调
    • 执行所有微任务
  6. 进入 close callbacks 阶段

    • 执行关闭回调
    • 执行所有微任务
  7. 循环:回到 timers 阶段,开始下一轮事件循环

实际应用场景

场景 1:定时器与 I/O 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 场景:需要在 I/O 操作完成后立即执行某些操作
const fs = require('fs');

fs.readFile(__filename, () => {
// I/O 回调在 poll 阶段执行
console.log('I/O 操作完成');

setTimeout(() => {
console.log('setTimeout 回调');
}, 0);

setImmediate(() => {
console.log('setImmediate 回调');
});
});

// 输出顺序:
// I/O 操作完成
// setImmediate 回调
// setTimeout 回调

场景 2:微任务优先级

1
2
3
4
5
6
7
8
9
10
11
12
// 场景:需要确保某些操作在其他微任务之前执行
process.nextTick(() => {
console.log('process.nextTick');
});

Promise.resolve().then(() => {
console.log('Promise.then');
});

// 输出顺序:
// process.nextTick
// Promise.then

场景 3:事件循环与异步操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 场景:理解事件循环如何处理多个异步操作
console.log('开始');

setTimeout(() => {
console.log('setTimeout 1');
process.nextTick(() => {
console.log('nextTick 1');
});
}, 0);

setTimeout(() => {
console.log('setTimeout 2');
}, 0);

process.nextTick(() => {
console.log('nextTick 2');
});

console.log('结束');

// 输出顺序:
// 开始
// 结束
// nextTick 2
// setTimeout 1
// nextTick 1
// setTimeout 2

4.3 process.nextTick

process.nextTick 是 Node.js 特有的微任务,它比 Promise.then 优先级更高。

process.nextTick 的特点

  • 优先级最高:在所有微任务中,process.nextTick 的优先级最高
  • 执行时机:在每个事件循环阶段结束后执行,无论当前处于哪个阶段
  • 队列独立:有自己独立的队列,不与其他微任务共享
1
2
3
4
5
6
7
8
9
process.nextTick(() => {
console.log('nextTick');
});

Promise.resolve().then(() => {
console.log('promise');
});

console.log('sync');

输出:

1
2
3
sync
nextTick
promise

执行顺序说明:

  1. 同步代码执行:console.log('sync')
  2. 清空 process.nextTick 队列:console.log('nextTick')
  3. 清空其他微任务队列:console.log('promise')

注意: 虽然 process.nextTick 是微任务的一种,但它的执行时机比其他微任务更早,甚至在事件循环的每个阶段切换前都会执行。


4.4 setTimeout vs setImmediate

这是一个经典面试题。

1
2
3
4
5
6
7
setTimeout(() => {
console.log('setTimeout');
}, 0);

setImmediate(() => {
console.log('setImmediate');
});

输出顺序不确定!

原因:

  • 浏览器中setTimeout(fn, 0) 实际上有最小延迟时间,根据 HTML5 规范,最小延迟时间为 4 毫秒
  • Node.js 中setTimeout(fn, 0) 实际上会被转换为 setTimeout(fn, 1)
  • 执行顺序取决于事件循环当前所处的阶段
    • 如果事件循环刚好进入 poll 阶段,setImmediate 会先执行
    • 如果事件循环在 timers 阶段,setTimeout 可能先执行

注意: 即使设置了 0 毫秒的延迟,回调函数也不会立即执行,而是会被放入宏任务队列,等待下一轮事件循环执行。

但在 I/O 回调中是确定的:

1
2
3
4
5
6
7
8
9
10
11
const fs = require('fs');

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);

setImmediate(() => {
console.log('setImmediate');
});
});

输出:

1
2
setImmediate
setTimeout

原因: I/O 回调在 poll 阶段执行,执行完后进入 check 阶段,所以 setImmediate 先执行。


五、浏览器 vs Node.js 事件循环对比

特性浏览器Node.js(11+)
宏任务setTimeout, setInterval, requestAnimationFrame, I/OsetTimeout, setInterval, setImmediate, I/O
微任务Promise.then, async/await, MutationObserverPromise.then, async/await, process.nextTick
微任务执行时机每个宏任务后每个宏任务后(与浏览器一致)
UI 渲染
阶段划分简单6 个阶段

六、常见面试题

面试题 1:什么是事件循环?

回答要点:

  1. JavaScript 是单线程的
  2. 事件循环让单线程的 JS 能处理异步操作
  3. 执行栈 + 任务队列(宏任务 + 微任务)
  4. 执行规则:一个宏任务,一批微任务

面试题 2:宏任务和微任务的区别?

回答要点:

  1. 宏任务:setTimeout, setInterval, I/O 等
  2. 微任务:Promise.then, async/await 等
  3. 微任务优先级更高
  4. 一个宏任务执行完,要清空所有微任务

面试题 3:代码输出题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
2
3
4
5
6
1
6
4
2
3
5

七、最佳实践

7.1 避免阻塞主线程

主线程阻塞的危害:

  • 页面卡顿,用户体验差
  • 动画不流畅
  • 事件响应延迟

解决方案:

1. 分批处理大型任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ❌ 不好的做法:一次性处理大量数据
function badProcess(data) {
for (let i = 0; i < data.length; i++) {
// 复杂处理
processItem(data[i]);
}
}

// ✅ 好的做法:分批处理
function goodProcess(data) {
let index = 0;
const batchSize = 100;

function processBatch() {
const end = Math.min(index + batchSize, data.length);
for (; index < end; index++) {
processItem(data[index]);
}

if (index < data.length) {
// 在下一个宏任务中继续处理
setTimeout(processBatch, 0);
}
}

processBatch();
}

2. 使用 Web Worker

对于计算密集型任务,使用 Web Worker 可以完全避免阻塞主线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 主线程
const worker = new Worker('worker.js');

worker.postMessage({ type: 'fibonacci', n: 40 });

worker.onmessage = (event) => {
console.log('Fibonacci result:', event.data);
};

// worker.js
self.onmessage = (event) => {
const { type, n } = event.data;
if (type === 'fibonacci') {
const result = fibonacci(n);
self.postMessage(result);
}
};

function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}

7.2 合理使用微任务

微任务的优势:

  • 执行时机早,在 UI 渲染前完成
  • 适合处理 DOM 变化
  • 可以批量处理操作

1. 使用 queueMicrotask 优化

1
2
3
4
5
6
7
8
// ✅ 使用 queueMicrotask 优化
function doSomethingAsync() {
if (cache.has(data)) {
queueMicrotask(() => callback(cache.get(data)));
} else {
fetch(data).then(callback);
}
}

2. 批量处理 DOM 操作

1
2
3
4
5
6
7
8
9
10
// ✅ 批量处理 DOM 操作
function updateUI(updates) {
// 收集所有需要的更新
const changes = collectChanges(updates);

// 在微任务中执行所有更新
queueMicrotask(() => {
applyChanges(changes);
});
}

7.3 优化定时器

定时器的注意事项:

  • setTimeout(fn, 0) 有最小延迟(浏览器 4ms,Node.js 1ms)
  • 定时器回调的执行时间不确定
  • 过多的定时器会影响性能

1. 使用 requestAnimationFrame 进行动画

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 使用 requestAnimationFrame 进行动画
function animate(element) {
let position = 0;

function update() {
position += 1;
element.style.transform = `translateX(${position}px)`;

if (position < 100) {
requestAnimationFrame(update);
}
}

requestAnimationFrame(update);
}

2. 合并定时器

1
2
3
4
5
6
7
8
9
10
11
// ❌ 不好的做法:多个独立定时器
setTimeout(() => updateUI(), 1000);
setTimeout(() => fetchData(), 1000);
setTimeout(() => checkStatus(), 1000);

// ✅ 好的做法:合并定时器
setTimeout(() => {
updateUI();
fetchData();
checkStatus();
}, 1000);

7.4 事件循环与异步编程模式

1. 使用 async/await 简化异步代码

1
2
3
4
5
6
7
8
9
10
11
// ✅ 使用 async/await 简化异步代码
async function fetchData() {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching data:', error);
throw error;
}
}

2. 合理使用 Promise.all

1
2
3
4
5
6
7
8
9
10
// ✅ 使用 Promise.all 并行处理多个异步操作
async function fetchMultipleData() {
const [users, posts, comments] = await Promise.all([
fetch('https://api.example.com/users').then(res => res.json()),
fetch('https://api.example.com/posts').then(res => res.json()),
fetch('https://api.example.com/comments').then(res => res.json())
]);

return { users, posts, comments };
}

7.5 性能优化建议

1. 减少微任务的数量

微任务过多的问题:

  • 可能会延迟 UI 渲染
  • 影响页面响应速度

解决方案:

  • 合并微任务
  • 避免在微任务中执行复杂操作
  • 对于非紧急操作,使用宏任务

2. 避免长任务

长任务的定义: 执行时间超过 50ms 的任务

解决方案:

  • 分解长任务为多个短任务
  • 使用 Web Worker 处理计算密集型任务
  • 使用 requestIdleCallback 处理非紧急任务

3. 合理使用 requestIdleCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ 使用 requestIdleCallback 处理非紧急任务
function processNonUrgentTask() {
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0) {
// 处理非紧急任务
processNextItem();
}

if (hasMoreItems()) {
requestIdleCallback(processNonUrgentTask);
}
});
}

4. 优化事件监听器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 不好的做法:频繁触发的事件监听器
window.addEventListener('scroll', () => {
// 复杂的滚动处理逻辑
updateScrollPosition();
});

// ✅ 好的做法:使用节流优化
window.addEventListener('scroll', throttle(() => {
updateScrollPosition();
}, 16)); // 约 60fps

function throttle(fn, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
return fn.apply(this, args);
}
};
}

八、总结

8.1 核心知识点

  1. 为什么是单线程:DOM 操作的互斥性
  2. 执行栈:后进先出,存储正在执行的函数
  3. 任务队列:宏任务 + 微任务
  4. 执行规则
    • 一个宏任务
    • 清空所有微任务(包括新产生的)
    • (可能)UI 渲染
    • 循环

8.2 记忆口诀

浏览器:

先同步,再异步;先微任务,再宏任务。

Node.js 11+:

和浏览器一致,一个宏任务,一批微任务。

事件循环是 JavaScript 的精髓,理解它能让你写出更高效、更优雅的异步代码!