☰
Vue竞态条件实战:从ESLint告警到异步请求覆盖修复
2026/10/14 6:44:51 网站建设 项目流程

做 Vue 项目的同学,几乎都见过这样一条 ESLint 报错:

Possible race condition: `xxx` might be reassigned based on an async operation.

我第一次看到时,第一反应是“哦,异步操作之后变量可能会被覆盖”,然后测了测功能,一切正常,就把它当成误报忽略了。直到后来维护一个后台管理项目,列表页在快速切换搜索关键词时反复出现“旧请求覆盖新请求”的现象,我回过头来再看这条告警,才意识到它提醒的是一个非常典型的并发问题:竞态条件(race condition)。这类 bug 的特点是平时不出现,一旦网络慢、用户操作快、接口响应乱序,数据就会悄悄错乱。这篇文章就围绕这条 Vue + ESLint 报错展开,讲讲它到底在警示什么、在 Vue 项目里最容易踩进哪些写法里,以及怎么彻底处理掉。

1. 先把报错拆开看:它到底在说什么

1.1 报错来源、规则名和触发代码

完整的报错文案一般长这样:

Possible race condition: `cachedToken` might be reassigned based on an async operation.

不管xxx是你哪个变量的名字,报错的指向都很明确:这个变量在异步操作挂起之后,会被重新赋值,而它的旧值在此之前已经被读取过。这个检查来自 ESLint 内置规则require-atomic-updates,如果你的项目用的是较新的 typescript-eslint,也可能来自@typescript-eslint/no-race-condition,文案几乎一样,只是分析逻辑更细。

我在项目里第一次被它拦住,是下面这种“缓存 + 并发调用”的写法:

let cachedToken = null async function getToken() { if (!cachedToken) { const response = await fetch('/api/token') cachedToken = await response.json() } return cachedToken }

报错会落在cachedToken = await response.json()这一行。规则看到的是:函数先读取了cachedToken,然后遇到await挂起,挂起结束后又写同一个变量。“先读后写中间夹着异步”就是它的判断依据,不需要看你是不是真的并发调用了这个函数。

1.2 竞态条件不是玄学,它是一张时间线

打个比方,你进一家只剩最后一个座位的餐厅,同时问店员“还有座吗”,店员说“有”,你和旁边的人同时往那个座位走,结果谁先到谁坐,另一个人扑空。这里的核心问题是“检查座位是否空着”和“坐下”这两步不是原子的。中间只要插入别人的动作,结果就会错乱。

getToken的问题一模一样。两个调用同时进来,假设getToken被 A、B 两处代码分别调用:

时间调用 A调用 B
T1读取 cachedToken,发现为空
T2发起网络请求读取 cachedToken,发现为空
T3请求挂起中发起网络请求
T4请求 A 返回,写入 cachedToken
T5请求 B 返回,写入 cachedToken(覆盖)

两次写入最后留下的是 B 的结果。如果两次请求返回的 token 恰好相同,问题不明显;一旦服务端返回了不同的值,A 的结果就被无声覆盖,所有通过cachedToken拿数据的消费方都会收到过期甚至错误的 token。

所以“竞态条件”这四个字翻译成人话就是:两个或多个操作在同一个数据上竞争,最终谁写入,取决于它们的完成顺序,而不是你预期的逻辑顺序。JavaScript 虽然是单线程语言,但await会把执行权交还事件循环,挂起期间其他任务完全可以跑,因此这种竞争在异步代码里一点都不罕见。

2. Vue 项目里最容易踩中的三类写法

2.1 异步 watch:关键词一变就发请求,旧响应却先回来

Vue 项目里最常见的一类竞态来自watch,尤其是监听搜索关键词然后异步拉数据的场景:

watch(keyword, async (newKeyword) => { const res = await fetchList(newKeyword) list.value = res.list })

这段代码往往不会触发上面的规则告警,因为规则主要盯着“同一变量先读后写”,而list.value在await之前并没有被读过。但竞态是真实存在的:用户先输入vue,watch 发出请求 A;紧接着输入vue3,发出请求 B。如果 A 的响应比 B 晚,A 就会把 B 的结果覆盖掉,最终列表展示的是vue的结果,搜索框里却写着vue3。

我第一次遇到这个现象时,先怀疑防抖没生效,又怀疑服务端缓存,排查了半天,最后打开 Network 面板一看请求返回顺序才确认:请求 A 返回时间晚于请求 B,页面按“最后写入覆盖”的规则渲染了旧数据。这个问题不是防抖能解决的,防抖只是减少请求数量,不能保证响应有序。

2.2 用 loading 标志做互斥的刷新函数

很多同学习惯用一个isLoading标志防止刷新按钮被连续点击:

const isLoading = ref(false) async function refresh() { if (isLoading.value) return isLoading.value = true const res = await fetchData() list.value = res.data isLoading.value = false }

这段代码里,isLoading.value在函数开头被读取,await之后又被写入,正好踩中require-atomic-updates的检测模式。规则想提醒的是:刷新期间确实拦住了一个新的进入者,但在await结束后,旧调用的收尾动作(比如isLoading.value = false)和另一个调用后续可能触发的写入,没有任何强制顺序。

举个例子:刷新函数内部其实有两条写入路径——list.value = res.data和isLoading.value = false。假设刷新期间用户又点了一次按钮,第二次调用被isLoading挡回去,看起来没问题;但如果第二次点击发生在第一次的await已经结束、isLoading还没来得及置回 false 的间隙呢?这个间隙理论上极小,但一旦接入网络、接口耗时不稳定,它就不是零概率事件。

2.3 缓存复用:先查缓存,没命中再去请求

第三种高频场景是“先读缓存,缓存为空再去请求”的通用逻辑:

let profileCache = null async function loadProfile() { if (profileCache) return profileCache const res = await request('/api/profile') profileCache = res.data return profileCache }

多个组件同时挂载,各自在自己的生命周期里调用loadProfile,所有调用都先看到了profileCache为空,于是各自发请求、各自赋值。请求重复发出只是浪费流量,后果可能不严重;但如果缓存对象被后续逻辑修改,第二次赋值会把已经被修改的缓存覆盖回去,问题就麻烦了。

这些场景都有一个共同结构:判断依据是共享变量的旧状态,中间隔着await,最后写回同一个共享变量。只要中间插入了其他调用,判断结果和写入结果就可能不一致。

3. 规则背后的静态分析逻辑:为什么它宁可“误报”

3.1 ESLint 怎么判断“可能被重新赋值”

require-atomic-updates的执行逻辑可以概括成两步:第一步,在函数作用域里收集所有在await或yield之前被读取过的变量;第二步,找出在挂起之后被重新赋值的变量。如果这两个集合有交集,就在赋值处报告Possible race condition。

它不关心你是否真的会并发地调用这个函数,也不分析网络上谁先返回,只要结构上有“先读 → 异步挂起 → 写回”的窗口,它就提醒你。所以很多人会觉得“我明明只调用了一次,它还是报”,这不是规则弱智,而是静态分析无法证明“没有第二次调用”。在 Vue 里,同一个方法可能被模板事件、watch、定时器、路由守卫多处调用,ESLint 不可能替你数清楚调用次数。

规则还有选项,比如不少版本支持allowProperties,设置成true后可以放行部分属性赋值。但我不建议为了消声直接开这个选项,因为它会连真实的问题一起放过。

3.2 为什么“宁杀一千”对工程是有价值的

JavaScript 单线程,不代表异步代码不会交错。await会把当前函数的执行权交回事件循环,事件循环里排队的其他任务——下一次点击、另一个 watch 回调、组件重新渲染触发的副作用——都可能在挂起期间跑起来。即使你肉眼感觉“不可能重叠”,只要有一个入口没有防重复,重叠就会发生。

规则提醒的是你代码里存在这样一个窗口,而不是告诉你“某个具体调用一定会出错”。这就好比过马路时看到黄灯,不意味着每次都会出事,但只要你无视它,出事的概率就从零变成某个大于零的数。前端性能优化可以靠感觉,并发正确性不能。

新版 typescript-eslint 提供的no-race-condition比核心规则更进一步,它能分析属性赋值、ref的.value更新等多个细分模式,对 Vue 3 组合式 API 的兼容性更好。如果你的项目是 Vue 3 + TypeScript,建议优先体验这条规则,它能把不少核心规则漏掉的情况补上。

4. 五类能直接落地的修复方案

4.1 方案一:把“写入之前先读旧值”的判断挪到 await 之后

先明确一点:规则的触发点是“旧状态判断 + await + 写回”。如果你本来要做的就是“只写一次”的缓存,最简单的修法是把判断挪到await之后:

let cachedToken = null async function getToken() { const response = await fetch('/api/token') const token = await response.json() if (!cachedToken) { cachedToken = token } return cachedToken }

两个并发调用即使都发出请求,await结束之后它们会依次走到if (!cachedToken),第一个写入成功,第二个看到已非空,就会跳过写入、直接返回第一个写入的值。请求是重复了,但数据一致性保住了。

要注意的是缓存维度。如果这个 token 是全局唯一的,这样写没问题;如果缓存和参数有关,比如按用户 id 缓存,请用Map而不是单个变量:

const profileCache = new Map() async function loadProfile(id) { const hit = profileCache.get(id) if (hit) return hit const profile = await request(`/api/profile/${id}`) if (!profileCache.has(id)) { profileCache.set(id, profile) } return profileCache.get(id) }

把“判断是否写入”放到拿到响应之后,规则看到profileCache在挂起之前没有被读取,告警自然消失,同时语义也正确。

4.2 方案二:请求序号,谁是最新的谁才有资格写

适合搜索、分页、tab 切换这类“新请求必须覆盖旧请求”的场景。原理是给每次请求发一个递增序号,响应回来时对比序号,只有最新一次请求才允许更新页面。

let searchSeq = 0 async function handleSearch(keyword) { const currentSeq = ++searchSeq const res = await fetchList(keyword) if (currentSeq !== searchSeq) return list.value = res.list }

解释一下这里发生了什么:++searchSeq先把全局序号加一,当前请求拿到属于自己的currentSeq。假设请求 A 先发出,拿到序号 1;随后请求 B 发出,序号变成 2,A 的currentSeq是 1,B 是 2。响应返回时,A 发现1 !== 2,知道自己已经被更新鲜的请求取代,直接丢弃;B 发现2 === 2,写入成功。整个过程和网络返回顺序无关,无论 A 多晚回来都没机会覆盖 B。

这个方案是我在列表页最常用的一套,成本极低、逻辑直观,推荐优先考虑。它的前提是“过期响应直接丢弃”,如果业务上要求“每次响应都要被消费”(比如聊天消息),那就不能用序号,要换队列或串行化。

4.3 方案三:AbortController,让旧请求真正消失

序号方案只是“不采用过期响应”,请求仍然发出去、仍然消耗带宽。如果想得更彻底一点,可以取消旧请求。浏览器原生fetch和较新版本的 axios 都支持AbortController:

let abortController = null async function handleSearch(keyword) { abortController?.abort() const controller = new AbortController() abortController = controller try { const res = await fetch(`/api/list?keyword=${keyword}`, { signal: controller.signal }) if (!res.ok) throw new Error('请求失败') list.value = await res.json() } catch (err) { if (err.name === 'AbortError') return throw err } }

axios 的写法类似,注意捕获取消异常:

import axios from 'axios' let abortController = null async function handleSearch(keyword) { abortController?.abort() abortController = new AbortController() try { const { data } = await axios.get('/api/list', { params: { keyword }, signal: abortController.signal }) list.value = data.list } catch (err) { if (axios.isCancel(err)) return throw err } }

被取消的请求会进入catch,所以必须把“取消导致的错误”当作正常流程吸收掉,否则用户会看到控制台一堆红色报错,甚至触发全局错误提示。这个方案除了避免旧数据覆盖,还能减少后端压力和带宽消耗,适合搜索、下载、轮询等频繁发起请求的场景。

4.4 方案四:Vue 响应式场景,用 watchEffect + onCleanup

如果竞态是响应式数据驱动的,Vue 3 的watchEffect提供了一套原生的清理机制,比手动包裹序号更贴合框架的写法。

watchEffect(async (onCleanup) => { let cancelled = false onCleanup(() => { cancelled = true }) const res = await fetchList(keyword.value) if (!cancelled) { list.value = res.list } })

当keyword再次变化或者组件卸载时,Vue 会在下一次副作用开始前调用onCleanup注册的回调,把上一个请求对应的cancelled置为true。旧请求继续跑完也没关系,它已经失去了写入资格。

这个写法的好处是把“取消标记”和“副作用生命周期”绑定在一起,不需要额外造一个全局计数器,也不会在组件卸载后继续写ref触发 Vue 的警告。唯一的注意点是第一次执行时onCleanup会被 Vue 注入并自动绑定,不要在内部重复调用watchEffect造成嵌套。

4.5 方案五:互斥锁请配 try/finally,关闭规则要写理由

如果场景确实需要“同一时间只允许一个刷新任务”,用互斥锁本身没错,但要注意两点:第一,锁的释放要放在finally里,保证请求报错时锁也会归还;第二,ESLint 可能依然对这一行告警。

let refreshing = false async function refresh() { if (refreshing) return refreshing = true try { const res = await fetchData() list.value = res.data } finally { refreshing = false } }

规则为什么还会报?因为refreshing在开头被读取,又在await之后被写入,结构上依然有“先读后写”。但这里的“先读后写”恰恰是互斥锁的保护机制,不是风险。这时候就要区分:这个函数是不是存在多个并发入口?如果你确认它在全局只有一个触发点,比如某个按钮的click事件且按钮没有其他入口,就可以针对这一行加注释说明后关闭。

// eslint-disable-next-line require-atomic-updates -- refreshing 是互斥锁,受单入口保护,非竞态 refreshing = false

如果入口不止一个,老老实实换序号方案,别用注释掩盖问题。

4.6 关于 eslint-disable:要关就关得明明白白

通用做法有两条路:一条是行内注释,一条是配置文件里改规则。行内注释适合“只有这一行是安全的,其他都要检查”的情况;配置文件关闭适合“这个规则在我们项目里噪音太多,我们内部用 code review 把关”的情况。

rules: { 'require-atomic-updates': ['warn', { allowProperties: true }] }

把error降成warn有时候反而是更好的策略,既保留提示,又不阻塞 CI。无论如何,关闭之前必须确认三点:代码里的并发窗口已经被业务逻辑消除;团队里有替代的防护手段,比如 code review 清单;注释里写清楚关闭的原因。最怕的是带新人时留下一堆“为什么关规则”的谜题,三个月后没人记得原因,只能靠猜。

5. 在项目里确认、配置与日常维护

5.1 先确认告警到底来自哪条规则

编辑器提示通常会把规则名带出来,比如require-atomic-updates。如果没显示,可以用命令行确认:

npx eslint src/components/ListPanel.vue

输出内容里会带规则名。想确认这条规则当前有没有被推荐配置默认开启,用--print-config:

npx eslint --print-config src/components/ListPanel.vue | grep 'atomic'

有这个输出说明规则已启用,没有输出就说明当前配置没开它,那你的告警可能来自 typescript-eslint 或其他自定义规则。别凭记忆判断,不同版本的 ESLint 对大版本推荐配置的规则组成有调整,现场查看最靠谱。

5.2 通用的两种配置写法

老式.eslintrc.js:

module.exports = { rules: { 'require-atomic-updates': 'error' } }

新版 flat config 的eslint.config.js:

import js from '@eslint/js' export default [ js.configs.recommended, { rules: { 'require-atomic-updates': 'error' } } ]

需要临时关闭就在对应配置里改成'off'或0。注意 flat config 下如果使用了多个配置块,规则的合并顺序是按数组顺序来的,后面的会覆盖前面的同名规则,别把覆盖写反了。

5.3 和 TypeScript ESLint 的配合

Vue 3 + TypeScript 项目的告警来源可能不是核心规则,而是 typescript-eslint。新版提供了更智能的no-race-condition,能识别ref(...).value、响应式对象属性等 Vue 场景的赋值模式。如果同时开着两条规则,会出现重复告警,建议留新弃旧:

rules: { 'require-atomic-updates': 'off', '@typescript-eslint/no-race-condition': 'error' }

这里要注意版本兼容:no-race-condition在一些 typescript-eslint 版本里还属于较新的规则,如果你用的版本不识别它,要么升级,要么退回核心规则。我的经验是先看报错信息末尾的规则名,再决定配置方向,避免“关了核心规则发现 TS 规则没生效,告警彻底消失但问题也没人管”的尴尬。

6. 一次真实排查实录与我的避坑清单

6.1 排查实录:列表页数据被旧请求覆盖

前一阵维护一个后台管理项目的列表页,搜索框已经加了 300ms 防抖,网络稍慢时仍偶发“列表内容和搜索词对不上”。我第一反应是服务端缓存问题,排查完发现接口本身没问题,于是打开浏览器开发者工具的 Network 面板,手动快速敲了几个字,观察请求顺序:第一次请求的响应明显晚于第二次返回,页面最终渲染的却是第一次的结果,这就是典型的竞态。

排查步骤当时是按这个顺序做的:

  1. 在请求函数里加日志,记录“请求发出顺序”和“响应返回顺序”,确认两者不一致。
  2. 对照 Network 面板的响应时间,确认慢的那次就是覆盖新数据的那次。
  3. 用请求序号方案修复,保留日志反复验证快速输入、切分页、切 tab 均未复现。
  4. 顺手用前面说的方法检查项目里其他异步函数,又找出两处 watch 也有同样隐患。

修复后这个页面没再出现数据错乱。但日志我没有立刻删,留了两个版本再清理,因为竞态问题本身就难复现,多验证一段时间更稳。

6.2 哪些看似“误报”的告警其实值得处理

我见过不少同事收到这条告警后直接关规则,理由是“我的功能只有一个入口”。但实际排查下来,很多“只有一个入口”的判断并不成立:函数可能被 watch 触发、被定时器触发、被父组件调用、被路由守卫调用。所以我给自己定了一个习惯:先数清所有调用链,再决定是不是误报。

缓存重复请求类的告警值得处理,不仅是为了消除警告,更是为了省下无意义的网络开销;loading 互斥类的告警大多可以降级为“确认后注释”;组件卸载后写入ref的告警则必须处理,因为它不只是竞态,还可能导致内存泄漏或页面残留警告。

6.3 避坑清单

  • 不要为了让告警消失,把赋值全部改成“局部变量 + setTimeout 延迟写入”,那只是把竞态从规则眼皮底下藏起来,问题原封不动。
  • 不要为了消除告警删掉互斥判断,那等于把保护拆了。
  • 使用 AbortController 时记得捕获取消异常,不要把取消误报成接口失败,也别把取消后的catch当成业务错误上报。
  • v-for 渲染多个子组件时,每个子组件内部的缓存和请求状态要放在组件实例或ref里,不要提升到模块顶层,否则所有实例共享一份数据,互相覆盖。
  • 请求序号方案不适合“需要消费每一次响应”的场景,比如聊天、消息推送,那时请改成队列或串行化,保证响应按顺序被处理。

说实话,这条告警在 Vue 项目里的价值被很多人低估了。我维护的项目现在默认保留它,遇到告警先顺着代码把并发入口梳理一遍,大部分时候都能揪出一个真实隐患。如果你也遇到了这条Possible race condition,别急着关规则,把它当成一次免费的并发 code review。每次处理完,项目里就又多了一块经过验证的稳健代码,这种积累比消除一条红色警告本身有价值得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询