1. 从回调地狱到 async/await:异步调用的三步进化
先聊个真实场景。你写的前端页面里有个上传功能,用户选了文件,点完上传按钮,接口返回的数据没等到,界面却先卡死了;或者更隐蔽一点,上传确实发出去了,但是因为请求还在 pending,代码已经往下执行了,后面依赖这次上传结果的逻辑全部变成了 undefined,用户只能刷新页面重试。很多人遇到这种问题第一反应是“网络请求错误”“系统错误”,但真正的原因往往不是服务端挂了,而是异步流程控制没做好。
这里就引出了今天要聊的核心主角:异步函数,以及它的两个关键字async和await。你在网上搜“异步函数”“async/await 用法”相关的内容,会看到大量讨论,但不少教程只讲语法不讲解为什么,新手抄完代码还是一头雾水。这篇博文我就从实际项目出发,把 async/await 的来龙去脉、核心细节、常见坑位和排查思路一次说透。这篇文章适合下面几类人:刚接触 JavaScript 异步编程的前端新手,后端 Node.js 开发中遇到 Promise 链式调用过长想重构的工程师,以及那些写 TypeScript 但一直没搞懂 async/await 基于 Promise 之上还扩展了什么的人。
先花点时间回顾异步编程的演进,因为不理解历史的人很难真正理解现状。早期 JavaScript 处理异步只有回调函数一种手段,比如setTimeout(callback, 1000)这种写法,代码层级一旦加深,就会出现臭名昭著的“回调地狱”:三层缩进起步,五层缩进很正常,改一个字段要在六七个闭包之间来回跳。后来有了 Promise,解决了回调嵌套的问题,可以用then链把异步流程拍平,但then链一旦分支多了,代码读起来仍然像是在拼乐高,每个积木块之间的逻辑关系要自己脑补。再后来,ES2017 标准把 async/await 正式纳入 JavaScript 语言规范,异步代码终于能写得和同步代码一样直白,可读性和可维护性都有了本质提升。
你可能在别的语言里也见过 async/await 的身影,比如 Rust 的 async/await、Python 的 async def 和 await、C# 的 async/await。这些语言的实现细节各不相同,但核心理念高度一致:暂停当前函数的执行,把控制权让给事件循环,等待异步操作完成后恢复执行,并且不阻塞主线程。理解这一层,你在不同语言之间切换时就不会觉得每次都是重新学一个新东西。
1.1 为什么说 async 函数是 Promise 的语法糖
需要先明确一个基本事实:async/await 并不是全新的异步模型,它本质上还是基于 Promise 的,只是换了一种更容易理解、更容易书写的表达方式。当你看到一个函数声明前缀了async关键字时,这个函数会自动返回一个 Promise 对象,就算你实际返回的是一个普通字符串,JS 引擎也会用Promise.resolve()把它包起来。
async function getUserName() { return 'zhang-san'; } // 等价于 function getUserName() { return Promise.resolve('zhang-san'); }这一点特别容易被忽略,但它直接解释了为什么在调用一个 async 函数时,不能直接使用它的返回值,而是要继续用then或者await去拿结果。有朋友写过这样的代码:
const name = getUserName(); console.log(name); // 输出 Promise { 'zhang-san' },而不是字符串这就是把 async 函数和普通函数的返回机制搞混了。记住一条:async 函数的返回值永远都是 Promise 对象,调用方要么用 await 接收,要么用 then 接收,不存在第三种直接取值的方式。
1.2 await 到底在等什么:暂停与恢复的底层逻辑
await关键字的语义是“暂停当前 async 函数内后续代码的执行,等待右侧表达式产生结果后再继续”。右侧表达式可以是一个 Promise 对象,也可以不是。如果右侧不是一个 Promise,JavaScript 会先把它用Promise.resolve()转成 Promise,再等待这个 Promise settle。
我常用一个生活类比来解释这个过程:你站在奶茶店排队,前面还有三个人,这时候如果你站在收银台前面干等,后面的人就全被你堵住了——这就是同步阻塞;有了 await,相当于你点完单之后先取个号,人走到旁边坐着,等叫号的时候再回来取——这就是异步非阻塞。事件循环机制保证了等待期间主线程依然能处理其他任务,页面不会因为一个漫长的网络请求而卡死。
有一点需要特别说明:await只能在async 函数内部使用。如果在普通函数里写出await something,语法层面就直接报错——这不是运行时的意外,而是语言规范做的明确限制。之所以这么设计,是因为 await 的暂停语义依赖 async 函数的 Promise 包装和返回机制,脱离了 async 函数的上下文,暂停和恢复的调度就无从谈起。
function getData() { const response = await fetch('/api/data'); // SyntaxError: await is only valid in async functions and the top level bodies of modules }上面这段代码在浏览器环境下会直接抛出语法错误,在 Node.js 老版本里也一样。正确的写法是把函数声明为 async。另外,现代浏览器和较新版本的 Node.js 都支持了顶层 await(Top-level await),也就是说在 ES 模块的顶层作用域里可以直接使用 await,不需要额外包一层 async。但要注意,这个特性仅对模块生效,在 CommonJS 模块里是不适用的,这属于环境差异,碰到报错时不要慌,先确认你当前的文件是不是 ES Module。
2. 核心使用场景:从基础语法到实战细节
说完底层原理,我们进入实操层面。async/await 的语法本身很简单,不外乎在函数前加async,在异步操作前加await,但真正决定代码质量的是对细节的把握:错误处理是否到位、并发控制是否合理、异常中断是否符合预期。这一章会逐个拆解这些核心细节,每一项都是项目里踩过坑换来的经验。
2.1 函数声明形式:async 不止能修饰 function
async关键字可以修饰各种形式的函数声明,不仅仅是传统的function。在做代码审查的时候,我经常发现有人把组件的箭头函数也写得又长又乱,完全没有利用 async 的灵活表现力。下面列出常见的几种形式,你可以直接照抄:
// 传统函数声明 async function loadData() { const res = await fetch('/api/list'); return res.json(); } // 函数表达式 const loadData = async function () { const res = await fetch('/api/list'); return res.json(); }; // 箭头函数 const loadData = async () => { const res = await fetch('/api/list'); return res.json(); }; // 对象方法 const api = { async list() { const res = await fetch('/api/list'); return res.json(); }, }; // 类方法 class DataService { async list() { const res = await fetch('/api/list'); return res.json(); } }这五种写法在实际项目里都有对应的适用场景。类方法适合做依赖注入和服务封装,对象方法适合做模块导出,箭头函数在 React 组件的useEffect内部回调里很常见。无论哪种形式,规则只有一个:只要有await,它所处的函数就必须带async。如果你把await写在一个普通箭头函数里,哪怕这个箭头函数被传进了一个 async 函数,它本身仍然不是 async,同样会报语法错误。
2.2 Promise.all 与串行/并行的抉择
真正容易出问题的地方是流程控制——是串行执行还是并行执行。先看一段典型的串行代码:
async function loadDashboard() { const user = await fetch('/api/user'); const orders = await fetch(`/api/user/${user.id}/orders`); const comments = await fetch(`/api/user/${user.id}/comments`); }这段代码能工作,但性能上有明显问题:后两个请求是完全独立的,彼此不依赖对方的数据,可是因为写了两个连续 await,它们变成了严格串行——第二个请求必须等第一个完成,第三个请求必须等第二个完成。在网络环境不佳时,这种写法会让整体耗时等于所有请求耗时的总和,数据库和接口压力也更大。
正确的做法是判断请求之间是否存在依赖关系。如果 B 请求需要 A 请求的返回值,比如上面代码里的/api/orders需要用到user.id,那串行就是必需的;如果两个请求互不依赖,就应该用Promise.all让它们并行跑:
async function loadDashboard() { const [user, orders, comments] = await Promise.all([ fetch('/api/user'), fetch('/api/orders'), fetch('/api/comments'), ]); }用Promise.all有三点要提醒。第一,任何一个 Promise 被 reject,整个 Promise.all 都会立即 reject,对应的 await 语句会抛出异常,所以必须配合 try/catch 或者.catch兜底。第二,如果确实需要所有请求都返回部分成功的结果,可以用Promise.allSettled替代,它不会因为某个失败而整体中断。第三,不要滥用 Promise.all,如果请求数会动态增长(比如用户勾选数量不定的文件批量上传),建议用一个Promise.all(uploadList.map(...))来构建 Promise 数组,而不是手动写出固定数量的 Promise。
2.3 错误处理:try/catch 到底该包在哪一层
async/await 时代最容易被低估的就是错误处理。你用 Promise 的then写法时,很多人习惯在链尾加一个.catch()把错误统一收集;换到 async/await 之后,如果仍然只在调用方做一次 try/catch,往往会出现错误上下文丢失的问题。
一个比较稳妥的分层策略是:每个 async 函数内部负责捕获自己这一层的错误,向上层传递规范化后的错误信息;最外层调用方做兜底处理,防止未捕获的异常直接逃逸成 unhandled rejection。举个例子,我要写一个用户登录函数,网络层可能出错、业务层可能返回账号密码错误、序列化层可能出 JSON parse 错误,如果不分层捕获,调试时要靠猜。
async function login(username, password) { try { const response = await fetch('/api/login', { method: 'POST', body: JSON.stringify({ username, password }), }); const data = await response.json(); if (data.code !== 0) { throw new Error(data.message || '登录失败'); } return data.data; } catch (error) { // 区分网络错误和业务错误 if (error instanceof TypeError) { throw new Error('网络请求失败,请检查网络连接'); } throw error; } }这里有个很有意思的细节:fetch在遇到网络中断、DNS 解析失败等场景时,会抛出一个TypeError类型的异常,而 HTTP 状态码是非 2xx 时,fetch 并不会自动认为那是错误,它照常 resolve。所以初学者经常遇到“接口明明返回 500,但 fetch 没进 catch 分支”的困惑。正确的处理方式是检查response.ok或response.status,人为把非 2xx 状态抛成一个业务错误。上面的代码简化处理了 JSON 解析,实际项目中你还要考虑response.json()本身也可能失败。这些边界情况不复杂,但组合在一起会非常磨人,建议在团队里沉淀一套统一的 HTTP 请求封装,而不是让每个开发自己写 fetch。
2.4 退出机制:await 中途返回时会不会继续执行
再聊一个特别容易困扰新手的问题:如果在 await 之前提前 return 或者 throw,后面的代码还执行不执行。先说结论:async 函数内部,一旦遇到 return 语句,函数会立即返回并 resolve,后面的代码不会再执行;一旦遇到 throw,函数会立即 reject,后面的代码也不执行。但这里有一个容易被忽略的操作:如果有 try/finally,finally 里的代码总会执行,这一点对资源释放场景很重要。
async function processFile(file) { const stream = openStream(file); try { await stream.read(); if (stream.size === 0) { return; // 提前退出,但 finally 会执行 } await stream.write(); return stream.close(); } finally { // 资源清理逻辑,无论成功失败都会执行 releaseStream(stream); } }你在写上传模块时候特别容易踩这个坑:想提前结束上传流程但又忘了释放文件句柄,导致文件被占用无法删除。合理使用 finally,能有效避免这种资源泄漏问题。
3. 实战案例:实现一个带进度反馈的文件上传模块
我觉得最好的学习方式不是看十个理论帖子,而是实现一个真实可运行的模块。这一章我们做一个文件上传功能,它同时用到了 async/await 的多个核心特性:网络请求异步化、错误处理、并发控制、进度反馈。这个案例也能直接回答很多人在“上传失败:网络请求错误”这类报错面前的无助感——很多上传问题根本原因是前端错误处理不完善,导致真实错误被吞掉了,只留下一个模糊的网络错误提示。
需求是这样:前端页面支持用户一次选择多个文件,点击上传后,多个文件并行上传,界面实时展示每个文件的上传进度,上传完成后汇总展示成功和失败的文件列表,失败的要能单独重试。
3.1 项目结构设计:核心工具函数与组件解耦
我不会把所有逻辑都塞进一个组件文件里,这样不利于单元测试也不利于复用。推荐的结构是:先写一个独立的upload.js核心工具模块,封装单个文件的上传逻辑;再写一个UploadPanel.js组件(或者任意的 UI 层)负责进度展示和用户交互;如果用的是 React/Vue,业务组件直接调用核心工具模块即可,网络层和 UI 层解耦后,后续做自动化测试也会方便很多。
这个设计同时还回应了一个很关键的问题:为什么我会把网络请求封装成返回 Promise 的函数,而不是把 async 逻辑直接写在组件里?原因很简单,组件里过多关注网络请求,会导致错误处理、参数校验、取消请求这些逻辑都混在一起,组件会变得臃肿且难以测试。把核心逻辑抽成独立模块后,你可以在 Node.js 环境下用模拟 fetch 直接做单元测试,不需要启动任何浏览器。
3.2 单个文件上传的 async 函数实现与错误边界
单文件上传的逻辑是整模块的基石。我用XMLHttpRequest而不是fetch,关键原因是你需要监听上传进度事件。fetch在多数浏览器里并不提供上传进度回调,虽然可以用 ReadableStream 做请求体流的进度跟踪,但实现复杂度高、兼容性也差。而XMLHttpRequest天生就带upload.onprogress事件,处理这类场景更直接。
function uploadFile(file, { onProgress = () => {}, signal } = {}) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('file', file); xhr.open('POST', '/api/upload'); xhr.timeout = 30000; // 30 秒超时 if (signal) { signal.addEventListener('abort', () => xhr.abort()); } xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); onProgress(percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { const result = JSON.parse(xhr.responseText); resolve(result); } catch (parseError) { reject(new Error('上传响应解析失败')); } } else { reject(new Error(`上传失败,HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('网络请求错误')); xhr.ontimeout = () => reject(new Error('上传超时')); xhr.onabort = () => reject(new DOMException('上传已取消', 'AbortError')); xhr.send(formData); }); }仔细看这段代码,我做了几层保护:超时处理、取消信号支持、错误响应体解析异常兜底、非 2xx 状态码转业务错误。这些处理有一个共同目的:让错误信息尽量具体,避免用户只看到一个模棱两可的“上传失败”。热词里经常出现的“上传失败:网络请求错误”,很多时候就是xhr.onerror被触发后的默认文案,它没有告诉用户是网络断连、超时、还是服务端 4xx/5xx。如果你在封装时就做了上述细化,用户看到的报错就能从一个笼统文案变成具体可执行的提示,排查成本也会大幅下降。
3.3 多个文件并发上传的正确姿势与并发上限控制
单文件上传搞定后,多文件上传的动作就清晰了:为每个文件调用uploadFile,拿到对应的 Promise,再通过Promise.all等待全部完成。但这里有个实际问题——如果一次拖入 300 个文件,每个文件都立刻发起并发请求,服务端压力会瞬间拉满,本地网络也会被占满。所以生产环境里通常还要加一层并发控制,限制同时上传的文件数。
用 async/await 实现并发控制的方案里,常见的是利用一个“任务池”思路:定义一个并发数为 N 的调度器,每次最多 N 个任务在跑,完成的补位,直到全部任务完成。实现并不复杂,思路就是维护一个working计数,每当一个 Promise settle,不管成功失败,计数减一,如果还有剩余任务,立即补充执行。
async function uploadAll(files, { concurrency = 3 } = {}) { const results = new Array(files.length); let index = 0; async function worker() { while (index < files.length) { const current = index++; const file = files[current]; try { const result = await uploadFile(file, { onProgress: (percent) => { // 更新当前文件的进度 }, }); results[current] = { file: file.name, status: 'success', data: result }; } catch (error) { results[current] = { file: file.name, status: 'failed', error: error.message }; } } } const workers = Array.from({ length: Math.min(concurrency, files.length) }, worker); await Promise.all(workers); return results; }这里有一个有意思的设计决策:为什么用while循环 +index++,而不是遍历生成 Promise?因为前者的每个 worker 是一个长期运行的任务,会自动领取还未处理的下一个文件,天然实现了动态负载均衡——处理快的 worker 多拿几个任务,处理慢的 worker 少拿几个,不会出现一个 worker 忙死另一个闲着的情况。如果你用遍历 + Promise.all 一次性创建所有上传任务,那就完全失去了调度能力,300 个请求同时打出去,也就是常说的“并发风暴”。
并发数怎么定?严格来说要看服务端能力和文件平均大小。我在实际项目中常用的值是从 2 到 5,图片类小文件取 5,大视频文件取 2,带宽充裕或服务端是异步转码的场景可以适当提高。如果不确定,就在测试环境逐步加压,观察服务端的响应时间和 CPU 占用,在“用户体验”和“服务端安全”之间找平衡点。
3.4 进度汇总:并发场景下如何回调 UI 层
多文件上传场景里,进度反馈不是单个文件的百分比,而是“这批次整体进度”。整体进度可以通过已完成的文件数 + 当前正在传输文件的已上传字节数汇总计算:整体百分比 = 已完成的文件字节数总和 + 正在上传文件的已上传字节数 / 所有文件的字节数总和 × 100。
实现时,让onProgress回调把当前文件的 percent 更新到一个可变的 Map 里,UI 层用 requestAnimationFrame 或 setInterval 周期性读取这个 Map 计算整体进度。这里有个工程经验:不要每个文件的 progress 事件都立刻触发 React setState 或 Vue 响应式更新,高频更新会反复触发组件重渲染,页面可能卡顿。比较务实的方案是节流——把进度更新频率限制到每 100ms 一次,或者用 requestAnimationFrame 合并到帧刷新时机,体验会顺滑很多。
4. 常见问题排查实录:async/await 随身避坑手册
写 async/await 代码这么多年,遇到频率最高的不是语法问题,而是一些看似随机、实际有规律可循的运行问题。这一章我把这些坑位整理成一个速查表,方便你下次碰到类似问题直接对照排查。
4.1 现象:请求一直 pending 不返回
排查思路分两步。第一步看 Promise 是否被 settle。如果接口请求一直 pending,且没有触发超时,最常见的两个原因:一是服务端接收到了请求但进程一直不返回,此时要看服务端日志和数据库连接池;二是前端忘记 return Promise,比如用了async function但函数内部调用fetch时前面忘了写await或者忘了return fetch(...),函数其实立即 resolve 了 undefined,看起来像什么都没发生。
第二步看是否有死锁。多个 async 函数相互 await 时,如果 A await B,B 又 await A,在事件循环里就形成了一个循环等待,谁都等不到对方的结果,请求看起来就是永久 pending。这类问题比较隐蔽,定位时要先梳理整个 await 调用链。
// 错误示范:忘记 return 导致函数立即 resolve async function getUser() { fetch('/api/user'); // 没有 return,也没有 await } const user = await getUser(); // user 是 undefined4.2 现象:async 函数里报错但 catch 不到
常见误区是被捕获到的错误信息太模糊,真正原因被吞掉了。比如在 async 函数里直接写await somePromise而没有给somePromise本身的生成过程加保护,somePromise的 executor 里同步抛错,这个错误会在 Promise 构造阶段就被捕获并转成 reject,通常try/catch能捕获到。但如果某个回调函数抛错发生在事件循环的下一个 tick,比如.then回调或者事件监听器里抛错,那就不一定是当前的 try/catch 能捕获的。
另外注意一个细节:try/catch包裹await时,如果被 await 的 Promise 已经被其他地方处理过(比如有人提前调用了promise.catch(() => {})),错误就会被那个 catch 消费掉,外层的 catch 反而拿不到。这个坑在团队协作开发时特别容易出现——A 写了底层函数,B 在调用前先做了一次兜底.catch,两个人的错误处理逻辑就叠加了。
4.3 现象:unhandledrejection 报错但代码看起来没问题
unhandledrejection 是前端监控系统里最常见的前端异常类型之一。根因是项目中的某个 Promise 被 reject,却没有对应的 catch/await 来处理。即便你大部分代码都用了 async/await,只要有一个函数返回 Promise 后调用方没有await,也没挂.catch,一旦 reject,就会变成 unhandled rejection。
我在团队里要求统一的约定:函数只要有 Promise 类型的返回值,调用方必须显式 await 或 catch,不能静默丢弃。审查代码时,凡遇到foo()这种光秃秃调用,而foo是一个 async 函数,都会盯住看它是否处理了错误。如果你有大量代码需要排查,可以在开发环境全局挂载process.on('unhandledRejection')事件,Node.js 里能打印出堆栈信息,浏览器里则监听unhandledrejection事件,配合 sourcemap 能快速定位到具体文件。
4.4 常见问题速查表
整理如下表格,基本覆盖了实战中的高频问题,可直接当作排查手册使用。
| 症状 | 常见原因 | 解决动作 |
|---|---|---|
| await 所在函数报 SyntaxError | 函数缺少 async 声明 | 补全函数声明为 async |
| async 函数返回值是 Promise 而不是预期值 | 调用方未使用 await 或 then 接收 | 调用处加 await |
| 多个并发请求耗时过长 | 请求串行执行,存在不必要依赖 | 用 Promise.all 并行化 |
| 上传一直 pending 无响应 | 缺少超时控制,或服务端未返回 | 设置 xhr.timeout 与服务端超时检查 |
| 接口返回 4xx/5xx 但 fetch 没进 catch | fetch 只在网络层失败时 reject | 主动检查 response.ok 并抛出业务错误 |
| 整体进度卡在某个百分比不动 | 单个文件上传因网络或服务端卡住 | 为上传增加超时与重试机制 |
| 并发请求导致接口报 429/503 | 并发数过高,服务端限流 | 限制并发数或使用队列调度 |
4.5 排查中值得留意的三种特殊场景
除了上面的表格,还有三个特殊场景值得单独说。
第一个是“隐藏的串行”。很多人认为自己用了 Promise.all 就是并发了,但实际上 Promise 数组的构造过程可能是串行的,比如在 map 回调里先 await 了一个异步函数,再返回新的 Promise,那么每个 Promise 的创建都依赖上一个请求的结果。要检查这一点,可以看数组构造阶段是否包含 await,如果包含,那本质上还是串行执行。
// 这是串行:map 的回调是 async 的,内部 await 阻断了循环 const results = await Promise.all( ids.map(async (id) => { const user = await getUser(id); return processUser(user); }) );这个例子其实比较微妙。map的回调是 async 的,所以它在每次循环里都会创建一个新的 Promise,但因为是异步执行的,多个循环里的getUser(id)会“同时”发出,不一定完全串行,这取决于 getUser 内部先同步执行到第一个 await 还是先返回 Promise。从这个细节你能看到,async 函数内部的“同步执行段”与会合点不同,会直接影响实际并发度。要真正可控地并发,还是建议回归到第 3 章的任务池模式,手动调度任务的启动时机。
第二个是“await 的扩容陷阱”。在循环里写await时,除非有明确的依赖关系,否则都要先问一句:这次循环的每个迭代是否真的依赖上一个迭代的结果?如果不是,就应该把 await 移出循环体,改成 map 生成 Promise 数组后统一 await。
第三个是“取消操作的无奈”。async/await 并没有原生的取消机制,你没有办法从一个 async 函数外部直接“暂停”或“终止”它。比较现实的做法是利用 AbortController 信号,把取消请求传入底层网络层(比如 fetch 或 xhr),当取消事件触发时,网络请求会被中断,对应的 Promise 会 reject。但是注意,如果 async 函数体里除了网络请求还有本地耗时的计算任务,AbortController 并不能中断那些计算逻辑,你需要自己检查取消标记。这一点在 React 组件卸载后更新状态时尤其重要——组件已卸载、但 setTimeout 或网络回调还在执行,然后 setState 一个已经不存在的组件,React 老版本会报 warning,新版本则是静默失效但不影响用户体验。正确的清理方式是组件卸载时执行 AbortController 的 abort,让后端的请求和本地回调都被终止。
5. 一些真正能让代码更稳的额外功课
如果你已经掌握了前面所有内容,恭喜,async/await 的主力用法你已经覆盖了九成。最后的额外功课是从“能用”到“专业”的跨越:代码风格上、可维护性上、团队协作上的一些细节打磨。
第一个建议是给每个 async 函数都明确写出返回类型。如果项目用了 TypeScript,async function getUserById(id: string): Promise<User>这样的签名非常直观;如果用的是 JavaScript,可以在 JSDoc 里写@returns {Promise<User>}。这样做的价值不在于给人看,更多是为了让 IDE 的智能提示和静态检查能帮你抓住错误——很多混乱都是“这个函数到底返回什么类型的数据”这种问题引起的。
第二个建议是正确处理 await 后续代码中的错误。有人喜欢把整个函数体包在一个巨大的 try/catch 里,函数体内 100 行代码,任何一步出错都走同一个 error handler。这种写法在业务简单时可行,但函数复杂后很难定位问题。更好的做法是缩小 try/catch 的范围,只包裹真正可能出错的异步调用,让普通逻辑保持扁平。
第三个建议是不要在不需要异步的地方强行 async。有些函数体内没有任何异步操作,却因为“可能将来会用到”就加了 async 关键字。这会让函数从同步变成异步返回 Promise,调用处的行为也随之改变,更糟的是错误栈和调用时机会变化,排查问题的成本增加。Rule of thumb 很简单:不需要 await,就不要加 async。
最后分享一个我从实践中养成的习惯:在 console.log 和调试器中,把 Promise 的 pending/resolved 状态视作第一级提示。调试 async 代码时,第一步永远是确认你想等待的 Promise 是否已经 settled。如果你看到 Promise 停留在 pending,先别急着调试业务逻辑,把注意力放在“为什么这个 Promise 没有 settle”——是没有 resolve,还是没有 reject,还是根本没人调用 resolve 函数。这一步往往就能定位到七成的异步 bug。
async/await 是一个入门极快但精进需要时间的话题,它的优雅建立在语言底层事件循环、微任务队列、Promise 状态机这些机制之上。等你真正理解了这些机制,再回头看那些“上传失败”“请求挂起”的诡异问题,很多都能在几秒内定位到根因。我个人的体会是:不要满足于会用语法,多花一点时间理解它在事件循环中的调度行为,你写出的异步代码会从“碰巧能跑”变成“稳定可控”。如果你在找一些练手项目,强烈建议把手里的上传功能、或者某个批量接口拉取的模块,用 async/await 重新实现一遍,并刻意加上错误分支和并发控制,完成这一步你基本就出师了。