异步请求的顺序,应该由数据依赖决定AI 主写
假设一个页面布局编辑器需要加载两类数据:
- 服务端保存的页面详情,例如当前使用双栏布局。
- 不同布局对应的预设配置,例如各区域的默认宽度和间距。
新建页面没有历史数据,可以选择一种基础布局完成初始化。为了方便讨论,假设默认使用单栏布局。
编辑页面刚挂载时,服务端详情还没有返回。此时表单为空,于是页面按照新建逻辑,请求了单栏布局的默认配置。
随后,服务端详情返回,表单被回填为双栏布局。到这里,页面状态仍然完全正确。
但先前发出的单栏配置请求仍在等待响应。它随后返回,又把单栏配置写入表单,覆盖了刚刚回填的双栏配置。
两次请求都成功返回了,数据也没有错,为什么页面最后显示的结果却不稳定?
请求顺序不等于生效顺序
理解这个问题,需要区分三种顺序:
- 发起顺序:哪个请求先发送。
- 返回顺序:哪个请求的响应先返回。
- 生效顺序:响应返回后,哪个结果先后写入页面状态。
这张图中,详情接口先写入双栏配置,布局配置接口随后又写入单栏配置。表单不会判断哪份数据更适合当前页面,只会保留最后一次写入的值。
因此,如果代码在每次响应返回后都直接更新表单,最终结果就取决于哪个响应更晚写入。
一次异步交错是怎样发生的
这里的两个请求访问的是不同接口。详情接口读取已保存的页面,布局配置接口读取单栏布局的默认参数。
它们互不冲突,却都会修改同一个表单:
- 详情接口返回后,把表单设置为已保存的双栏布局。
- 布局配置接口返回后,把默认的单栏参数写入表单。
问题就发生在这里。布局配置请求发出时,表单虽然为空,但这并不代表页面没有已保存的布局。
此时详情接口仍在请求中,前端只是暂时不知道布局是什么。只有确认这是新建页面,或者服务端确实没有保存布局,加载默认值的条件才成立。
代码却把“暂时还没拿到布局”当成了“布局为空”,提前请求了单栏默认配置。等到这个请求返回时,详情接口已经把双栏配置写入表单,于是默认值又覆盖了服务端数据。
所以,排查竞态不能只看两个请求谁快谁慢,还要看它们之间是否存在依赖。
请求返回时,发起条件可能已经变化
布局配置接口返回的单栏参数并没有错。问题在于,页面已经不再需要这份参数。
页面请求单栏配置时,代码依据的是这个判断:
表单中的布局字段为空,所以请用单栏配置初始化。
这个判断混淆了两种状态:布局确实为空,以及布局尚未加载完成。等到详情接口返回双栏布局,这个区别才显现出来。
此时单栏配置本身仍然正确,但它从一开始就不适用于这个已有数据的编辑页面。
这也是为什么只查看接口日志很难发现问题。日志会显示两个请求都成功,参数和响应也都正常。真正出错的环节,是响应返回后对表单的写入。
请求成功只说明拿到了结果。这个结果能否用于当前页面,取决于发起它所需的条件是否已经确定。
有依赖的数据,应当按依赖顺序加载
页面详情和布局配置并不是两个互不相关的初始化任务。
布局配置请求需要一个确定的布局,而这个布局来自页面详情。只有详情返回后,程序才能知道应该加载双栏配置,还是在确认没有已保存布局时使用单栏默认配置。
它们的依赖关系是:
页面详情
→ 确定当前布局
→ 加载该布局的配置
→ 写入表单
既然下一步依赖上一步的结果,代码也应该按照这个顺序执行:
async function initializePage() {
const detail = await fetchPageDetail();
const layout = detail?.layout ?? DEFAULT_LAYOUT;
const layoutConfig = await fetchLayoutConfig(layout);
form.setFieldsValue({
...layoutConfig.defaults,
...detail,
layout,
});
}
这段流程先等待页面详情,再从详情中确定布局,最后请求对应的布局配置。写入时默认配置在前、服务端详情在后,已有数据不会被默认值覆盖。
表单只在这些数据准备完成后统一写入,不需要靠两个请求的返回顺序决定最终结果。
问题代码通常把这条链拆散了:一个 effect 请求页面详情,另一个 effect 发现布局字段为空,便请求默认配置。两个 effect 各自更新表单,却没有表达“布局配置依赖页面详情”这层关系。
多个 effect 本身不是问题。互不依赖的数据可以并行加载;但如果后一个请求的参数或执行条件来自前一个请求,就应该显式串联,而不是让它们从共享状态中猜测对方是否已经完成。
这里也不能简单地把“表单为空”当作使用默认值的条件。编辑页面加载过程中,空值可能只表示详情尚未返回。默认值应该在详情加载完成,并确认没有已保存布局之后才参与初始化。
总结
这个问题表面上是默认值覆盖了服务端数据,实际原因却是代码没有表达数据之间的依赖关系。
页面详情决定布局,布局决定加载哪份配置。只要这条关系被拆到多个独立的异步流程中,最终状态就可能取决于响应返回顺序。
解决问题的重点不是让某个请求更快,也不是调整 effect 的先后,而是让代码中的执行顺序与数据依赖顺序一致:
先拿到依赖的数据
→ 再确定下一步请求
→ 最后统一写入状态
请求发起顺序不等于结果生效顺序。只有把依赖关系写进异步流程,页面状态才不需要依靠最后一次 set 碰运气。