K 的一隅

JavaScript JavaScript 核心机制

Promise 不是异步任务:状态、链式调用与结果采用

从快递运单号与后续派送说起,理解 Promise 状态、executor、then 链、结果采用、错误恢复与组合方法。

11 分钟阅读 更新于 2026-07-28
本文目录

寄出包裹后,你拿到一串运单号。运单号不是包裹本身,也不会亲自开车上路;它只代表“这次寄送以后会有一个结果”。签收成功,运单对应妥投;中途破损,运单对应失败原因。

Promise 的位置很像这串运单号。网络请求、定时器或文件读取才是实际工作,Promise 只是向调用者公开一次未来完成。把两者分开,是理解 Promise 的第一步:创建 Promise 不等于创建后台线程,拿到 Promise 也不等于工作尚未开始。

运单不是货车:Promise 表示一次未来结果

一个函数可以先启动工作,再立刻把 Promise 交给调用者:

js
function loadMenu() {
  return fetch('/api/menu').then((response) => response.json())
}

const menuPromise = loadMenu()

这里真正发起网络请求的是浏览器的 Fetch 实现。menuPromise 表示请求与解析最终会成功还是失败。Promise 统一了“以后给一个结果”的接口,却不规定结果通过网络、计时器还是其他方式产生。

Promise 也不保证操作一定异步。下面的 executor 会在构造期间立即执行:

js
const tracking = new Promise((resolve) => {
  console.log('创建运单')
  resolve('已揽收')
})

console.log('拿到运单号')
tracking.then((status) => console.log('状态更新', status))

先打印“创建运单”,再打印“拿到运单号”,最后才打印“状态更新 已揽收”。同步发生的是 executor 调用;异步安排的是 .then() 注册的 reaction。

先猜输出:executor 什么时候执行

先不要运行,猜下面四行日志的顺序:

js
console.log('A')

const promise = new Promise((resolve) => {
  console.log('B')
  resolve('D')
})

promise.then(console.log)
console.log('C')

结果是 A、B、C、Dnew Promise(executor) 会立即同步调用 executor,所以 B 不需要排队。调用 resolve('D') 确定结果后,已经登记的处理器也不会插进当前同步代码中间;对应的 Promise reaction 会在之后运行。

ECMAScript 用 Job 描述这类 reaction 的执行安排,浏览器宿主把它接入微任务机制。准确的说法是“Promise 的处理器通过相关 Job 在微任务检查点获得执行机会”,而不是“Promise 本身就是一个微任务”。

这也解释了一个调试现象:如果 executor 自己做了很重的同步计算,页面仍会立刻卡住。Promise 外壳不会把 executor 自动搬到后台。

三种状态与一次确定

Promise 有三种状态:

  • pending:结果尚未确定。
  • fulfilled:成功完成,并带有一个 fulfillment value。
  • rejected:失败完成,并带有一个 rejection reason。

fulfilledrejected 合称 settled。Promise 一旦 settled,就不能再改成另一种状态:

js
const order = new Promise((resolve, reject) => {
  resolve('已出餐')
  reject(new Error('又说缺货'))
  resolve('换成炒饭')
})

order.then(
  (value) => console.log(value),
  (error) => console.error(error),
)

只有第一次有效,输出“已出餐”。这条规则让多个竞争来源可以尝试报告结果,而消费方只面对一次稳定完成。不过,“第一次调用 resolve”与“此刻已经 fulfilled”仍不能永远画等号。

调用 resolve 不一定立刻 fulfilled

如果传给 resolve 的是另一个 Promise,外层会采用它的最终结果:

js
let finishInner

const inner = new Promise((resolve) => {
  finishInner = resolve
})

const outer = new Promise((resolve) => {
  resolve(inner)
})

outer.then((value) => console.log(value))
finishInner('真正结果')

外层已经不能再被其他 resolvereject 改写,但它要等 inner 完成后,才最终 fulfilled 为“真正结果”。这可以理解为结果已经被“锁定到 inner”,而不是外层立即拿到了成功值。

带有可调用 then 属性的对象称为 thenable。Promise 解析过程也会尝试采用 thenable:

js
const ticket = {
  then(resolve) {
    resolve('第三方结果')
  },
}

Promise.resolve(ticket).then(console.log)

这种互操作能力让不同 Promise 实现可以连接,但读取 then、调用它或第三方逻辑自身都可能抛错。规范的解析过程还要处理重复回调、自解析等边界,所以手写一个“看起来像 Promise”的对象通常不是好主意。

then 注册处理器,并返回一个新 Promise

.then() 做两件容易被混成一件的事:给当前 Promise 登记处理器,并立即返回一个新的 Promise。

js
const first = Promise.resolve(2)
const second = first.then((value) => value * 3)

console.log(first === second) // false
second.then(console.log)     // 6

second 的命运由处理器执行结果决定,而不是简单复制 first。这正是链式调用能逐步转换数据的原因:

js
fetch('/api/user')
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    return response.json()
  })
  .then((user) => user.name)
  .then((name) => console.log(name))

每一个 .then() 都产生下一环。阅读长链时,可以在纸上给每一环单独画一个 Promise,标出它等待哪一个处理器,而不要把整条链想成同一个 Promise 被反复修改。

即使原 Promise 早已 settled,后来才登记的处理器仍会运行,并且不会同步插入当前调用栈:

js
const ready = Promise.resolve('完成')

setTimeout(() => {
  ready.then(console.log)
  console.log('刚登记')
}, 100)

定时器回调内先打印“刚登记”,当前任务结束后的微任务检查点才打印“完成”。Promise 会记住状态,不会因为处理器来晚了就错过结果。

返回值如何决定下一环

处理器有四种最值得区分的出口:

js
Promise.resolve(1)
  .then((value) => value + 1)                  // 普通值:下一环 fulfilled 为 2
  .then(() => { throw new Error('配菜失败') }) // throw:下一环 rejected
  .catch(() => Promise.resolve('重新配菜'))    // Promise:采用它的最终结果
  .then(() => ({ then: (resolve) => resolve('打包') })) // thenable
  .then(console.log)

处理器没有显式 return 时,等同于返回 undefined,所以下一环会 fulfilled 为 undefined。这是常见的断链来源:

js
getUser()
  .then((user) => {
    saveUser(user) // 忘了 return
  })
  .then(() => {
    // 此时不保证 saveUser 已完成
  })

如果后一步依赖保存完成,应该 return saveUser(user)。下一环采用这个 Promise,链才会等待它。Promise 链传递的是处理器返回结果,不会自动发现函数体里启动了哪些异步操作。

处理器也不能返回它自己所决定的那个 Promise,否则形成无法兑现的循环,链会以 TypeError 拒绝。实际开发很少故意这么写,但封装复杂链时应避免循环采用关系。

rejection 如何穿过链并被恢复

如果 .then() 没有提供 rejection 处理器,失败会传到下一环:

js
Promise.reject(new Error('支付失败'))
  .then((value) => value.orderId)
  .then((id) => loadOrder(id))
  .catch((error) => {
    console.error(error.message)
    return { status: '待重试' }
  })
  .then((state) => console.log(state.status))

前两个成功处理器都不会执行,错误一直到 .catch() 才被接住。.catch(onRejected) 可以看作 .then(undefined, onRejected) 的常用入口。

catch 返回普通值后,它产生的新 Promise 是 fulfilled,因此后面的 .then() 会继续运行。这叫错误恢复,不是错误“还在链里但被隐藏”。如果当前层只能记录信息、不能恢复,就应重新抛出:

js
request()
  .catch((error) => {
    console.error('请求失败', error)
    throw error
  })

还要注意 .then(success, failure)failure 处理的是前一个 Promise 的 rejection,接不住同一个 success 处理器内部新抛出的错误。那个错误属于 .then() 返回的新 Promise,应由下一环的 .catch() 处理。

同一个 Promise 上并列登记的处理器也不会自动首尾相接:

js
const source = Promise.resolve(1)

source.then((value) => value + 1).then(console.log) // 2
source.then((value) => value + 10).then(console.log) // 11

两个第一层处理器都读取 source 的值 1,分别决定各自返回的新 Promise;第二条分支不会收到第一条算出的 2。只有写成连续 .then(...).then(...),后一环才消费前一环的结果。真实业务里,一次请求可能同时驱动缓存、埋点与界面更新,这种分支很常见。分支之间若存在先后依赖,应把依赖写进同一条链或明确汇总,不能依赖处理器碰巧按登记顺序执行。

处理器执行顺序也不能被误解为并行。它们会按 Job 调度逐个取得 JavaScript 执行权;某个处理器做长计算,后续处理器和渲染仍会等待。Promise 能组织结果依赖,却不会替长任务切片。

finally 负责清理,不负责改写普通结果

.finally() 适合关闭加载状态、释放界面锁等无论成功失败都要做的清理:

js
showLoading()

loadMenu()
  .finally(() => hideLoading())
  .then(showMenu)
  .catch(showError)

finally 回调不接收成功值或失败原因。正常返回时,原来的结果会继续传递:

js
Promise.resolve('原结果')
  .finally(() => '不会替换')
  .then(console.log) // 原结果

但如果 finally 抛错,或者返回一个 rejected Promise,新错误会覆盖原结果:

js
Promise.resolve('原本成功')
  .finally(() => Promise.reject(new Error('清理失败')))
  .catch((error) => console.log(error.message))

输出“清理失败”。清理失败有时确实应该让整个流程失败,但不要把 .finally() 当作既能统一清理、又能随意改写业务值的 .then()

四种组合方法分别在等什么

多个 Promise 怎样汇总,取决于业务需要哪种“完成”:

js
const fast = Promise.resolve('缓存')
const slow = new Promise((resolve) => {
  setTimeout(() => resolve('网络'), 100)
})
const broken = Promise.reject(new Error('镜像不可用'))

Promise.all([fast, slow])             // 全部 fulfilled;一个拒绝就拒绝
Promise.allSettled([fast, broken])    // 全部 settled;逐项给出状态
Promise.any([broken, slow])           // 任意一个 fulfilled;全拒绝时 AggregateError
Promise.race([slow, broken])          // 第一个 settled,成功失败都算

Promise.all 适合“所有结果缺一不可”,并保持输入顺序输出值;它快速拒绝时,其他已经启动的操作不会自动停止。Promise.allSettled 适合批量任务需要逐项汇报。Promise.any 适合多个来源取第一个成功结果,全部失败才以 AggregateError 拒绝。Promise.race 常用于竞争结果或配合超时信号,但第一个失败也会直接让它失败。

空输入也有明确语义:Promise.all([])Promise.allSettled([]) 完成为空数组;Promise.any([]) 拒绝;Promise.race([]) 会一直 pending。是否应该让空集合进入这些组合方法,要由业务契约决定。

工作何时启动,与何时等待是两回事

Promise 不负责把工作变成串行或并行。函数何时被调用,通常才决定工作何时启动:

js
const wait = (name, ms) => new Promise((resolve) => {
  setTimeout(() => resolve(name), ms)
})

async function serial() {
  const a = await wait('A', 300)
  const b = await wait('B', 300)
  return [a, b]
}

async function together() {
  const aPromise = wait('A', 300)
  const bPromise = wait('B', 300)
  return Promise.all([aPromise, bPromise])
}

serial 等第一次完成后才调用第二次,约需两段等待;together 先调用两次,底层计时同时推进,再统一等待。实际耗时受调度影响,但两种启动结构不同。

反过来,一组已经创建的 Promise 放进普通数组,工作可能早就开始了。之后才调用 Promise.all,只是选择如何观察结果,不是这一刻才“开启并发”。同时启动网络工作也不代表主线程并行执行 JavaScript 的 CPU 计算。

Promise 不提供通用取消

调用者不再需要某个结果,不等于底层操作已经停止。Promise 标准没有一个能取消任意操作的通用 .cancel()

js
const controller = new AbortController()

const requestPromise = fetch('/api/menu', {
  signal: controller.signal,
})

controller.abort()

requestPromise.catch((error) => {
  if (error.name === 'AbortError') {
    console.log('请求已取消')
  }
})

这里是 Fetch 理解 AbortSignal 并停止自身工作,随后让相关 Promise rejected。对于计时器,要 clearTimeout;对于 Worker、流或第三方库,要使用各自定义的停止协议。单纯忽略 Promise 只能表示“不再消费结果”,不能保证释放底层资源。

超时也一样。拿一个拒绝 Promise 与请求做 Promise.race,可以让观察者先得到超时错误,但如果没有同时中止请求,网络仍可能继续。控制流结束和资源停止必须分别设计。

容易踩的坑:Promise 不是微任务、线程或事件流

第一种误解是“代码包进 Promise 就异步了”。executor 同步执行,重计算照样阻塞主线程。要并行计算,应考虑 Worker;要延后执行,应明确使用相应调度机制。

第二种误解是“Promise 就是微任务”。Promise 是有状态的对象;.then() 等处理器的 reaction 通过 Job 获得之后的执行机会。把对象、回调和调度队列混成一个词,会让输出题越背越乱。

第三种误解是“Promise 能不断推送新值”。一个 Promise 只最终完成一次。多次调用 resolve 不会产生多次通知;持续事件适合事件监听,可拉取的多项异步结果适合异步迭代器或流。

第四种误解是“加一个 catch 就处理好了”。如果 catch 只打印后返回,链已经从失败恢复;上层可能误以为操作成功。错误应该在能够补偿、转换用户状态或增加有效语境的边界处理。

下一步:一次完成不够时,怎样逐项取值

Promise 很适合一次最终结果:一份菜单、一次保存、一个用户对象。但分页数据、消息流或按需生成的序列不只完成一次。调用者如果想主动索取“下一项”,需要的不是对同一个 Promise 反复 resolve,而是一套能重复调用 next() 的协议。

下一章从可迭代对象与迭代器开始,把“一次未来结果”转向“按需取得多个结果”。