
1. 先聊聊为什么要把 Promise 和 async/await 放在一起做前端的朋友应该都有这种体验早些年写异步代码全靠回调函数层层嵌套一旦逻辑复杂起来回调金字塔马上能把人绕晕。后来 Promise 出现了它把异步操作包装成一种状态机通过.then()链式调用解决了回调地狱的一部分问题。但.then()用多了也有它的麻烦——尤其是当你需要按照特定顺序依次处理多个异步任务时连续十几个.then()串在一起代码读起来依然不够直观排查问题全靠断点调试慢慢追。于是 async/await 就来了。注意async/await 并不是取代 Promise 的新机制它是建立在 Promise 之上的一种语法糖。你可以把它理解成Promise 是底层的异步容器async/await 则是给你一个“同步写法”的入口。也就是说写 async/await 的代码本质上还是在操作 Promise只不过你不再需要手动写.then()和.catch()了代码的可读性会大幅度提升尤其是处理串行任务和错误捕获的时候写起来非常顺手。这篇文章我想从实际开发的角度出发把 Promise 和 async/await 的调用关系、转换方法、错误处理、常见坑位都梳理一遍。无论你是刚接触前端异步编程的新手还是已经写过不少业务代码但总觉得哪里不太踏实的中级开发者这篇文章应该都能帮你把这一块的知识补完整。我会用大量可直接跑的示例代码来讲每个代码片段都有明确的使用场景说明不会只丢一个干巴巴的“Hello World”。提示文中的所有示例代码均基于现代 JavaScript 语法建议使用 Node.js 14 以上的版本或者在主流浏览器中直接运行。2. 基础调用怎么把一个 Promise 用 async/await 拿回来2.1 从 Promise 到 async/await 的转换思路先看一个最基础的 Promise 例子。假设我们要模拟一个获取用户信息的异步请求用 Promise 写是这样的function fetchUser(id) { return new Promise((resolve, reject) { setTimeout(() { if (id 0) { resolve({ id: id, name: 张三 }); } else { reject(new Error(无效的用户ID)); } }, 1000); }); } // 使用 .then() 调用 fetchUser(1) .then(user console.log(用户信息, user)) .catch(error console.error(出错了, error.message));这个写法本身没有大问题但注意一旦后续操作变多比如拿到用户信息之后还要继续请求他的订单、订单里的商品、商品的评价…….then()就开始不够优雅了。用 async/await 改写方式很简单先在函数前面加上async关键字然后在异步操作前面加上awaitasync function getUserInfo() { try { const user await fetchUser(1); console.log(用户信息, user); } catch (error) { console.error(出错了, error.message); } } getUserInfo();这里每一步的逻辑都变得很直白await fetchUser(1)的意思是“等这个 Promise 完成完成之后把结果赋值给 user”。如果 Promise 被 reject 了异常会被catch捕获和同步代码里try/catch的体验几乎一模一样。关键点在哪里关键在于你必须在async函数内部才能使用await。直接在普通函数里写await fetchUser(1)JavaScript 解析器会直接报语法错误。这个约束的本质是await本身就是为 async 函数设计的它依赖 async 函数内部的特殊执行上下文来管理暂停与恢复。2.2 await 到底“停”住了什么很多新手对await有一个误解觉得它会让整个程序停下来等待。实际上不是的await只暂停当前 async 函数内部的后续代码执行并不会阻塞事件循环。换句话说await把当前这一小段代码的执行权交了出去等 Promise 完成后再恢复执行。你可以用下面这段代码实际感受一下console.log(脚本开始); async function testAwait() { console.log(async 函数内部await 之前); await new Promise(resolve setTimeout(resolve, 2000)); console.log(async 函数内部await 之后); } testAwait(); console.log(脚本结束);这段代码在控制台输出的顺序是脚本开始 async 函数内部await 之前 脚本结束 async 函数内部await 之后看到没有await等待的这两秒里外层代码该执行还是继续执行。这就是为什么用 async/await 处理异步任务时页面不会因此“卡死”——await只是把这一处代码变成异步的等待点事件循环一直在正常运转。2.3 串行调用多个 await 顺序排队在真实业务里最常见的场景就是“先拿用户再拿用户的订单列表”。这种有依赖关系的异步任务天然适合串行执行function fetchUser(id) { return new Promise((resolve, reject) { setTimeout(() { resolve({ id, name: 李四 }); }, 500); }); } function fetchOrders(userId) { return new Promise((resolve, reject) { setTimeout(() { resolve([ { id: 1001, total: 199 }, { id: 1002, total: 299 } ]); }, 800); }); } async function getUserWithOrders(id) { const user await fetchUser(id); const orders await fetchOrders(user.id); console.log(user.name 的订单, orders); return { user, orders }; } getUserWithOrders(1);在这个示例中fetchOrders必须拿到user.id才能发起请求因此两个await按照先后顺序严格执行。如果改用.then()来写虽然也可以实现但嵌套层级会明显增加而且返回值想要继续往下传代码会变得很臃肿。另外提醒一下上面代码里的总等待时间是 500ms 800ms也就是大约 1.3 秒。因为第二步依赖第一步的结果这里的串行等待是不可避免的。但如果你有两个互相之间没有依赖的异步任务还把它们写成两个await依次执行就有点浪费了。这种情况应该用Promise.all并行处理。2.4 并行调用没有依赖就别傻傻排队来一个很常见的反例。有朋友在业务代码里写过这样的逻辑async function loadPageData() { const user await fetchUser(1); const notice await fetchNotice(); // 后续处理... }这里fetchUser和fetchNotice之间没有依赖关系一个需要 500ms一个需要 700ms。如果按上面的写法串行执行总耗时是 1200ms。但你要是用Promise.all把两个请求同时发出去总耗时只要最慢的那个也就是 700msasync function loadPageData() { const [user, notice] await Promise.all([ fetchUser(1), fetchNotice() ]); // user 和 notice 都已经拿到 console.log(user, notice); }Promise.all接收一个 Promise 数组然后返回一个新的 Promise这个新 Promise 会在所有成员都成功时带着结果数组 resolve任何一个成员失败则立即 reject。配合 async/await 使用非常顺手解构赋值直接拿到每一项的结果代码短小精悍。还有一个Promise.allSettled也值得提一下它和Promise.all不太一样Promise.allSettled不会因为某一个请求失败就整体失败它会等所有 Promise 都执行完之后把每个 Promise 的状态和结果如实返回。适合那种“一个失败了不影响其他继续执行”的场景比如批量上报数据时某一项失败了也不希望整个批量流程中断。3. 实战拆解async/await 调用 Promise 的完整场景3.1 封装一个统一的异步请求工具函数只看语法层面的示例还不够真正写业务的时候我们需要一个更通用的封装。假设你在项目中用 axios 发请求正常情况下所有的请求都返回 Promise。我们可以封装一个统一的请求方法专门负责处理响应数据、错误提示和业务状态码async function request(url, options {}) { try { const response await fetch(url, options); if (!response.ok) { throw new Error(HTTP 状态码异常 response.status); } const data await response.json(); // 假设后端返回结构为 { code, data, message } if (data.code ! 0) { throw new Error(data.message || 业务处理失败); } return data.data; } catch (error) { // 统一在这里做错误上报或者提示 console.error([request] 请求失败, url, error); throw error; // 继续向上抛让调用方决定如何处理 } } // 实际调用 async function getProductDetail(productId) { const detail await request(/api/product/detail?id productId); renderProduct(detail); }这段封装的思路值得注意函数内部把异常捕获之后做了一次统一的日志记录然后重新向外抛出。为什么要再抛一次因为不同业务场景对同一个错误的处理方式不一样有的地方需要弹提示框有的地方只需要静默忽略。统一封装不应该替调用方做所有决定它做好兜底然后把决策权交给上层。这个模式在团队开发里很常见避免了每个业务函数都重复写一堆try/catch去处理相同的网络错误。3.2 在 Vue / React 组件里异步初始化数据前端框架里使用 async/await 的场景更多。拿 React 的函数组件距离我们经常在useEffect里拉取数据import { useEffect, useState } from react; function UserDashboard({ userId }) { const [user, setUser] useState(null); const [loading, setLoading] useState(true); useEffect(() { // 必须用一个内部 async 函数因为 useEffect 的回调本身不推荐返回 Promise async function loadUser() { try { const data await fetchUser(userId); setUser(data); } catch (error) { console.error(用户信息加载失败, error); } finally { setLoading(false); } } loadUser(); }, [userId]); if (loading) return div加载中.../div; return div{user ? user.name : 暂无数据}/div; }这里有一个很容易踩的坑直接在useEffect里写await是不行的因为useEffect的回调函数不是 async 函数。正确的做法是把async逻辑包在一个内部函数里然后调用这个内部函数。另外finally这个块在异步场景里非常有用——不管成功还是失败loading 状态都应该被正确关闭finally保证了这个逻辑一定被执行。Vue 3 的组合式 API 写法其实更直接一些import { ref, onMounted } from vue; setup() { const user ref(null); const loading ref(true); onMounted(async () { try { user.value await fetchUser(1); } catch (error) { console.error(error); } finally { loading.value false; } }); return { user, loading }; }Vue 的onMounted回调可以直接支持 async 函数内部代码组织起来非常清爽。不过不管在哪个框架里核心逻辑都是一样的async/await 负责把异步逻辑组织成顺序代码框架的生命周期钩子负责决定何时触发这些逻辑。3.3 循环里的 await别写错位置循环中调用异步函数也是一个高频场景。比如有一个包含多个 ID 的数组你需要为每个 ID 请求一份数据。如果你对每个元素执行await它们的执行方式是串行的async function fetchUsersFromIds(ids) { const users []; for (const id of ids) { const user await fetchUser(id); users.push(user); } return users; }这种写法逻辑没有问题但性能是值得商榷的如果 ids 有 10 个每个请求耗时 300ms串行执行总耗时就是 3 秒。而且这些请求之间完全没有依赖关系完全可以并行。改成并行方案async function fetchUsersFromIds(ids) { const promises ids.map(id fetchUser(id)); return Promise.all(promises); }一行map加一个Promise.all总耗时降到约 300ms。这背后的原理并不复杂ids.map(id fetchUser(id))会立刻把 10 个请求全部发出去它们并发执行而不是等一个完成再发下一个。日常开发中你需要一个判断标准如果某次循环的结果会影响下一次循环的参数就必须串行 await如果互相独立优先考虑并行方案。顺便提一句for...of和forEach在 async/await 下表现不同。forEach里的 async 函数不会等彼此完成因为forEach本身不感知 Promise。你可能以为这样写没问题// 错误示范forEach 无法 await async function fetchUsersFromIds(ids) { const users []; ids.forEach(async id { const user await fetchUser(id); users.push(user); }); console.log(users); // 这里大概率是空数组因为异步还没完成 }这就是一个经典的并发 bugforEach回调执行到await时让出执行权forEach自己马上就结束了根本不会等待内部 Promise 完成。所以循环体内需要 await 的时候老老实实用for...of或者Promise.all。3.4 await 与非 Promise 值它其实很宽容再看一个细节await后面不一定要跟 Promise它可以跟任意普通值。如果你写await 42JavaScript 会直接把 42 作为完成值返回相当于await Promise.resolve(42)。这个特性的实际意义在于你可以在一个 async 函数里统一用 await 去读取数据而不需要关心对方返回的是 Promise 还是普通值。比如你有一个配置对象可能是同步的常量也可能是从接口动态获取的。你可以这样处理function getConfig() { // 假设有时候返回普通对象有时候返回 Promise return Math.random() 0.5 ? { theme: dark } : Promise.resolve({ theme: light }); } async function initApp() { const config await getConfig(); applyConfig(config); }不管getConfig返回什么await 都能稳妥地把最终值取出来。这种宽容性让 async 函数的接口设计更好做对接代码混用同步和异步数据源也能保持一致的书写方式。4. 错误处理uncaught in promise 到底怎么回事4.1 没有 catch 的 Promise 会怎样很多朋友遇到过一个控制台报错一大段红字写着Uncaught (in promise) ...尤其常见于引用了第三方编辑器或者某些 UI 组件的时候。这个错误的本质是一个 Promise 被 reject 了但是没有对应的.catch()或者await捕获它。举个例子function mightFail() { return new Promise((resolve, reject) { reject(new Error(服务器拒绝了这个请求)); }); } mightFail(); // 没有调用 .catch 也没有 await运行这段代码控制台就会输出Uncaught (in promise) Error: 服务器拒绝了这个请求。这不是代码不能运行而是运行时把没有被处理的 Promise 失败告知了你。在严格的前端规范里这种未捕获的 Promise 拒绝通常意味着你的代码存在潜在缺陷——某次请求失败了但没有任何用户提示或日志记录。解决办法很简单要么调用方捕获要么在 Promise 内部保证 reject 一定被消费mightFail().catch(error { console.error(我捕获到这个错误了, error.message); });或者用 async/await 方式async function main() { try { await mightFail(); } catch (error) { console.error(我捕获到这个错误了, error.message); } }4.2 引用第三方组件时报 uncaught in promise 的排查思路热词里有一条很具体的报错“引用 wangeditor 报 uncaught (in promise) error: unable to find a host window element”。这是在实际项目里很容易撞上的场景。这类报错的典型原因是第三方组件内部异步初始化某个功能时依赖的 DOM 元素尚未挂载到页面上或者组件实例在一个不合适的生命周期里被创建了。遇到这类问题不要先从 Promise 的角度去排查而要先看组件挂载的时机。比如在 Vue 里如果你在created钩子中初始化富文本编辑器此时模板还没渲染完成编辑器找不到宿主元素就会在内部的异步流程里抛出一个 Promis rejection最终表现为uncaught in promise错误报告。解决办法是把初始化工作挪到onMounted或者nextTick之后执行import { onMounted, nextTick } from vue; setup() { onMounted(async () { await nextTick(); // 此时 DOM 已经渲染完成再初始化编辑器 const editor new Editor(#editor-container); }); }这是排查uncaught in promise的一个通用思路先关注时序再看数据。很多异步报错不是逻辑写错而是执行顺序不对导致某个依赖值不存在。定位时用 Chrome DevTools 的调用栈Call Stack看一下从哪个 async 函数抛出来的通常很快就能锁定问题。4.3 catch 到了不等于万事大吉捕获错误只是第一步更关键的是捕获之后做什么。很多团队的业务代码里有一个坏习惯catch (error) { console.log(error) }。这句话在开发环境里没问题但它掩盖了一个事实——用户并没有收到任何反馈业务流程可能还是中断的。真正好的错误处理设计需要考虑三层技术层面错误被捕获了不产生uncaught in promise产品层面用户看到了一个友好的提示或者流程有一个降级方案工程层面错误信息被上报到了监控平台方便后续改进举个例子假如你的页面要加载用户列表加载失败后你可以降级成显示缓存数据同时提示“网络异常当前展示的是缓存内容”。这是一个比较好的处理方式async function loadUserList() { try { const list await fetchUserList(); renderList(list); } catch (error) { console.error([loadUserList] 加载失败, error); const cachedList readFromCache(); if (cachedList.length 0) { renderList(cachedList); showNotice(网络异常当前展示的是缓存内容); } else { showErrorPage(); } } }错误处理不是把 catch 写上就是完成而是要让代码在各种失败路径下都能给用户一个合理的交代。4.4 async 函数内部的异常会变成 Promise 拒绝还有一个容易忽略的细节async 函数内部如果抛出了同步异常这个异常不会直接冒泡到调用栈之外而是会被自动封装成一个 rejected Promise。看这个例子async function extractName(obj) { return obj.user.name; // obj 是 undefined 时会抛 TypeError } extractName(undefined).catch(error { console.log(捕获到了, error.message); });obj.user这一行在异步函数里抛出的 TypeError不会直接崩掉程序而是变成 extractName 返回的 Promise 的拒绝原因。这就是为什么热词里有“uncaught in promise typeerror: cannot read properties of undefined (reading ...)”这种报错。它本质上是一个异步形态的空值访问异常。代码评审时看到这类问题重点应该放在防御式编程上async function extractName(obj) { return obj?.user?.name || 未知用户; }用可选链操作符先做一层防御很多烦人的 uncaught in promise 其实都能在源头被消灭掉。5. 面试题向Promise / async / await 的几个高频考点5.1 输出顺序题你算得准吗问一道非常经典的面试题猜猜下面这段代码控制台输出的顺序。console.log(1); setTimeout(() { console.log(2); }, 0); Promise.resolve().then(() { console.log(3); }); (async function () { console.log(4); await Promise.resolve(); console.log(5); })(); console.log(6);正确答案是1、4、6、3、5、2。没猜对的朋友也不用灰心这里牵扯到 JavaScript 的事件循环机制。同步代码先执行微任务Promise 回调在当前同步代码结束后、下一个宏任务之前批量执行宏任务setTimeout排在最后。所以顺序是先打印 1然后遇到 setTimeout 挂起遇到 Promise.then 挂入微任务队列进入 async 函数立即打印 4碰到 await 暂停把后续的打印 5 挂入微任务队列回到外层打印 6同步代码执行完毕开始处理微任务队列先打印 3 再打印 5最后执行 setTimeout 回调打印 2。这类题目的价值在于帮你建立对异步调度机制的直觉。做业务开发时虽然不常需要精确到队列里的先后顺序但要理解多个异步任务之间的执行相对顺序才能在调试时准确判断谁是“先发出去的”。5.2 async/await 本质上是什么面试中还有一类追问async 函数的返回值是什么答案是只能是 Promise。不管你在 async 函数内部返回什么值它都会被 Promise.resolve() 包装。比如async function foo() { return bar; } foo(); // 返回 Promise { bar } foo().then(value console.log(value)); // 打印 bar还记得上一节说的 await 可以接受普通值吗JavaScript 在实现 async 函数时给返回值加了这层隐式包装所以调用一个 async 函数得到的永远是 Promise这也是 async/await 与 Promise 深度绑定最直接的一个体现。另外一个常见问题是能不能只在普通函数里用 await不能。await 是保留关键字设计上只允许出现在 async 函数内部。但有一个场景例外现代浏览器和 Node.js 的部分版本支持“顶层 await”top-level await在 ES 模块中可以直接使用 await 而不需要包一层 async 函数// 仅在 ES module 环境中支持 const data await fetchData();不过实际业务项目中最好还是把顶层 await 限制在不需要对外暴露复杂错误处理的可控场景大部分业务逻辑放在 async 函数里组织更稳妥。5.3 多个 catch 的取舍await vs .catch那问题来了到底什么时候用await配合try/catch什么时候用.catch()个人经验里有一条非常实际的原则如果你在一个复杂的业务函数中需要连续处理多步异步操作用 try/catch 统一包起来更合适如果你只是希望某个独立的异步请求失败后不影响主流程用.catch()更简洁。比如你要并发请求三个接口但其中第二失败了不影响第一和第三的展示那你适合这样做const [a, b] await Promise.all([ fetchA().catch(() null), fetchB().catch(() null) ]); // b 失败了就是 null不会让整个 Promise.all 中断这种写法比较反直觉——为什么不直接Promise.all(...).catch()因为.catch()会让整个 Promise.all 失败从而让另一个成功的请求结果也丢失。逐个给每个异步加.catch(() null)做降级才能保证彼此隔离。这是业务实践中一个非常实用的技巧。5.4 Promise、async/await 和事件循环的联系最后把异步编程三条线串一下Promise 提供异步结果的状态管理async/await 提供可读的消费方式事件循环负责调度这些异步任务在合适的时间点执行。这三个概念彼此独立又互相依赖。比如你问“Promise 是宏任务还是微任务”标准答案是Promise 的回调属于微任务microtask它在当前宏任务结束后、浏览器渲染前批量执行。所以遇到大量 Promise 递归时要注意微任务队列可能出现饥饿——微任务不断插入让浏览器迟迟等不到渲染时机长列表动画会显得卡顿。日常开发中我们不需要每次写异步代码前都重新推导一遍事件循环但必须建立这种心智模型await 后面的代码并不一定会按照书写顺序紧接着执行它要先排队等下一轮微任务调度。看到这里你再去回看第五节开头那道输出顺序题就完全没有压力了。6. 让你少走弯路的十个经验点6.1 await 位置不对时的代码义诊有一次我帮同事查一个 bug他写的代码大概是这样的async function submitForm() { const formData getFormData(); const result await saveToServer(formData); if (result.success) { showSuccessTip(); } }表面看没有毛病但问题出在getFormData()里调用了某个异步校验流程而他没有 await 就直接返回数据表单里的某些字段还没校验完成就被提交上去了。这一类问题排查起来非常隐蔽。经验是如果一个函数内部有异步操作它的返回值就应该是一个 Promise调用方要么 await 它要么明确地不关心结果。千万不能“迷迷糊糊地调用飘飘忽忽地用”。6.2 不要在所有地方都加上 async有个相反倾向的问题有些同事只要看到函数里面有一点异步味道就立刻给整个函数加上 async。这样会带来两个副作用一是函数返回值变成 Promise原来同步返回可以被直接消费的地方被迫加上 await导致改动面扩散二是在不需要等待的地方加了 await 会无端增加微任务的开销。判断依据非常简单你需不需要在这个函数内部使用 await需要就加 async不需要就不加。6.3 超时控制Promise 没有内置超时机制一个永远挂起的 Promise 会一直卡住你的 async 函数。实际业务中接口超时是家常便饭所以封装一个带超时控制的 Promise 非常有必要function withTimeout(promise, ms, timeoutMessage 请求超时) { return new Promise((resolve, reject) { const timer setTimeout(() { reject(new Error(timeoutMessage)); }, ms); promise.then( value { clearTimeout(timer); resolve(value); }, error { clearTimeout(timer); reject(error); } ); }); } // 用法 async function loadData() { const data await withTimeout(fetchData(), 5000); return data; }这个工具的巧妙之处在于它同时监听了原 Promise 和超时定时器谁先到就先用谁的结果。原 Promise 完成后记得清除定时器避免定时器在背后多做一次无效的触发。对于用户感知较强的场景还可以在 catch 里判断错误消息是不是自定义的“请求超时”从而给用户不同的提示文案。6.4 防重复提交表单提交时用户连续点击按钮会触发多个并行请求造成重复数据。传统防抖方案在异步场景下不一定可靠因为请求正在飞行的时候无法预知会成功还是失败。配合 async/await 做一个简单的锁let submitting false; async function submitOrder() { if (submitting) { return; // 直接忽略重复点击 } submitting true; try { await createOrder(); showSuccess(); } finally { submitting false; } }finally在这里保证了无论请求成功失败锁都能被正确解开不会出现一次失败后永远无法再次提交的问题。这是 async/await 相对于传统回调时代的一大进步——你终于可以用同步的思维去管理一个异步流程的“生命周期”了。6.5 隐藏的 Promise 竞态竞态问题race condition在异步代码里非常隐蔽。常见的场景是用户快速切换选项卡先后发出了多个请求如果第一个请求比第二个慢它可能在第二个请求之后返回导致界面显示了上一次选择的内容造成数据错乱。解决办法是忽略过期请求的返回结果let requestSeq 0; async function handleTabChange(tabId) { const currentSeq requestSeq; const data await fetchTabData(tabId); if (currentSeq requestSeq) { // 只有最新一次请求的结果才允许渲染 renderData(data); } }核心逻辑是“我给每个请求发一个递增编号只有编号等于当前最大编号的请求返回结果才被认账”。这种写法比用 AbortController 取消请求更轻量而且不是所有请求库都支持取消。当然如果你的请求库支持 AbortController也可以结合使用从根源上避免浪费带宽。7. 最后聊点实际的写到这里基本把 Promise 通过 async/await 调用的方方面面都过了一遍。从最初的语法转换、串行并行编排到错误处理、面试题拆解再到经验坑位每一部分背后都是我在实际项目和代码评审里踩过、也帮别人排查过的问题。我个人最深的体会是async/await 真正的价值不是让你少写几个.then()而是让异步代码的意图表达变得更清晰。代码首先是给团队里的同事读的其次才是给机器执行的。当你把一步接一步的异步流程写得像同步代码一样顺畅时出 bug 的概率会明显下降。如果你正在学习这一块建议别急着背面试题先找一个真实的小需求练手比如做一个输入框防抖搜索、一个商品列表加载更多、一个表单提交按钮防重复……把 async/await 用到这些场景里跑通一个完整的工程流程比看十篇文章都管用。再分享一个小技巧作为收尾调试异步代码时可以在 async 函数入口和每个 await 之后分别打console.trace()这样能非常清晰地看到异步调用的时序栈。配合 Chrome DevTools 里的 Sources 面板给await处添加断点你就能像调试同步代码一样逐步查看 Promise 的中间状态。这套调试方法帮你摆脱了“异步只能靠猜”的困境值得一试。