Promise:从会写 then,到真把状态和链路讲清

Promise 最容易让人产生“已经会了”的错觉。接口请求、懒加载、弹窗确认、定时任务,项目里到处都在用它;可一旦从“会写 then”往前多走半步,问到状态流转、错误传播、并发控制和手写实现,很多熟练感都会瞬间掉下去。

我后来整理面试题时感受尤其明显。平时 code review 里,最常见的 Promise 写法几乎只有一种:

1request().then(res => {
2  console.log(res)
3})

一旦遇到多个异步任务、错误传递、并发请求,就开始混乱。这东西考的不是背功,而是你脑子里有没有一条完整的异步控制链。

后来我把 Promise 相关的问题收成七道,从概念一路排到手写实现,结果越整理越发现,真正容易讲含糊的地方并不是 API 名字,而是它们背后的执行语义。组里一个刚转前端没多久的同事听说我在整理这个,主动提出来当"陪练",让我拿这七道题考考他,顺便帮他查缺补漏。后面就按这七道题往下走,把常见坑和对应的推导一起捋清楚。

开场白:Promise 到底解决了什么

正式出题前我先问了一句闲话:"你为什么用 Promise?"他说"不用回调地狱啊"。方向对,但只对了一半。

我俩都是从"回调地狱"那一代过来的。最早的写法一层套一层,缩进能顶到屏幕右边:

1getUser(function (user) {
2  getOrders(user.id, function (orders) {
3    getOrderDetail(orders[0].id, function (detail) {
4      // 三层了,再往下没法看了
5    }, onError)
6  }, onError)
7}, onError)

但最难受的其实不是缩进,是错误处理——每一层都得自己接一个 onError,漏一个就静默失败。换成 Promise 之后,链式加一个 catch 兜底,可读性是质变。所以理解 Promise 不能只盯着语法,要记住它解决的是两个痛点:流程的串/并联组织错误的统一传递。后面所有的题,都是围着这两件事转的。

第一题:三种状态,他背出来了,追问就卡了

"Promise 有几种状态?"这题他答得飞快:

  • pending
  • fulfilled
  • rejected

状态一旦从 pending 变成 fulfilled 或 rejected,就不会再变。我追问:"那这段代码输出什么?"

1const promise = new Promise((resolve, reject) => {
2  resolve(1)
3  reject(new Error('fail'))
4})
5
6promise.then(value => {
7  console.log(value) // 1
8})

他答对了,后面的 reject 不会生效。但我再追一句"状态不可逆这个设计到底好在哪",他就开始绕圈子了。我给他的说法是:它保证了一个 Promise 的结果是确定的、只会兑现一次,所以你可以放心地把它当成一个"未来的值"传来传去,甚至可以对同一个 Promise 多次 .then,每次都拿到同样的结果。异步的结果从"某次回调的入参"变成了"一个可以持有的值",这是整个模型的地基。

顺手我又补了一个他没接住的点:"executor 是同步执行的,你知道吗?"new Promise(fn) 里那个 fn 在构造的那一刻就跑了,不是等 .then 才跑:

1console.log('a')
2new Promise(resolve => {
3  console.log('b')   // 同步!构造时立刻执行
4  resolve()
5})
6console.log('c')
7// 输出 a b c

这也意味着,你把几个请求塞进数组的那一刻,请求就已经发出去了——这个细节在后面 Promise.all 那题还会回收。另外 executor 里同步抛出的异常会被自动转成 reject,不会炸到外面,catch 接得住。

第二题:输出题,他错了,我差点也错

"这段输出什么?"

1console.log(1)
2Promise.resolve().then(() => console.log(2))
3console.log(3)

他答 1 2 3。错了,是 1 3 2then 注册的回调永远异步执行,而且是放进微任务队列——哪怕 Promise 已经是 resolved 状态,then 里的回调也要等当前同步代码全跑完才执行。

我没资格笑他,因为我刚理解 Promise 那会儿在同一个地方栽过——以为 Promise.resolve().then 是立刻执行的,在一个依赖执行顺序的地方调试了很久。记住一句话:then 回调一定进微任务,绝不插队同步代码

然后我把题加码,宏任务也摆进来:

1console.log('start')
2setTimeout(() => console.log('timeout'), 0)   // 宏任务,最后
3Promise.resolve()
4  .then(() => console.log('then1'))           // 微任务
5  .then(() => console.log('then2'))           // 上一个微任务跑完才排进来
6console.log('end')
7// 输出顺序:start  end  then1  then2  timeout

同步的 startend 先跑完;然后清空微任务队列 then1then2;最后才轮到宏任务 setTimeout。哪怕 setTimeout 的延时写成 0,它也排在所有微任务后面——微任务优先于宏任务。这道题他对了一半:timeout 排最后他知道,then2 为什么在 end 之后他讲不出机制。视频里我讲这段时自己也是边讲边虚,挂了之后把这几行贴进控制台跑了一遍才踏实。输出题的正确打开方式不是背,是贴进控制台核对

第三题:链式调用和那个不报错的 return

"then 为什么能链起来?"他说"因为 then 返回 this"。不对,then 返回的是一个新的 Promise,不是原来那个:

1Promise.resolve(1)
2  .then(value => value + 1)
3  .then(value => {
4    console.log(value) // 2
5  })

如果 then 里返回一个 Promise,后面的 then 会等它完成:

1fetchUser()
2  .then(user => fetchOrders(user.id))
3  .then(orders => {
4    console.log(orders)
5  })

这比嵌套回调清楚很多。然后我给他看了一段我 code review 里见得最多的 bug——忘了 return

1// 错误:没有 return,第二个 then 拿到的是 undefined
2fetchUser()
3  .then(user => {
4    fetchOrders(user.id)   // 这个 Promise 没被返回,链断了
5  })
6  .then(orders => {
7    console.log(orders)    // undefined!
8  })

少写一个 return,链就"断"了——后面的 then 不会等前面那个异步操作,拿到的是 undefined,而且不报错,特别难查。这种 bug 在我们项目里的现象往往是"数据偶尔是空的",查到最后就是漏了 return。用箭头函数的隐式返回能少踩这个坑:.then(user => fetchOrders(user.id))

这题他答砸之后我给他留了道课后题,是我笔记里的一道值穿透:

1Promise.resolve(1)
2  .then(2)                 // 参数不是函数,直接被忽略
3  .then(function () {})    // 是函数,但什么都没 return
4  .then(console.log)

第一个 then 传的是数字不是函数,规范规定非函数参数直接忽略,值 1 会"穿透"到下一层;第二个 then 没有 return,值就变成了 undefined。所以最后打印 undefined。这两条规则拆开都不难,合在一道题里就很能筛出"到底懂不懂链式传值"。

另外,then 其实接受两个参数:then(onFulfilled, onRejected)。第二个参数和 catch 看起来都能处理错误,但有个区别:

1promise.then(onSuccess, onError)        // onError 抓不到 onSuccess 内部抛的错
2promise.then(onSuccess).catch(onError)  // catch 能抓到 onSuccess 内部的错

then 的第二个参数只处理它前面那个 Promise 的 reject,处理不了同一个 thenonSuccess 自己抛的异常。所以实践里我基本只用 .catch,更不容易漏。

第四题:错误会向后传,但也会被悄悄吞掉

这题从一段正常代码开始:

1fetchUser()
2  .then(user => {
3    if (!user.active) {
4      throw new Error('用户不可用')
5    }
6
7    return fetchOrders(user.id)
8  })
9  .then(orders => {
10    console.log(orders)
11  })
12  .catch(error => {
13    console.log(error.message)
14  })

前面任何一步抛错,都会顺着链进入后面的 catch——这就是 Promise 对"每层接一个 onError"的回答。小谢这题答得不错,但我问他"catch 完之后链是什么状态",他愣了。看这段:

1request()
2  .catch(error => {
3    console.log(error)
4  })
5  .then(() => {
6    // 这里还会继续执行!
7  })

catch 本质也是 then,它返回的还是新 Promise——错误被它接住之后,链就恢复成"正常"状态继续往下走。如果错误已经处理完、允许继续,这样写没问题;如果不允许继续,就得在 catch 里把错误重新 throw 出去。catch 不是终点站,是收费站——不主动拦,车过了站还接着开。

还有一类更隐蔽的吞错:根本没写 catch。一个 Promise reject 了又没人接,浏览器会甩一个 Unhandled promise rejection 的警告,但这个警告很容易被忽略,而且不会中断程序。这里我给他讲了个我们项目的真事:去年我们在全局挂了个监听,专门兜这种漏网之鱼:

1window.addEventListener('unhandledrejection', event => {
2  report('unhandled_rejection', { reason: String(event.reason) })
3  event.preventDefault()  // 阻止控制台默认报错
4})

上线后才发现,原来线上一直有不少没被 catch 的请求失败,平时根本没人注意。这个监听帮我们捞出过好几个隐藏 bug。讲到这他在那边记笔记记得飞快——这种东西书上不太写,是被线上环境教出来的。

第五题:Promise.all,一道题里藏着一次两秒变三百毫秒的优化

"页面进来要同时拉用户、菜单、公告,怎么写?"他写对了:

1Promise.all([
2  fetchUser(),
3  fetchMenu(),
4  fetchNotice()
5]).then(([user, menu, notice]) => {
6  console.log(user, menu, notice)
7})

追问一:"有一个失败了会怎样?"Promise.all 只要有一个失败,整体就失败。一句话能验证:

1Promise.all([Promise.resolve(1), Promise.reject('e')])
2  .then(v => console.log('then', v), e => console.log('catch', e))
3// => catch e
4// 只要有一个 reject,整体直接走 reject,拿到的是那个失败的原因 'e',
5// 另一个已经成功的 1 你根本拿不到

这适合强依赖场景,比如页面必须同时拿到用户、权限和菜单才能渲染。但如果某个模块失败不该影响其他模块——比如公告挂了不该把整个首页拖死——就不能裸用 Promise.all,要给每个子 Promise 单独兜底:

1Promise.all([
2  fetchUser().catch(error => ({ error })),
3  fetchNotice().catch(error => ({ error }))
4])

让每个成员失败时也"正常"返回一个带 error 标记的值,Promise.all 整体就不会因为一个失败而全盘 reject。顺带说个新闻:TC39 那边有个 Promise.allSettled 的提案干的就是这件事——不管成员成败它都 resolve,返回一个 { status, value/reason } 的数组,每项的成败自己看 status。提案还在流程里走,浏览器现在没有,2019 年想要这个效果就是像上面那样自己包一层。等它真进了标准,这段手工代码就可以退休了。

追问二:"结果数组的顺序是按谁先完成排的吗?"不是——结果顺序和传入顺序一致,跟完成先后无关,所以才可以放心地解构 [user, menu, notice]

追问三是重头戏:"Promise.all 是它让请求并发的吗?"其实不是。第一题埋的伏笔在这里回收:Promise 一创建,executor 就同步执行了——你把 fetchUser() 塞进数组的那一刻,请求已经发出去了,all 只负责"等齐"。所以下面这种写法是串行的,经常被误以为并行:

1// 串行!第二个请求要等第一个 await 完才发出
2const user = await fetchUser()
3const menu = await fetchMenu()
4
5// 真并发:两个请求几乎同时发出,再一起等
6const [user, menu] = await Promise.all([fetchUser(), fetchMenu()])

这里我又贡献了一个项目故事:去年优化过一个商品详情页,里面五六个互不依赖的接口被写成了一串 await,串行加起来快两秒。改成 Promise.all 一把并发,直接压到三百多毫秒。**看到连续的 await 就该条件反射地问一句:它们真的互相依赖吗?**这是性价比最高的一类前端优化。

第六题:race 和超时,settle 一次就定局

"给一个请求加 5 秒超时,怎么做?"他知道用 race

1Promise.race([
2  request(),
3  timeout(5000)
4])

超时函数:

1function timeout(ms) {
2  return new Promise((resolve, reject) => {
3    setTimeout(() => {
4      reject(new Error('请求超时'))
5    }, ms)
6  })
7}

但两个追问都没接住。第一问:"超时之后,那个 HTTP 请求取消了吗?"没有——race 只是让当前这个 Promise 先失败,底层请求还在后台跑,只是它的结果没人要了。真要中断底层请求,得靠 axios 的 cancelToken 或者 XMLHttpRequest.abort()race 本身做不到。

第二问用一个延时确定的例子:"这段输出什么?"

1Promise.race([
2  new Promise(r => setTimeout(() => r('slow'), 50)),
3  Promise.resolve('fast'),
4]).then(v => console.log(v))
5// => fast
6// 'fast' 已经 resolved,第一个微任务回合就胜出;50ms 后的 'slow' 再 resolve 也无人理会

race 取的是最先 settle 的那一个,不管它是 resolve 还是 reject——只要有一个定了,整个 race 就定了。所以超时那个 Promise 一旦先 reject,整体就是 reject,哪怕真实请求 0.1 秒后成功了也没用。

我自己项目里更常用的不是裸 race,而是封装成一个"给任意请求加超时"的工具,视频里我把它敲给他看:

1function withTimeout(promise, ms = 5000) {
2  let timer
3  const timeout = new Promise((_, reject) => {
4    timer = setTimeout(() => reject(new Error('请求超时')), ms)
5  })
6  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
7  // finally 里清掉定时器,否则请求很快就回来了,定时器还白白挂 5 秒
8}
9
10// 用起来
11withTimeout(fetchUser(), 3000).then(...).catch(...)

那个 clearTimeout 最容易漏。不清的话,请求明明 100ms 就回来了,5 秒的定时器还在后台挂着,攒多了也是浪费。finally 是 ES2018 进来的,Node 10 和新浏览器都有了,正好适合干这种"无论成败都要做的收尾"——老项目要兼容就退回 .then(fn, fn) 的写法。

压轴题:手写 Promise,把我俩一起考住的题

最后一题:"写一个简化版 Promise,能先 .then 注册回调、后 resolve 出结果。"他写了两行就停住了,卡在最关键的地方:resolve 还没发生的时候,then 传进来的回调放哪?

我张嘴想讲,讲了个开头就发现不对——我平时"会用"Promise,但"先注册、后兑现"这个机制我从来没有亲手实现过,嘴里的话全是雾。这就是这场模拟面试对我最大的暴击:考别人考到最后,考出了自己的底

年后上班第一天晚上,我把它写了出来。核心就三件事:保存状态、保存回调、resolve 后执行回调:

1function SimplePromise(executor) {
2  this.state = 'pending'
3  this.value = undefined
4  this.callbacks = []
5
6  const resolve = value => {
7    if (this.state !== 'pending') return
8
9    this.state = 'fulfilled'
10    this.value = value
11    this.callbacks.forEach(callback => callback(value))
12  }
13
14  executor(resolve)
15}
16
17SimplePromise.prototype.then = function(onFulfilled) {
18  if (this.state === 'fulfilled') {
19    onFulfilled(this.value)
20  } else {
21    this.callbacks.push(onFulfilled)
22  }
23}

答案其实朴素得让人泄气:callbacks 数组就是那个"放哪"。还没 resolve 时,then 把回调存进数组;resolve 那一刻,把数组里攒的回调全部执行。状态不可逆就是开头那个 if (this.state !== 'pending') return

写完第一版我又对照真 Promise 挑了三处硬伤补了第二版的思路。一是真 Promise 的 then 回调是异步的(第二题的 1 3 2),我这版在已 fulfilled 时是同步调用的,要包一层 setTimeout(fn, 0) 才像样(真实现进的是微任务队列,setTimeout 只是形似);二是没有 reject 和 catch,失败路径要照着成功路径再铺一条:state 多一个 'rejected'、回调分成两个数组存;三是 then 没有返回新的 Promise——这也是最关键的一处,第三题刚讲过"then 返回的是一个新的 Promise"才让链式成立,但这里的实现根本没有 return new SimplePromise(...),所以它的 .then 根本无法链起来,和第三题的核心结论直接矛盾。修这个要把 then 改成:把 onFulfilled 的返回值包进一个新 Promise resolve,才算支持链式传值。真写全 Promises/A+ 规范很长,但把这三步补完,第二、三、四题的所有行为就都能在自己的实现里对上号了——输出题背一百道,不如让自己的实现跑出同样的输出

加时赛:async/await 拆回 Promise

题出完已经十一点多,小谢反过来问了我一题:"现在都写 async/await 了,Promise 这些还重要吗?"

重要,因为 async/await 只是语法糖,底子还是 Promise。2019 年它已经能放心用了——Node 8 以上、主流浏览器都支持,配合 Babel 更没问题。它读起来像同步代码:

1async function loadPage() {
2  const user = await fetchUser()
3  const orders = await fetchOrders(user.id)
4  return orders
5}

记住两件事:async 函数的返回值一定是个 Promise;await 后面跟的表达式如果是 Promise,就等它 resolve 并取出值,如果是普通值就直接用。

错误处理是我自己栽过的坑。Promise 链里错误自动往后传,但 async 里 await 抛错就是普通的异常,得用 try/catch 接:

1async function loadPage() {
2  try {
3    const user = await fetchUser()
4    const orders = await fetchOrders(user.id)
5    return orders
6  } catch (err) {
7    this.$message.error('加载失败')
8    throw err   // 视情况决定要不要继续往外抛
9  }
10}

但满屏 try/catch 也挺丑。我们团队里流行过一种"Go 风格"的写法,把错误和结果一起返回,避免每个 await 都包一层:

1function to(promise) {
2  return promise.then(data => [null, data]).catch(err => [err, null])
3}
4
5const [err, user] = await to(fetchUser())
6if (err) return handle(err)

这种写法见仁见智,强依赖场景我还是更喜欢直接 try/catch,逻辑更直白。但有一点是确定的:别在 async 函数里把该并发的请求写成连续 await——第五题那个性能坑,在 async/await 里更隐蔽,因为它看起来太"顺"了。语法糖包住的是写法,包不住模型;模型不清楚,糖越甜坑越深。

收卷之后:请求错误要统一

复盘的时候我跟小谢说,这七道题在项目里最终会汇成一件事:别让每个接口自己处理错误结构。我们项目的请求层是这么收的:

1function request(url) {
2  return axios.get(url).then(res => {
3    if (res.data.code !== 0) {
4      return Promise.reject({
5        code: res.data.code,
6        message: res.data.message || '请求失败'
7      })
8    }
9
10    return res.data.data
11  })
12}

页面里从此只有一种句式:

1request('/api/user')
2  .then(user => {
3    this.user = user
4  })
5  .catch(error => {
6    this.$message.error(error.message)
7  })

业务失败和 HTTP 失败在一处归一成同一种 reject,页面的 catch 才能写得又薄又稳。第四题的错误传递、第五题的并发兜底,全都要以"错误结构统一"为前提——地基不平,上面的每一层都得各自找平。

这一晚留下的东西

昨天小谢发消息说,把手写 Promise 自己默写了三遍,值穿透那道课后题也想明白了。他吃透得怎么样是他的事了,我倒是先欠下一笔账被讨清了:Promise 不是只会写 then。它真正解决的是异步流程组织、错误传递和并发控制——状态不可逆是地基,微任务是时序,链式是流程,catch 是收费站,all 和 race 是并发的两种收敛方式。

出题考别人这件事,比自己刷题狠多了。自己刷题,含糊的地方可以划过去;给人讲,每一个"呃,这个就是……"都是当场曝光的债。写异步代码时先想清楚任务是否依赖、失败是否中断、错误在哪里统一处理,比堆多少个 then 都重要——这句话我原本也会说,但直到被自己出的题问住那晚,才算真的懂。