题目
请详细说明 JavaScript 事件循环机制(Event Loop)的工作原理,以及宏任务和微任务的执行顺序。
🎯 面试速答
TIP
事件循环是 JavaScript 处理异步操作的核心机制。因为 JavaScript 是单线程的,同一时间只能做一件事,所以需要事件循环来协调各种异步任务的执行,避免阻塞主线程。
简单来说,事件循环的工作流程是这样的:首先执行所有的同步代码,然后检查有没有微任务需要执行,如果有就把所有微任务都执行完,接着再执行一个宏任务,然后又回到检查微任务这一步,这样不断循环。
这里面涉及到三个核心概念:调用栈、宏任务队列和微任务队列。调用栈就是存放当前正在执行的代码的地方,是一个后进先出的结构。当遇到异步操作时,比如 setTimeout 或者 Promise,它们的回调函数会被放到对应的任务队列里等待执行。
宏任务和微任务的区别很重要。宏任务包括 setTimeout、setInterval、I/O 操作这些,而微任务主要是 Promise 的 then、catch、finally 回调,还有 queueMicrotask 这些。关键点是微任务的优先级比宏任务高,每次事件循环都会把所有微任务清空后,才会执行一个宏任务。
举个例子,如果代码里既有 setTimeout 又有 Promise,那执行顺序一定是:先执行同步代码,然后执行 Promise 的回调,最后才执行 setTimeout 的回调。这就是为什么即使 setTimeout 设置延迟为 0,它也不会立即执行,因为它要等微任务队列清空后才轮到它。
另外需要注意的是,浏览器的页面渲染通常发生在宏任务之间,所以如果微任务太多或者执行时间太长,也会影响页面渲染,导致页面卡顿。
在实际开发中,理解事件循环可以帮助我们更好地处理异步操作的执行顺序,避免一些意外的 bug,也能帮助我们优化性能,比如把耗时的操作分片执行,避免阻塞主线程。
📝 标准答案
核心要点
单线程特性:
- JavaScript 在同一时间只能做一件事
- 长时间运行的任务会阻塞其他任务
事件循环核心组件:
- 调用栈(Call Stack):存储代码执行位置
- 任务队列(Task Queue):存放待执行的回调函数
- 事件循环(Event Loop):协调调用栈和任务队列
任务分类:
- 宏任务(Macro Tasks):setTimeout、setInterval、I/O、UI 渲染
- 微任务(Micro Tasks):Promise、queueMicrotask、MutationObserver
执行顺序:
- 执行同步代码
- 清空所有微任务
- 执行一个宏任务
- 重复上述过程
详细说明
1. 调用栈(Call Stack)
调用栈是一个后进先出(LIFO)的数据结构,用于存储代码执行的上下文。
function first() {
console.log('first');
second();
console.log('first end');
}
function second() {
console.log('second');
}
first();
// 调用栈变化:
// 1. first() 入栈
// 2. console.log('first') 入栈 → 执行 → 出栈
// 3. second() 入栈
// 4. console.log('second') 入栈 → 执行 → 出栈
// 5. second() 出栈
// 6. console.log('first end') 入栈 → 执行 → 出栈
// 7. first() 出栈2. 任务队列(Task Queue)
任务队列分为宏任务队列和微任务队列,用于存放异步操作的回调函数。
// 宏任务示例
setTimeout(() => {
console.log('setTimeout');
}, 0);
// 微任务示例
Promise.resolve().then(() => {
console.log('Promise');
});
console.log('同步代码');
// 输出顺序:
// 同步代码
// Promise
// setTimeout3. 事件循环流程
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
// 执行流程:
// 1. 执行同步代码:输出 '1' 和 '4'
// 2. setTimeout 回调进入宏任务队列
// 3. Promise 回调进入微任务队列
// 4. 同步代码执行完,清空微任务队列:输出 '3'
// 5. 执行宏任务队列:输出 '2'
// 最终输出:1 4 3 2🧠 深度理解
宏任务 vs 微任务
宏任务(Macro Tasks)
宏任务是由宿主环境(浏览器或 Node.js)提供的异步任务。
// 常见的宏任务
setTimeout(() => {}, 0);
setInterval(() => {}, 0);
setImmediate(() => {}); // Node.js
requestAnimationFrame(() => {}); // 浏览器
I/O 操作
UI 渲染微任务(Micro Tasks)
微任务是由 JavaScript 引擎提供的异步任务,优先级高于宏任务。
// 常见的微任务
Promise.resolve().then(() => {});
queueMicrotask(() => {});
MutationObserver(() => {}); // 浏览器
process.nextTick(() => {}); // Node.js(优先级最高)执行顺序详解
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. 执行同步代码:输出 '1' 和 '6'
// 2. 清空微任务队列:
// - 执行 Promise.then:输出 '4'
// - setTimeout 进入宏任务队列
// 3. 执行第一个宏任务:
// - 输出 '2'
// - Promise.then 进入微任务队列
// 4. 清空微任务队列:输出 '3'
// 5. 执行第二个宏任务:输出 '5'
// 最终输出:1 6 4 2 3 5复杂示例
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();
}).then(() => {
console.log('promise2');
});
console.log('script end');
// 执行流程:
// 1. 同步代码:
// - 'script start'
// - 'async1 start'
// - 'async2'
// - 'promise1'
// - 'script end'
// 2. 微任务队列:
// - 'async1 end' (await 后面的代码)
// - 'promise2'
// 3. 宏任务队列:
// - 'setTimeout'
// 最终输出:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeoutasync/await 与事件循环
async function foo() {
console.log('foo start');
await bar();
console.log('foo end');
}
async function bar() {
console.log('bar');
}
foo();
console.log('script end');
// await 相当于:
function foo() {
console.log('foo start');
return Promise.resolve(bar()).then(() => {
console.log('foo end');
});
}
// 输出:
// foo start
// bar
// script end
// foo endNode.js 中的事件循环
Node.js 的事件循环分为 6 个阶段:
// Node.js 事件循环阶段
┌───────────────────────────┐
┌─>│ timers │ // setTimeout、setInterval
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │ // 系统操作的回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │ // 内部使用
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ poll │ // I/O 回调
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ check │ // setImmediate
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │ // socket.on('close')
└───────────────────────────┘
// process.nextTick 和 Promise 在每个阶段之间执行Node.js 特殊 API
// process.nextTick(优先级最高)
process.nextTick(() => {
console.log('nextTick');
});
// setImmediate(check 阶段)
setImmediate(() => {
console.log('setImmediate');
});
// setTimeout(timers 阶段)
setTimeout(() => {
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('Promise');
});
// 输出顺序:
// nextTick
// Promise
// setTimeout
// setImmediate浏览器渲染时机
console.log('1');
setTimeout(() => {
console.log('2');
// 浏览器可能在这里渲染
}, 0);
Promise.resolve().then(() => {
console.log('3');
// 不会在这里渲染,因为微任务还没清空
});
console.log('4');
// 渲染时机:
// 1. 执行同步代码
// 2. 清空所有微任务
// 3. 浏览器渲染(如果需要)
// 4. 执行下一个宏任务常见误区
误区 1:setTimeout(fn, 0) 立即执行
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
// 输出:1 3 2
// setTimeout 的回调会在下一个宏任务执行,不是立即执行误区 2:微任务会阻塞渲染
// ❌ 错误:大量微任务会阻塞渲染
Promise.resolve().then(function loop() {
// 无限循环创建微任务
Promise.resolve().then(loop);
});
// 页面会卡死,因为微任务队列永远清不完误区 3:所有异步操作都是宏任务
// Promise 是微任务,不是宏任务
Promise.resolve().then(() => {
console.log('微任务');
});
setTimeout(() => {
console.log('宏任务');
}, 0);
// 输出:微任务 宏任务实际应用场景
1. 优化性能
// ❌ 每次操作都触发渲染
for (let i = 0; i < 1000; i++) {
element.style.left = i + 'px';
// 浏览器可能每次都渲染
}
// ✅ 批量更新,只渲染一次
requestAnimationFrame(() => {
for (let i = 0; i < 1000; i++) {
element.style.left = i + 'px';
}
});2. 确保执行顺序
// 确保在 DOM 更新后执行
element.textContent = 'new text';
// 使用微任务确保在下次渲染前执行
Promise.resolve().then(() => {
console.log(element.textContent); // 'new text'
});
// 使用宏任务在渲染后执行
setTimeout(() => {
console.log('渲染完成');
}, 0);3. 避免阻塞
// ❌ 长时间运行的同步代码会阻塞
function heavyTask() {
for (let i = 0; i < 1000000000; i++) {
// 耗时操作
}
}
heavyTask(); // 阻塞主线程
// ✅ 分片执行
function heavyTaskAsync(start, end) {
for (let i = start; i < end; i++) {
// 处理一部分
}
if (end < 1000000000) {
setTimeout(() => {
heavyTaskAsync(end, end + 10000);
}, 0);
}
}
heavyTaskAsync(0, 10000);💡 面试回答技巧
🎯 一句话回答(快速版)
事件循环是 JS 处理异步的机制:先执行同步代码,然后清空所有微任务(Promise),再执行一个宏任务(setTimeout),循环往复。微任务优先于宏任务。
📣 口语化回答(推荐)
面试时可以这样回答:
"事件循环是 JavaScript 处理异步操作的核心机制。因为 JS 是单线程的,不能同时做多件事,所以需要事件循环来协调异步任务。
核心概念有三个:调用栈存放正在执行的代码,任务队列存放待执行的回调,事件循环负责把任务队列里的任务放到调用栈执行。
任务分两种:宏任务和微任务。宏任务包括 script 整体代码、setTimeout、setInterval、I/O 等;微任务包括 Promise.then、MutationObserver、queueMicrotask 等。
执行顺序是这样的:
- 先执行同步代码(这本身就是一个宏任务)
- 同步代码执行完,清空所有微任务队列
- 然后执行一个宏任务
- 再清空所有微任务
- 循环往复
关键点是微任务优先于宏任务,而且每次会清空所有微任务,但只执行一个宏任务。
所以 Promise.then 的回调一定比 setTimeout 先执行,即使 setTimeout 是 0 毫秒。"
推荐回答顺序
先说是什么:
- "事件循环是 JavaScript 处理异步操作的核心机制"
- "由于 JavaScript 是单线程,事件循环使其能够处理非阻塞操作"
再说核心组件:
- "调用栈:存储代码执行位置"
- "任务队列:存放待执行的回调函数"
- "事件循环:协调调用栈和任务队列"
然后说执行流程:
- "执行同步代码"
- "清空所有微任务"
- "执行一个宏任务"
- "重复上述过程"
最后说应用:
- "理解异步代码的执行顺序"
- "优化性能,避免阻塞"
- "正确处理 Promise 和 setTimeout"
重点强调
- ✅ 微任务优先于宏任务执行
- ✅ 每次清空所有微任务,但只执行一个宏任务
- ✅ 浏览器渲染在宏任务之间进行
- ✅ 长时间运行的同步代码会阻塞事件循环
可能的追问
Q1: 为什么微任务优先于宏任务?
A:
- 微任务是 JavaScript 引擎提供的,优先级更高
- 微任务通常用于处理 Promise 等需要尽快执行的操作
- 设计上保证了 Promise 的及时响应
Q2: setTimeout(fn, 0) 的延迟是多少?
A:
- 浏览器最小延迟是 4ms(HTML5 规范)
- Node.js 最小延迟是 1ms
- 实际延迟取决于当前任务队列的情况
Q3: 如何避免事件循环阻塞?
A:
- 避免长时间运行的同步代码
- 使用 Web Worker 处理耗时计算
- 分片执行大任务
- 使用 requestIdleCallback 在空闲时执行
Q4: Node.js 和浏览器的事件循环有什么区别?
A:
- Node.js 有 6 个阶段,浏览器只有宏任务和微任务
- Node.js 有 process.nextTick 和 setImmediate
- Node.js 的 setTimeout 和 setImmediate 执行顺序不确定
💻 代码示例
综合练习题
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5');
setTimeout(() => {
console.log('6');
}, 0);
});
setTimeout(() => {
console.log('7');
Promise.resolve().then(() => {
console.log('8');
});
}, 0);
Promise.resolve().then(() => {
console.log('9');
});
console.log('10');
// 执行流程分析:
// 1. 同步代码:1 4 10
// 2. 微任务队列:5 9
// 3. 宏任务 1:2 → 微任务:3
// 4. 宏任务 2:7 → 微任务:8
// 5. 宏任务 3:6
// 最终输出:1 4 10 5 9 2 3 7 8 6async/await 练习题
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
await async3();
console.log('async1 end 2');
}
async function async2() {
console.log('async2');
}
async function async3() {
console.log('async3');
}
console.log('script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => {
console.log('promise2');
}).then(() => {
console.log('promise3');
});
console.log('script end');
// 输出:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// async3
// promise3
// async1 end 2
// setTimeout实战:实现一个任务调度器
class Scheduler {
constructor(limit) {
this.limit = limit;
this.queue = [];
this.running = 0;
}
add(task) {
return new Promise((resolve) => {
this.queue.push({ task, resolve });
this.run();
});
}
run() {
while (this.running < this.limit && this.queue.length) {
const { task, resolve } = this.queue.shift();
this.running++;
task().then((result) => {
resolve(result);
this.running--;
this.run();
});
}
}
}
// 使用
const scheduler = new Scheduler(2);
const timeout = (time) => {
return new Promise((resolve) => {
setTimeout(resolve, time);
});
};
const addTask = (time, order) => {
scheduler.add(() => timeout(time)).then(() => {
console.log(order);
});
};
addTask(1000, '1');
addTask(500, '2');
addTask(300, '3');
addTask(400, '4');
// 输出:2 3 1 4
// 解释:最多同时执行 2 个任务