K 的一隅

JavaScript JavaScript 核心机制

Promise.then 为什么总比 setTimeout 先执行:微任务与渲染时机

用购物车改价后的显示时机,拆开 Promise reaction、微任务检查点、定时器任务与浏览器渲染。

10 分钟阅读 更新于 2026-07-24
本文目录

购物车里一瓶水原价 3 元,点击优惠券后,代码已经把价格改成了 2 元。紧接着读取 JavaScript 变量,得到的是 2;可眼睛什么时候看到页面上的“2 元”,还取决于浏览器什么时候获得渲染机会。

在同步代码、Promise 回调、定时器和绘制之间,不是简单的一条队伍。

价格已经改了,眼睛为什么还没看见

先看一段故意把页面拖住的代码:

js
price.textContent = '2 元'

const start = performance.now()
while (performance.now() - start < 1000) {}

DOM 中的文本已经被修改,但当前任务又忙了一秒。浏览器不能在这个 JavaScript 循环中间随意绘制,用户通常要等任务结束后才看到新价格。

如果任务结束时还有 Promise 回调待执行,浏览器也不是立刻绘制。它通常要先执行一次微任务检查点。这就是 .then 经常比定时器更早、也比肉眼看到的页面变化更早的原因。

先猜输出:同步、Promise、定时器

js
console.log('同步开始')

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

Promise.resolve().then(() => {
  console.log('Promise')
})

console.log('同步结束')

典型浏览器中的结果是:

text
同步开始
同步结束
Promise
timeout

当前脚本先从头运行到尾。.then 处理器不会在 Promise.resolve() 那一行同步调用,而是安排为 Promise reaction 对应的 Job;宿主把这类 Job 接入微任务处理。当前任务结束后进行微任务检查点,它先运行;定时器回调属于之后才有机会选择的任务。

下面把同一轮事件循环里的顺序画成流程(和第四章动图是同一套模型,这里强调「绘制」插在哪一步):

Promise 完成和回调执行不是同一件事

Promise 有状态,.then 注册的是状态确定后要执行的 reaction。即使 Promise 已经 fulfilled,处理器仍不能在当前同步代码中突然插队:

js
const ready = Promise.resolve('现货')

ready.then((value) => {
  console.log('取到:', value)
})

console.log('先离开柜台')

先输出“先离开柜台”。这个异步保证很重要:调用 .then 的函数不必猜处理器会同步执行还是异步执行。无论 Promise 是早已完成还是稍后完成,reaction 都通过 Job 调度。

ECMAScript 规范描述 Promise reaction Jobs,以及把 Job 交给宿主调度的抽象操作;HTML 宿主再规定浏览器的微任务队列和检查点。教学里常把两层都叫“微任务”,日常够用,但要知道 Promise 的语言语义与浏览器事件循环并非同一份规范。

queueMicrotask 则是浏览器直接提供的微任务排队 API:

js
queueMicrotask(() => console.log('手工微任务'))

它适合需要与 Promise reaction 处在相近调度层级、但不需要创建 Promise 结果的场景。

微任务检查点会一直清到队列为空

微任务检查点不是“只处理检查开始时已有的那批”。运行一个微任务时,如果它又排入新微任务,新成员也会在同一个检查点继续被处理,直到队列为空:

js
queueMicrotask(() => {
  console.log('微任务 A')

  queueMicrotask(() => {
    console.log('微任务 C')
  })
})

queueMicrotask(() => {
  console.log('微任务 B')
})

console.log('同步')

结果是 同步、A、B、C。A 运行时 C 才进入队列,所以排在已经等待的 B 后;但 C 仍会在浏览器选择下一个普通任务之前执行。

Promise 链也利用这个规则。前一个 .then 的处理器返回后,后一个 reaction 才具备运行条件并被排入队列:

js
Promise.resolve()
  .then(() => console.log('第一段'))
  .then(() => console.log('第二段'))

它们不是在一次函数调用里同步连跑,而是两个先后发生的 reaction Job。

链中返回的值决定下一段收到什么。返回普通值,后续 Promise 以该值 fulfilled;返回 Promise 或 thenable,后续会采用它最终的状态;抛出异常,后续 Promise rejected:

js
Promise.resolve(2)
  .then((value) => value * 3)
  .then((value) => {
    throw new Error(`库存只有 ${value} 件`)
  })
  .catch((error) => {
    console.log(error.message)
  })

每个处理器仍通过相应 reaction Job 运行。这里的 .catch 不是同步的 try...catch 穿过几次函数返回,而是 Promise 链根据 rejected 状态安排后续处理。

Promise 是状态容器,不是“一个微任务”

“Promise 是微任务”是一句容易背错的简称。Promise 是表示异步结果及其状态的对象;通常进入微任务队列的是 .then.catch.finally 对应的 reaction Job,而不是 Promise 对象本身。

创建 Promise 时,executor 还会立即同步执行:

js
const order = new Promise((resolve) => {
  console.log('executor')
  resolve()
})

order.then(() => console.log('reaction'))
console.log('同步末尾')

输出是 executor、同步末尾、reaction。如果只会背“Promise 走微任务”,很可能错误地把 executor 也排到后面。

resolve() 也不等于立即调用所有处理器。它会改变或锁定 Promise 的后续状态处理,并让满足条件的 reaction 按规则排队。若解析的是另一个 Promise 或 thenable,中间还涉及采用其最终状态的过程。

微任务里的异常也要区分来源。queueMicrotask 回调直接抛错,宿主会按未捕获异常报告;.then 处理器抛错,则会让 .then 返回的 Promise rejected。若没人处理这个 rejection,浏览器之后可能报告未处理的 Promise rejection。两者都在微任务阶段运行,错误传播渠道却不相同。

js
Promise.resolve()
  .then(() => {
    throw new Error('商品不存在')
  })
  .catch((error) => {
    console.log('已处理:', error.message)
  })

这也是不能为了少写几个字符,就把所有 Promise 用法机械替换成 queueMicrotask 的原因。

渲染是一种机会,不是每轮必做的作业

常见流程图写成:

text
一个任务 → 清空微任务 → 渲染 → 下一个任务

作为第一张图没有问题,但“渲染”不能读成每一轮强制发生。浏览器会判断是否到了合适的渲染时机、文档是否需要更新、页面是否可见以及刷新率等条件。两个很短的任务之间可能没有实际绘制,后台标签页的渲染也可能被明显限制。

更稳妥的理解是:任务完成并执行微任务检查点后,事件循环在适当条件下可能进行渲染更新。JavaScript 能制造机会,却不能要求屏幕在每次 DOM 赋值后立即发出一帧。

这也是为什么下面的三个值未必逐个被肉眼看见:

js
progress.textContent = '1'
queueMicrotask(() => {
  progress.textContent = '2'
})
queueMicrotask(() => {
  progress.textContent = '3'
})

微任务之间不会穿插渲染。检查点结束时,DOM 很可能已经是最终的 3

requestAnimationFrame 站在什么位置

如果代码要在下一次绘制前更新动画状态,可以使用 requestAnimationFrame

js
requestAnimationFrame((timestamp) => {
  box.style.transform = `translateX(${timestamp % 300}px)`
})

回调会在浏览器准备更新渲染时运行,通常与显示刷新节奏配合。它不是普通任务的别名,也不是 Promise 微任务的替代品。页面不可见时,回调频率还可能暂停或降低。

想让一个 DOM 中间状态确实有机会显示,常见做法是先设置状态,再把下一阶段工作放到后续渲染帧。不过一次 requestAnimationFrame 只说明回调位于某次渲染更新前,并不承诺前一行已经被用户看见一整帧。若业务对“两次绘制之间”有严格要求,需要结合两帧安排并实际测量。

用于动画时,优先依据回调提供的时间戳计算进度,而不是假定每 16.67 毫秒必定调用一次。高刷新率屏幕、后台节流和繁忙主线程都会改变间隔。

有时还会看到 requestAnimationFrame 回调里读取布局、再写入样式。这样做是为了让工作靠近渲染更新,并把读取和写入有意识地分组;它不保证任何 DOM 操作都变快。若代码交替读写会触发布局信息的属性,浏览器可能被迫提前计算布局,调度位置和具体 DOM 访问模式都要检查。

微任务太勤快,也会把页面饿住

因为检查点会持续处理新加入的微任务,下面的代码可以长期不给普通任务和渲染留下机会:

js
function again() {
  queueMicrotask(again)
}

again()

每个微任务结束前又安排下一个,队列始终无法真正清空。定时器、点击处理器和绘制可能迟迟得不到机会。这叫微任务饥饿,比“单个函数很长”更隐蔽,因为每个回调看起来都很短。

因此,不要把大批 CPU 工作拆成无穷微任务来“避免阻塞”。微任务适合在当前同步操作之后、其他任务之前完成少量一致性工作;需要把执行机会还给浏览器时,应安排后续任务、使用合适的调度 API,或把重计算移到 Worker。

框架常借助微任务批量合并状态更新,这能避免同一轮同步修改触发多次重复工作。但框架也必须控制批量过程,不能无限制造新的更新。

因此“想尽快执行就用微任务”不是好原则。微任务的优先位置意味着它有责任尽快结束。日志上报、复杂数据转换、海量列表处理若都塞进微任务,虽然比定时器早,却可能让用户更晚看到页面。调度选择应从“谁必须先看到一致状态、谁可以等待下一次机会”出发。

容易踩的坑:背口诀不等于理解调度

“同步 → 微任务 → 宏任务”只能解最小例子。它漏掉了当前代码属于哪个任务、微任务执行时还能继续排队、不同任务源、渲染机会以及环境差异。

另一个误解是“.then 总比 setTimeout 先”。如果两者不是在同一个同步阶段登记,或者定时器任务已经开始执行,不能靠这句话跨时间倒推。更准确的结论是:在前面的示例中,当前任务结束后的微任务检查点先处理已排队的 Promise reaction,之后事件循环才选择定时器任务。

还要避免把浏览器结论原样搬到所有 JavaScript 宿主。Node.js 如何安排自己的队列与阶段,需要按 Node 的文档单独分析。

最后,微任务先于定时器也不等于微任务拥有更高的操作系统线程优先级。这里讨论的是同一个事件循环中的处理顺序,不是 CPU 调度优先级。把两套概念混在一起,会误以为 Promise 能抢占正在执行的同步函数。

实际排查顺序问题时,可以给日志附上任务来源和阶段,而不是只打一个数字。例如记录“点击同步段”“点击内的 microtask”“timer task”,再在 Performance 面板中对照。日志的打印顺序只能告诉你结果,来源标签才能帮助解释为什么形成这个结果。

如果问题只在某个框架中出现,还要确认框架自己的批处理队列何时接入微任务。语言和浏览器规则决定外层顺序,框架调度器仍可能在一个微任务里合并、排序或跳过内部更新,不能把框架的 nextTick 与原生 queueMicrotask 直接画等号。

先把原生最小示例跑通,再逐层加回框架调度,通常比在完整页面里猜某个日志为何提前更有效。

验证题:在微任务里再加入一个微任务

不运行代码,先写下顺序:

js
console.log('A')

setTimeout(() => console.log('B'), 0)

Promise.resolve().then(() => {
  console.log('C')
  queueMicrotask(() => console.log('D'))
})

queueMicrotask(() => console.log('E'))

console.log('F')

答案是 A、F、C、E、D、B。C 排队早于 E;C 执行时加入 D,D 在 E 后面,但仍属于同一次微任务检查点。确认答案后再自己跑一遍,并在 D 中继续加入一个微任务,观察规则是否改变。

下一步:如果不是等回调,而是主动要下一个值

事件循环解决“什么时候轮到一段代码”。另一类问题是“数据应该怎样逐个提供”。

数组可以被 for...of 遍历,自定义对象为什么默认不行?循环每一步到底向谁索取下一个值?下一章从一台不会提前打印整卷号码的取号机开始,拆开可迭代对象和迭代器这两个经常被混叫的角色。