ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【JS代码分析】漏写 await 导致异步流程静默失效的原理与排查

【JS代码分析】漏写 await 导致异步流程静默失效的原理与排查

目录

一、代码:

二、不写 await,程序到底发生了什么

2.1 直观现象:代码“跳过”了网络等待

2.2 具体后果拆解

2.3 一个类比

三、深挖一层:为什么 JS 天生“不会等”

3.1 JavaScript 是单线程的

3.2 事件循环与微任务

3.3 async 函数到底做了什么

3.4 Promise 对象的一个关键特性:状态不可逆且“晚到的数据没人接”

四、判断法则:什么时候必须写 async/await

五、不用 async/await 的等价写法

六、几个容易踩的坑

七、总结

一、代码:

这篇文章起源于一个真实的场景:一段 uni-app 的支付代码,因为漏写了await,导致用户点击“确认支付”后毫无反应。代码看起来完全正常,逻辑也没有问题,但功能就是不通。本文借此聊聊:什么情况下必须写async/await,不写会发生什么,以及背后的底层原理。

先看这段出问题的代码(简化版):

// 支付渠道确认支付 const onPaymentConfirm = async ({ index }) => { if (index === 0) return uni.utils.toast('暂不支持微信支付!') const res = await postOrderPayApi({ paymentMethod: index, orderId: orderId.value, payCallback: 'http://localhost:5173/#/subpkg_medicine/pay_result/pay_result', }) // 检测接口是否调用成功 if (res.code !== 10000) return uni.utils.toast(res.message) // 支付宝支付页面 // #ifdef H5 // 引导用户支付(地址跳转方式) window.location.href = res.data.payUrl // #endif // #ifdef MP-WEIXIN // 引导用户支付(wx.requestPayment 小程序) wx.requestPayment({ // 4 个参数 }) // #endif }

这段代码的执行链条是:

调用支付接口 → → 等待服务器返回 → 校验 code → 拿到 res.data.payUrl → 跳转支付宝收银台 ↑ ↑ 需要等待网络 依赖上一步结果

关键在于:后面的每一步都依赖postOrderPayApi的返回结果。res.code、res.data.payUrl,全都要等网络请求回来才有值。

现在把async和await去掉,看看会发生什么。

二、不写 await,程序到底发生了什么

2.1 直观现象:代码“跳过”了网络等待

const onPaymentConfirm = ({ index }) => { if (index === 0) return uni.utils.toast('暂不支持微信支付!') const res = postOrderPayApi({...}) // ⚠️ 没有 await console.log(res) // Promise { <pending> } if (res.code !== 10000) return uni.utils.toast(res.message) window.location.href = res.data.payUrl }

如果在这段代码里打断点或加日志,会看到这样的执行时序:

T+0ms :发起请求,postOrderPayApi 立即返回一个 pending 状态的 Promise T+0ms :console.log(res) → 打印出 Promise 对象本身 T+0ms :读取 res.code → undefined T+0ms :undefined !== 10000 → 恒成立,toast(undefined),函数提前 return T+800ms :服务器响应到达,Promise 被 resolve —— 但函数早就结束了,没有任何代码在等它

也就是说,请求确实发出去了,服务器也确实处理了,但后续逻辑在请求返回之前就已经“跑完了”。用户点击确认支付,页面纹丝不动。

2.2 具体后果拆解

代码实际值后果
res一个 pending 的 Promise 对象不是预期的接口数据
res.codeundefinedPromise 对象上没有这个属性
res.code !== 10000true(恒成立)每次都走“失败”分支
uni.utils.toast(res.message)提示undefined用户看到空提示
window.location.href = ...永远执行不到核心功能完全失效

这段代码最“阴险”的地方在于:它不会报任何错。没有 TypeError,没有控制台红色报错,流程看起来正常走完了,只是静默地失效。这种 Bug 在 code review 里非常容易被漏掉。

2.3 一个类比

// 有 await:像在餐厅点餐后坐下等 const res = await postOrderPayApi({...}) // 走到这行之后,res 一定是"做好的菜"(接口数据) // 没 await:像点完餐转身就走 const res = postOrderPayApi({...}) // 走到这行时,res 只是"取餐小票"(Promise),菜还没做好

await做的事情,就是让函数在这行暂停,直到 Promise 被 resolve,再把 resolve 的值交给左边的变量,然后才继续往下执行。

三、深挖一层:为什么 JS 天生“不会等”

3.1 JavaScript 是单线程的

浏览器(以及小程序运行时)中,JS 只有一个主线程,这个线程同时还要负责渲染页面、响应用户点击。如果网络请求是“同步阻塞”的——也就是发请求后线程原地等待 800ms——那么这 800ms 内:

  • 页面完全卡死,无法点击、无法滚动
  • 动画冻结
  • 如果同时有多个请求,卡顿会叠加

所以 JS 的所有 IO 操作(网络请求、定时器、读写存储)都被设计成异步非阻塞的:调用后立即返回一个“凭证”(Promise),线程继续干别的活,等结果回来后再通过某种方式通知你。

3.2 事件循环与微任务

异步结果回来后是怎么“通知”你的?靠事件循环(Event Loop):

调用栈(同步代码) ↓ 执行完毕 微任务队列(Promise.then 回调、await 后续代码、queueMicrotask) ↓ 清空 宏任务队列(setTimeout、网络请求回调、IO 事件) ↓ 取出一个执行,然后回到"清空微任务"

当我们写:

const res = await postOrderPayApi({...}) console.log(res)

引擎实际上把它编译成了类似这样的结构:

postOrderPayApi({...}).then((value) => { const res = value console.log(res) // ...await 之后的代码都在这个"回调"里 })

await之后的代码,本质上是注册到微任务队列里的回调。这就是为什么“等待”能做到不阻塞线程——不是线程停下来了,而是把后续代码挂起来,等 Promise resolve 后再调度执行。

这也解释了一个经典面试题:

console.log('1') setTimeout(() => console.log('2'), 0) Promise.resolve().then(() => console.log('3')) console.log('4') // 输出顺序:1 4 3 2 // 同步代码先跑完 → 微任务(3)→ 宏任务(2)

3.3 async 函数到底做了什么

有两个细节经常被混淆:

①async只负责“包装返回值”。async 函数的返回值永远是一个 Promise(return 的值会被Promise.resolve()包一层)。它本身不产生任何等待。

② 真正实现“等待”的是await。所以:

  • 只写async不写await→ 函数体依然是同步执行到底,异步照样不等
  • 只写await不写async→ 语法错误(顶层 await 除外,需要模块环境)

再深挖一点:await右边如果跟的不是 Promise,比如await 42,也会被包装成Promise.resolve(42)再等待——所以 await 会至少引入一次微任务调度,即使右边是同步值。这也是某些性能敏感场景需要注意的细节。

3.4 Promise 对象的一个关键特性:状态不可逆且“晚到的数据没人接”

postOrderPayApi返回的 Promise 在被 resolve 之后,数据就“存在”这个 Promise 里了。但如果你没有通过await或.then()去订阅它,这个结果就是无主的——就像快递放到了门口,但没人去取。这就是前面那个 Bug 的本质:

不是数据没回来,而是回来的数据没有人消费。

四、判断法则:什么时候必须写 async/await

结合上面的原理,可以总结出一个简单的判断法则:

后续代码是否依赖异步操作的返回值?依赖,就必须等待。

必须写await的典型场景:

// ✅ 后续代码用到 res const res = await postOrderPayApi({...}) if (res.code !== 10000) return // ✅ 多个请求有依赖关系,必须串行 const user = await getUserApi() const orders = await getOrdersApi(user.id) // ✅ 需要按顺序执行的异步流程 await saveOrder() await showToast('下单成功') uni.navigateTo({ url: '/pages/result/result' })

不依赖返回值时,可以不写:

// ✅ 只是"发射后不管"(fire-and-forget),比如埋点上报 reportApi({ event: 'click_pay' }) // ✅ 并行请求互不依赖,甚至不该用 await 串行 const [a, b] = await Promise.all([getA(), getB()]) // 而不是分别 await 两次

注意最后一点:不必要地连续 await 会拖慢执行。两个互不依赖的请求分别 await,总耗时是两者之和;用Promise.all并行,总耗时只取最慢的那个。这也是“不是所有异步都要 await”的另一面。

【前端性能优化】优化数据加载:用 Promise.all 从串行到并行https://blog.csdn.net/weixin_52047874/article/details/162552812?ops_request_misc=elastic_search_misc&request_id=99dae8309f5e9a2557dbdcdac5bf5017&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~ElasticSearch~search_v2-18-162552812-null-null.541^v3^pc_search_result_blog1&utm_term=%E5%BC%82%E6%AD%A5&spm=1018.2226.3001.4450

五、不用 async/await 的等价写法

async/await是 ES2017 引入的语法糖,底层完全基于 Promise。不用它,就必须用.then()链把“后续逻辑”搬进回调里:

const onPaymentConfirm = ({ index }) => { if (index === 0) return uni.utils.toast('暂不支持微信支付!') postOrderPayApi({...}) .then((res) => { // 只有在这个回调里,res 才是接口数据 if (res.code !== 10000) return uni.utils.toast(res.message) window.location.href = res.data.payUrl }) .catch((err) => { // await 自动抛出的异常,链式写法里要靠 catch 兜底 uni.utils.toast('支付发起失败,请重试') }) }

对比一下两种写法,就能理解await到底优势在哪里:

.then()链async/await
代码形态回调嵌套,层层缩进同步风格,自上而下
错误处理每条链挂.catch()一个try/catch包住整个流程
中间变量作用域被回调函数切割共享同一个函数作用域
调试断点跨越回调边界像调试同步代码一样逐行 F10

六、几个容易踩的坑

最后补充几个和这个主题相关的常见坑,都源自同一件事——对“等待”的误解:

坑 1:循环里 await,串行变慢

// ❌ 串行执行,10 个请求耗时叠加 for (const id of ids) { await deleteItem(id) } // ✅ 并行执行 await Promise.all(ids.map((id) => deleteItem(id)))

坑 2:await 写在了没有 return 的回调里

// ❌ forEach 的回调不是 async 函数,内部的 await 不会被外层等待 list.forEach(async (item) => { await doSomething(item) }) console.log('done') // 会先于 doSomething 打印 // ✅ 用 for...of for (const item of list) { await doSomething(item) }

坑 3:忘记 await,且没有任何报错

这就是本文开头那个 Bug。防御方法:

  • 对返回 Promise 的接口调用,养成“要么 await,要么 .then(),要么注释说明 fire-and-forget”的习惯
  • 配置 ESLint 规则 no-floating-promises(需 TypeScript),或使用@typescript-eslint/ eslint-plugin-promise 辅助检查
  • Code review 时重点盯“赋值给变量的异步调用”

坑 4:try/catch 之外的 await 异常被吞掉

await一个被 reject 的 Promise 相当于throw。如果整个 async 函数的调用方也没有 catch,最终会产生unhandled promise rejection——控制台可能只有一条不起眼的警告。所以 async 函数内的关键 await 应该包在try/catch里。

七、总结

回到最初的问题:为什么这个支付函数必须写 async/await?

  1. 因为 JS 的网络请求是异步非阻塞的,调用后立即返回一个 pending 的 Promise,而不是数据;
  2. 因为后续代码(校验res.code、跳转res.data.payUrl)依赖请求的返回值,必须建立“先等结果、再执行”的顺序;
  3. 不写await,res就是 Promise 对象本身,res.code恒为undefined,校验恒失败,核心跳转逻辑永远执行不到,而且全程无报错、静默失效。

一句话记住这个法则:

异步调用的结果,你不等它,它就只是个 Promise;你等它,它才是数据。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进