用户先点了文件 A,又点了文件 B。标题显示 B,正文却是 A 的内容。两次读取返回的数据都没错,问题出在 A 的结果到达时,用户已经换了选择,却没有任何代码拦住它。
“点击 → 加载 → 渲染”这条调用链解释不了这个现象,因为两次点击走的是完全相同的函数。还得知道两个请求各自什么时候开始、什么时候结束,以及谁有权更新界面。下面用一个小的 JavaScript 示例走一遍,也会检查只修正文时容易漏掉的错误提示和加载状态。
先读到第一个 await
下面是一个简化的文件读取器。load 返回文件内容的 Promise,publish 把局部更新合并到界面状态。两者都从参数传入,所以示例不依赖任何 UI 框架:
export function createUnsafeReader(load, publish) {
return async function select(file) {
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
publish({ text });
} catch (error) {
publish({ error: error.message });
} finally {
publish({ loading: false });
}
};
}
await 之前,函数记下选中的文件,并清空旧内容。等待期间,另一次 select 可以开始。await 之后,没有任何检查确认界面是否还属于这次请求。
局部变量 file 在等待期间不会变,共享的界面状态却会变。整个 bug 就在这里:函数手里的数据对它自己来说没错,但它已经不是该显示的那次调用了。
画一条时间线
用两个可以手动控制的读取,让 B 先返回、A 后返回:
| 步骤 | 事件 | 当前文件 | 正文 | 加载中 |
|---|---|---|---|---|
| 1 | 选择 A,开始读取 | A | 空 | 是 |
| 2 | 选择 B,开始读取 | B | 空 | 是 |
| 3 | B 读取成功 | B | B 的内容 | 否 |
| 4 | A 读取成功 | B | A 的内容 | 否 |
两次调用跑的是同一段代码。这个返回顺序让“最后完成的读取”赢了,而界面要的是“最后选择的文件”。
动手修之前,先确认界面到底该怎么表现。单文件预览只需要显示最新选择;批量导入要保留每个结果,保存队列可能还要求按顺序写完,这些场景直接丢掉旧结果反而是错的。下面的修法只适用于新选择会取代旧选择的视图。
不用定时器也能稳定复现
让 A 等 100 毫秒、B 等 10 毫秒的测试又慢,又依赖调度。换成由测试控制完成时机的 Promise:
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
分别创建 A、B 两个读取,先选中两个文件,再按 B、A 的顺序让它们完成:
const a = deferred(),
b = deferred();
const state = {};
const select = createUnsafeReader(
(file) => (file === 'A' ? a : b).promise,
(patch) => Object.assign(state, patch),
);
const first = select('A'),
second = select('B');
b.resolve('B text');
await second;
a.resolve('A text');
await first;
assert.equal(state.file, 'B');
assert.equal(state.text, 'A text'); // 确认旧实现的问题能够复现。
最后一个断言故意检查错误结果,用来证明示例确实能触发问题。修复版的测试则要求正文是 B。复现和修复分成两个测试,就不会有人把“测试通过”误读成“旧实现没问题”。
给每次选择编个号
单个视图用一个递增计数器就够了。选择开始时加一,把编号存在本次调用里,写回任何结果之前先和最新编号比较:
export function createReader(load, publish) {
let latest = 0;
return async function select(file) {
const request = ++latest;
publish({ file, text: '', error: null, loading: true });
try {
const text = await load(file);
if (request === latest) publish({ text });
} catch (error) {
if (request === latest) publish({ error: error.message });
} finally {
if (request === latest) publish({ loading: false });
}
};
}
计数器属于这个读取器实例。放到全局,一个窗口里的点击会让另一个窗口的结果作废;放进 select 内部,每次调用都从头计数,根本分不清请求。
只比较路径也不行。用户按 A → B → A 的顺序选择时,第一次和第三次路径相同,但第一次读到的可能是更旧的文件内容。编号能区分这两次。
示例假设 publish 同步更新状态,load 失败时抛出 Error。如果你的框架会批量更新、组件可能被卸载,或者接口会抛出别的类型,这些情况也要处理。
用三次独立读取测试 A → B → A
每次调用都要有自己的 deferred Promise。前面的测试把 A、B 各映射到一个 Promise;如果第二次选择 A 时还复用第一个,就把需要区分的两次请求合并了,测不出这个问题。
这里假设 load 对同一路径的不同调用可能返回不同快照,例如两次读取之间文件被修改。测试直接提供“A version 1”和“A version 2”来控制结果。这些是教学用例,并非 Yenpo 的线上事故记录。如果加载器始终返回同一份不可变快照,这个场景就不会出现正文版本差异;分析真实项目时,要先查清缓存和读取方式。
下载文件里增加了一个故意不完整的 createPathOnlyReader。它记录 selectedFile,在成功、报错和 finally 三处都用 file === selectedFile 判断。再次选中 A 后,第一次 A 的结果仍能通过所有判断:
| 事件 | 只比较路径 | 比较请求编号 |
|---|---|---|
| 依次选择 A、B、A | 当前路径是 A | 当前请求编号是 3 |
| 第三次请求返回 A version 2 | 显示 A version 2 | 显示 A version 2 |
| B 返回 | 忽略 B | 忽略请求 2 |
| 第一次请求返回 A version 1 | 显示 A version 1 | 保留 A version 2 |
两个配对测试按完全相同的顺序运行这两种读取器。名称带 reproduce: 的测试故意要求出现旧版本,证明路径判断确实失效;请求编号版则要求保留 version 2。另外两个测试让第一次 A 在第三次仍未完成时返回:旧的成功结果不能写正文或关闭加载状态,旧的失败不能显示错误;当前 A 自己失败时,错误仍要正常显示。
让两个读取器各管各的状态
每个独立视图创建自己的读取器,分别保存 latest,并让 publish 更新各自的状态。同一视图后续的选择继续使用原来的读取器;如果每次点击都重新创建,就失去了让旧请求作废的共用计数器。
最后一个测试创建左右两个读取器,各有独立的 deferred 加载器和状态对象,再按以下顺序运行:
- 左侧选 A,右侧选 A,左侧再选 B。左侧当前编号是 2,右侧仍是 1。
- 让右侧 A 返回,左侧 B 继续等待。右侧应显示结果并结束加载,左侧仍为空且保持加载。
- 让左侧旧的 A 失败。左侧应继续等待 B,不显示旧错误。
- 让左侧 B 返回。左侧显示 B,右侧仍保留自己的 A。
如果把 latest 移到模块顶层,第二步就会失败:左侧的新选择会让右侧请求作废,右侧一直停在加载状态。即使计数器独立,共用一个界面状态对象也会互相覆盖。测试会在交错完成后检查两边的状态。
报错和加载状态也会写错地方
如果只给 publish({ text }) 加判断,正文错乱是没了,但还剩两个问题:
- 选了 B 之后 A 失败了。A 的
catch把“A 读取失败”显示在 B 的界面上。 - B 还在加载,A 先结束了。A 的
finally把 B 的加载状态提前关掉了。
要测出第二种情况,得让 A 完成、B 继续等待,然后立刻检查 loading 仍是 true。如果等两个请求都结束再看最终状态,就看不到加载状态被提前关掉的那一刻。
第一种情况,在 B 等待时让 A 失败,检查 B 既没有出现旧错误,也还在加载,然后再让 B 完成。另外单独测一下当前请求失败时错误能正常显示:一个把所有错误都吞掉的“修复”,也能通过旧错误那条测试。
所以真正要守住的规则,比“最后正文是对的”更严格:只有当前请求能在完成时写界面,不管写的是正文、错误,还是关闭加载状态。
取消请求和忽略结果是两回事
如果加载器支持取消,取消 A 能省掉一些无用功。但发出取消之后,A 仍然可能写回:结果可能已经在缓存里了,底层操作可能无视取消信号,也可能正好在取消的同时完成。
取消用来停掉不再需要的工作,编号用来决定结果能不能改界面。用之前要看清加载器是否真的支持 AbortSignal,不是所有返回 Promise 的接口都支持。
忽略旧结果,也撤销不了 load 过程中已经做过的事。示例里的加载器只返回文本;如果真实的加载器还会写共享缓存或修改数据模型,这些写入要另外追踪。编号只保护界面本身。
跑一下测试
下载 async-selection.mjs,用 Node.js 22 或更新版本运行:
node --test async-selection.mjs
十个测试覆盖最初的 A → B 覆盖问题、成功/错误/加载状态保护、A → B → A 重复请求,以及两个独立读取器。两个名称带 reproduce: 的测试故意检查错误结果,其余八个验证应有的行为。不用定时器,也没有外部依赖。
放到真实项目里,要找到入口处理函数、每一个 await 或回调、等待期间可能被改动的共享状态,以及返回路径上的每一处写入。把事件顺序记在这些源码位置旁边。
在 Yenpo 里,可以开着入口文件,让 AI Reader 追踪 await 前后的状态变化。核对回答时,分别看成功、报错和 finally 三条路径。如果它只讲到“Promise 返回了”,就接着问:哪个请求有资格使用这个结果?