async/await 异步函数实战:从Promise到并发控制文件上传 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 对象也可以不是。如果右侧不是一个 PromiseJavaScript 会先把它用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 都支持了顶层 awaitTop-level await也就是说在 ES 模块的顶层作用域里可以直接使用 await不需要额外包一层 async。但要注意这个特性仅对模块生效在 CommonJS 模块里是不适用的这属于环境差异碰到报错时不要慌先确认你当前的文件是不是 ES Module。2. 核心使用场景从基础语法到实战细节说完底层原理我们进入实操层面。async/await 的语法本身很简单不外乎在函数前加async在异步操作前加await但真正决定代码质量的是对细节的把握错误处理是否到位、并发控制是否合理、异常中断是否符合预期。这一章会逐个拆解这些核心细节每一项都是项目里踩过坑换来的经验。2.1 函数声明形式async 不止能修饰 functionasync关键字可以修饰各种形式的函数声明不仅仅是传统的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/finallyfinally 里的代码总会执行这一点对资源释放场景很重要。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 BB 又 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 声明补全函数声明为 asyncasync 函数返回值是 Promise 而不是预期值调用方未使用 await 或 then 接收调用处加 await多个并发请求耗时过长请求串行执行存在不必要依赖用 Promise.all 并行化上传一直 pending 无响应缺少超时控制或服务端未返回设置 xhr.timeout 与服务端超时检查接口返回 4xx/5xx 但 fetch 没进 catchfetch 只在网络层失败时 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 函数都明确写出返回类型。如果项目用了 TypeScriptasync function getUserById(id: string): PromiseUser这样的签名非常直观如果用的是 JavaScript可以在 JSDoc 里写returns {PromiseUser}。这样做的价值不在于给人看更多是为了让 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 重新实现一遍并刻意加上错误分支和并发控制完成这一步你基本就出师了。