ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ESLint报错Possible race condition解析:Vue异步竞态条件与修复方案

ESLint报错Possible race condition解析:Vue异步竞态条件与修复方案

第一次在CI日志里看到这行报错时,我一度以为是某个第三方ESLint插件新出的规则。后来翻规则文档才发现,这个提示Vue项目里高频出现的Possible race condition: xxxx might be reassigned based on an outdated value of xxxx,其实来自 ESLint 核心规则require-atomic-updates,和Vue本身没有直接关系,只是Vue项目里太容易踩中了。

这条规则背后涉及的“竞态条件”,是前端异步编程里很容易被忽略、但一旦出问题就非常隐蔽的坑。尤其是搜索列表、分页加载、下拉刷新这类用户操作频繁的场景,用户快速连续操作几次,界面很可能就把旧数据显示成了新数据。这篇文章会从报错本身出发,讲清楚这个规则的判定逻辑、如何在真实组件里复现和验证竞态,然后给出从“先让它不响”到“真正修复竞态”的完整思路和代码改造方案。

1. 报错现场:构建流水线被一条Rule卡住以后

1.1 控制台和CI上出现的完整报错长什么样

在Vue项目里跑npm run lint,或者本地开发时打开编辑器,最常见的展示形式类似下面这样:

error Possible race condition: `list` might be reassigned based on an outdated value of `list` require-atomic-updates

注意结尾的规则名:require-atomic-updates。报错里的变量名不是固定的,它取决于你的代码里哪个变量在异步边界后被重新赋值。我在实际项目中看到过articleList、tableData、searchResult、loading、page这些各种名字都有。

报错位置通常会指向某个async方法里await之后的赋值语句。比如:

async fetchList() { const { data } = await getArticleList(params) this.list = data }

只要this.list在它自己的作用域里被识别成“共享可变状态”,这条赋值就可能被规则标记出来。如果你继续往下看ESLint的规则说明,大多只会讲“变量在await之后被重新赋值,可能导致竞争条件”,但到底为什么会有竞争、是不是每个报错都需要改,很多人并不清楚。这也是这篇文章想重点解决的问题。

1.2 它来自核心规则,不是Vue或TypeScript插件的专属规则

排查规则来源其实很快,不需要猜。在项目目录里执行:

npx eslint --print-config src/views/SearchList.vue

输出的配置对象会列出该文件最终生效的所有规则和级别。搜索require-atomic-updates,你会看到它的级别通常继承自eslint:recommended,也就是ESLint官方推荐规则集,默认级别是error。

很多脚手架项目最终都会启用eslint:recommended,所以Vue项目里出现这条规则并不奇怪。它之所以在Vue项目里出现频率高,主要是因为:

  • ESLint的require-atomic-updates检查的是async函数,而Vue的methods、组合式函数、Vuex的action里大量使用async/await。
  • 开发时习惯把请求结果直接赋给 Vue 的响应式字段,比如this.list = data,这是一种典型的共享可变状态写入。
  • 模板里的事件监听让用户操作可以随时触发新的异步调用,天然满足“并发调用”这个前置条件。

所以同样一条规则,在纯函数式代码里可能很少触发,在业务型Vue项目里就成了高频报错。

1.3 变量名会变,但问题本质一致

我见过很多同事看到报错的第一反应,是去搜索引擎搜“vue eslint Possible race condition”,结果发现不同文章里示例变量名都不一样,很容易误以为是不同问题。其实规则只看代码形态:一个异步函数里,某个共享状态在await之后被重新赋值。所以不管它提示的是list还是data,本质都是同一个问题。

搞清楚这一点之后,先别急着改代码,下一步要去理解规则为什么把“重新赋值”当成潜在风险。

2. require-atomic-updates到底在检查什么

2.1 await不是暂停,而是并发交接点

很多人对await的理解是“停在这里等结果出来再继续”,这个理解在单次调用里没问题,但在并发场景下会引人误解。

JavaScript是单线程事件循环,await确实会暂停当前函数的执行,但暂停期间事件循环不会停。用户可能又触发了一次点击、输入、滚动,另一个异步任务可能正在更新同一个数据。也就是说,await的恢复点其实是一个“交接点”:当前函数让出了执行权,其它代码获取了执行机会,等你被恢复时,你之前读过的那些共享状态,可能已经不是原来的值了。

打个比方:候诊室里的座位在你去缴费时,可能已经被别人占了。你回来后如果还按“之前记得的座位号”直接坐下,就坐错了位置。require-atomic-updates想提醒的,就是这种“回到现场后,用过期信息覆盖共享状态”的行为。

2.2 规则关心的代码形态:共享可变状态 + await后的重新赋值

ESLint官方文档里给出的最简示例是这样的:

let result async function foo() { result = await bar() }

这里result是一个模块级的let变量,它不属于某个函数的局部作用域,多个并发的foo()调用都会向同一个result写值。假设foo()被快速调用两次,第二次先完成,result先被赋值成第二次结果,接着第一次也完成了,result又被覆盖成第一次的结果。这种情况下,最后一次写入的反而可能是过期数据。

规则把这类“跨过await边界、对共享可变状态进行非原子赋值”的行为,统一标记为潜在竞态。它不关心你的赋值表达式是this.list = data还是list = list.concat(data),只要写入目标是一个可被多个执行流共享的变量,就会纳入检查范围。

2.3 规则不检测局部变量,也不会发现所有竞态

有个容易混淆的点是:require-atomic-updates主要针对共享状态,纯函数内的局部const变量一般不会触发。例如下面这段代码通常不会报错:

async function fetchList() { const { data } = await getList() return data }

因为data是局部变量,函数每次调用都有自己的独立空间,不存在两个人同时写同一个data的问题。

反过来,规则没报也不代表没有竞态。比如 Vuex 里 action 内commit的顺序错乱、组件卸载后异步回调又去修改 DOM、WebSocket 消息多次推送时对同一个数组做累积更新,这些场景规则不一定能识别,但它们同样是竞态问题。因此这条ESLint规则更像一个“哨兵提示”,而不是“竞态检测器”。看到报错需要人工判断,没看到也不能高枕无忧。

2.4 对Vue项目尤其重要的 allowProperties 选项

require-atomic-updates有一个选项叫allowProperties。简单理解:默认情况下,规则会检查类似this.list = data这类“对象属性赋值”;如果把allowProperties设为true,规则只会检查模块级普通变量,不再对对象属性赋值报警。

Vue项目里大量数据都在this上,所以这个选项直接决定了Vue项目里此规则的噪音有多大。在.eslintrc.js中配置:

module.exports = { rules: { 'require-atomic-updates': ['error', { allowProperties: true }], }, }

如果是新版本的 flat config,写法类似:

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

设置之后再跑一遍 lint,很多Vue页面里的this.list = data类报错会消失。但这个选项只是降低规则的噪音,并不会修复真实存在的竞态。因此我更倾向于把它当成一种“评估后的放宽”,而不是默认操作。真正要不要放宽,应该先判断项目里那些被标记的赋值有没有可能真的旧覆盖新。

3. 实际组件里的竞态时间线:搜索列表复现

3.1 一个典型的触发场景

用一个后台管理系统常见的搜索列表举例。用户在搜索框里输入关键词,每次输入都触发一次列表请求:

<template> <input v-model="keyword" @input="fetchList" placeholder="输入关键词搜索" /> <ul> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </ul> </template> <script> import { searchByKeyword } from '@/api/search' export default { data() { return { keyword: '', list: [], } }, methods: { async fetchList() { const keyword = this.keyword const { data } = await searchByKeyword({ keyword }) // ESLint 可能在这里报: // Possible race condition: `list` might be reassigned based on an outdated value of `list` this.list = data }, }, } </script>

这段代码核心问题在于:this.list是响应式共享状态,searchByKeyword是异步请求,await期间用户完全可以再次输入并触发新的fetchList。ESLint把这个赋值标记出来,是因为它无法证明这一刻“旧的响应不会晚于新响应到达”。

3.2 两次输入之间发生了什么

假设用户快速输入“vue”和“vue cli”,请求发起和返回的真实时间线可能是这样的:

  1. input事件触发,用户输入 “vue”,第一个请求req1发出。
  2. 用户继续输入“vue cli”,input事件再次触发,第二个请求req2发出。
  3. 后端处理req2更快,先返回结果,this.list被赋值为关键词“vue cli”的数据,界面显示正确。
  4. 后端处理req1稍慢,后返回结果,this.list又被改回关键词“vue”的数据,界面被旧数据覆盖。

从用户视角来看,明明输入了“vue cli”,列表却显示了“vue”的结果。这种问题在本地快速网络下不一定能复现,因为请求返回顺序通常和发出顺序一致;一旦上了慢网络、弱网环境,或者后端接口本身对不同关键词处理耗时不一样,问题会频繁出现。

3.3 报错和真实bug的对应关系

在这个例子中,ESLint报出的list,恰好就是会被旧响应覆盖的那个变量。可以这么理解:规则不是百分百知道你的接口谁快谁慢,但它看到了一个足够危险的代码形态,于是先报警,再由人去判断。

也有一些场景属于规则报了、但实际并不会产生问题:

  • 初始化页面时只调用一次,不存在并发触发。
  • 每次调用前都有严格的状态锁,比如if (this.loading) return,保证同时只有一个请求在跑。
  • 变量是组件私有且组件卸载后不再更新。

如果确认属于这些情况,那么规则报警可以视为误报或噪声,选择性地关闭规则是合理的。但如果你的代码像上面搜索列表那样,允许用户高频触发同一个异步过程,就应该认真看待这条规则,而不是急着关掉。

4. 从消音到修复:四种处理方式的边界

4.1 临时消音:注释、关闭规则、allowProperties

对单个位置的代码,可以加行内注释:

async fetchList() { const { data } = await searchByKeyword({ keyword }) // eslint-disable-next-line require-atomic-updates this.list = data }

也可以全局关闭:

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

或者用前面提到的allowProperties: true降低对Vue响应式属性的检查噪音。这三种都是“让规则不再响”的办法,但它们只是让 lint 通过,不会让竞态消失。适合使用的场景是:你已经确认这个异步赋值不存在并发风险,或者暂未重构但需要先让CI跑通,然后再排期修复。不适合一上来就全局关掉,否则等于把哨兵撤了,后面有人写出真正的竞态代码也没人提醒。

4.2 伪修复:局部变量中转为什么没有意义

网上有一种常见的“修复方案”是先把接口返回值存到一个局部变量,再把局部变量赋给this.list:

async fetchList() { const { data } = await searchByKeyword({ keyword }) const nextList = data this.list = nextList }

这种写法对规则来说,确实可能降低部分版本的告警概率,因为规则在静态分析时不再直接看到“等待后对this.list的读取和写入路径”,但它完全没有解决并发问题。两次并发的请求依然会各自拿到结果,依然会按“谁后返回谁后赋值”的顺序覆盖this.list。所以我在实际操作中不推荐这种改法,它只会让问题更隐蔽。判断一个修复方式有没有用,不是看ESLint是否还报错,而是看旧响应晚到之后,UI会不会被错误更新。

4.3 AbortController:让旧请求直接闭嘴

这是从网络层解决问题的思路:当新请求发出时,直接把还没返回的旧请求取消掉。旧请求被取消后,它的 Promise 会进入 reject 分支,后续赋值代码不会执行,界面也就不会被覆盖。

在支持signal的请求库中,比如 axios 0.22 以上版本,可以这样写:

data() { return { controller: null, list: [], } }, methods: { async fetchList() { if (this.controller) { this.controller.abort() } const controller = new AbortController() this.controller = controller try { const { data } = await searchByKeyword( { keyword: this.keyword }, { signal: controller.signal } ) this.list = data } catch (error) { // 旧请求被取消时,直接忽略错误 if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') { return } throw error } }, }

这里的核心逻辑是:每次调用前,先把上一次的controllerabort 掉。后续的await会立即因为你中途 abort 而抛错,于是旧请求的赋值永远不会执行。新请求不受影响,正常进入try块完成赋值。

原生fetch用法类似,错误名称就是AbortError。如果项目还在用 axios 的CancelToken,思路一样,只是错误判断要用axios.isCancel(error)。实测下来,这个方案对搜索、筛选、分页这类高频触发场景效果最好,因为旧请求连返回的机会都没有了,服务端压力也小一些。

4.4 请求令牌模式:不依赖取消的兜底

有时候请求库不支持取消,或者接口本身已经发出,取消也没有意义。这时候可以用“令牌”来标识请求的时效性:每次触发新请求时,令牌加一。响应回来后检查令牌是否还是当前值,如果不是,说明已经有更新的请求发生,直接丢弃这次结果。

data() { return { requestSeq: 0, list: [], } }, methods: { async fetchList() { const seq = ++this.requestSeq const { data } = await searchByKeyword({ keyword: this.keyword }) // 如果期间又发起了新请求,seq 已经变化,说明这次结果已过期 if (seq !== this.requestSeq) { return } this.list = data }, }

这个方案最大的优点是底层请求库无关,fetch、axios、甚至某些 SDK 的异步方法都能用。缺点是没有真正取消旧请求,旧请求仍然会占用网络和计算资源,只是结果被丢弃了。和 AbortController 组合使用,效果最好:先取消旧请求,再靠令牌机制兜底,双保险处理那些取消不及时的边界情况。

4.5 四种方式的取舍参考

处理方式是否会修复真实竞态适合场景风险
单行注释关闭否确认无并发风险的单次初始化掩盖问题
全局关闭或 allowProperties否整库评估后有意识地降低噪音后续新增代码无人提醒
局部变量中转否不推荐隐藏问题,甚至不一定会让 ESLint 恢复安静
AbortController 取消是搜索、筛选、分页等高并发依赖请求库支持 signal
请求令牌模式是各类请求 SDK旧请求仍会占用资源
取消 + 令牌组合是高并发复杂场景代码量稍多

5. 工程化落地:请求层统一封装与验证

5.1 封装一个“只认最后一次请求”的组合函数

如果每个业务函数里都手写一遍 AbortController 加 seq 判断,代码会非常啰嗦,而且很容易写漏。更好的做法是在请求层统一封装一个工具,把竞态策略收敛到一个函数里。

下面是一个 Vue 3 组合式函数风格的简化示例,核心思想是“取消旧请求 + 令牌校验”双保险:

import { ref } from 'vue' export function useLatestRequest(requestFn) { let seq = 0 let controller = null async function run(...args) { // 取消上一个请求 if (controller) { controller.abort() } controller = new AbortController() const current = ++seq try { const data = await requestFn(...args, { signal: controller.signal, }) // 令牌校验,防止取消不及时导致旧结果仍然返回 if (current !== seq) { return undefined } return data } catch (error) { if ( error.name === 'AbortError' || error.code === 'ERR_CANCELED' || current !== seq ) { return undefined } throw error } } function cancel() { seq++ if (controller) { controller.abort() } } return { run, cancel } }

在组件中,可以通过setup创建实例并返回给模板或methods使用:

import { useLatestRequest } from '@/utils/useLatestRequest' import { searchByKeyword } from '@/api/search' export default { setup() { const latestSearch = useLatestRequest(searchByKeyword) return { latestSearch, } }, data() { return { keyword: '', list: [], } }, methods: { async fetchList() { const data = await this.latestSearch.run({ keyword: this.keyword }) if (data) { this.list = data } }, }, beforeUnmount() { this.latestSearch.cancel() }, }

这样业务代码里既不需要关心 AbortController 的细节,也不需要维护requestSeq全局计数,每个页面只需要关注“拿到最新一次结果之后怎么渲染”。

组件卸载时调用cancel()也很重要。否则组件已经销毁,异步请求返回后可能还会尝试给已经卸载的组件赋值,在严格模式或配置了部分 lint 规则时会产生额外警告,也会造成无意义的内存占用。

5.2 为什么要在请求层解决,而不是每个页面单独处理

把竞态策略封装在组合函数或请求层工具里,最大的好处是行为统一。如果每个页面自己写一套,有人可能会漏掉取消逻辑,有人可能只加一个if判断,最后谁也说不清项目里到底哪些接口还有竞态风险。封装之后,新的页面只要调用同一个工具,就能够默认获得“最新请求优先”的语义。

另一个好处是便于测试。请求层是纯逻辑,不依赖具体组件的生命周期。可以直接在测试环境里模拟两次快速调用,第一次的返回值晚返回,断言最终数据来自第二次调用。如果竞态逻辑散落在各个组件里,测试成本会高出很多。

5.3 验证修复是否真的有效

改造之后,至少要做两轮验证:

第一轮,确认 lint 通过。直接在项目里运行:

npm run lint

此时如果之前只剩这个竞态报错,应该已经不再出现。如果还有,查看是不是同一变量在别的位置还有类似的 await 后赋值。

第二轮,人工构造慢网络复现。打开浏览器开发者工具的 Network 面板,把网络切换成 Slow 3G,然后在搜索框里快速输入两个不同关键词,观察请求列表的返回顺序。修复前的典型现象是:先发出的请求后返回,列表最终显示旧关键词结果。修复后,虽然请求仍然可能乱序返回,但 UI 最终一定保留最后一次请求的结果。

还可以在fetchList里临时加打印日志,把每次响应回来时的keyword和 token 打出来,确认过期响应确实被丢弃。验证结束后再移除调试日志。

5.4 和同体系其它 lint 规则的配合

处理完require-atomic-updates之后,项目里同类异步问题还可以通过其它规则来兜底。比较常见的是@typescript-eslint/no-misused-promises,它会警告在应该处理 Promise 的地方直接传入了异步函数,能帮助避免一部分“漏捕获异常”或“监听器里传 async 函数”的问题。还有一个no-async-promise-executor,检查new Promise(async () => {})这种反模式。它们和require-atomic-updates关注点不同,但都属于异步边界上的代码质量防线。

不过 lint 规则始终只是辅助。真正安全的异步代码,还是需要开发者对“哪些状态是共享的、哪些时序可能交错”有清晰认知。规则的提示就像是修车厂的检测灯,灯亮了说明有隐患,但能不能修好,要看你是不是真正理解引擎的工作原理。

我自己处理这类报错的习惯是:先不关规则,打开代码把await前后那段逻辑通读一遍。如果确实存在用户连续触发、响应乱序的可能,就补上取消或令牌机制;如果确认是单次初始化,再考虑用注释或allowProperties降噪。竞态问题不会因为ESLint闭嘴就自动消失,它只是暂时不被提醒,风险还留在那里。希望这篇文章能帮你在下次看到Possible race condition的时候,不再只是搜索“怎么关闭这个报错”,而是能快速判断出它到底在保护什么,以及你的代码是不是真的需要修复。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进