异步代码的四类时序问题:回调时机、错误归属、请求竞态与闭包快照
异步代码的难处,往往不在那几个并发执行的任务本身,而在任务返回后,结果的到达顺序会互相穿插。
商家后台订单页有个典型场景:两个 tab,分别是「全部订单」和「待发货」。测试报了一个 bug:快速点「全部」再点「待发货」,界面停在「待发货」,列表里展示的却是全部订单的数据。
接口各自返回正常,状态管理赋值正常,组件渲染的也是最新的 state。最后把请求耗时打出来才定位:
- 「全部订单」请求耗时 300ms
- 「待发货」请求耗时 40ms
用户点第二个 tab 时,第一个请求还在路上;等它返回后,又把界面覆盖成了第一份数据。
代码单独看没问题,单点一次也正常。缺的是那一句判断:第二次操作发生之后,第一次的结果还算不算数。
异步的一个值有两类问题:值被谁改、值什么时候到。这里集中说第二类。常见表现有四条:
- Promise 已经拿到结果,回调也不会进入当前同步段执行;
try/catch包住异步调用,错误仍然可能漏掉;- 两个请求,后发的先回来,稍后到达的结果会覆盖界面;
- 闭包读的是绑定的当前值;React 里每次渲染会新建一套绑定。
一、Promise 已 resolve,回调也不会立刻执行
场景:拉一次订单总数,更新计数器,然后在同步代码里读。
let orderCount = 0;
Promise.resolve(42).then(value => {
orderCount = value;
console.log("[then 里]", orderCount);
});
console.log("[同步代码]", orderCount);
Promise.resolve(42) 返回的 Promise 已经完成,结果就在里面。实际输出:
[同步代码] 0
[then 里] 42
即使 Promise 已经确定,.then 回调也不会插进当前正在执行的同步代码。回调只会进入微任务队列,等当前同步代码跑完,再按队列顺序执行。
这里有个容易混的点:resolve 和「有最终结果」不是一回事。Promise.resolve(p) 里如果传入的 p 本身还挂起,返回的 Promise 也还没有最终结果。本节说的是结果当场就存在的情况。
「有结果了」和「拿结果的代码执行了」是两个时刻。
这个时间差还会影响定时器顺序:
同步代码 → 微任务 A → 微任务 B → setTimeout(fn, 0)
当前同步代码执行完后,会先把微任务队列清空,然后才轮到定时器回调,哪怕延迟写的是 0。反过来,微任务挂太多会把定时器往后挤。挂 20 万个微任务时,setTimeout(fn, 0) 实际大约在 24ms 后才执行。
绝大多数业务代码不需要刻意区分微任务和宏任务。真正要关心的是「B 必须在 A 之后」这种依赖关系。如果 B 依赖 A 的结果,用 await 或 Promise 链把依赖显式写出来,不要指望 setTimeout(fn, 0) 帮忙排队。
但要注意边界:await 只能串起有依赖关系的两步。两次互相独立的用户操作之间谁先谁后,await 管不了,那是第三节的竞态问题。
二、try/catch 包住异步调用,错误为什么仍然会漏
场景:成员权限修改后写一条审计日志。日志失败不能影响主流程,所以外面包了 try/catch。
try {
saveAuditLog("SO-26090100087"); // async 函数,没有 await
showToast("保存成功");
} catch (e) {
reportError(e);
}
实际结果:
try 块跑完了,catch 一次都没进
未处理的 rejection:审计日志写入失败: SO-26090100087
原因不在 try/catch,而在调用方式。saveAuditLog 是 async 函数,调用后立即返回一个 Promise,控制权马上交回调用点。等这个 Promise 真正拒绝时,try 块已经执行完了。那个 rejection 属于这个 Promise,不属于已经结束的同步调用。
数组回调里更隐蔽:
ids.forEach(async id => {
await saveAuditLog(id);
});
forEach 不认识 Promise,它只会把每个回调调用一遍,然后直接返回。回调返回的 Promise 被丢弃;里面的错误自然没人接。
map 的情况类似,只是它会先收集 Promise 到数组里。关键不是用哪个 API 收集,而是数组里这些 Promise 最后有没有人等待、组合或逐项处理。如果数组本身被丢弃,行为跟 forEach 没有区别。Promise.all、allSettled、any,或者自己写调度器,都只是手段。
一个更完整的批量版本:
async function saveAuditLogs(ids: readonly string[]) {
const input = [...ids]; // 先拍快照,等待期间原数组可能被别处改掉
const rs = await Promise.allSettled(input.map(id => saveOne(id)));
const ok: string[] = [];
const failed: string[] = [];
rs.forEach((r, i) => {
(r.status === "fulfilled" ? ok : failed).push(input[i]!);
});
return { ok, failed };
}
实际输出:{ ok: ['SO-26090100091'], failed: ['SO-26090100087'] }。
但不要把这条记成「try/catch 对异步没用」。它做两件事没有问题:
- 接住当前调用栈里的同步异常;
- 接住
await在当前这一行重新抛出的 rejection。
真正容易想错的是下面这种:
async function saveAuditLog() {
throw new Error("参数错误"); // 整个函数没有 await
}
try {
saveAuditLog();
} catch {
// 进不来
}
结果:
try 块正常跑完
未处理的 rejection: 参数错误
一个函数只要声明成 async,函数体里的 throw 就会变成 Promise rejection,哪怕它发生在第一个 await 之前。调用 async 函数,拿到的永远是 Promise。函数体一旦开始执行,里面抛出的东西都算在返回的那个 Promise 头上。
分界线不是「错误发生在 await 前还是 await 后」,而是当前这一行拿到的是一个同步抛出的异常,还是一个已经拒绝的 Promise。
反过来,也不要走另一个极端:「所有 Promise 都必须 await」。审计日志本来就不该卡住主流程,故意不等它是正确的。但故意不等,要把归属交出去:
void saveAuditLog(orderId).catch(reportError); // 不等,但错误有人收
showToast("保存成功");
这里的 void 是写给后续维护者看的,表示「我知道这里返回 Promise,不打算 await」。要紧的不是等不等,而是这个 Promise 最后归谁。
失败能不能被接住,就看 Promise 是交给 await、return,还是 .catch() 负责。三个都没有,它就没有归宿。
三、两个请求,后发的先回来
开头那个 bug 的正主。它最难缠的地方在于:单点一次完全正常,只有两次调用重叠才暴露。
场景重新写一下:
async function onTabClick(tab) {
const data = await fetchOrders(tab);
render(data); // 哪个回来渲染哪个
}
单看一次点击,这段代码没有逻辑问题。
「全部订单」300ms 返回,「待发货」40ms 返回。用户先点「全部」,10ms 后再点「待发货」,实际顺序:
40ms 待发货返回,渲染待发货数据
300ms 全部订单返回,再次渲染全部订单数据
最终:用户停在待发货 tab,界面上是全部订单
本地开发接口通常只有几毫秒延迟,这个问题不容易偶发。它挑网络慢、用户手快的时候出现。要稳定复现也不难:给其中一个接口人为加几百毫秒延迟。
处理思路有两种,按场景选择。
思路一:请求带序号,只认最后一次
type Latest = { stale: false; data: T } | { stale: true };
function createLatestOnly() {
let latest = 0;
return async (task: () => Promise): Promise> => {
const seq = ++latest;
try {
const data = await task();
return seq === latest ? { stale: false, data } : { stale: true };
} catch (e) {
if (seq !== latest) return { stale: true }; // 过期请求失败,不打扰当前界面
throw e; // 当前请求失败,需要让调用方知道
}
};
}
const latestOrders = createLatestOnly();
async function onTabClick(tab: TabKey) {
try {
const r = await latestOrders(() => fetchOrders(tab));
if (!r.stale) render(r.data); // 调用端要自己拦截 stale 结果
} catch (e) {
reportError(e);
}
}
这里有两层 try/catch,都不能省。
请求不只会慢,还会失败。如果内层不拦截,一个过期请求失败时,await task() 会直接抛出,根本走不到 seq === latest 的判断。结果就是:过期请求的错误被当成当前请求的错误弹给用户,而且这个 rejection 本身也没有被妥善处理。
「不打扰界面」不等于「当没发生过」。过期请求里的网络异常、鉴权失败,线上仍然需要记录。丢弃前打一条日志,但不要把错误弹给用户。
返回值用判别联合,不要用 null 当哨兵。如果业务本身允许返回 null,哨兵和真实值会撞在一起。
思路二:AbortController,新请求发出前取消旧请求
let controller: AbortController | undefined;
async function onTabClick(tab: TabKey) {
controller?.abort(); // 取消上一次请求
controller = new AbortController();
try {
const data = await fetchOrders(tab, {
signal: controller.signal,
});
render(data);
} catch (e) {
if (e instanceof DOMException && e.name === "AbortError") {
return; // 预期内的取消,静默处理
}
reportError(e); // 真错误就地处理
}
}
这里的 catch 不是可选项。abort() 会让对应请求的 Promise 变成 rejection,不接住就是未处理的 rejection:
未处理的 rejection: AbortError
最后一行也不要写成 throw e。这个函数挂在点击事件上,事件处理器返回的 Promise 不一定会被框架消费。抛出后如果没人接,又变成一个未处理的 rejection。要么就地上报,要么在挂载处显式交付:
onClick={() => void onTabClick(tab).catch(reportError)}
不要让它悬在半空。
两种方式的区别是:序号法让旧请求跑完,只是丢弃结果;AbortController 会中止客户端对旧请求的等待和读取,可能减少后续网络传输,但不保证服务端已经开始处理的业务会停下来。请求如果已经到了服务端,该写的订单还是会写。
只想防界面被覆盖,序号法够用。请求层本身支持 signal 的,顺手把取消接上。
判断是否要加这层保护的基准是:结果回来的那一刻,发起它时依赖的前提还成立吗?同一个位置会被多次写入的地方——tab 切换、搜索联想、快速翻页——是最常见情况。组件已经卸载、用户已经登出、上游依赖数据已经变化,也都属于「前提不存在」。前提不会变的一次性操作,加这套反而是过度设计。
四、回调读到的值,是「发起时的值」还是「执行时的值」
先看一个不涉及 React 的例子:
let pageSize = 20;
const readLater = () => pageSize;
setTimeout(() => console.log(readLater()), 0);
pageSize = 50;
输出是 50,不是 20。闭包持有的不是「当时的值」,而是变量绑定本身。绑定后来被改,回调执行时读到的自然是新值。
经典的 var 循环问题也来自这里:三个 var 回调共享同一个 i,循环结束时 i 已经是 4。let 每轮新建一个绑定,所以每个回调各读各的。
React 的情况正好相反。
React 每次渲染都是一次新的函数调用,const [count] = useState(...) 在这一次调用里是不变的。这次渲染创建的回调,捕获的是这次渲染的 count。
做一个对照:
当前最新 count = 2
第 1 次渲染创建的回调读到 = 0
| 可变绑定(外层 var/let) | React 每次渲染 |
|---|---|
| 一个绑定,值被反复修改 | 每次渲染新建一套绑定 |
| 回调读到的是最新值 | 回调读到的是创建时那次渲染的值 |
这不是 React 另搞了一套闭包规则,它用的还是同一条词法闭包规则。区别在于绑定的寿命:模块里的 let 从头到尾是同一个绑定;组件函数每渲染一次就执行一次,每次都创建新的局部绑定。用写 setTimeout 的那套直觉去套 React 回调,预期正好相反。
但「读到旧值」不等于读错了。很多时候它就是需要的值:用户点提交那一刻的表单内容,发起请求那一刻的筛选条件,应该用点击时的快照。React 把每次渲染当作一个 state 快照,这是特性,不是缺陷。
正解不是「想办法读到最新值」,而是先问:这个延迟执行的操作,该用发起时的值,还是执行时的值?
let value = 0;
function withSnapshot(delay: number): Promise {
const snapshot = value; // 发起那一刻就取出来
return new Promise(r => setTimeout(() => r(snapshot), delay));
}
function withLatest(delay: number): Promise {
return new Promise(r => setTimeout(() => r(value), delay)); // 执行时才读
}
(async () => {
const snap = withSnapshot(0);
const live = withLatest(0);
value += 1;
value += 1;
console.log(await snap, await live); // 0 2
})();
JS 版本去掉类型标注就是同样的代码。
对到 React 上,有两件经常被混为一谈的事:
- 延迟回调需要读取最新 state:用
useRef存一份,在 Effect 或事件回调里更新ref.current,需要读最新值的地方读它; - 只是要基于上一个 state 算出下一个:用函数式更新
setCount(c => c + 1)。它是基于 pending 队列里的 state 计算下一个值,不是通用的「读取最新 state」手段。
判断回调到底读到新值还是旧值,只需要看两件事:
- 这个回调捕获的是哪一次的词法环境?
- 那个环境里的绑定,或者绑定指向的对象,后来有没有被改过?
提 PR 前的 10 秒自查清单
大部分问题可以靠一次全局搜索定位。
- 搜
try {:块里的 Promise 是交给await、return还是.catch()负责?不要只看缩进。 - 搜
.forEach(async、.map(async:回调返回的每个 Promise,有没有明确的消费方和错误归属? - 事件处理器返回 Promise 时,框架会不会处理它的 rejection?不确定就自己就地处理。
- tab 切换、搜索联想、快速翻页这类位置,有没有做「只认最后一次」?
- 异步结果返回时,发起它时的前提还在吗:组件没卸载、用户没登出、依赖数据没变。
setTimeout或事件回调里读的变量,要的是「挂上去那一刻的值」还是「执行那一刻的值」?- React 延迟回调里读 state:确认要的是「触发那一刻的快照」还是「执行那一刻的最新值」。
- 「B 依赖 A」的地方,用
await或 Promise 链写出来了吗?还是靠setTimeout(fn, 0)碰运气? - 单测不要只写一次
await Promise.resolve()去冲队列,要等那个真正的业务 Promise;涉及定时器就上假定时器。 - 浏览器挂
unhandledrejection,Node 挂unhandledRejection做兜底监控。注意拼写不同,而且这只是最后一道网,不能替代就地处理。
前两条最值得先看。第 1 条靠一次全局搜索就能把待查点拉出来;第 4 条不用搜代码,产品里所有用户会连点两下的位置,基本都有竞态。
四件事,其实是同一件事
回调没执行、错误没人收、后发先到、读到旧值——表面上互不相干,根子是同一个:
代码是从上往下写的,但它不是从第一行开始按顺序跑完的。每个任务内部可以按序执行,任务与任务之间、事件与回调之间会互相穿插。编辑器里显示的是一条竖线,运行时可能是几条互相交错的时间线,语法不会提醒你这一层。
try 和它包住的那行,在代码上是嵌套关系,运行时可能已经不在同一条时间线上。两个 await 上下相邻,中间可能隔着 300ms 和一次用户点击。
所以真正值得记住的不是四条规则,而是一个习惯:写下每个异步调用时,多问半秒——从这一行到结果返回,中间还能发生什么?
- 用户能再点一次吗?
- 这个变量会被改吗?
- 如果这次请求失败,谁会知道?
第三个问题尤其难在本地复现,本地接口响应太快,竞态很难自然撞上。需要稳定复现时,给其中一个接口人为加固定延迟即可。