- 一、为什么 JS 是单线程
- 二、调用栈、任务队列与事件循环
- 三、宏任务与微任务
- 四、Promise 状态机
- 五、async / await:Promise 的语法糖
- 六、面试中的完整执行顺序模型
- 七、总结
JavaScript 是单线程语言,却能处理高并发,靠的就是事件循环(Event Loop)。几乎所有前端面试都会考它,但能讲透的人并不多。本文从底层机制讲起,帮你彻底吃透。
一、为什么 JS 是单线程
JS 最初设计用于浏览器 DOM 操作。如果 JS 是多线程,两个线程同时修改同一个 DOM 节点,浏览器就无法确定以谁为准。因此 JS 被设计为单线程——所有任务在同一个线程上排队执行。
单线程的问题很明显:遇到耗时操作(网络请求、定时器、I/O)会阻塞。解决方案就是异步 + 事件循环。
二、调用栈、任务队列与事件循环
- 调用栈(Call Stack):记录当前正在执行的函数调用,后进先出。
- 任务队列(Task Queue):存放待执行的回调任务。
- 事件循环:不断”检查调用栈是否为空 → 为空则取出队首任务执行”的循环。
console.log('start') // 1. 同步,立即执行
setTimeout(() => { // 2. 注册一个宏任务
console.log('setTimeout')
}, 0)
Promise.resolve().then(() => { // 3. 注册一个微任务
console.log('promise')
})
console.log('end') // 4. 同步
执行顺序是 start → end → promise → setTimeout。为什么微任务先于宏任务?这就要引入宏任务 vs 微任务的概念了。
三、宏任务与微任务
- 宏任务(Macro Task):
setTimeout、setInterval、setImmediate、I/O、UI 渲染、requestAnimationFrame。 - 微任务(Micro Task):
Promise.then、MutationObserver、queueMicrotask、async/await的后续。
关键规则:一次事件循环只执行一个宏任务,但会把当前所有微任务清空,然后才进入下一个宏任务。
console.log('1')
setTimeout(() => console.log('2'), 0) // 宏任务
Promise.resolve().then(() => {
console.log('3')
// 微任务中再注册微任务
Promise.resolve().then(() => console.log('4'))
})
console.log('5')
// 输出:1 5 3 4 2
因为微任务会”一次性清空”,所以 3、4 都在 2 之前输出。
四、Promise 状态机
Promise 有且仅有三种状态,且一旦改变不可逆:
pending(进行中)fulfilled(已成功)rejected(已失败)
const p = new Promise((resolve, reject) => {
// 同步执行
setTimeout(() => resolve('ok'), 1000)
})
p.then(
(v) => console.log('成功', v),
(e) => console.log('失败', e)
)
经典面试题:手写 Promise.all
function myAll(promises) {
return new Promise((resolve, reject) => {
const results = []
let count = 0
if (promises.length === 0) return resolve([])
promises.forEach((p, i) => {
Promise.resolve(p).then(
(value) => {
results[i] = value // 保持顺序
if (++count === promises.length) resolve(results)
},
(err) => reject(err) // 任一失败即整体失败
)
})
})
}
五、async / await:Promise 的语法糖
async 函数返回一个 Promise,await 会暂停当前函数,等 Promise 落定后再继续:
async function getData() {
const user = await fetch('/api/user') // 等待
const list = await fetch('/api/list') // 串行
return { user, list }
}
// 注意:两个独立的请求可以并行,用 Promise.all 更高效
async function getDataFast() {
const [user, list] = await Promise.all([
fetch('/api/user'),
fetch('/api/list'),
])
return { user, list }
}
性能提醒:多个相互独立的异步请求,务必用
Promise.all并行,不要用多个await串行等待,否则白白增加耗时。
高频综合题
async function test() {
console.log('a') // 同步
await Promise.resolve() // 后面的代码进入微任务
console.log('b')
}
test()
console.log('c')
// 输出:a c b
await 相当于 Promise.resolve().then(...),所以 b 在微任务阶段执行,晚于同步的 c。
六、面试中的完整执行顺序模型
一张图记住(浏览器):
同步代码 → 执行完 → 清空所有微任务 → 渲染 → 执行下一个宏任务 → 清空微任务 → ...
完整推导示例:
console.log('script start')
setTimeout(() => console.log('timer1'), 0) // 宏任务1
setTimeout(() => console.log('timer2'), 0) // 宏任务2
Promise.resolve()
.then(() => {
console.log('micro1')
Promise.resolve().then(() => console.log('micro2'))
})
console.log('script end')
// 输出顺序:
// script start → script end → micro1 → micro2 → timer1 → timer2
七、总结
掌握事件循环,意味着你能:
- 预测代码执行顺序 —— 排 bug 时能定位异步时序问题;
- 写出高性能异步代码 —— 知道何时用
Promise.all、何时避免回调地狱; - 深入理解框架 —— Vue 的 nextTick、React 的调度器本质都建立在此之上。
这一章是 JS 的”地基”,地基打牢了,上层框架都是浮云。建议配合我之前写的《JavaScript 作用域》《闭包》《this》一起复习,构成完整的 JS 知识体系。
