competitionFn —— 前端竞态安全管道
一、先说问题:什么是"竞态"?
想象一个常见场景:用户在搜索框输入关键词,每次输入都会请求接口获取结果。
用户输入 "A" → 发起请求①(慢,需要 2 秒)
用户输入 "B" → 发起请求②(快,只要 0.5 秒)请求②先回来了,页面显示了 B 的结果。但 1.5 秒后,请求①也回来了,页面被 A 的结果覆盖了。
用户搜的是 "B",看到的却是 "A" 的结果 —— 这就是竞态问题。
类似场景还有很多:
- 切换 Tab 时,上一个 Tab 的请求还没回来
- 短时间内多次点击按钮
- 下拉刷新时,旧数据的新请求覆盖了新数据
二、传统做法:手动管理版本号
let version = 0;
async function search(keyword: string) {
const mine = ++version;
const data = await fetchData(keyword); // 异步请求
if (mine !== version) return; // 手动判断:如果不是最新的就放弃
this.result = data; // 只有最新的才更新
}痛点:
- 每个地方都要自己声明
version变量,容易忘 - 如果步骤多了(请求 → 处理 → 再请求 → 再处理),每步之间都要判断
- 如果一个步骤内部有多个异步操作,中间也想判断,写起来更麻烦
三、competitionFn:一个通用的竞态安全方案
核心思想
给每次调用一个版本号,任何时刻只有最新版本能继续执行,旧版本自动作废。
函数签名
competitionFn(key: string, steps: any[])| 参数 | 说明 |
|---|---|
key | 唯一标识。相同 key 的调用互斥,不同 key 互不影响 |
steps | 步骤数组,按顺序执行。每步可以是 Promise 或函数 |
ctx 上下文对象
每个 step 函数会收到 (上一步的结果, ctx),其中 ctx 提供:
| 属性/方法 | 说明 |
|---|---|
ctx.mine | 当前调用的版本号(数字,创建时快照,不会变) |
ctx.isLatest() | 检查当前调用是否仍然是最新的。可在任何异步操作后调用 |
四、怎么用?
1. 最简单的用法:直接传 Promise
async function search(keyword: string) {
await competitionFn('user-search', [
this.service.search(keyword), // Promise → 自动等待
(data) => {
this.result = data; // 只有最新调用才会走到这里
},
]);
}快速连搜三次(key 相同):
- 第一次:请求慢 → 被第二次取代 → 结果丢弃
- 第二次:请求慢 → 被第三次取代 → 结果丢弃
- 第三次:请求完成 → 更新页面 ✅
2. 多步骤串联
await competitionFn('load-detail', [
this.service.fetchList(), // 第1步:请求列表
(list, ctx) => { // 第2步:保存列表
ctx.selectedId = list[0]?.id;
},
(list) => this.service.fetchDetail(list[0]?.id), // 第3步:用第1步的结果请求详情
(detail, ctx) => { // 第4步:展示详情
this.detail = detail;
},
]);每一步之间都会自动检查版本,如果已经不是最新调用,直接停止。
3. 在步骤内部手动检查(关键能力)
有时候一个步骤内部有多个异步操作,你想在每个 await 之后确认自己还没过时:
await competitionFn('save-form', [
async (_prev, ctx) => {
// 第一个异步操作
const validateResult = await this.service.validate(formData);
if (!ctx.isLatest()) return; // 👈 等完后检查:是否已被新调用取代?
// 第二个异步操作
const saveResult = await this.service.save(formData);
if (!ctx.isLatest()) return; // 👈 再检查一次
this.result = saveResult;
},
]);
ctx.isLatest()让你可以在步骤内部的任意位置检查,不再局限于"步骤之间"。
4. 不同 key 互不干扰
// 这两个调用用的是不同的 key,互不影响
await competitionFn('search-api', [...]); // key = 'search-api'
await competitionFn('suggest-api', [...]); // key = 'suggest-api'五、一张图总结
用户操作 调用① (key=搜索) 调用② (key=搜索)
│ │ │
├─ 输入A ──→ mine=1, 开始请求A ──→ │
│ │ │
├─ 输入B ────────────────────────────→ mine=2, 开始请求B
│ │ │
│ A的结果回来了 B的结果回来了
│ │ │
│ isLatest()? isLatest()?
│ 1 ≠ 2 → false ❌ 2 == 2 → true ✅
│ 自动丢弃 更新页面六、与传统写法的对比
| 手动管理版本号 | competitionFn | |
|---|---|---|
| 每个场景都要声明变量 | ✅ 是 | ❌ 不需要,传 key 即可 |
| 多步骤间自动检查 | ❌ 每步手写 | ✅ 自动 |
| 步骤内部也能检查 | ❌ 要自己暴露方法 | ✅ ctx.isLatest() |
| 上下文共享(ctx) | ❌ 自己管理 | ✅ 自动传递 |
七、实现原理
完整源码不到 20 行,核心逻辑非常简单:
// 用一个对象记录每个 key 当前的最新版本号
const _competitionVersions: Record<string, number> = {};
export async function competitionFn(key: string, steps: any[]) {
// 1. 版本号 +1,代表"我是一次新的调用"
if (!_competitionVersions[key]) _competitionVersions[key] = 0;
const mine = ++_competitionVersions[key];
let prevResult: any;
// 2. 构造上下文对象,暴露给使用者
const ctx: any = {
/** 当前调用的版本号(快照,不会变) */
mine,
/** 实时检查:我的版本号还等于最新版本号吗? */
isLatest: () => mine === _competitionVersions[key],
};
// 3. 逐步执行
for (const step of steps) {
// 每步之前先检查:如果已经不是最新调用,直接退出
if (!ctx.isLatest()) return;
if (typeof step === 'function') {
// 函数 → 执行,传入 (上一步结果, ctx)
prevResult = await step(prevResult, ctx);
} else {
// Promise → 直接等待
prevResult = await step;
}
}
return prevResult;
}三句话看懂:
- 每次调用把
key对应的版本号+1,记为自己的mine - 每执行一步之前,比较
mine是否还等于最新版本号,不是就退出 ctx.isLatest()是同一个比较逻辑的封装,供步骤内部随时调用
八、注意事项
- key 要唯一:同一个业务场景用同一个 key,不同业务用不同的 key
- 旧调用不会 cancel 网络请求:它只是让旧调用"不再继续执行后续步骤",请求本身仍在进行
- ctx 可以存临时数据:
ctx.xxx = ...在不同 step 之间共享,但只属于当前调用
