前端知库

回调地狱是什么?Promise 链式调用怎样解决它?then 的返回值有哪些规则?

几个异步操作要按顺序执行时,回调一层套一层,错误处理也要每层写一遍,这就是回调地狱。Promise 的 then 总是返回一个新的 Promise,回调返回的值、抛出的错误、返回的 Promise 决定这个新 Promise 的结果,所以多个步骤能写成一条平铺的链,错误统一交给末尾的 catch。

参考答案

简答

比如先登录、再拿用户信息、再拿订单列表,每一步都要等上一步的结果。用回调写,就是在第一个回调里发第二个请求,在第二个回调里发第三个请求,代码一层层往右缩进,每层还要单独处理失败,这就是回调地狱,也叫“厄运金字塔”。

Promise 的关键在于 then 会立即返回一个新的 Promise。上一步的回调里返回下一个请求的 Promise,下一个 then 就会等它完成再执行。这样嵌套就变成了一条从上往下的链,任何一步失败,都会跳过后面的 then,落到链尾的 catch。

then 返回的新 Promise 结果怎么定,看回调做了什么:返回普通值,新 Promise 就以这个值兑现;什么都不返回,就以 undefined 兑现;抛出错误,就以这个错误拒绝;返回一个 Promise 或 thenable,就跟着它的最终结果走。

要注意两点。第一,在 then 里发起了异步操作却忘了 return,下一步就不会等它,拿到的是 undefined。第二,then 的参数不是函数时会被忽略,值会原样传下去,所以 .then(42) 不会把结果变成 42。

回调地狱长什么样

MDN 用的例子是三个异步步骤依次执行,每一步都依赖上一步的结果:

JavaScript
doSomething(function (result) {
  doSomethingElse(result, function (newResult) {
    doThirdThing(newResult, function (finalResult) {
      console.log(`Got the final result: ${finalResult}`);
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

问题不只是缩进难看。failureCallback 写了三遍,任何一层忘了传,那一步的错误就没人管了。回调里如果直接 throw,外面的 try/catch 也接不住,因为回调执行时外面的代码早就跑完了。

换成 Promise 链以后是这样:

JavaScript
doSomething()
  .then((result) => doSomethingElse(result))
  .then((newResult) => doThirdThing(newResult))
  .then((finalResult) => console.log(`Got the final result: ${finalResult}`))
  .catch(failureCallback);

步骤还是那三步,但它们是平铺的,错误处理只写一次。MDN 也说明了 catch(failureCallback) 就是 then(null, failureCallback) 的简写。

then 的返回值规则

下面这条链把几种情况都走了一遍。注意每一步打印的值是从哪里来的。

JavaScript
Promise.resolve(1)
  .then((value) => value + 1)
  .then((value) => {
    console.log(value); // 2
  })
  .then((value) => {
    console.log(value); // undefined
    return new Promise((resolve) => setTimeout(() => resolve('等到了'), 10));
  })
  .then((value) => {
    console.log(value); // 等到了
    throw new Error('出错了');
  })
  .then(() => {
    console.log('这一行不会执行');
  })
  .catch((error) => {
    console.log(error.message); // 出错了
    return '恢复';
  })
  .then((value) => {
    console.log(value); // 恢复
  });

第二个 then 打印了 2,却没有 return,所以第三个 then 收到的是 undefined。第三个 then 返回了一个 10 毫秒后才兑现的 Promise,第四个 then 就会等这 10 毫秒,拿到“等到了”,而不是拿到那个 Promise 对象本身。

第四个 then 抛了错,then 返回的 Promise 就被拒绝,后面那个只传了成功回调的 then 被跳过,错误一路往下找到 catch。catch 本身也返回新的 Promise,它的回调返回了“恢复”,链就回到成功状态,最后一个 then 照常执行。

这些规则在 MDN 的 then() 页面里写得很明确:回调返回值,新 Promise 以该值兑现;没有返回,以 undefined 兑现;抛错,以该错误拒绝;返回已兑现或已拒绝的 Promise,跟随它的值或原因;返回仍在等待的 Promise,等它落定后跟随它的结果。

参数不是函数、返回 thenable 时会怎样

JavaScript
Promise.resolve('原值')
  .then(42)
  .then((value) => console.log(value)); // 原值

Promise.reject(new Error('失败'))
  .then((value) => value)
  .catch((error) => console.log(error.message)); // 失败

const thenable = { then(resolve) { resolve('来自 thenable'); } };
Promise.resolve()
  .then(() => thenable)
  .then((value) => console.log(value)); // 来自 thenable

.then(42) 为什么还是“原值”?MDN 说明,onFulfilled 不是函数时,内部会换成 (x) => x,把值原样传下去;onRejected 不是函数时,会换成 (x) => { throw x; },把错误继续往下抛。第二段里只写了成功回调,拒绝原因就被这个“抛出函数”传给了后面的 catch。

第三段返回的是一个带 then 方法的普通对象,也就是 thenable。Promise 会调用它的 then 来取值,所以下一步拿到的是“来自 thenable”。MDN 的说法是,链上的 thenable 总会被解析,onFulfilled 永远收不到 thenable 本身。

还有一个边界:回调返回的正是 then 自己返回的那个 Promise,它就要等自己,永远等不完。规范规定这种情况以 TypeError 拒绝,在 Node.js 22.23 里运行 const p = Promise.resolve().then(() => p),p 会被一个 TypeError 拒绝。

链上的回调什么时候执行

then 的回调总是异步执行,就算 Promise 早就兑现了也一样,它会作为微任务排队,等当前同步代码跑完才执行。所以链上每一步之间都隔着至少一次微任务。具体顺序见《事件循环中,任务和微任务按什么顺序执行?》。

常见追问

then 和 catch 都返回新的 Promise,为什么不能把同一个 Promise 的 then 写两次当链用?

p.then(a); p.then(b); 是给同一个 p 挂了两个回调,a 和 b 拿到的都是 p 的结果,互相不传值。要让 b 拿到 a 的返回值,必须写成 p.then(a).then(b),在 a 返回的新 Promise 上继续挂。

catch 后面还能接 then 吗?

能。catch 也返回新的 Promise,它的回调正常返回,后面就继续走成功分支;在 catch 里再抛错,就交给更后面的 catch。可以利用这一点在中途兜底,让后续步骤继续执行。

有了 async/await 还需要懂 then 链吗?

需要。await 后面的值也按同样的规则解析,async 函数返回的也是 Promise。忘了 return、错误往哪里传这些问题,换成 await 写法依然存在,见《async/await 如何处理异常和并发?》。