问题概述
今天在开发时遇到了一个有趣的问题。我在实现一个异步任务提交和状态轮询的功能时,使用了两个 useFetcher:
- 第一个 fetcher 用于任务提交,使用 submit 方法(这样可以触发页面 credit 的自动刷新)
- 第二个 fetcher 用于状态轮询,使用 load 方法(因为不需要页面刷新数据)
结果发现了一个奇怪的现象:第一个任务能正常工作,但提交第二个任务后,轮询返回的却始终是第一个任务的数据, 后面也是返回的永远是第一个任务的结果.
后面发现这个问题其实是和 fetcher 的加载有关, 在 remix 某个版本之后, fetcher 的 load 也会随着 submit 之后的页面数据更新而更新. 参考
fetcher.load’s revalidate by default after action submissions and explicit revalidation requests via useRevalidator. Because fetcher.load loads a specific URL they don’t revalidate on changes to route param or URL search param. You can use shouldRevalidate to optimize which data should be reloaded.
useFetcher 简介
useFetcher 是 Remix 提供的一个强大的 Hook,用于处理非导航场景下的数据获取和提交。它的主要特点是:
- 可以在不触发完整页面导航的情况下进行数据操作
- 提供了 Form 组件用于数据提交
- 支持 load 和 submit 两种数据操作方式
- 可以使用 state 获得当前 fetcher 状态 方便实现 Pending UI 和 Optimistic UI
问题复现
我简化了我的代码, 假设有这两个代码文件:
test.tsx
:
1 | import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; |
test.status.tsx
1 | import { LoaderFunctionArgs } from "@remix-run/node"; |
这里就是简化了我的实际场景,假设每次提交都会返回一个不同的 taskId,返回之后进行 poll, poll 到结果之后设置 taskId 为空字符串去清理掉 interval,完成一个任务的提交-poll-获得结果结束
,这里把 poll 注释掉了,先看看这个情况下会怎么样.
然后点击两次让他模拟两个任务
- 第一次任务 id: 141f56eb-a7ac-41b4-99bd-9a6bbfafedfe
- 第二次任务 id: 708e98f1-84e3-4b1f-9fbe-0db82498147c
第二次任务 id poll 的时候返回没有问题. 但是我们在这里又看到了一个被取消的请求,这个就是 poll fetcher 发出的,他的任务 id 还是第一个.
所以在第二次任务 submit 之后,poll fetcher 立即更新自己的状态,此时他的路径是第一次的,所以他又用第一次的任务发了请求,只不过useEffect
立即触发,导致并发请求取消了第一次的请求.
而我的代码里用了 interval,初始会慢几秒,把注释的逻辑打开,让 interval 生效.
1 | useEffect(() => { |
再次运行, 点击两次:
复现了… … 他只会去请求第一次任务的 id.
问题分析
这个问题的核心在于 Remix 的 revalidate 机制。在较新版本的 Remix 中,当进行 submit 操作后(不管是 fetcher.submit 还是 Form/ fetcher.Form 的 submit) 执行后,所有的 fetcher 的 load 操作 都会进行 revalidate。这个机制本意是保持数据一致性,但在轮询场景下会导致问题
- 第一个任务提交后,pollFetcher 开始轮询, 正常完成 此时 pollFetcher 内部状态里记录了第一次任务的查询 url(
/status/test?taskId=第一次任务
) - 第二个任务提交时,submitFetcher 的提交触发了页面更新, pollFetcher 的 load 的请求会被默认重新加载
- pollFetcher 被重新更新,但此时内部仍然保持着第一个任务的 URL, 而此时 useEffect 里用了 interval 导致第二个任务的查询请求会延后执行
- pollFetcher 更新后的结果返回了 taskId 被置为空数组 interval 被清理
- 由于 interval 被清理,新的轮询请求无法发出
- 结果就是永远只能看到第一个任务的数据
如果这个时间设置比较短或者任务查询的接口耗时超过了 interval 设置的间隔时间,这 bug 还不会出现,比较幽默了.
解法
要 fix 的话需要在 poll 结束之后就立即把 poll fetcher 的状态重置为 init(这个 api 没提供), 或者禁止这个 poll fetcher 的 revalidate 行为(这个只能在 route 层面禁用掉整个/test/status 的 api 调用,没法针对一个 fetcher).
当我还在思考单独禁用某个 fetcher 的 revalidation 似乎比较常用但官方为什么不提供的时候, 找到了这个: fetcher revalidation after actions, 原来早就有大佬想到了, 只不过当时 fetcher 是不会被 revalidate 的, 后面 remix 改成了会自动 revalidate, 不过大佬说的加个参数… …是一直没加
那我最后是怎么修的呢… 很简单, 把 poll fetcher 换成个 fetch 调用就行 😂😂😂
或者在test.status.tsx
里增加:
1 | export const shouldRevalidate: ShouldRevalidateFunction = ({ |
让这个 loader 永远不要被重新加载