目录
一、代码:
二、不写 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.code | undefined | Promise 对象上没有这个属性 |
res.code !== 10000 | true(恒成立) | 每次都走“失败”分支 |
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?
- 因为 JS 的网络请求是异步非阻塞的,调用后立即返回一个 pending 的 Promise,而不是数据;
- 因为后续代码(校验
res.code、跳转res.data.payUrl)依赖请求的返回值,必须建立“先等结果、再执行”的顺序; - 不写
await,res就是 Promise 对象本身,res.code恒为undefined,校验恒失败,核心跳转逻辑永远执行不到,而且全程无报错、静默失效。
一句话记住这个法则:
异步调用的结果,你不等它,它就只是个 Promise;你等它,它才是数据。