Appearance
JavaScript 事件循环
什么是事件循环
事件循环(Event Loop)是 JavaScript 中处理异步操作的机制,它负责协调代码的执行、事件的触发、网络请求等异步任务的处理。
JavaScript 是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作(如网络请求、定时器、DOM 事件等),JavaScript 引入了事件循环机制,使得这些异步操作可以在后台处理,而不会阻塞主线程的执行。
事件循环的基本概念
1. 调用栈(Call Stack)
调用栈是 JavaScript 中用于跟踪函数调用的结构,它遵循后进先出(LIFO)的原则。当执行一个函数时,它会被推入栈顶;当函数执行完毕时,它会被弹出栈。
javascript
// 示例
function foo() {
console.log("foo");
bar();
}
function bar() {
console.log("bar");
}
foo();
// 调用栈的变化:
// 1. 推入 foo
// 2. 执行 foo,输出 'foo'
// 3. 推入 bar
// 4. 执行 bar,输出 'bar'
// 5. 弹出 bar
// 6. 弹出 foo2. 消息队列(Message Queue)
消息队列(也称为任务队列)用于存储待处理的异步任务。当异步任务完成时,它会被添加到消息队列中。
3. 微任务队列(Microtask Queue)
微任务队列用于存储优先级较高的异步任务,如 Promise 的回调函数、MutationObserver 的回调函数等。
4. 事件循环的执行流程
事件循环的基本执行流程如下:
- 执行调用栈中的同步任务,直到栈为空
- 执行微任务队列中的所有任务
- 从消息队列中取出一个任务执行
- 重复步骤 1-3
javascript
// 示例
console.log("开始");
// 异步任务(宏任务)
setTimeout(() => {
console.log("setTimeout");
}, 0);
// 微任务
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("结束");
// 输出顺序:
// 开始
// 结束
// Promise
// setTimeout
// 执行流程:
// 1. 执行 console.log('开始')
// 2. 将 setTimeout 回调添加到消息队列
// 3. 将 Promise 回调添加到微任务队列
// 4. 执行 console.log('结束')
// 5. 执行微任务队列中的 Promise 回调,输出 'Promise'
// 6. 从消息队列中取出 setTimeout 回调执行,输出 'setTimeout'宏任务和微任务
1. 宏任务(Macro Task)
宏任务是指需要经过事件循环才能执行的任务,包括:
setTimeoutsetIntervalsetImmediate(Node.js)I/O操作DOM事件requestAnimationFrame- 页面渲染
2. 微任务(Micro Task)
微任务是指在当前任务执行完毕后立即执行的任务,包括:
Promise的then、catch、finally回调async/await中的异步操作process.nextTick(Node.js)MutationObserver回调
3. 执行顺序
事件循环中,宏任务和微任务的执行顺序如下:
- 执行当前宏任务中的同步代码
- 执行所有微任务
- 渲染页面(如果需要)
- 从消息队列中取出下一个宏任务执行
javascript
// 示例
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. 执行同步代码:console.log('1'), console.log('6')
// 2. 执行微任务:console.log('4'),并将 setTimeout 添加到消息队列
// 3. 从消息队列中取出第一个宏任务:console.log('2')
// 4. 执行微任务:console.log('3')
// 5. 从消息队列中取出下一个宏任务:console.log('5')事件循环的工作原理
1. 调用栈的工作原理
调用栈是一个后进先出(LIFO)的数据结构,用于跟踪函数的调用。当执行一个函数时,它会被推入栈顶;当函数执行完毕时,它会被弹出栈。
javascript
// 示例
function a() {
console.log("a 开始");
b();
console.log("a 结束");
}
function b() {
console.log("b 开始");
c();
console.log("b 结束");
}
function c() {
console.log("c 开始");
console.log("c 结束");
}
a();
// 输出顺序:
// a 开始
// b 开始
// c 开始
// c 结束
// b 结束
// a 结束
// 调用栈的变化:
// 1. 推入 a
// 2. 执行 a 中的 console.log('a 开始')
// 3. 推入 b
// 4. 执行 b 中的 console.log('b 开始')
// 5. 推入 c
// 6. 执行 c 中的 console.log('c 开始') 和 console.log('c 结束')
// 7. 弹出 c
// 8. 执行 b 中的 console.log('b 结束')
// 9. 弹出 b
// 10. 执行 a 中的 console.log('a 结束')
// 11. 弹出 a2. 异步任务的处理
当遇到异步任务时,JavaScript 会将其交给相应的 Web API(在浏览器中)或 Node.js API(在 Node.js 中)处理,而不会阻塞主线程的执行。
当异步任务完成时,它会将回调函数添加到消息队列(宏任务)或微任务队列中。
javascript
// 示例
console.log("开始");
// 异步任务(宏任务)
setTimeout(() => {
console.log("setTimeout");
}, 0);
// 异步任务(微任务)
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("结束");
// 输出顺序:
// 开始
// 结束
// Promise
// setTimeout
// 执行流程:
// 1. 执行 console.log('开始')
// 2. 遇到 setTimeout,将其交给 Web API 处理
// 3. 遇到 Promise,将其交给 Web API 处理
// 4. 执行 console.log('结束')
// 5. 调用栈为空,执行微任务队列中的 Promise 回调
// 6. 微任务队列为空,从消息队列中取出 setTimeout 回调执行3. 事件循环的迭代
事件循环不断地重复以下步骤:
- 检查调用栈是否为空
- 如果为空,执行所有微任务
- 从消息队列中取出一个宏任务执行
- 重复上述步骤
javascript
// 示例
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:
// 执行同步代码:console.log('1'), console.log('6')
// 执行微任务:console.log('4'),并将 setTimeout 添加到消息队列
// 微任务队列为空
// 迭代 2:
// 从消息队列中取出第一个宏任务:console.log('2')
// 执行微任务:console.log('3')
// 微任务队列为空
// 迭代 3:
// 从消息队列中取出下一个宏任务:console.log('5')
// 微任务队列为空浏览器中的事件循环
1. 浏览器的事件循环模型
浏览器中的事件循环由以下部分组成:
- 调用栈:执行同步代码和处理函数调用
- Web API:处理异步任务(如 setTimeout、XMLHttpRequest、DOM 事件等)
- 消息队列:存储宏任务的回调函数
- 微任务队列:存储微任务的回调函数
- 渲染线程:负责页面的渲染
2. 渲染流程
在浏览器的事件循环中,页面渲染通常发生在微任务执行完毕后,下一个宏任务执行之前。
渲染流程包括:
- 计算样式
- 布局
- 绘制
javascript
// 示例
console.log("开始");
// 宏任务
setTimeout(() => {
console.log("setTimeout");
}, 0);
// 微任务
Promise.resolve().then(() => {
console.log("Promise");
// 微任务执行完毕后会进行渲染
});
console.log("结束");
// 执行流程:
// 1. 执行同步代码
// 2. 执行微任务
// 3. 渲染页面
// 4. 执行宏任务3. requestAnimationFrame
requestAnimationFrame 是浏览器提供的 API,用于在下次渲染之前执行回调函数,通常用于动画效果。
requestAnimationFrame 的回调函数会在渲染之前执行,它的执行时机在微任务之后,宏任务之前。
javascript
// 示例
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
requestAnimationFrame(() => {
console.log("4");
});
console.log("5");
// 输出顺序:
// 1
// 5
// 3
// 4
// 2
// 执行流程:
// 1. 执行同步代码:console.log('1'), console.log('5')
// 2. 执行微任务:console.log('3')
// 3. 执行 requestAnimationFrame 回调:console.log('4')
// 4. 渲染页面
// 5. 执行宏任务:console.log('2')Node.js 中的事件循环
1. Node.js 的事件循环模型
Node.js 的事件循环与浏览器的事件循环有所不同,它由以下 6 个阶段组成:
- timers:处理
setTimeout和setInterval的回调 - I/O callbacks:处理 I/O 操作的回调
- idle, prepare:内部使用
- poll:获取新的 I/O 事件,执行 I/O 相关的回调
- check:处理
setImmediate的回调 - close callbacks:处理关闭事件的回调(如
socket.on('close', ...))
2. Node.js 中的宏任务和微任务
在 Node.js 中,宏任务和微任务的执行顺序如下:
- 执行当前阶段的任务
- 执行所有微任务
- 进入下一个阶段
3. process.nextTick
process.nextTick 是 Node.js 提供的 API,它会将回调函数添加到当前阶段的微任务队列的最前面,优先于其他微任务执行。
javascript
// 示例
console.log("1");
setTimeout(() => {
console.log("2");
process.nextTick(() => {
console.log("3");
});
Promise.resolve().then(() => {
console.log("4");
});
}, 0);
process.nextTick(() => {
console.log("5");
Promise.resolve().then(() => {
console.log("6");
});
});
Promise.resolve().then(() => {
console.log("7");
process.nextTick(() => {
console.log("8");
});
});
console.log("9");
// 输出顺序:
// 1
// 9
// 5
// 7
// 8
// 6
// 2
// 3
// 4
// 执行流程:
// 1. 执行同步代码:console.log('1'), console.log('9')
// 2. 执行微任务(process.nextTick 优先):
// - console.log('5'),并添加 Promise 到微任务队列
// - console.log('7'),并添加 process.nextTick 到微任务队列
// - console.log('8')
// - console.log('6')
// 3. 进入 timers 阶段,执行 setTimeout 回调:console.log('2')
// 4. 执行微任务:
// - console.log('3')
// - console.log('4')4. setImmediate vs setTimeout
在 Node.js 中,setImmediate 和 setTimeout(..., 0) 的执行顺序取决于它们被调用的位置:
- 在主模块中,
setTimeout(..., 0)可能会先于setImmediate执行 - 在 I/O 回调中,
setImmediate会先于setTimeout(..., 0)执行
javascript
// 示例 1:主模块中
console.log("开始");
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
console.log("结束");
// 可能的输出:
// 开始
// 结束
// setTimeout
// setImmediate
// 示例 2:I/O 回调中
const fs = require("fs");
console.log("开始");
fs.readFile(__filename, () => {
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
});
console.log("结束");
// 输出:
// 开始
// 结束
// setImmediate
// setTimeout事件循环的应用场景
1. 理解异步代码的执行顺序
事件循环机制可以帮助我们理解异步代码的执行顺序,特别是在处理多个异步任务时。
javascript
// 示例
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
setTimeout(() => {
console.log("4");
}, 0);
});
new Promise((resolve) => {
console.log("5");
resolve();
}).then(() => {
console.log("6");
});
console.log("7");
// 输出顺序:
// 1
// 5
// 7
// 3
// 6
// 2
// 4
// 执行流程:
// 1. 执行同步代码:console.log('1'), console.log('5'), console.log('7')
// 2. 执行微任务:console.log('3')(添加 setTimeout 到消息队列), console.log('6')
// 3. 从消息队列中取出第一个宏任务:console.log('2')
// 4. 从消息队列中取出下一个宏任务:console.log('4')2. 优化渲染性能
通过合理安排代码的执行时机,可以优化页面的渲染性能。
javascript
// 示例:优化渲染性能
function updateUI() {
// 执行 DOM 更新
console.log("更新 DOM");
// 使用 requestAnimationFrame 在渲染之前执行动画
requestAnimationFrame(() => {
console.log("执行动画");
});
// 使用 Promise 在微任务中执行数据处理
Promise.resolve().then(() => {
console.log("处理数据");
});
}
updateUI();
// 输出顺序:
// 更新 DOM
// 处理数据
// 执行动画
// 执行流程:
// 1. 执行 updateUI 函数
// 2. 执行同步代码:console.log('更新 DOM')
// 3. 执行微任务:console.log('处理数据')
// 4. 执行 requestAnimationFrame 回调:console.log('执行动画')
// 5. 渲染页面3. 避免阻塞主线程
通过将耗时的操作放入异步任务中,可以避免阻塞主线程的执行。
javascript
// 示例:避免阻塞主线程
function processLargeData(data) {
// 耗时的操作
console.log("开始处理数据");
// 将耗时操作放入微任务中
return new Promise((resolve) => {
// 模拟耗时操作
setTimeout(() => {
console.log("数据处理完成");
resolve("处理结果");
}, 1000);
});
}
console.log("开始");
processLargeData().then((result) => {
console.log("结果:", result);
});
console.log("继续执行其他任务");
// 输出顺序:
// 开始
// 开始处理数据
// 继续执行其他任务
// 数据处理完成
// 结果: 处理结果4. 处理并发请求
事件循环机制可以帮助我们高效地处理并发请求。
javascript
// 示例:处理并发请求
function fetchData(url) {
return fetch(url).then((response) => response.json());
}
async function fetchMultipleData() {
console.log("开始请求数据");
// 并发请求
const [data1, data2, data3] = await Promise.all([
fetchData("https://api.example.com/data1"),
fetchData("https://api.example.com/data2"),
fetchData("https://api.example.com/data3"),
]);
console.log("所有数据请求完成");
console.log("数据1:", data1);
console.log("数据2:", data2);
console.log("数据3:", data3);
}
fetchMultipleData();
console.log("继续执行");
// 输出顺序:
// 开始请求数据
// 继续执行
// 所有数据请求完成
// 数据1: {...}
// 数据2: {...}
// 数据3: {...}事件循环的常见问题
1. 回调地狱
回调地狱(Callback Hell)是指多层嵌套的回调函数,使代码难以阅读和维护。
javascript
// 示例:回调地狱
setTimeout(() => {
console.log("第一个回调");
setTimeout(() => {
console.log("第二个回调");
setTimeout(() => {
console.log("第三个回调");
setTimeout(() => {
console.log("第四个回调");
}, 1000);
}, 1000);
}, 1000);
}, 1000);
// 解决方案:使用 Promise 或 async/await
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function sequentialAsync() {
console.log("开始");
await delay(1000);
console.log("第一个回调");
await delay(1000);
console.log("第二个回调");
await delay(1000);
console.log("第三个回调");
await delay(1000);
console.log("第四个回调");
}
sequentialAsync();2. 内存泄漏
如果回调函数中引用了外部变量,而这些变量又没有被正确释放,可能会导致内存泄漏。
javascript
// 示例:潜在的内存泄漏
function createEventListener() {
const largeObject = new Array(1000000).fill("data");
document.getElementById("button").addEventListener("click", () => {
console.log("按钮被点击");
// 回调函数引用了 largeObject,可能导致内存泄漏
});
}
createEventListener();
// 解决方案:使用 WeakRef 或手动移除事件监听器
function createEventListener() {
const largeObject = new Array(1000000).fill("data");
const button = document.getElementById("button");
function handleClick() {
console.log("按钮被点击");
}
button.addEventListener("click", handleClick);
// 手动移除事件监听器
return function cleanup() {
button.removeEventListener("click", handleClick);
};
}
const cleanup = createEventListener();
// 当不再需要时调用 cleanup()
// cleanup();3. 长时间运行的任务
长时间运行的任务会阻塞主线程,导致页面卡顿。
javascript
// 示例:长时间运行的任务
function calculatePrimes(n) {
const primes = [];
for (let i = 2; i <= n; i++) {
let isPrime = true;
for (let j = 2; j < i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i);
}
}
return primes;
}
console.log("开始计算");
const primes = calculatePrimes(1000000); // 这会阻塞主线程
console.log("计算完成,找到", primes.length, "个质数");
// 解决方案:使用 Web Workers 或分批处理
// 使用 Web Workers
// worker.js
/*
function calculatePrimes(n) {
const primes = [];
for (let i = 2; i <= n; i++) {
let isPrime = true;
for (let j = 2; j < i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i);
}
}
self.postMessage({ primes });
}
self.onmessage = function(e) {
const n = e.data;
calculatePrimes(n);
};
*/
// 主线程
/*
const worker = new Worker('worker.js');
console.log('开始计算');
worker.postMessage(1000000);
worker.onmessage = function(e) {
console.log('计算完成,找到', e.data.primes.length, '个质数');
};
*/
// 分批处理
function calculatePrimesInBatches(n, batchSize = 10000) {
return new Promise((resolve) => {
const primes = [];
let current = 2;
function processBatch() {
const end = Math.min(current + batchSize - 1, n);
for (let i = current; i <= end; i++) {
let isPrime = true;
for (let j = 2; j < i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i);
}
}
current = end + 1;
if (current <= n) {
// 在下一个事件循环中处理下一批
setTimeout(processBatch, 0);
} else {
resolve(primes);
}
}
processBatch();
});
}
async function main() {
console.log("开始计算");
const primes = await calculatePrimesInBatches(1000000);
console.log("计算完成,找到", primes.length, "个质数");
}
// main();事件循环的最佳实践
1. 使用 Promise 和 async/await
使用 Promise 和 async/await 可以使异步代码更易读、更易维护。
javascript
// 示例:使用 async/await
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchData(url) {
const response = await fetch(url);
const data = await response.json();
return data;
}
async function main() {
try {
console.log("开始");
await delay(1000);
console.log("等待 1 秒");
const data = await fetchData("https://api.example.com/data");
console.log("获取数据:", data);
console.log("完成");
} catch (error) {
console.error("错误:", error);
}
}
main();2. 合理使用微任务和宏任务
根据任务的性质,合理选择使用微任务或宏任务。
- 微任务:适合处理不需要等待渲染的任务,如数据处理、状态更新等
- 宏任务:适合处理需要等待渲染或其他异步操作的任务,如定时器、网络请求等
javascript
// 示例:合理使用微任务和宏任务
function handleUserInput() {
// 处理用户输入(同步)
console.log("处理用户输入");
// 使用微任务处理数据(不阻塞渲染)
Promise.resolve().then(() => {
console.log("处理数据");
});
// 使用宏任务执行耗时操作(允许渲染)
setTimeout(() => {
console.log("执行耗时操作");
}, 0);
// 使用 requestAnimationFrame 执行动画(与渲染同步)
requestAnimationFrame(() => {
console.log("执行动画");
});
}
handleUserInput();
// 输出顺序:
// 处理用户输入
// 处理数据
// 执行动画
// 执行耗时操作3. 避免阻塞主线程
避免在主线程中执行长时间运行的任务,使用 Web Workers 或分批处理。
javascript
// 示例:使用 Web Workers
// worker.js
/*
self.onmessage = function(e) {
const { start, end } = e.data;
const primes = [];
for (let i = start; i <= end; i++) {
let isPrime = true;
for (let j = 2; j < i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i);
}
}
self.postMessage(primes);
};
*/
// 主线程
/*
const worker = new Worker('worker.js');
worker.onmessage = function(e) {
console.log('找到质数:', e.data);
};
worker.postMessage({ start: 2, end: 1000000 });
console.log('计算中...');
*/4. 优化事件监听器
合理使用事件监听器,避免内存泄漏。
javascript
// 示例:优化事件监听器
class Component {
constructor() {
this.handleClick = this.handleClick.bind(this);
this.button = document.getElementById("button");
this.button.addEventListener("click", this.handleClick);
}
handleClick() {
console.log("按钮被点击");
}
destroy() {
// 移除事件监听器
this.button.removeEventListener("click", this.handleClick);
}
}
// 使用
const component = new Component();
// 当不再需要时调用 destroy()
// component.destroy();5. 理解渲染时机
理解页面渲染的时机,合理安排代码的执行顺序。
javascript
// 示例:理解渲染时机
function updateUI() {
// 1. 执行 DOM 更新
const element = document.getElementById("counter");
element.textContent = parseInt(element.textContent) + 1;
// 2. 微任务:在渲染之前执行
Promise.resolve().then(() => {
console.log("微任务:DOM 内容:", element.textContent);
});
// 3. requestAnimationFrame:在渲染之前执行
requestAnimationFrame(() => {
console.log("requestAnimationFrame:DOM 内容:", element.textContent);
});
// 4. 宏任务:在渲染之后执行
setTimeout(() => {
console.log("宏任务:DOM 内容:", element.textContent);
}, 0);
}
// 初始值
document.getElementById("counter").textContent = "0";
// 调用 updateUI
updateUI();
// 输出顺序:
// 微任务:DOM 内容: 1
// requestAnimationFrame:DOM 内容: 1
// 宏任务:DOM 内容: 1
// 执行流程:
// 1. 执行同步代码(DOM 更新)
// 2. 执行微任务
// 3. 执行 requestAnimationFrame 回调
// 4. 渲染页面
// 5. 执行宏任务事件循环的兼容性
1. 浏览器兼容性
事件循环是 JavaScript 的核心特性,所有现代浏览器都支持。在旧版本浏览器中,可能存在一些差异:
- IE10 及以下版本的事件循环实现与现代浏览器有所不同
- 旧版本浏览器对 Promise 和 async/await 的支持有限
2. Node.js 兼容性
Node.js 的事件循环在不同版本中也有一些变化:
- Node.js 10+ 的事件循环行为与浏览器更接近
- 早期版本的 Node.js 可能存在一些差异
3. 兼容性解决方案
对于不支持现代特性的环境,可以使用 polyfill 或转译工具。
javascript
// 示例:使用 polyfill
// 对于 Promise
if (!window.Promise) {
// 使用 Promise polyfill
// https://github.com/taylorhakes/promise-polyfill
}
// 对于 async/await
// 使用 Babel 转译
// https://babeljs.io/总结
事件循环是 JavaScript 中处理异步操作的核心机制,它使得单线程的 JavaScript 能够高效地处理并发任务。事件循环的主要组成部分包括:
- 调用栈:执行同步代码和处理函数调用
- Web API/Node.js API:处理异步任务
- 消息队列:存储宏任务的回调函数
- 微任务队列:存储微任务的回调函数
事件循环的执行流程如下:
- 执行调用栈中的同步任务
- 执行所有微任务
- 渲染页面(浏览器)
- 从消息队列中取出一个宏任务执行
- 重复上述步骤
宏任务和微任务的区别:
- 宏任务:包括 setTimeout、setInterval、DOM 事件等,需要经过事件循环才能执行
- 微任务:包括 Promise 回调、async/await 等,在当前任务执行完毕后立即执行
通过理解事件循环的工作原理,我们可以:
- 更好地理解异步代码的执行顺序
- 优化页面的渲染性能
- 避免回调地狱和内存泄漏
- 编写更高效、更可维护的异步代码
在现代 JavaScript 中,Promise 和 async/await 已经成为处理异步操作的主流方式,但事件循环仍然是理解 JavaScript 异步行为的基础。通过掌握事件循环,我们可以更深入地理解 JavaScript 的运行机制,编写更加健壮的代码。