深入理解 JavaScript 事件循环与异步编程


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)setTimeoutsetIntervalsetImmediate、I/O、UI 渲染、requestAnimationFrame
  • 微任务(Micro Task)Promise.thenMutationObserverqueueMicrotaskasync/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

因为微任务会”一次性清空”,所以 34 都在 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

七、总结

掌握事件循环,意味着你能:

  1. 预测代码执行顺序 —— 排 bug 时能定位异步时序问题;
  2. 写出高性能异步代码 —— 知道何时用 Promise.all、何时避免回调地狱;
  3. 深入理解框架 —— Vue 的 nextTick、React 的调度器本质都建立在此之上。

这一章是 JS 的”地基”,地基打牢了,上层框架都是浮云。建议配合我之前写的《JavaScript 作用域》《闭包》《this》一起复习,构成完整的 JS 知识体系。


Content