编程 异步代码的四类时序问题:回调时机、错误归属、请求竞态与闭包快照

2026-09-03 00:22:24

异步代码的四类时序问题:回调时机、错误归属、请求竞态与闭包快照

异步代码的难处,往往不在那几个并发执行的任务本身,而在任务返回后,结果的到达顺序会互相穿插。

商家后台订单页有个典型场景:两个 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.allallSettledany,或者自己写调度器,都只是手段。

一个更完整的批量版本:

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 对异步没用」。它做两件事没有问题:

  1. 接住当前调用栈里的同步异常;
  2. 接住 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 是交给 awaitreturn,还是 .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 上,有两件经常被混为一谈的事:

  1. 延迟回调需要读取最新 state:用 useRef 存一份,在 Effect 或事件回调里更新 ref.current,需要读最新值的地方读它;
  2. 只是要基于上一个 state 算出下一个:用函数式更新 setCount(c => c + 1)。它是基于 pending 队列里的 state 计算下一个值,不是通用的「读取最新 state」手段。

判断回调到底读到新值还是旧值,只需要看两件事:

  • 这个回调捕获的是哪一次的词法环境?
  • 那个环境里的绑定,或者绑定指向的对象,后来有没有被改过?

提 PR 前的 10 秒自查清单

大部分问题可以靠一次全局搜索定位。

  1. try {:块里的 Promise 是交给 awaitreturn 还是 .catch() 负责?不要只看缩进。
  2. .forEach(async.map(async:回调返回的每个 Promise,有没有明确的消费方和错误归属?
  3. 事件处理器返回 Promise 时,框架会不会处理它的 rejection?不确定就自己就地处理。
  4. tab 切换、搜索联想、快速翻页这类位置,有没有做「只认最后一次」?
  5. 异步结果返回时,发起它时的前提还在吗:组件没卸载、用户没登出、依赖数据没变。
  6. setTimeout 或事件回调里读的变量,要的是「挂上去那一刻的值」还是「执行那一刻的值」?
  7. React 延迟回调里读 state:确认要的是「触发那一刻的快照」还是「执行那一刻的最新值」。
  8. 「B 依赖 A」的地方,用 await 或 Promise 链写出来了吗?还是靠 setTimeout(fn, 0) 碰运气?
  9. 单测不要只写一次 await Promise.resolve() 去冲队列,要等那个真正的业务 Promise;涉及定时器就上假定时器。
  10. 浏览器挂 unhandledrejection,Node 挂 unhandledRejection 做兜底监控。注意拼写不同,而且这只是最后一道网,不能替代就地处理。

前两条最值得先看。第 1 条靠一次全局搜索就能把待查点拉出来;第 4 条不用搜代码,产品里所有用户会连点两下的位置,基本都有竞态。

四件事,其实是同一件事

回调没执行、错误没人收、后发先到、读到旧值——表面上互不相干,根子是同一个:

代码是从上往下写的,但它不是从第一行开始按顺序跑完的。每个任务内部可以按序执行,任务与任务之间、事件与回调之间会互相穿插。编辑器里显示的是一条竖线,运行时可能是几条互相交错的时间线,语法不会提醒你这一层。

try 和它包住的那行,在代码上是嵌套关系,运行时可能已经不在同一条时间线上。两个 await 上下相邻,中间可能隔着 300ms 和一次用户点击。

所以真正值得记住的不是四条规则,而是一个习惯:写下每个异步调用时,多问半秒——从这一行到结果返回,中间还能发生什么?

  • 用户能再点一次吗?
  • 这个变量会被改吗?
  • 如果这次请求失败,谁会知道?

第三个问题尤其难在本地复现,本地接口响应太快,竞态很难自然撞上。需要稳定复现时,给其中一个接口人为加固定延迟即可。

复制全文 生成海报 JavaScript 异步 Promise 竞态 闭包

推荐文章

程序员茄子在线接单