本节摘要:Promise 是一台三态状态机——pending 只能单向迁移到 fulfilled 或 rejected,迁移不可逆;then、catch、finally 的回调无论注册早晚,都以微任务形式按注册顺序执行;链上的错误会穿透直到最近的 rejection 处理器。本节拆状态迁移与排队规则、then 的两次注册问题、错误穿透的边界,并手写 all 与 race 的核心逻辑。
先看一段暴露排队规则的代码:
const p = Promise.resolve('值'); p.then(v => console.log('第一个注册', v)); console.log('同步代码'); p.then(v => console.log('第二个注册', v)); // 实测输出: // 同步代码 // 第一个注册 值 // 第二个注册 值
两个要点。第一,then 回调是微任务:即便 Promise 已经落定(值就绪),回调也不当场执行,排队等栈清空——「同步代码」夹在两次注册之间打印在最前。第二,回调按注册顺序执行:第一个 then 先入队先执行。再看已落定与未落定时注册的差异:
const late = new Promise(resolve => { setTimeout(() => { resolve('结果'); late.then(v => console.log('落定后注册,仍排队', v)); Promise.resolve().then(() => console.log('同级微任务')); }, 0); }); // 实测:结果出现后 → 落定后注册,仍排队 → 同级微任务 // 落定后再注册的 then 也走微任务,且排在它注册时刻之后的同级微任务之前
Promise 只有三个状态:pending(未落定)、fulfilled(已兑现)、rejected(已拒绝)。迁移规则是本节的物理定律:只能从 pending 迁出一次,方向二选一,之后永久冻结。
let resolveFn, rejectFn; const p = new Promise((res, rej) => { resolveFn = res; rejectFn = rej; }); resolveFn('first'); rejectFn('second'); // 无效:已经迁移,静默忽略 resolveFn('third'); // 同样无效 p.then(v => console.log('拿到', v), e => console.log('拒绝', e)); // 实测:拿到 first(second、third 石沉大海)
resolve 与 reject 是「一次性闸门」。工程含义:同一个 Promise 对应的结果只有一个,不存在「先成功后失败」的中间态叠加——这与回调风格(成功回调和失败回调都可能被调、甚至调多次)是本质的安全升级,回调时代的「双调用事故」在 Promise 里结构性地消除了。
then 返回的是新的 Promise,链因此得以延续。链上每一环的落定值由回调的返回值决定:返回普通值,下一环 fulfilled;返回 Promise,下一环「采用」它的终态;回调抛异常,下一环 rejected。这三条规则合起来就是「链式异步」的全部语法:
Promise.resolve(1) .then(v => v + 1) // 返回 2 → 下一环 fulfilled 2 .then(v => Promise.resolve(v * 10)) // 返回 Promise → 采用,fulfilled 20 .then(v => { throw new Error('炸了'); }) // 抛异常 → 下一环 rejected .catch(e => console.log('接住', e.message)) // 接住 炸了 → 返回 undefined → fulfilled .then(() => console.log('catch 之后链恢复')); // 照常执行
注意最后一环:catch 处理完错误后链条恢复正常继续向下传 fulfilled。想让错误继续传播就在 catch 里重新抛出。
rejection 沿链向下寻找最近的 rejection 处理器(then 的第二个参数或 catch),途中所有只有成功分支的 then 一律跳过——这就是「错误穿透」:
Promise.reject(new Error('源头错误')) .then(v => console.log('不会执行')) .then(v => console.log('也不会执行')) .catch(e => console.log('穿透三层,在这里接住:', e.message)); // 实测:穿透三层,在这里接住:源头错误
这个特性决定了 catch 的最佳位置:放在链尾统一处理,而不是每环都包一层。对比回调时代每层都要写的 if (err) return cb(err),链式穿透把错误处理从「每层手续」变成「终点一站」。
三个精细边界要划清。边界一:catch 能接住链上的同步异常与异步 rejection,但接不住「回调里的异步事故」——在 then 回调里自己发起的、没有链入的 Promise 出了错,外层 catch 无能为力(这正是第 7 章 unhandledrejection 存在的原因)。边界二:then 的两个参数与 catch 不完全等价——成功分支抛错时,then(null, h) 里的 h 能接住当前环的错误,但接不住自己这环成功回调的错误(因为 h 注册在同一环);catch 等价于 then 之后的独立一环,语义更纯粹,推荐统一用 catch。边界三:finally 不接收值也不改变值,只做清理,落定状态原样传给下一环。

理解了状态机与排队,两个组合器可以当场手写(面试高频,更是检验理解的好题):
function myAll(promises) { return new Promise((resolve, reject) => { const results = []; let done = 0; let list = [...promises]; if (list.length === 0) return resolve([]); list.forEach((p, i) => { Promise.resolve(p).then(v => { results[i] = v; // 按原位落子,与完成顺序无关 if (++done === list.length) resolve(results); }, reject); // 任一失败,整体立刻 rejected }); }); } function myRace(promises) { return new Promise((resolve, reject) => { for (const p of promises) { Promise.resolve(p).then(resolve, reject); // 第一个落定者说了算,其余忽略 } }); }
myAll 的两个细节值得咀嚼:results[i] = v 保证输出顺序等于输入顺序,即使后启动的先完成;Promise.resolve(p) 是把非 Promise 值也纳入统一协议(规范要求 all 接受任意可迭代与裸值)。myRace 则展示「一次性闸门」的妙用——先到的 resolve 或 reject 独占结果,后来的调用全部静默失效,不需要任何取消逻辑。
四个组合器的选型一句话:all 要么全有要么全无(并行拉取拼页面数据);race 做超时竞速(与一个 setTimeout 的 rejection 赛跑);allSettled 要完整战报(批量任务逐个汇报);any 有一个成功就认(多源备份取最快可用)。第 7 章会把 race 超时模式展开成完整的错误处理案例。
⚠️ 常见坑:忘记 return 导致链断裂——
then(() => fetch(x))与then(() => { fetch(x); })的区别是后者返回 undefined,下一环立刻 fulfilled,异步等待凭空消失。链上丢 return 是新手 Promise 链「顺序错乱」的头号原因。
把状态机思维用到工程组合上,两个惯用件当场拼出来。
withTimeout:给任意 Promise 加截止时间。 竞速语义的直译:
function withTimeout(promise, ms, message = '操作超时') { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } async function slowWork() { await new Promise(r => setTimeout(r, 500)); return '慢工出细活'; } withTimeout(slowWork(), 100) .then(v => console.log(v)) .catch(e => console.log('接到:', e.message)); // 接到: 操作超时
两个细节:finally 里清定时器——正常完成时别让定时器空响(reject 一个已无人理会的 Promise 就是 unhandledrejection 的来源之一);race 的语义是「赛出结果」而不是「取消输家」,slowWork 仍在后台跑完,真正的取消要靠 AbortController(第 5 章与第 7 章的配合)。
withRetry:失败重试加指数退避。 递归式 then 的标准写法:
async function withRetry(task, { times = 3, base = 200 } = {}) { for (let attempt = 1; ; attempt++) { try { return await task(); } catch (e) { if (attempt > times || !e.retryable) throw e; await new Promise(r => setTimeout(r, base * 2 ** (attempt - 1))); console.log(`第${attempt}次重试前等待中`); } } } let flakyCalls = 0; const flaky = () => new Promise((res, rej) => { flakyCalls++; flakyCalls < 3 ? rej(Object.assign(new Error('抖一下'), { retryable: true })) : res('第三次成功'); }); withRetry(flaky).then(v => console.log(v, '共调用', flakyCalls)); // 第三次成功 共调用 3
重试的前提是「失败可判定」(自定义错误的 retryable 标记,第 7 章的类型设计在此复用)与「副作用可承受」——对写操作盲目重试可能造成重复下单,这类接口要配幂等键才敢重试。两个组合件合计三十行,覆盖了业务里八成的「超时与重试」需求,而且是能读懂、能改动的三十行。
then 的两个参数和 catch 同时写,谁先接住?
then(onOk, onBad) 的 onBad 只接「上一环」的错误,接不住 onOk 自己抛的;catch 是独立一环,上一环与再上一环的成功分支错误都能接。所以「每环都写双参」不如「链尾一个 catch」,语义更干净、代码更短。
Promise.all 失败时,能拿到已成功的那部分结果吗?
不能,all 的语义是「一荣俱荣一损俱损」——失败即整体 rejected,已完成的结果被丢弃。要完整战报用 allSettled:返回每项的 status 与 value 或 reason,自己分流处理。批量任务的「尽力而为」场景(比如并行拉十个配件,缺一两个降级)是 allSettled 的主场。
finally 里能拿到结果或改结果吗?
拿不到也改不了:finally 的回调不接收参数,返回值(除非是会 reject 的 Promise)也不影响链的落定值。这个「透传」设计正是它的用途——清理逻辑不该知道也不该改动业务结果。想在完成后「既处理值又做清理」,用 then 链接 catch 兜底,finally 只放真正的清理。
用三段代码做「落定值追踪」的专项训练——每题只问一件事:最后一个 then 收到什么?
then 回调的执行顺序在同环内一定按注册序吗?
一定。同一 Promise 上先注册的 then 先排队、先执行;不同环之间则严格串行——第二环的注册发生在第一环执行之后,天然晚于第一环。因此「输出顺序题」里 then 的判定只有两问:这是同一环还是下一环?注册发生在落定前还是落定后?两问答完顺序即定,没有任何例外条款。
思考题一:返回值穿透。
Promise.resolve('起点') .then(() => { /* 没有 return */ }) .then((v) => console.log('甲:', v)); // 甲: undefined
无 return 的回调返回 undefined,下一环收到的就是 undefined——「链断了」不是链停了,是值变成了 undefined 继续传。这是第 4.3 节「丢 return」事故的微缩版。
思考题二:嵌套 Promise 的自动解包。
Promise.resolve('外层') .then(() => Promise.resolve(Promise.resolve('最内层'))) .then((v) => console.log('乙:', v)); // 乙: 最内层
then 的回调若返回 Promise,下一环「采用」其终态;解包是递归的,嵌套几层都会拆到底。所以「多包一层 Promise」不会造成值访问的额外层级——但会把落定时机推迟至少一个微任务位次。
思考题三:catch 之后链的走向。
Promise.reject(new Error('失误')) .catch(() => '补救结果') .then((v) => { throw new Error('二次失误'); }) .catch((e) => console.log('丙:', e.message)) // 丙: 二次失误 .then(() => console.log('丙后:', '链还活着')); // 丙后: 链还活着
错误被 catch 接住后链恢复 fulfilled;恢复之后再次抛错,会被下一个 catch 接住。链不是「一次错误定终身」,而是「每段各自处理各自的意外」。三题合起来,把「下一环收到什么」的判定训练成条件反射:看上一环的返回值——普通值直传、Promise 解包、异常变 rejection,就这三条。
Promise 能取消吗?
语言层面不能——状态机只有「落定」没有「作废」,这是刻意的极简设计。工程上的「取消」有三层实现:竞速遮蔽(race 一个 rejection,旧 Promise 照跑但结果被忽略,本节 withTimeout 的原生语义); AbortController(fetch 等支持信号的平台接口,真正中断工作);组合层约定(用第七章的自定义错误标记「已取消」,调用方静默处理)。需要丰富取消语义的场景(多次订阅、退订、缓冲)已经超出 Promise 的表达力,那是事件或响应式库的领地。
怎么让一组异步任务严格串行?
经典需求「按顺序执行、前一个结果给后一个」有两种写法:for...of 循环里 await(第 4.3 节的正解,直观且好调试);以及函数式的 reduce 版本——ids.reduce(async (chain, id) => chain.then(() => handler(id)), Promise.resolve())。reduce 版把链当累加器,一行完成但可读性陡降,且初值与类型推断容易写错。团队里立规矩:串行优先用循环,reduce 版只出现在「函数式管道的一致性」确有价值的地方。
then 回调里抛的错,能被同一个 then 的第二个参数接住吗?
不能——同一环的两个参数是并列的岔路:成功走第一个、失败走第二个,成功分支抛的错误属于「下一环的输入」,只能被后面的 catch 接。想让「本环错误的处理逻辑」也覆盖「本环成功逻辑的错误」,就必须把它放进独立的下一环(catch),或者干脆用 async 加 try-catch 的同步形态。这个细节是 then 双参与 catch 语义差异的完整版,也是「链尾统一 catch」这条规约的最后一根支柱。
状态机加排队,就是 Promise 的全部机制:三态单向一次迁移、then 回调微任务排队、错误穿透到最近处理器、返回值决定下一环——四条铁律覆盖了从面试手写题到工程组合件的全部题型,组合器与思考题则把它们焊进手感。下一节把这套机制穿上同步语法的外衣——看 async 与 await 如何用一颗糖把 then 链折叠成人眼顺滑的形状,以及这颗糖在循环与并发场景里的正确吃法。