JavaScript 事件循环与异步编程深度解析:从宏任务到微任务的完整链路

前端工程

先从一个 bug 说起

去年同事写了一个轮询函数,页面跑了 30 秒后浏览器直接卡死:

function pollServer() {
  while (true) {
    const response = fetch('/api/status');  // 本意是每秒查一次
    // ...
  }
}

他一眼没看出问题——fetch 是异步的,while(true) 怎么会阻塞?这就是经典的「把异步当多线程」误区。while(true) 在主线程上死循环,事件循环根本没有机会去执行 fetch 的回调。理解事件循环,就是理解 JavaScript 的单线程并发模型。你会发现面试官最爱问的那些 Promise 执行顺序题,解起来其实有规律可循。


调用栈(Call Stack)

JavaScript 引擎只有一个调用栈。函数调用往里压,返回往外弹:

function multiply(a, b) {
  return a * b;             // 4. 执行乘法,返回结果
}

function square(n) {
  const result = multiply(n, n);  // 3. 调用 multiply,压入栈
  return result;             // 5. 返回
}

function main() {
  const value = square(5);  // 2. 调用 square,压入栈
  console.log(value);       // 6. 输出 25
}

main();  // 1. 调用 main,压入栈

用图来表示这个压栈过程:

栈底 → 栈顶

main() 入栈
main() → square(5) 入栈
main() → square(5) → multiply(5, 5) 入栈
main() → square(5) → multiply 返回 → 出栈
main() → square 返回 → 出栈
main() → console.log(25) 入栈 → 出栈
main 返回 → 出栈
栈空

调用栈爆了就是 RangeError: Maximum call stack size exceeded——通常是无限递归。在 Chrome 里调用栈上限约 10000 帧。


任务队列(Task Queue)

调用栈空了,事件循环从任务队列里取一个任务执行。任务队列里的每一项叫宏任务(MacroTask)

哪些操作会产生宏任务?

来源 示例
setTimeout / setInterval setTimeout(() => {}, 0)
I/O 操作 文件读写、网络请求回调
UI 渲染 requestAnimationFrame(渲染前)、浏览器渲染帧
用户交互 clickkeydown 事件回调
setImmediate Node.js 专用
MessageChannel port.postMessage()
console.log('1');

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

console.log('3');

// 输出:1 → 3 → 2
// 因为 setTimeout 的回调被放入宏任务队列,
// 必须等当前调用栈清空后才能执行

即使用 setTimeout(fn, 0)fn 也要等当前所有同步代码执行完。这不是延迟 0ms,而是「尽快,但不在当前这个 tick」。


微任务(MicroTask)

微任务是 ECMAScript 规范中的概念——每个宏任务执行完毕后,事件循环会清空整个微任务队列,再取下一个宏任务。

产生微任务的操作:

来源 示例
Promise.then/catch/finally Promise.resolve().then(fn)
async/await(await 之后的部分) await something
queueMicrotask queueMicrotask(fn)
MutationObserver DOM 变化回调

这是面试题的核心考点:微任务在宏任务之前执行。

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. 微任务队列:[then(()=>4)]  → 输出 4,将 setTimeout(5) 放入宏任务
// 3. 宏任务队列:[setTimeout(2), setTimeout(5)]
// 4. 取第一个宏任务:输出 2,then(()=>3) 放入微任务
// 5. 清空微任务:输出 3
// 6. 取第二个宏任务:输出 5

Promise 执行顺序终极归纳

把上面两个队列的关系串起来,得到完整的事件循环模型:

┌──────────────────────────────────────┐
│           调用栈(同步)               │
│  ┌────────────────────────────────┐  │
│  │ function A → function B → ...  │  │
│  └────────────────────────────────┘  │
│          ↓ 调用栈空了                  │
│  ┌────────────────────────────────┐  │
│  │      微任务队列(清空整个队列)      │  │
│  │  then → then → queueMicrotask  │  │
│  └────────────────────────────────┘  │
│          ↓ 微任务空了                   │
│  ┌────────────────────────────────┐  │
│  │      宏任务队列(取一个执行)        │  │
│  │  setTimeout → click → I/O → ...│  │
│  └────────────────────────────────┘  │
│          ↓ 回到调用栈                   │
│          循环往复...                    │
└──────────────────────────────────────┘

一个完整的 tick:

  1. 取一个宏任务,执行
  2. 执行过程中产生的微任务全部在当前 tick 内执行完
  3. 微任务中产生的微任务也会在同一个 tick 执行完(递归清空)
  4. 渲染(如果需要)
  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');

// 输出:script start → async1 start → async2 → promise1
//       → script end → async1 end → promise2 → setTimeout
//
// 关键理解:
// - Promise 构造器里的代码是同步的
// - async 函数内部 await 之前的代码是同步的
// - await 相当于 Promise.resolve().then(...),await 后面的代码进微任务

async/await 的底层转换

async/await 本质上是 Generator + Promise 的语法糖。

// 我们写的:
async function fetchUser() {
  const response = await fetch('/api/user');
  const user = await response.json();
  return user;
}

// 等价于:
function fetchUser() {
  return Promise.resolve().then(() => {
    return fetch('/api/user');
  }).then((response) => {
    return response.json();
  });
}

// 更精确的等价(用 Generator 模拟):
function asyncToGenerator(generatorFunc) {
  return function () {
    const gen = generatorFunc.apply(this, arguments);

    return new Promise((resolve, reject) => {
      function step(key, arg) {
        let result;
        try {
          result = gen[key](arg);
        } catch (error) {
          return reject(error);
        }

        const { value, done } = result;

        if (done) {
          return resolve(value);
        } else {
          return Promise.resolve(value).then(
            (val) => step('next', val),
            (err) => step('throw', err)
          );
        }
      }

      step('next');
    });
  };
}

这里有一个关键细节:await 后面的表达式会用 Promise.resolve() 包装。如果 await 后面不是一个 Promise,会被自动转为已解决的 Promise。所以 await 42 等价于 await Promise.resolve(42)


Node.js 中的事件循环

Node.js 的事件循环比浏览器复杂——它分了 6 个阶段,每个阶段有自己的 FIFO 队列:

   ┌───────────────────────┐
┌─>│        timers         │  setTimeout、setInterval 回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │   pending callbacks   │  延迟到下一轮的 I/O 回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │  内部使用
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │        poll           │  获取新的 I/O 事件(核心阶段)
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │        check          │  setImmediate 回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │    close callbacks    │  socket.on('close', ...)
│  └──────────┬────────────┘
└──────────────┘
      每个阶段结束后 → 清空微任务队列

Node.js vs 浏览器的关键差异

差异点 浏览器 Node.js
宏任务类型 setTimeout, I/O, UI 渲染 6 个阶段各有不同队列
setImmediate 不存在 check 阶段执行
process.nextTick 不存在 优先级高于微任务
微任务时机 每个宏任务后 每个阶段切换时 + 每个宏任务后
await 后的微任务 插在当前微任务队列 同浏览器

process.nextTick 的陷阱

console.log('1');

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

Promise.resolve().then(() => console.log('4'));

console.log('5');

// 输出:1 → 5 → 2 → 3 → 4
// 注意:nextTick 优先于 Promise 微任务!
// 而且 nextTick 回调里的 nextTick 会插入当前队列头部

process.nextTick 并不属于事件循环的任何阶段。它在当前操作完成后、事件循环继续前立即执行。递归调用会饿死事件循环:

// ❌ 危险:事件循环永远不会前进
function starve() {
  process.nextTick(starve);
}

实战调优:避免长任务阻塞

问题:一次渲染 10000 条数据

// ❌ 主线程卡死数秒
function renderAll(items) {
  items.forEach(item => {
    const div = document.createElement('div');
    div.textContent = item.name;
    container.appendChild(div);
  });
}

方案一:时间切片(Time Slicing)

function renderChunked(items, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (; index < end; index++) {
      const div = document.createElement('div');
      div.textContent = items[index].name;
      container.appendChild(div);
    }

    if (index < items.length) {
      // 交出控制权,让浏览器处理 UI
      setTimeout(processChunk, 0);
    }
  }

  processChunk();
}

方案二:requestIdleCallback

function renderIdle(items) {
  let index = 0;

  function processIdle(deadline) {
    // deadline.timeRemaining() 返回当前帧剩余时间
    while (index < items.length && deadline.timeRemaining() > 1) {
      const div = document.createElement('div');
      div.textContent = items[index].name;
      container.appendChild(div);
      index++;
    }

    if (index < items.length) {
      requestIdleCallback(processIdle);
    }
  }

  requestIdleCallback(processIdle);
}

方案三:Web Worker(真正并行)

// main.js
const worker = new Worker('/workers/data-processor.js');

worker.postMessage({ data: rawData, action: 'transform' });

worker.onmessage = (event) => {
  // 渲染处理好的数据
  renderData(event.data);
};

// workers/data-processor.js
self.onmessage = (event) => {
  const { data, action } = event.data;

  if (action === 'transform') {
    const processed = data.map(item => ({
      ...item,
      displayName: item.name.toUpperCase(),
      formattedDate: new Date(item.createdAt).toLocaleDateString(),
    }));

    self.postMessage(processed);
  }
};

三种方案的选择:

场景 推荐方案 原因
DOM 批量操作 时间切片 Worker 无法操作 DOM
非紧急后台任务 requestIdleCallback 利用空闲时间
CPU 密集型计算 Web Worker 真正独立线程
超大数据流 ReadableStream + Worker 流式处理

监控微任务堆积

// 检测长时间运行的微任务
let microtaskCount = 0;
const originalThen = Promise.prototype.then;

Promise.prototype.then = function (onFulfilled, onRejected) {
  microtaskCount++;

  if (microtaskCount > 1000) {
    console.warn('微任务可能堆积!当前计数:', microtaskCount);
    console.trace();
  }

  setTimeout(() => {
    microtaskCount = Math.max(0, microtaskCount - 1);
  }, 0);

  return originalThen.call(this, onFulfilled, onRejected);
};

更实用的方式是用 Performance Observer 监控长任务:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`检测到长任务:${entry.duration.toFixed(1)}ms`);
      // 上报到监控系统
    }
  }
});

observer.observe({ type: 'longtask', buffered: true });

常见面试题速览

Q:setTimeout(fn, 0)Promise.resolve().then(fn) 谁先执行?

A:Promise.then 先执行。因为它是微任务,setTimeout 是宏任务。每个宏任务执行完,微任务队列必须清空。

Q:requestAnimationFrame 在事件循环的哪个位置?

A:在渲染阶段之前。浏览器可能在一次事件循环中执行多个宏任务后才渲染一次。rAF 在渲染前执行,适合做动画。

Q:Node.js 中 setTimeout(fn, 0)setImmediate(fn) 谁先?

A:在主模块中不确定(依赖进程性能),但在 I/O 回调中 setImmediate 一定先于 setTimeout

Q:await 后面的代码一定在微任务里执行吗?

A:不一定。如果 await 右边的值已经是 resolved 的(比如 await 42),V8 会优化为同步执行。取决于具体实现。


相关工具

本站提供浏览器本地工具,免注册即可试用 →

#JavaScript#事件循环#异步编程#Promise#教程