面试里问 Promise,最常见的翻车点不是 new Promise 怎么写,而是被问到「上一个 then 里 return 了个东西 / throw 了个错,下一个 then 到底走 onFulfilled 还是 onRejected」时开始含糊。这个问题之所以容易答错,是因为它同时牵扯三件事:then 的返回值如何被 Promise.resolve 包装、返回一个 thenable 时链会不会「暂停」、以及 throw 在哪个位置才能变成 rejection。本文用可运行的代码把这些路径一条条跑出来,给出状态迁移表和几个真实会遇到的坑。
先明确一条总规则:then 永远返回一个全新的 Promise
不管回调里写了什么,p.then(onFulfilled, onRejected) 的返回值都是一个新的 Promise,记作 p2。p2 的状态不由 p 决定,而由回调的执行结果决定。这是整条链的核心。
const p = Promise.resolve(1);
const p2 = p.then(v => v + 1);
console.log(p === p2); // false,永远是 false
p2.then(v => console.log('p2:', v)); // p2: 2
回调的执行结果归纳为四类,对应 p2 的四种归宿:
| 回调内的动作 | p2 的状态 | p2 的终值 / 拒因 | 下游走哪个回调 |
|---|---|---|---|
return x(x 非 thenable) |
fulfilled | x | onFulfilled |
| 无 return(返回 undefined) | fulfilled | undefined | onFulfilled |
return somePromise 或 thenable |
由被返回者决定 | 跟随被返回者 | 跟随被返回者 |
throw e / 回调内同步抛错 |
rejected | e | onRejected |
这张表就是面试答案的骨架。下面逐行展开,重点是第三行和第四行——这两行是绝大多数线上 bug 的来源。
return 普通值与 return thenable:语义完全不同
返回非 thenable 值时是「立即定型」
Promise.resolve('a')
.then(v => {
console.log('step1', v);
return 'b';
})
.then(v => {
console.log('step2', v); // step2 b
return { ok: true, data: v };
})
.then(v => console.log('step3', v)); // step3 { ok: true, data: 'b' }
注意 return { ok: true } 不会被当成 Promise,对象不是 thenable(没有 then 方法),所以直接成为终值。这一点常被误解成「返回对象会被包装」,不会。
返回 thenable 时链会「解开」并等待
规范里这一步叫 PromiseResolveThenableJob:如果 onFulfilled 返回的是对象且其 then 是函数,那么 p2 会调用这个 then,并把 p2 的 resolve/reject 传进去,于是 p2 的状态被「交给」被返回的对象来决定。表现为:后面所有的 then 都会等待这个 thenable 落定。
Promise.resolve()
.then(() => {
return new Promise(resolve => {
setTimeout(() => {
console.log('内层 resolve');
resolve('inner-value');
}, 500);
});
})
.then(v => console.log('下游收到:', v));
// 输出顺序:
// (约 500ms 后)内层 resolve
// 下游收到: inner-value
这种写法是串行异步的标准姿势,例如先取 token 再发请求:
function getToken() {
return fetch('/api/token').then(r => r.json()).then(d => d.token);
}
function getProfile() {
return getToken()
.then(token => fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` }
}))
.then(r => r.json());
}
getProfile().then(p => console.log(p.name));
第一个 then 返回的是 fetch 产生的 Promise(thenable),第二个 then 会自动等到 HTTP 响应头到达后再执行回调,不需要手写 await 也不需要嵌套。
误返回 thenable 的经典事故:数据里恰好有 then 字段
如果接口返回的 JSON 里存在名为 then 的字段,并且值恰好是函数(少见但存在),或者某些 SDK 的对象带有 then 方法,把它 return 出去会导致链的行为完全不可控。更常见的是返回了一个 Promise 却忘了链式接下去:
// 错误:内层 Promise 没有被串联,外层先落定
Promise.resolve()
.then(() => {
new Promise(resolve => setTimeout(resolve, 300, 'late'));
return 'done'; // 直接返回字符串
})
.then(v => console.log('下游收到:', v));
// 下游收到: done —— 'late' 永远不会出现在链上
这正是 ESLint 规则 no-async-promise-executor 之外的另一个常被忽略的点:promise/catch-or-return 只能查漏 catch,查不到「漏 return」。真正的 bug 现象是「下游拿不到数据、日志打印 undefined」,排查时先确认每个异步调用是否被 return。
throw 与同步异常:turn rejection,且直穿到最近的 onRejected
在 then 回调里 throw、调用未定义变量、读 null 的属性,这些同步异常都会被 Promise 捕获并转成 rejection,而不是像在 setTimeout 回调里那样直接抛给全局。
Promise.resolve(1)
.then(v => {
JSON.parse('{invalid}'); // 同步抛 SyntaxError
})
.then(
v => console.log('会不会执行?', v),
err => console.log('被捕获:', err.constructor.name) // 被捕获: SyntaxError
);
关键点在于:onRejected 里的返回值同样会沿链继续「续命」。也就是说,错误一旦被 onRejected 处理(回调正常返回,没有再次 throw),下游就走 onFulfilled,链恢复成 fulfilled 状态。
Promise.reject(new Error('boom'))
.catch(err => {
console.log('处理了:', err.message);
return 'recovered';
})
.then(v => console.log('下游是 fulfilled:', v)); // 下游是 fulfilled: recovered
反过来,如果 onRejected 里又抛了错,或者 return 了一个 rejected 的 Promise,错误就会继续往下传:
Promise.reject(new Error('e1'))
.catch(err => {
throw new Error('e2'); // 吞掉 e1,抛出 e2
})
.catch(err => console.log(err.message)); // e2
面试常考的一个变体是问:.catch(...) 之后如果不再 catch,抛出的 e2 会怎样?答案是变成未处理的 rejection,在 Node.js 下触发 unhandledRejection,在浏览器里打 Uncaught (in promise) Error: e2。这条日志在控制台里非常常见,排查不到源头就是因为中间某个 catch 里又抛了。
异步回调里的异常不会被当前 then 捕获
Promise.resolve()
.then(() => {
setTimeout(() => {
throw new Error('异步里抛错');
}, 0);
return 'ok';
})
.catch(err => console.log('能捕获吗?', err.message));
// 输出:(Node) Uncaught Error: 异步里抛错 —— 并可能导致进程退出
// 捕获不到,因为定时器回调运行时外层 Promise 早已落定
这是面试的深水区:Promise 只能捕获回调函数体同步执行期间的异常。setTimeout、事件监听、fs 回调里抛错,都不会变成 Promise rejection。解决办法是在那层回调里自己包 try/catch,或改用 fs.promises / util.promisify 之类返回 Promise 的 API。
链上状态的传递规则:错误会跳过 onFulfilled
before 讲行为,先给出「跳转规则」:一个已 reject 的 Promise,其 .then(onFulfilled) 里的 onFulfilled 不会被调用,且不会阻止拒绝继续向后传播——中间那些没有写 onRejected 的 then 会被直接跳过。
Promise.reject(new Error('x'))
.then(v => console.log('A', v)) // 跳过
.then(v => console.log('B', v)) // 跳过
.then(v => console.log('C', v)) // 跳过
.catch(err => console.log('D 捕获到', err.message)); // D 捕获到 x
同理,onRejected 也不能处理「成功值」,它只管失败路径。这带来一个实践结论:链尾必须有 catch,因为中间任何一个 then 的 onRejected 都可能再次抛错;同时也不要在链尾之前随意加 catch,否则会掩盖上游错误并让后续代码误以为一切正常。
一个真实的线上报错:TypeError: Cannot read properties of undefined
function loadUser(id) {
return fetch(`/api/users/${id}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.catch(err => {
console.error('loadUser 失败', err);
// 这里没有 return,也没有 throw
});
}
loadUser(999)
.then(user => console.log(user.name)); // TypeError: Cannot read properties of undefined
报错发生在 user.name,根因却在 loadUser 内部的 catch:它吞掉了错误并隐式返回 undefined,于是下游进入 fulfilled 分支而此时 user 是 undefined。修复方式是显式把错误抛回去,或者让调用方明确拿到一个可判别的结果:
// 方案一:继续抛错,把决定权交给调用方
.catch(err => {
console.error('loadUser 失败', err);
throw err;
});
// 方案二:返回带标记的结果对象
.catch(err => ({ ok: false, error: err }));
特殊路径:resolve 一个 Promise、返回自身、以及 then 的非函数参数
resolve 一个 thenable 会被展平
const inner = Promise.resolve('inner');
new Promise(resolve => {
resolve(inner);
}).then(v => console.log(v)); // inner
resolve(inner) 不会让外层以 inner 这个 Promise 为终值,而是等待 inner 落定后采用它的值或原因。这解释了为什么 Promise.resolve(p) === p 在 p 是原生 Promise 时为 true(规范允许直接返回),但 new Promise(r => r(p)) 一定返回一个新实例。
返回自身会死锁并报 TypeError
let p;
p = new Promise(resolve => {
setTimeout(() => resolve(1), 0);
}).then(() => p);
// 浏览器控制台:TypeError: Chaining cycle detected for promise #
// Node.js:TypeError [ERR_PROMISE_CHAIN_CYCLE]?
// 实际 Node 输出为:TypeError: Chaining cycle detected for promise
规范里 ResolvePromise 检查 resolution === promise 时会以 TypeError 拒绝。这类代码不常见,但有人写递归重试逻辑时容易踩到。
then 的参数不是函数时被静默忽略
Promise.resolve(1)
.then('not a function')
.then(null)
.then(undefined)
.then(v => console.log(v)); // 1,透传
Promise.reject(new Error('m'))
.then(undefined, 'nope')
.catch(err => console.log(err.message)); // m,依旧透传
非函数参数会被替换成内建的透传函数(identity / thrower),所以状态和值原样往后走。这解释了为什么 .catch(console.error) 是有效写法(console.error 是函数),而把字符串塞进 then 不会报错但也没效果。
面试速查:把状态迁移写成代码验证
const log = (...a) => console.log(...a);
// 场景 1:回调里 return 普通值
Promise.resolve(1)
.then(v => { log('1 进入', v); return v * 2; })
.then(v => log('1 下游', v), e => log('1 onRejected', e.message));
// 场景 2:回调里 throw
Promise.resolve(1)
.then(() => { throw new Error('E2'); })
.then(v => log('2 下游', v), e => log('2 onRejected', e.message));
// 场景 3:返回 rejected Promise
Promise.resolve(1)
.then(() => Promise.reject(new Error('E3')))
.catch(e => log('3 onRejected', e.message));
// 场景 4:onRejected 里正常 return
Promise.reject(new Error('E4'))
.then(null, e => { log('4 处理', e.message); return 'fixed'; })
.then(v => log('4 下游', v));
/*
输出(顺序稳定):
1 进入 1
2 onRejected E2
3 onRejected E3
4 处理 E4
1 下游 2
4 下游 fixed
*/
注意输出顺序:全部回调都进微任务队列,所以「进入」先于「下游」。面试若追问时序,可以用 await Promise.resolve() 或 queueMicrotask 辅助验证。
用 async/await 对齐理解
async 函数其实就是 Promise 链的语法糖,两者的状态规则一致:return x 等价于 resolve(x),throw e 等价于 reject(e),而返回一个 thenable 同样会被 await 展平。
async function f() {
return Promise.resolve('v'); // 等价于回调 return 一个 Promise
}
f().then(v => console.log(v)); // v
async function g() {
throw new Error('as'); // 等价于 reject
}
g().catch(e => console.log(e.message)); // as
区别在于:await 在 async 函数内会把 rejection 转成同步 throw,所以 try/catch 能接住;而裸 Promise 链只能靠 catch。写业务代码时统一用 async/await、只在需要并发编排时用 Promise.all/S.allSettled,能规避本文大部分坑。
小结
记住三句话就够应付大部分面试追问:then 返回的是新 Promise;回调正常 return 就让新 Promise 成功,throw 就让它失败;return 一个 Promise 则把状态决定权交给它。错误会跳过没有 onRejected 的 then 一路向后,直到被某个 onRejected 消化(此后恢复成功)或到达链尾变成 unhandled rejection。工程上的高频事故是「catch 里吞错不返回」和「异步调用忘记 return」,排查时优先看这两处。
FAQ
Q1:then 里 return Promise.reject(e) 和直接 throw e 有区别吗?
结果等价,都让下游进入 rejected 并携带同一个原因。差别在微任务层数:return Promise.reject(e) 多了一层 thenable 展平,回调执行时机比 throw 多一个微任务 tick。肉眼不可见,但在严格校验时序的测试里可能影响断言顺序。
Q2:为什么链尾 catch 里 console.error 之后仍然报 Uncaught (in promise)?
因为 catch 的回调本身抛了错。常见于 .catch(e => { throw e; }) 之后没有再接 catch,或者回调里访问了 err.response.data 而 err.response 不存在。在 catch 里再套一层 try/catch,或保证 catch 之后还有兜底 catch。
Q3:Promise.all 中某个 Promise 抛错,其他的是否会停止?
不会停止。Promise.all 只是「首个 rejection 立即让外层 reject」,其余 Promise 仍在继续执行,它们的后续副作用照常发生,只是结果被忽略。需要全部结果时用 Promise.allSettled,它永远 fulfilled,返回 [{status:'fulfilled',value}, {status:'rejected',reason}] 数组。
Q4:finally 的返回值会影响状态吗?
不影响状态,但会影响值。finally 回调正常返回(包括返回普通值)时,原状态和原值透传;如果 finally 里 throw 或返回 rejected Promise,则会覆盖原结果。所以不要在 finally 里 return 业务数据,那个值会被丢弃。
Q5:Node 里报 UnhandledPromiseRejectionWarning 但代码明明有 catch?
检查 catch 是否挂在了错误的链上,例如 someFn() 内部创建了 Promise 但没返回它,外部的 catch 根本没挂到那条链上。另一个原因是 catch 在事件循环的后续 tick 才被挂载(先 reject 后 .catch),Node 16+ 会直接以非零码退出。用 process.on('unhandledRejection', ...) 打印堆栈能快速定位。
Q6:then 的第二个参数和 catch 有区别吗?
有。写在同一个 then 的第二个参数只能捕获它上游的 rejection,捕获不到同一个 then 里 onFulfilled 抛出的错;而 .catch(fn) 等价于 .then(undefined, fn),挂在链尾就能兜住前面所有环节。这正是推荐统一用 catch 的原因。