资讯动态

接口数据赋值总失败?彻底搞懂async/await与Promise时序

发布时间:2026/10/14 3:29:31 来源:尧图企业网站定制
数据是根据接口请求来的要在页面变量上完成赋值就必须等接口返回之后再做这件事。这句话听起来像废话但实际项目里因为这个跌跟头的人一抓一大把我自己刚写前端时也栽过Network面板里接口明明200正常返回页面打印出来的变量却是undefined甚至直接打印出一个Promise对象。这篇笔记就来复盘一下“接口数据赋值”这件事重点讲清楚async/await在其中的作用覆盖原生JavaScript、React Hooks和Vue三种常见场景也会把事件循环的时序和排查链路一起聊透。1. 同样的代码为什么你拿到的是Promise而不是数据1.1 现场还原控制台里那个Promise { }我见过太多类似的提问了写了一个接口请求想把它返回的数据赋给一个变量结果打印出来是这样的let data null; function loadData() { data request(/api/user/list); // request 返回一个 Promise } loadData(); console.log(data); // Promise { pending }第一眼看到这个输出很多人会怀疑是不是网络问题或者接口写错了。实际上网络没有任何问题就是你把Promise当成了真实数据。request(/api/user/list)这个调用只要一执行会立刻返回一个Promise对象。这个对象代表“将来某个时刻会得到的数据”。在请求还没返回、Promise还没进入fulfilled状态之前你是拿不到里面的真实数据的。上面这段代码的data request(...)本质上是把“一个未来才确定的值”的容器赋给了变量容器本身当然打印不出列表数据。类比一下你点了外卖手机里立刻生成一个订单号。订单号不是饭它是将来饭到了之后可以用来取餐的凭证。把订单号塞进变量里然后打印变量当然只能看到订单号不可能看到红烧肉。Promise就是这个外卖订单await才是真正等你拿到餐的动作。1.2 更迷惑的现场值确实是后取的为什么还是undefined如果说打印Promise还算好判断下面这种写法就迷惑多了let data null; async function loadData() { const response await request(/api/user/list); data response.data; } loadData(); console.log(data); // undefined这次data response.data确实在await之后语法上没有任何毛病。但console.log(data)打印的还是undefined为什么因为await只暂停了loadData这个async函数内部的后续代码它不会暂停外面的同步代码。loadData()调用之后函数开始执行走到await那里挂起控制权立刻交回给外层。外面的console.log(data)是同步代码跟着就执行了。而此刻loadData内部的赋值可能根本没跑到呢。这里最大的认知误区在于很多人习惯从上往下读代码以为loadData()跑完才会执行下面的console.log。但真实世界不是这样的async/await并没有把一个异步函数变成同步函数它只是让异步函数内部的代码“看起来像同步”了。函数外部的人是不会站在原地等它的。2. async/await的底层逻辑外卖订单的等待哲学2.1 Promise的三种状态和await的等待对象要理解async/await先得把Promise搞明白。Promise有且仅有三种状态状态含义类比pending请求还在路上结果未知外卖还在配送fulfilled请求成功拿到了数据外卖已送达可以开吃rejected请求失败得到错误信息外卖送丢了商家退款await的关键作用就是让当前async函数的执行“暂停”下来直到它后面那个Promise离开pending状态。如果Promise变成fulfilledawait表达式的值就是resolve出来的数据如果变成rejectedawait会直接把这个异常抛出来需要try/catch去接。所以await request(/api/user/list)这句代码的真实意思是先把请求发出去拿到那个Promise然后这个async函数暂时挂起不往下走。等响应回来了Promise转为fulfilled函数再从挂起的地方恢复执行。挂起期间外面该干什么干什么。2.2 await后面的代码到底什么时候被推进这里有个非常关键的事件循环概念await挂起函数之后后面那一段代码并不会立刻被塞回执行栈而是要等当前同步代码全部跑完再通过微任务队列来恢复。看一个典型例子console.log(1: 同步代码开始); const promise request(/api/list); console.log(2: 拿到了Promise但数据还没到); promise.then(() { console.log(4: Promise的then回调属于微任务); }); console.log(3: 同步代码结束); async function run() { const response await request(/api/list); console.log(5: await后面的代码本质上是另一步微任务); data response.data; } run(); console.log(6: run()返回了但它内部还没执行完);这个例子里输出的顺序大致是1、2、3、6然后才是4或5两者的先后取决于哪个Promise先resolve。注意第6行run()调用返回了但函数内部可能才刚把请求发出去。所以你如果在run()后面立刻读取data读到的必然是旧值。这也是为什么“在await后面赋值”只是第一步你还得保证“读取这个变量”的操作也在合适的时机发生。多数情况下正确做法是把读取和渲染也放进这个async函数内部或者把async函数的返回值交给Promise链去传递。2.3 为什么await只能在async函数里不能在外面随意用这个限制不是设计者拍脑袋定的而是语法层面的必然。因为await的作用是“挂起当前函数”它需要依赖整个函数被包裹在一个异步上下文中。没有async修饰JavaScript解析器不知道当前这个作用域该如何挂起、如何恢复。如果你写const data await request(/api/list);在普通函数或全局顶层解析器会直接报错SyntaxError: await is only valid in async functions and the top level bodies of modules模块顶层现在已经支持顶层await了但在业务代码里我仍然建议别这么用。顶层await会让后续所有模块的加载等待这个请求完成很容易拖慢首屏也不方便做错误隔离。规规矩矩包进一个async函数是这个场景下最稳的选择。3. 三种常见工程场景下的数据赋值写法3.1 原生JavaScript把赋值放进await之后并驱动渲染如果在纯函数页面里没有框架的帮助最简单的范式是这样的async function loadAndRender() { let list []; try { const response await request(/api/list); list response.data.list || []; } catch (error) { console.error(拉取列表失败, error); } // 到了这一步list 才是真正已经拿到的数据 renderList(list); } loadAndRender();两个要点。第一renderList(list)必须放在await之后。如果你把renderList(list)放到loadAndRender()调用之后那你渲染的仍然是初始的空数组。第二记得给list一个初始默认值。就算接口失败页面也不至于因为undefined崩溃。有些同学会问能不能不用赋值直接把数据return出去async function loadList() { const response await request(/api/list); return response.data.list; } loadList().then((list) { console.log(list); // 到这里list 才是真实数据 });这种写法也行本质上是把“在await之后赋值”挪到了then回调里。两种都符合异步时序选哪种取决于代码风格。但我个人更推荐第一种把赋值、渲染都收敛到同一个async函数内语义更直观出错时排查范围更小。3.2 React HookssetState必须在await之后useEffect别标asyncReact函数组件里最常见的接口数据赋值场景是把请求结果set到state中。在一个用户列表页里代码一般长这样function UserList() { const [userList, setUserList] useState([]); async function fetchData() { const response await request(/api/user/list); // 一定等接口返回后再赋值 setUserList(response.data.list ?? []); } useEffect(() { fetchData(); }, []); return Table dataSource{userList} /; }有两个容易踩的坑。第一个坑setUserList不能写到fetchData()调用之后。很多人会在useEffect里先fetchData()再setUserList(...)写成这样useEffect(() { fetchData(); setUserList(...); // 此时 data 还没到拿不到 }, []);这必然拿不到数据。setState需要数据而数据在未来的某个时刻才来两头对不上。第二个坑useEffect的回调不能直接标成async。useEffect(async () { const response await request(/api/list); setUserList(response.data.list); }, []);React的useEffect要求回调要么不返回任何内容要么返回一个清理函数。async函数永远返回PromiseReact会把这个Promise当成清理函数轻则在控制台报警告重则影响组件卸载时的清理逻辑。正确做法是把async逻辑包在内部函数里像上面的fetchData那样。如果在组件卸载后请求结果才回来setUserList会被调用到已卸载组件上。严格模式下React会警告对这种场景可以加一个忽略标记useEffect(() { let ignore false; async function fetchData() { const response await request(/api/user/list); if (!ignore) { setUserList(response.data.list ?? []); } } fetchData(); return () { ignore true; }; }, []);这个ignore标记算是一个很实用的防竞态手法组件卸载或依赖项变化时清理函数把ignore置为true后到的响应就不再触发setState。3.3 Vue 3组合式APIonMounted里请求setup别整体asyncVue 3的组合式API写法script setup import { ref, onMounted } from vue; const list ref([]); async function loadList() { const { data } await request(/api/list); list.value data.items ?? []; } onMounted(loadList); // 模板里直接用 list /script关键点在于loadList是一个async函数但onMounted(loadList)只是一个回调注册不会阻塞组件挂载。等到接口返回list.value被赋上新值Vue的响应式系统会自动触发视图更新。这里要特别提醒不要试图把script setup整个改成async setup()。一旦setup函数变成async组件会被当作异步组件处理需要外层配合Suspense才能正常渲染。在业务项目里这样做会把简单问题复杂化。老老实实把请求放进onMounted、事件函数或者watch回调里才是多数团队的常规做法。Vue 2的Options API也类似mounted钩子可以写成async函数async mounted() { const { data } await request(/api/list); this.list data.items; }这个写法在Vue 2里是允许的因为mounted钩子不会被Vue当作需要返回清理函数的机制Vue 2没有这种约束。4. 从网络面板到控制台的排查链路await之后还是undefined怎么办4.1 用事件循环视角解释完整时序还是那个列表页我们把执行细节展开同步代码开始执行进入fetchData。执行request(/api/list)请求发出返回一个Promise。执行awaitasync函数挂起。调用栈空了同步代码继续跑。等着微任务队列被处理。网络请求返回Promise进入fulfilled相关回调进入微任务队列。事件循环处理微任务async函数从挂起点恢复。response.data被读取setState触发。组件重新渲染表格拿到新数据。如果打印顺序不对比如第6步还没发生你在某个同步位置就去读list读到的当然是初始值。4.2 逐步排查的七步流程如果遇到“接口返回了但变量还是undefined”我建议按这条链路逐步查打开Nnetwork面板确认请求有没有真正发出去。如果请求根本没发问题在调用时机比如条件没满足、函数没被触发。看响应内容。很多时候不是“没拿到数据”而是“拿到的结构跟你预期不一样”。比如接口实际返回的是{ rows: [] }你却写了response.data.list。在赋值前打印response。确定response本身是不是undefined以及字段层级对不对。在赋值后打印data。确认赋的到底是不是undefined还是说赋值根本没执行到。看打印顺序。如果在fetchData()调用后的下一行打印那几乎必然是旧值因为await后面的代码还没跑。检查代码里有没有遗漏await。比如把let data await request(...)写成let data request(...)那就把Promise赋过去了。检查Promise有没有被rejected。如果请求失败且没写catch赋值语句会被异常打断变量保持初始值控制台还会报一个Unhandled promise rejection。其中第6、7步是最容易被忽略的。我遇到过不止一次接口在Network面板里显示红色前端代码却完全没有错误处理数据赋值失败后页面一片空白最后查半天才发现是后端参数校验没通过返回了500。4.3 竞态条件最后一次请求不一定是最后返回的还有一种非常隐蔽的赋值问题叫竞态。用户快速连点两次“搜索”按钮第一次请求出去了第二次也出去了。理论上第二次比较新但如果网络抖动导致第一次的响应比第二次晚返回那“后返回的数据”就会把“用户想要的最后一次查询结果”覆盖掉。解决办法通常有三种思路请求序号标记每次发起请求前给当前请求生成一个自增ID响应回来时对比ID只有最新ID才允许赋值。AbortController取消前一次请求发起新请求前把上一次的请求abort掉。利用请求库自带的取消机制。序号标记的实现很简单let requestSeq 0; async function search(keyword) { const currentSeq requestSeq; const { data } await request(/api/search, { keyword }); if (currentSeq requestSeq) { list.value data.items; } }每次调用searchrequestSeq都会自增。响应回来时如果currentSeq已经不是最新的了就说明这次请求已经被更新的请求“淘汰”了赋值操作直接跳过。这个手法在搜索框联想、下拉刷新这类高频场景里非常实用。5. 赋值之外的配套工程错误处理、并发优化与统一封装5.1 错误处理不能省await不会自动接住异常很多新手以为用了await请求失败时顶多返回null不会让程序崩。这是误解。如果Promise进入rejected状态await会让它像普通异常一样被抛出而且异常必须有人接住否则函数直接中断。正确的姿势是try/catch加兜底值async function loadData() { let data []; try { const response await request(/api/list); data response.data.list ?? []; } catch (error) { console.error(列表加载失败使用兜底数据, error); data []; } return data; }无论成功还是失败data都有一个确定的值。调用方拿到的就永远不会是undefined。页面渲染时也就不需要到处做非空判断。Loading状态和Error状态也应该纳入同一套异步流程const state reactive({ loading: false, error: null, list: [], }); async function loadList() { state.loading true; state.error null; try { const { data } await request(/api/list); state.list data.items; } catch (error) { state.error error.message; } finally { state.loading false; } }5.2 独立并行的请求不要用await串着来有一种写法在功能上没错但性能上很吃亏const a await request(/api/a); const b await request(/api/b);两个接口毫无依赖关系却被迫串行先等a回来再发b。总耗时是a和b的耗时之和。应该用Promise.all把两个请求同时发出去const [aRes, bRes] await Promise.all([ request(/api/a), request(/api/b), ]); const a aRes.data; const b bRes.data;这里顺便解释一下Promise.all的语义它接收一个Promise数组返回一个新的Promise等数组里所有Promise都变成fulfilled才resolve。只要有一个rejected整体就rejected。所以如果其中一个请求允许失败、且失败不阻塞另一个建议用Promise.allSettled。循环里也同理不要写成for (const id of ids) { const res await request(/api/item/${id}); results.push(res.data); }如果ids有二十个这么写就是二十次串行。可以先把所有请求都发出去再一次性等待const requests ids.map((id) request(/api/item/${id})); const responses await Promise.all(requests); const results responses.map((res) res.data);5.3 我习惯做的一步统一request封装让业务代码只管await把“数据赋值是接口请求来的”这件事做得更顺我最终建议在项目里做一个统一请求封装不要直接在各处裸用fetch或原生的XMLHttpRequest。统一封装里集中处理几件事基础URL和超时设置。统一的请求头比如token携带。统一响应拦截把后端返回包装成统一结构。统一的错误提示和登录过期跳转逻辑。在封装层把返回Promise稳定化所有业务代码都用await接。比如这样一个基础封装async function request(url, options {}) { const response await fetch(url, { headers: { Content-Type: application/json, ...(options.headers || {}), }, ...options, }); if (!response.ok) { throw new Error(请求失败${response.status}); } const data await response.json(); return data; }业务组件里就不用关心fetch细节只要记住一条原则所有依赖接口数据的赋值都必须放在await之后。我最后再分享一个自己在编程时保留的习惯给变量赋接口数据前永远假设后端返回的数据结构可能是“脏的”。不管文档里写得多清楚实际联调中经常出现字段名多了个下划线、空数组变成了null、新字段没上线这类事。所以?? []、|| {}这类兜底我会在赋值阶段就处理掉。这样“数据根据接口请求来、使用async和await完成赋值”这件事才能真正从语法正确走向运行稳定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑