尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

前端异步竞态问题详解:循环组件接口赋值失败的四套解决方案

前端异步竞态问题详解:循环组件接口赋值失败的四套解决方案 做前端时间久了总会遇到一些看起来“莫名其妙”的 bug。一张列表页几个组件用 v-for 循环渲染出来接口全部返回 200页面上的数据却张冠李戴——A 卡片显示着 B 的详情或者某个组件的数据永远是空的控制台偶尔还飘一条“unmounted component”的警告。第一次遇到这种问题我排查了大半天最后发现根子不在接口也不在组件本身而在自己写的异步代码里。这就是标题里说的那个经典场景循环加载组件后组件调用接口赋值失败。本质上是异步竞态问题race condition和框架关系不大Vue 会踩React 也会踩。这篇文章写给正在被“数据错乱”“赋值不生效”“偶发刷新就好”折磨的前端开发者。我会用完整的代码还原现场拆透竞态产生的底层原理再给四套从“能用”到“好用”的解决方案最后附上我这些年排查同类问题的速查清单。看完你可以直接抄作业也能真正理解为什么这样改能解决问题。1. 先还原现场循环组件赋值失败的几种表现1.1 一张列表页引发的“鬼故事”先看一个非常典型的业务场景。假设你有一个用户列表页每个用户卡片根据 id 拉取自己的详情。父组件大概长这样template div button clickfilter vip只看VIP/button button clickfilter all全部用户/button UserCard v-foru in showList :keyu.id :useru / /div /template script setup import { computed, ref } from vue import UserCard from ./UserCard.vue const users ref([ { id: 1, type: vip }, { id: 2, type: normal }, { id: 3, type: vip } ]) const filter ref(all) const showList computed(() { return filter.value all ? users.value : users.value.filter(u u.type filter.value) }) /scriptUserCard 组件内部在挂载后请求详情script setup import { onMounted, ref } from vue import { fetchUserDetail } from /api const props defineProps([user]) const detail ref(null) onMounted(async () { const res await fetchUserDetail(props.user.id) detail.value res.data // 赋值失败的现场 }) /script这段代码单看没有任何问题。接口地址对参数对赋值对。但只要你快速切换“只看VIP”和“全部用户”就会出现数据串、值不刷新、甚至空白卡片。原因很简单在循环渲染的场景下每次过滤都会创建一组新的 UserCard 实例旧实例被销毁但它们发出去的请求并没有被取消。这些请求返回时回调函数仍然会尝试给已经卸载的组件赋值。更隐蔽的是另一种情况父组件通过:keyu.id复用了同一个组件实例但 user 对象已经变了。此时 onMounted 不会重新触发子组件完全没有感知到 props 变化自然也不会发新请求。这种属于“组件复用导致的不刷新”和竞态是邻居但经常一起出现排查时别搞混。1.2 “赋值失败”的三种真实样貌我在实际项目里见过各种“赋值失败”的现场归纳起来有三类特征差别挺大第一类是数据一直是初始值或空。卡片显示的是默认的 loading或者压根什么都没渲染。请求本身成功返回了但回调执行时组件已经卸载。在 Vue 里给卸载组件的 ref 赋值不会报错但也不会有任何效果在 React 里旧版本会报setState on unmounted component的警告。这类问题最迷惑人的地方在于你打断点看接口数据明明回来了于是你会怀疑是不是渲染层出了问题。第二类是数据张冠李戴。A 卡片显示了 B 用户的名字和描述。这是因为多个组件并发发请求慢请求最后返回把本属于自己组件的数据覆盖成了旧数据。尤其是加了keep-alive或组件被缓存时旧实例还活着赋值仍然生效串数据就非常明显。第三类是偶发不稳定。第一次进入正常快速操作几次后开始错乱刷新又好了。这种最折磨人因为测试复现概率低你甚至怀疑是偶发的网络问题。其实只要请求响应顺序变得不可控竞态就会表现为“概率性出现”网络越差越明显。判断标准很简单操作越快越容易出现、刷新就恢复、接口成功率 100% 但页面数据不对——三个特征占两条基本就是竞态问题。2. 竞态的底层原理为什么接口成功了数据却是错的2.1 谁最后说话谁就赢了很多人不理解我明明先发的请求为什么后返回的数据会覆盖先返回的这取决于你对异步模型的直觉。你以为是排队先发先回但现实是网络请求离开浏览器之后到达服务器的时间、处理耗时、返回路径完全不受你控制。打个比方。你在办公室同时邮件问了两个同事同一个数据先问的同事 A 去了趟茶水间晚问的同事 B 立刻回了邮件。你先把 B 的结果填进表格过一会儿 A 回来了又用 A 的旧数据把表格覆盖了。表格最终显示的是 A 的数据但 A 是你先问的那个。糟糕的是你其实更想要 B 的答案因为你的问题已经更新了。程序里也是一样。假设有两个请求// 请求1先发起 fetch(/api/detail?id1) // 网络慢3秒后返回 // 请求2后发起 fetch(/api/detail?id2) // 网络快1秒后返回请求 2 先返回赋值给 detail请求 1 后返回又把 detail 覆盖掉。最终页面上显示的是 id1 的数据但用户当前看到的可能已经是 id2 的卡片。后发起的不一定后返回后返回的却一定会覆盖前面赋值的结果。这就是竞态的底层逻辑多个异步操作共享同一个“写入目标”最终状态由最后完成的操作决定而不是由最后发起的操作决定。2.2 组件生命周期是如何放大竞态的单独两个请求竞态处理起来不难。但加上组件生命周期问题就复杂了。循环渲染的组件是动态存在和销毁的。列表过滤、条件渲染、路由切换、滚动懒加载任何操作都可能让一个组件在请求还没返回时就先被卸载。此时发生了两件事。第一回调里的赋值变成了对“已销毁组件”的状态写入。Vue 的响应式系统在组件卸载后effect 已经停止收集依赖赋值不会让你报错但视图也不会更新这非常容易被忽略。React 则会在开发环境下打印警告提示你对一个已卸载组件调用了 setState。第二闭包捕获了过期的上下文。组件 A 发出的请求回调函数里引用的 props、ref、甚至是组件实例都来自 A 创建那一刻的闭包。如果 A 已经被销毁但请求还活着回调执行时就成了“幽灵代码”。数据改了但改的是内存中一个没有任何视图依赖的对象既浪费资源也容易掩盖真正的问题。Vue 和 React 在警告层面有差异但底层逻辑完全一致只要异步回调还在执行它就有机会污染共享状态。循环组件只是让这种污染发生的频率更高因为你同时创建了几十个组件也同时发出几十个请求任何一个晚回的响应都可能成为那个“最后的说话者”。2.3 被忽视的第二战场loading 和分页状态竞态影响的远不止“赋值结果”。还有一类隐藏战场平时很少有人提但线上事故率很高——loading 状态。假设你先发了一个慢请求 A又发了一个快请求 B。B 先返回数据渲染成功了此时你把 loading 置为 false 是合理的。但如果 A 先返回、B 后返回呢A 返回后你把 loading 置为 false界面已经停止加载可 B 还在路上。等 B 返回时因为数据更新了不算出错但用户已经提前看到了“空数据 无 loading”的页面体验非常差。反过来如果 A 返回失败你把 error 置为 true而 B 其实已经成功了界面上却展示错误提示。分页场景就更典型。用户快速点了第 2 页、第 3 页第 2 页请求慢、第 3 页请求快。第 3 页先渲染第 2 页晚回后把列表整体替换成第 2 页的数据。用户明明停在“第 3 页”的高亮按钮上看到的却是第 2 页的内容。所以当你解决“组件调用接口赋值失败”时不要只盯着数据字段。凡是异步返回后要修改的状态——loading、error、分页的 page、列表的 list——全部都需要竞态保护。这也是我把竞态处理单独抽象出来的原因它不是某个字段的小修补而是一套关于“响应如何写入状态”的规则。3. 四套方案从“能用”到“好用”3.1 方案一请求序号对比最朴素也最通用解决竞态最直接的办法就是给每次请求发一个“编号”响应回来时先检查自己是不是最新编号。不是就丢是就赋值。代码很简单let requestSeq 0 async function loadDetail(id: string) { const current requestSeq // 颁发新编号 const res await fetchDetail(id) if (current ! requestSeq) return // 已经有更新请求当前响应作废 data.value res }每次调用loadDetailrequestSeq自增current保存本次的编号。假设第一次调用时 current1第二次调用时 requestSeq2。第一次的响应即使晚归current(1) 不等于 requestSeq(2)直接丢弃。这个方案的优点是极度通用不受请求库限制。fetch、axios、微信小程序的 wx.request、甚至 WebSocket 消息都能用同一套逻辑。它不需要取消网络请求只是放弃“旧响应写入状态”的权利。缺点是请求本身没有省掉带宽和服务器压力还是在。如果用户在搜索框里连续输入了 10 次就会发出 10 个请求其中 9 个白白浪费。所以它适合作为兜底或者请求成本不高的场景。3.2 方案二AbortController 真取消如果想让旧请求根本别回来用 AbortController。这是浏览器原生 APIfetch 标准支持let controller: AbortController | null null async function loadDetail(id: string) { controller?.abort() // 取消上一次请求 const currentController new AbortController() controller currentController try { const res await fetch(/api/detail?id${id}, { signal: currentController.signal }) detail.value await res.json() } catch (e) { if (e.name AbortError) return // 取消是预期行为忽略 throw e // 其他错误继续抛出 } }每次发起新请求前把上一次的 abort 掉。旧请求会立即进入 reject 状态回调根本不会执行到赋值那一步。这是真正意义上的“让过期请求死掉”。用 axios 的同学也支持 signal这是新版标准axios.get(/api/detail, { signal: currentController.signal })老项目如果还在用 CancelTokenconst source axios.CancelToken.source() axios.get(/api/detail, { cancelToken: source.token }) source.cancel()注意一点取消后 promise 走的是 reject不是 resolve。如果你不捕获 AbortError控制台会有一片红色的Unhandled Promise Rejection。这属于“修了竞态又现新报错”的典型翻车现场我在 4.3 节会专门演示。AbortController 的优点是节省资源浏览器层面会停止等待响应缺点是它只能配合支持 signal 的请求库使用而且每个请求都要手动管理 controller 的生命周期代码会稍微变多。3.3 方案三卸载标记只治半截病还有一种常见做法是组件卸载时立一个标记回调回来先看一眼let alive true onMounted(() { alive true loadDetail() }) onUnmounted(() { alive false }) async function loadDetail() { const res await fetchDetail(id) if (!alive) return detail.value res }React 里对应的写法是useEffect(() { let active true fetchUserDetail(id).then(res { if (active) setDetail(res.data) }) return () { active false } }, [id])这个方案解决的是“组件卸载后还在赋值”这一半问题。它能在很多时候消灭控制台警告也能避免对已销毁的响应式对象做无意义赋值。但请注意它完全没有解决“数据覆盖”这一半问题。两个都还挂载着的组件A 先发请求后返回B 后发请求先返回B 赋值后 A 返回值又覆盖了 B——卸载标记拦不住这种情况因为 A 根本没卸载。而且依赖数组[id]里的 id 变化时旧的 effect 会先跑 cleanup把 active 置为 false这其实只是“针对当前 effect 的卸载标记”不是全局竞态保护。所以我把它定位成“辅助方案”必须和请求序号或 AbortController 搭配使用。单独用会在搜索框场景下漏掉大多数乱序问题。3.4 方案四封装统一的竞态处理工具对于团队项目最省心的做法是直接封装一个带竞态保护的请求 hook。这样每个业务组件不需要重复写“序号判断”“卸载标记”调用方只关心数据。我在 React 项目里常用的封装长这样import { useCallback, useEffect, useRef, useState } from react export function useAsyncDataT(fetcher: () PromiseT) { const [data, setData] useStateT | null(null) const [loading, setLoading] useState(false) const seqRef useRef(0) const run useCallback(async () { const seq seqRef.current setLoading(true) try { const res await fetcher() if (seq ! seqRef.current) return // 已过期 setData(res) } finally { if (seq seqRef.current) setLoading(false) // 只有最新请求能关 loading } }, [fetcher]) useEffect(() { return () { seqRef.current // 组件卸载时让所有在途回调失效 } }, []) return { data, loading, run } }Vue 组合式函数版本更贴近响应式习惯import { ref } from vue export function useRequestT(fn: () PromiseT) { const data refT | null(null) const loading ref(false) let seq 0 const run async () { const current seq loading.value true try { const res await fn() if (current ! seq) return data.value res } finally { if (current seq) loading.value false } } return { data, loading, run } }这个封装把两件最关键的事藏在了内部第一用 seq 保证只有最后一次发起的请求能写数据第二loading 也只受最后一次请求控制不会出现“旧响应提前关掉 loading”的问题。团队用这种方式最大的收益是统一。不会出现十个人写了十种不同的竞态判断逻辑代码评审时只需要检查“有没有用 useRequest”而不是逐行看每个组件的异步回调。如果还想省流量可以在 run 内部再加 AbortController但多数业务场景下请求序号方案已经能解决 90% 的问题。3.5 方案对比与选型建议这里把四个方案的边界和适用场景整理成一张表方便你对照着选方案能防数据乱序能防卸载赋值能节省流量代码侵入推荐场景请求序号法能需配合卸载标记不能低任意请求最简单通用AbortController能能能中搜索联想、高频轮询、昂贵接口卸载标记不能能不能低仅作为辅助兜底统一封装由内部实现由内部实现由内部实现封装后极低团队长期维护的项目个人小项目我推荐“请求序号法 卸载标记”两行代码解决问题不需要理解太多 API。项目里网络请求频繁、用户操作快比如即时搜索、表格筛选、地图撒点我推荐 AbortController省流量是一方面更重要的是能给用户更快的反馈——旧请求被取消后对应组件的 loading 状态能立刻让位给新请求。团队项目没有犹豫空间直接上封装并把“异步回调必须走封装”写进团队约定。4. 实操过程从一个带 bug 的组件到修好的完整代码4.1 先搭一个能稳定复现问题的工程光讲原理不够我们直接跑一个能复现问题的最小工程。用 Vite 初始化一个 Vue 项目就行不需要任何额外依赖。先做一个模拟接口让响应延迟随机一些好让乱序概率变大export function fetchUserDetail(id: number, delay?: number) { return new Promise(resolve { const wait delay ?? Math.random() * 1500 setTimeout(() { resolve({ id, name: 用户-${id}, fetchedAt: Date.now() }) }, wait) }) }Math.random() * 1500是复现竞态的关键。网络真实环境下延迟不均匀这里用随机延迟模拟。你可以故意给某个 id 传固定的大 delay比如让 id1 的请求固定 3 秒返回其他 id 固定 200ms 返回这样竞态几乎是必现的。页面结构就用 1.1 节的 UserCard 例子。复现步骤打开页面等第一屏数据加载完成快速点击“只看VIP”和“全部用户”按钮连续切换 5 次以上观察卡片名称和用户 id 是否匹配以及是否有卡片内容始终不更新在慢网络或者加了随机延迟后这个 bug 基本百发百中。我第一次演示给同事看的时候大家的表情都是一副“这也行”的样子。因为单次请求怎么测都对只有高频操作才会触发。4.2 第一步加竞态锁两行代码解决乱序先不引入任何高级 API给loadDetail加上请求序号判断script setup import { onMounted, ref } from vue import { fetchUserDetail } from /api const props defineProps([user]) const detail ref(null) let requestSeq 0 onMounted(() { loadDetail() }) async function loadDetail() { const current requestSeq const res await fetchUserDetail(props.user.id) if (current ! requestSeq) return // 已被更新的请求顶替 detail.value res } /script这段代码的核心是requestSeq。组件每调用一次loadDetail它都会自增。假设组件先收到 id1 的 props发请求得到序号 1然后 props 变化又发请求得到序号 2。序号 1 的响应晚归时current是 1requestSeq已经是 2直接 return不再污染视图。注意这里我没有写 props 变化的监听。真实业务中如果同一个组件实例要响应不同的 user还需要加watch(() props.user.id, loadDetail)。这个属于“循环渲染组件不刷新”的补充场景和竞态处理是两件事但经常会同时出现。加上它对整个方案没有副作用。4.3 第二步接入 AbortController让旧请求真正死掉请求序号法解决了“赋值错乱”但没解决“浪费流量”。升级到 AbortControllerscript setup import { onMounted, onUnmounted, ref } from vue import { fetchUserDetail } from /api const props defineProps([user]) const detail ref(null) let controller null onMounted(() { loadDetail() }) onUnmounted(() { controller?.abort() }) async function loadDetail() { controller?.abort() const curController new AbortController() controller curController try { const res await fetchUserDetail(props.user.id, { signal: curController.signal }) detail.value res } catch (e) { if (e?.name AbortError) return throw e } } /script这里有几个细节必须注意都是踩过坑才总结出来的。第一个是controller?.abort()写在最前面。它的作用是当这个组件第一次发请求后还没返回第二次loadDetail被调用就先把上一次请求取消。如果两次请求来自同一个组件实例这个步骤能保证同一时刻只有一个请求在途。第二个是把新的 AbortController 赋给一个局部变量再赋给 controller。这里稍微有点绕但很关键。假设你直接写controller.abort(); controller new AbortController()当组件快速连续调用时Promise 里的 signal 始终指向最新的 controller取消动作可能会误伤新请求。用curController局部变量固定住本次请求的信号就不会发生这种误伤。第三个是 catch 里的判断。abort()调用后fetch 的 promise 会以 AbortError 的 reason 进入 reject。如果不判断这个错误会冒泡成一个 unhandled rejection在控制台刷红。判断e?.name AbortError表示明确接收“取消”这个预期行为直接 return其他错误继续抛给上层处理。如果你用的请求库是 axios判断方式略有不同catch (e) { if (axios.isCancel(e)) return throw e }axios.isCancel是 axios 自带的取消错误识别方法。老项目如果是 CancelToken 方案取消后同样走 catch同样需要这个判断否则和 fetch 一样会报未捕获的 promise 错误。还有一个容易忽略的点onUnmounted 里也必须调用一次controller?.abort()。很多人的写法是在 loadDetail 里管好了 abort却忘了组件销毁时还有可能在途请求。当列表被过滤、路由被切换、弹窗被关闭组件卸载了如果请求还在飞回调仍然可能执行。虽然 AbortController 不会阻止回调进入 catch但至少可以避免请求继续占用资源也为后续“组件卸载后不该有任何响应处理逻辑”提供了一个干净的状态。4.4 如何验证竞态真的解决了改完之后你不能只看“好像没问题了”要有一套标准的验证流程。第一步打开 Chrome DevTools 的 Network 面板观察请求列表。连续快速切换过滤条件时你应该能看到一批状态为(canceled)的请求。这些就是被 AbortController 取消的旧请求。如果 Network 里没有任何 canceled 记录说明 abort 路径没走到可能是 signal 没有正确传递或者请求已经完成了才调 abort。第二步做一个数据一致性断言。在 fetchUserDetail 的返回结果里带上 id然后在模板里显示detail?.id并和卡片自己的user.id对比。快速切换多次后保证两者始终一致。实际上你也可以在 console 里写一段循环快速切换按钮的逻辑模拟用户高频操作const buttons document.querySelectorAll(button) setInterval(() { buttons.forEach(btn btn.click()) }, 50)跑 10 秒如果页面没有出现串数据、没有控制台报错竞态基本就是被稳住了。第三步做“卸载后不赋值”的验证。给 id1 的请求人为设置 5 秒延迟挂载后 2 秒直接切换到其他过滤器让组件卸载等 5 秒过去看控制台。修复前大概率会有警告或无效赋值修复后应该风平浪静。第四步如果项目是 React 且开启了 StrictMode请特别注意开发环境下 effect 会故意执行两次这会让竞态问题更容易暴露。这其实是好事相当于框架帮你提前测试了并发场景。不要因为 StrictMode 下出现重复请求就急着关掉先排查是不是代码本身没有幂等处理。5. 常见问题速查与排查思路5.1 赋值失败的排查清单我把这些年遇到过的“组件调用接口赋值失败”问题整理成了一张速查表每个症状对应优先排查的方向和解决思路症状优先排查对应解法数据一直是初始值/空回调是否在组件卸载后执行卸载标记 请求取消数据被旧请求覆盖是否存在并发请求未设竞态锁请求序号 / AbortController首次进入正常切换后错乱组件是否被复用props 变化没有触发重新请求watch props 竞态保护控制台大量 Unhandled Rejection取消请求后没忽略 AbortErrorcatch 里判断 error.name偶发出现刷新就恢复请求顺序和网络延迟不可控用随机延迟压测稳定复现后修复接口成功但赋值后 DOM 不更新组件实例已销毁或 ref 目标不对检查闭包引用使用统一 hook特别提醒一句不是所有“赋值失败”都是竞态。接口返回结构变化、字段名写错、变量作用域被覆盖这些都很常见。但当你发现“偶发”“操作快了才出现”“刷新又好了”这三个特征时没必要去怀疑接口直接朝竞态方向排查效率最高。还有一个容易被忽略的线索修复后如果 Network 面板出现大量(canceled)不代表是问题恰恰是解决生效的证据。有些同学看到 canceled 以为是 bug又把 abort 删了等于把修好的问题重新放出来。5.2 竞态、防抖、节流是一回事吗不是。这个必须讲清楚因为搜索框场景里三者的关系经常被搞混。防抖debounce解决的是“请求发得太频繁”的问题。用户在搜索框连续输入你不想每个字符都发请求于是等他停下来 500ms 再发。节流throttle解决的是“请求密度太大”的问题比如滚动加载时每 200ms 只允许发一个请求。但这两个方案都不关心“返回结果怎么用”。你防抖后还是可能连续发了两个请求先发的慢、后发的快后发先回、先发后回照样乱序。防抖只减少了竞态出现的概率并没有消除竞态本身。正确姿势是组合拳用户输入用防抖减少请求次数请求返回用竞态锁保证只有最新请求能写入。举个例子搜索框“苹果”→“苹果手机”防抖保证只发出两次请求但请求 1 可能因为网络慢而晚归如果没有竞态保护最终显示的是“苹果”的结果而用户输入框里是“苹果手机”。所以我一直跟团队强调防抖和节流是前置优化竞态保护是后置安全。两者不冲突也不能互相替代。凡是异步请求不管有没有做防抖节流都应该默认带一层竞态保护。5.3 团队代码规范里怎么给异步请求上保险文章最后这部分分享几条切实可行的团队约定。单个组件里的竞态好修真正麻烦的是团队里每个人都有自己的一套写法。有的用布尔标记有的用取消请求有的什么都不写。代码评审的时候你不可能每次都在几十个文件里逐个找问题。我目前觉得最有效的三条规范是第一所有异步请求必须通过统一封装。不管是 useRequest 还是别的方式业务组件里不允许直接裸写 fetch/axios 加赋值。团队评审时只需要看封装内部是否带竞态保护业务代码自动获得保护。第二只要回调里要修改响应式状态必须同时回答三个问题这个回调是不是最新的组件卸载了它还回来吗回来之后会给谁赋值这三个问题在代码里用注释写清楚或者直接用方案里的竞态锁表达清楚。坏味道是那种在回调里天女散花一样地 setState却不关心自己是否过期。第三组件销毁时关闭所有在途请求。能用 abort 就 abort不能 abort 就递增一次请求序号让所有在途回调失效。这个动作应该出现在 onUnmounted 或 useEffect cleanup 里而不是指望父组件提前把数据停下来。我现在的习惯是写每个请求前先在草稿上画一条时间线请求发起时间、预期返回时间、用户可能发出下一次操作的时间。三条线一画竞态风险一目了然。多数竞态问题在设计阶段就能被发现根本不用等测试来抓。顺便再补一个排查线上竞态的土办法所有请求带一个 requestId 参数或者至少在后端日志里记录“发起序号”。一旦线上出现数据串了可以直接比较每个请求的发起时间和返回时间谁覆盖了谁一目了然。没有日志凭感觉排查真的会疯。这个看起来像是后端的事但前端可以在发起请求时把序号放进自定义 header成本极低收益在出事故时非常值。
返回列表