先看一道经典的输出顺序题,先别运行,在心里猜一下结果:

console.log("1");

setTimeout(() => console.log("2"), 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

正确答案是 1 → 4 → 3 → 2。 "先 1 后 4"很好理解,同步代码按顺序执行;但为什么 0ms 的 setTimeout 反而排在 Promise 后面?这就涉及事件循环(Event Loop)对两种任务队列的调度规则。

两种任务:宏任务与微任务

JavaScript 是单线程语言,同一时刻只能执行一段代码,其他等待执行的工作会进入队列。浏览器把任务分成两类:

  • 宏任务(Macro Task):整体脚本、setTimeoutsetInterval、I/O 事件、UI 渲染、requestAnimationFrame 等;
  • 微任务(Micro Task)Promise.then / catch / finallyqueueMicrotaskMutationObserver 等。

事件循环每一轮(tick)的规则是:

  1. 从宏任务队列取一个宏任务执行;
  2. 执行完立刻清空整个微任务队列(期间新产生的微任务也会被追加执行);
  3. 必要时执行 UI 渲染;
  4. 回到第 1 步,取下一个宏任务。

关键就在第 2 步:微任务队列在每次宏任务结束后被"清空"而不是"只取一个"。所以上面那道题里, Promise 的回调虽然比 setTimeout 晚注册,却赶在同一次 tick 内被执行了。

async / await 的本质

async 函数返回一个 Promise,await 之后的代码等价于放进 then 回调里。换句话说,await 后面的代码是微任务

async function demo() {
  console.log("A");
  await 1;               // 到这里暂停,后面的代码进入微任务队列
  console.log("B");      // 微任务
}
demo();
console.log("C");
// 输出:A → C → B

一个容易踩的坑:Promise 内的同步执行

console.log("start");

new Promise((resolve) => {
  console.log("executor");  // Promise 构造器是同步执行的!
  resolve();
}).then(() => console.log("then"));

console.log("end");
// 输出:start → executor → end → then

Promise 的 executor 函数(构造器里那个回调)是立即同步执行的, 只有 then / catch 注册的回调才是微任务。很多新手在这里判断失误。

渲染时机:为什么说"不要用 setTimeout 卡帧"

浏览器的渲染发生在宏任务之间的间隙。微任务在渲染之前被清空, 意味着如果你在微任务里连续修改 DOM,浏览器会在同一次渲染中一次性合成,不会闪烁。 而 setTimeout 属于宏任务,可能被推迟到下一帧,这也是为什么 requestAnimationFrame 比 setTimeout 更适合做动画: 它保证在每次渲染前执行,跟得上屏幕刷新率。

Node.js 的差异

Node.js 的事件循环还有 process.nextTick(优先级高于 Promise 微任务)、 setImmediate 等额外队列,与浏览器的实现并不完全一致。 在浏览器环境做前端开发时,记住"宏任务之间清空微任务"这一条规则就足以应对绝大多数问题。

总结:同步代码先跑完 → 每执行完一个宏任务就清空全部微任务 → 再取下一个宏任务。用这个模型去推任何输出顺序题,基本不会错。