深入理解 JavaScript 事件循环机制

深入理解 JavaScript 事件循环机制

JavaScript事件循环

事件循环是 JavaScript 实现异步编程的核心机制,深入理解它对于编写高性能的前端应用至关重要。本文将从原理、实践到优化,全面解析事件循环。

一、事件循环的基本概念

1.1 为什么需要事件循环?

JavaScript 是单线程语言,这意味着它一次只能执行一个任务。如果所有操作都是同步的,那么耗时操作(如网络请求、文件读写)会阻塞整个程序,导致界面冻结。

事件循环机制让 JavaScript 能够”并发”处理多个任务,但实际上是利用时间分片的方式,在不同的任务之间快速切换。

1.2 调用栈(Call Stack)

调用栈是 JavaScript 执行代码的地方,采用后进先出(LIFO)的原则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function first() {
console.log('First start');
second();
console.log('First end');
}

function second() {
console.log('Second');
}

first();

// 输出顺序:
// First start
// Second
// First end

执行过程:

  1. first() 被压入栈
  2. 输出 “First start”
  3. second() 被压入栈
  4. 输出 “Second”
  5. second() 从栈中弹出
  6. 输出 “First end”
  7. first() 从栈中弹出

二、任务队列系统

2.1 宏任务(Macrotask)

宏任务是由宿主环境(浏览器或 Node.js)发起的任务,每次事件循环只执行一个宏任务。

常见的宏任务:

  • setTimeout
  • setInterval
  • setImmediate(Node.js)
  • I/O 操作
  • UI 渲染
1
2
3
4
5
6
7
8
9
console.log('1');

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

console.log('3');

// 输出:1, 3, 2

2.2 微任务(Microtask)

微任务的优先级高于宏任务,在每次事件循环结束时,会清空所有微任务队列。

常见的微任务:

  • Promise.then/catch/finally
  • process.nextTick(Node.js)
  • MutationObserver
1
2
3
4
5
6
7
8
9
console.log('1');

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

console.log('3');

// 输出:1, 3, 2

三、事件循环的执行顺序

3.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
console.log('Start');

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

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

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

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

console.log('End');

// 输出顺序:
// Start
// End
// Promise 1
// Promise 2
// Timeout 1
// Timeout 2

执行流程:

  1. 执行同步代码:Start, End
  2. 检查微任务队列,执行所有微任务:Promise 1, Promise 2
  3. 执行一个宏任务:Timeout 1
  4. 再次检查微任务队列(空)
  5. 执行下一个宏任务:Timeout 2

3.2 复杂案例分析

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

详细解析:

第一轮事件循环:

  1. 执行同步代码:1, 6
  2. 微任务队列:[Promise.then (输出 4)]
  3. 宏任务队列:[setTimeout (输出 2)]

第二轮事件循环:

  1. 执行所有微任务:输出 4
    • 在微任务中添加新的宏任务:setTimeout (输出 5)
  2. 微任务队列:[Promise.then (输出 3)]
  3. 宏任务队列:[setTimeout (输出 2), setTimeout (输出 5)]

第三轮事件循环:

  1. 执行第一个宏任务:输出 2
  2. 执行产生的微任务:输出 3
  3. 微任务队列:[]
  4. 宏任务队列:[setTimeout (输出 5)]

第四轮事件循环:

  1. 执行宏任务:输出 5

四、Node.js 中的事件循环

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

Node.js 的事件循环比浏览器更复杂,包含六个阶段:

  1. Timers:执行 setTimeoutsetInterval 的回调
  2. Pending Callbacks:执行 I/O 回调(除了 close、timers、check)
  3. Idle, Prepare:内部使用
  4. Poll:获取新的 I/O 事件,执行 I/O 回调
  5. Check:执行 setImmediate 的回调
  6. Close Callbacks:执行 socket 等的 close 回调

4.2 process.nextTick vs setImmediate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
setImmediate(() => {
console.log('immediate 1');
});

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

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

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

// 输出:
// nextTick 1
// nextTick 2
// immediate 1
// immediate 2

注意: process.nextTick 的优先级高于所有微任务和宏任务,会在当前操作完成后立即执行。

五、常见陷阱与最佳实践

5.1 避免长时间占用事件循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 错误示例:阻塞事件循环
function heavyTask() {
for (let i = 0; i < 1e9; i++) {
Math.sqrt(i);
}
}

// 正确示例:分片处理
async function heavyTaskAsync() {
const total = 1e9;
const chunk = 1e7;

for (let i = 0; i < total; i += chunk) {
for (let j = i; j < Math.min(i + chunk, total); j++) {
Math.sqrt(j);
}
await new Promise(resolve => setTimeout(resolve, 0));
}
}

5.2 合理使用微任务

1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误示例:过度使用微任务导致队列堆积
function processTasks(tasks) {
tasks.forEach(task => {
Promise.resolve().then(() => processTask(task));
});
}

// 正确示例:批量处理
async function processTasks(tasks) {
for (const task of tasks) {
await processTask(task);
}
}

5.3 避免事件循环饥饿

1
2
3
4
5
6
7
8
9
10
11
// 错误示例:递归调用导致事件循环饥饿
function recursiveTask() {
doSomeWork();
setTimeout(recursiveTask, 0);
}

// 正确示例:给其他任务留出执行时间
function balancedTask() {
doSomeWork();
setTimeout(() => requestAnimationFrame(balancedTask), 0);
}

六、性能优化技巧

6.1 使用 requestAnimationFrame 优化动画

1
2
3
4
5
6
7
8
9
10
// 使用 requestAnimationFrame 替代 setInterval
function animate() {
// 更新动画状态
updateAnimationState();

// 继续下一帧
requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

6.2 使用 Web Worker 处理计算密集型任务

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

worker.postMessage({ data: largeDataSet });

worker.onmessage = (e) => {
const result = e.data;
console.log('计算完成:', result);
};

// worker.js
self.onmessage = (e) => {
const result = heavyComputation(e.data.data);
self.postMessage(result);
};

6.3 使用 Idle Callbacks 执行低优先级任务

1
2
3
4
5
6
7
8
9
10
11
12
13
// 在浏览器空闲时执行任务
function processLowPriorityTasks(tasks) {
const deadline = performance.now() + 16; // 一帧的时间

while (tasks.length > 0 && performance.now() < deadline) {
const task = tasks.shift();
processTask(task);
}

if (tasks.length > 0) {
requestIdleCallback(() => processLowPriorityTasks(tasks));
}
}

七、调试事件循环

7.1 使用 Performance API

1
2
3
4
5
6
7
8
9
10
11
12
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.name, entry.duration);
}
});

observer.observe({ entryTypes: ['measure', 'mark'] });

performance.mark('start');
// 执行代码
performance.mark('end');
performance.measure('Execution time', 'start', 'end');

7.2 使用 Chrome DevTools

  1. 打开 Performance 面板
  2. 点击 Record 开始记录
  3. 执行你的代码
  4. 停止记录,分析事件循环活动

八、总结

理解事件循环机制是掌握 JavaScript 异步编程的基础。通过合理利用宏任务和微任务,避免长时间占用事件循环,我们可以编写出高性能、流畅的 Web 应用。

关键要点:

  1. 微任务优先级高于宏任务
  2. 每次事件循环只执行一个宏任务
  3. 每次宏任务执行后会清空所有微任务
  4. 避免长时间阻塞事件循环
  5. 合理使用 Web Worker 处理计算密集型任务

事件循环是 JavaScript 的核心机制,掌握它将帮助你编写更高效的代码。