先看一道经典的输出顺序题,先别运行,在心里猜一下结果:
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):整体脚本、
setTimeout、setInterval、I/O 事件、UI 渲染、requestAnimationFrame等; - 微任务(Micro Task):
Promise.then / catch / finally、queueMicrotask、MutationObserver等。
事件循环每一轮(tick)的规则是:
- 从宏任务队列取一个宏任务执行;
- 执行完立刻清空整个微任务队列(期间新产生的微任务也会被追加执行);
- 必要时执行 UI 渲染;
- 回到第 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 等额外队列,与浏览器的实现并不完全一致。
在浏览器环境做前端开发时,记住"宏任务之间清空微任务"这一条规则就足以应对绝大多数问题。
总结:同步代码先跑完 → 每执行完一个宏任务就清空全部微任务 → 再取下一个宏任务。用这个模型去推任何输出顺序题,基本不会错。