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 2。then 注册的回调永远异步执行,而且是放进微任务队列——哪怕 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
同步的 start、end 先跑完;然后清空微任务队列 then1、then2;最后才轮到宏任务 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,处理不了同一个 then 里 onSuccess 自己抛的异常。所以实践里我基本只用 .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 都重要——这句话我原本也会说,但直到被自己出的题问住那晚,才算真的懂。