ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

React Router 竞态条件(Race Conditions)处理机制:如何自动取消过期网络请求并保证 UI 数据最新

React Router 竞态条件(Race Conditions)处理机制:如何自动取消过期网络请求并保证 UI 数据最新 React Router 竞态条件Race Conditions处理机制如何自动取消过期网络请求并保证 UI 数据最新【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router当用户在一个单页应用中快速连续点击链接、重复提交表单或在搜索框中连续输入时多个网络请求会同时处于 in-flight进行中状态。如果不对这些并发请求做管理界面就可能把过期请求的结果渲染出来导致显示的数据与用户当前操作不匹配——这类 bug 正是 Web UI 中最常见的竞态条件race conditions。本指南以 docs/explanation/race-conditions.md 为主线面向Framework 与 Data 两种使用模式文档头部标注[MODES: framework, data]完整讲解 React Router 如何借鉴浏览器行为自动处理导航、表单提交与useFetcher场景下的网络并发并在源码层面剖析其取消过期请求、提交最新数据的实现原理最后给出 type-ahead 搜索框这一典型实战案例。读完你将能理解什么情况下 React Router 会自动帮你取消请求、什么是stale过期请求以及服务端仍可能存在的竞态边界在哪里。无法消灭所有竞态但 React Router 自动处理最常见的那一类React Router 官方文档开宗明义地指出虽然几乎不可能消灭应用中所有可能的竞态条件但React Router 会自动处理 Web 用户界面中最常见的竞态条件。这意味着绝大多数由请求返回顺序与用户操作顺序不一致引发的 UI 错乱你不需要自己写任何防抖、序号比对或取消逻辑。这一设计能力的价值前提是数据路由data router架构下导航、表单提交和 fetcher 所产生的数据请求都由路由器统一调度而不是由每个组件各自裸发fetch。因此路由器有能力在新的用户事件到来时主动中断并取消旧的进行中请求。下面我们先看它模仿的母本——浏览器的默认行为。浏览器行为React Router 并发设计的蓝本React Router 对网络并发的处理很大程度上受到浏览器处理文档请求document request时默认行为的启发。把导航和表单提交都理解为浏览器的单例事件浏览器自身已经给出了一套简单直觉的规则场景一连续点击链接。假设你点击一个链接跳转到新文档在页面尚未加载完成之前又点击了另一个链接浏览器会取消第一个请求cancel the first request立即处理新的导航immediately process the new navigation。场景二连续提交表单。当一个待处理的表单提交被新的提交打断时同样适用该规则第一个提交被取消新提交被立即处理。也就是说浏览器从来不会让两条文档级的请求并驾齐驱而是始终优先响应最近一次用户意图。这套直觉正是 React Router 客户端路由想要复刻的行为基准也是后续理解其内部abort()调用的起点。React Router 行为导航与表单的后来居上式取消与浏览器一致React Router 在链接导航和表单提交这两类中断场景下的表现是当中断发生时取消所有 in-flight进行中的数据请求立即处理最新的事件。例如在一个客户端导航中点击Link会为目标 URL 匹配到的每个loader发起数据请求如果新的导航打断了旧的导航React Router 会取消先前发出的 loader 请求只允许最新一批请求继续执行docs/explanation/concurrency.md 中对这一对位关系有逐条对照说明。从源码层面可以确认这条先取消、再开新的实现路径导航发起函数startNavigation的第一件事就是中止任何进行中的导航——router.ts 中有明确注释Abort any in-progress navigations and start a new one其实现为// 中止任何进行中的导航并开始一个新导航 pendingNavigationController pendingNavigationController.abort(); pendingNavigationController null; pendingAction historyAction;每一次导航都会新建一个独立的AbortController并把它的signal注入到为该导航创建的客户端请求中router.ts。这样一旦新事件到来旧导航的 controller 被abort()其绑定的数据请求就会随之在浏览器层面中止。这也解释了 router.ts 中dispose()路由销毁清理同样会对pendingNavigationController执行abort()——因为进行中的导航本质上是一个可以被随时回收的单例请求。为什么是取消而不是忽略旧结果这里值得强调一个与纯前端丢弃旧响应做法的关键差别React Router 是在请求层面通过AbortController真正取消请求而不是等旧响应返回后简单地丢弃。被中止的请求不会继续占用浏览器网络资源这比响应到达后再做比对丢弃更彻底地避免了过期数据被提交到 UI也让 loader/action 的 fetch 逻辑能第一时间感知中断。Fetcher 的细微差别可自我中断不可打断他人useFetcher与导航不同它不是单例事件——应用中可以同时存在多个 fetcher 实例每个实例拥有独立的state、data与生命周期。因此并发规则出现了两点微调一个 fetcher 无法打断另一个 fetcher 实例的请求。例如列表页中两个独立条目的删除操作可以同时进行互不影响可参见 docs/explanation/form-vs-fetcher.md 中RecipeListItem的例子每个条目各自useFetcher()isDeleting状态彼此独立。但 fetcher 可以打断自己。当同一个 fetcher 实例发起新的请求时规则与导航一致取消中断掉的请求立即处理新请求。后一点的实现同样清晰可见源码中用一个以 fetcher key 为索引的 Map 保存每个 fetcher 的取消控制器——router.ts 声明了let fetchControllers new Mapstring, AbortController()每次 fetcher 发起请求都会为它 new 一个 controller 存入该 Map例如 fetcher loader 路径 router.ts。当同一 fetcher 重新提交时旧的 controller 会被取出并中止专用辅助函数 abortFetcher 的实现为function abortFetcher(key: string, reason?: unknown) { let controller fetchControllers.get(key); if (controller) { controller.abort(reason); fetchControllers.delete(key); } }正是因为请求与 fetcher key 一一绑定自我中断才能精确到单个 fetcher 实例。Fetcher 之间的互动点共享的 revalidation虽然 fetcher 之间互不打断但它们会在revalidation重新验证环节发生交集当一个 fetcher 的 action 请求返回浏览器后React Router 会重新验证全部页面数据对所有匹配路由的 loader 重新发起数据请求。于是可能出现多个 revalidation 请求同时 in-flight的情况——例如三个表单提交先后完成各自触发一次页面级 revalidation。面对并发 revalidationReact Router 的策略是提交所有新鲜fresh的 revalidation 响应并取消任何过期的stale请求。所谓 stale 请求指的是启动时间早于某个已返回请求的那个请求。简而言之请求以谁更新、谁优先为原则同样在返回的路上后发起的 revalidation 更可能包含前序 action 对服务端数据的修改因此后发起者覆盖先发起者。doc 中给出的对照时间线|表示提交开始✓表示 action 完成并开始 revalidation✅表示 revalidated 数据提交到 UI❌表示请求被取消可以直观展示三种提交交错完成的正常状态submission 1: |----✓-----✅ submission 2: |-----✓-----✅ submission 3: |-----✓-----✅但如果后续提交的 revalidation先于更早的提交返回早期的数据会被丢弃submission 1: |----✓---------❌ submission 2: |-----✓-----✅ submission 3: |-----✓-----✅提交 2 的 revalidation 比提交 1 开始得晚、却落得早所以提交 1 的请求被取消UI 只提交提交 2 的结果——因为它请求得晚更可能同时包含提交 1 与提交 2 造成的更新。源码中负责执行这一策略的是 abortStaleFetchLoads函数接收一个landedId刚返回的 revalidation 的递增序号遍历fetchReloadIds中所有进行中的重载请求凡id landedId即启动早于已返回者且仍处于loading状态就调用abortFetcher(key)取消之并将它们标记为 donefunction abortStaleFetchLoads(landedId: number, fetchers: Mapstring, Fetcher): boolean { let yeetedKeys []; for (let [key, id] of fetchReloadIds) { if (id landedId) { let fetcher fetchers.get(key); invariant(fetcher, Expected fetcher: ${key}); if (fetcher.state loading) { abortFetcher(key); fetchReloadIds.delete(key); yeetedKeys.push(key); } } } markFetchersDone(yeetedKeys, fetchers); return yeetedKeys.length 0; }这套后来者优先 过期者作废的机制恰好印证了文档的结论React Router 对网络的管理预防了绝大多数由网络竞态引起的常见 UI bug——旧请求永远不会把旧数据闪进视图覆盖新结果。服务端边界被取消的请求仍然会到达服务器必须清醒认识的一个边界是网络本身不可预测而且被浏览器取消的请求依然可能在服务端被处理完毕。文档明确说明了两点取消请求只是释放浏览器侧资源AbortController.abort()只是让浏览器不再关心该请求的响应它无法追上并阻止一个已经发出、已经到达服务端的请求。因此服务端仍可能存在竞态与数据完整性问题——例如被取消的请求在打断它的 action 完成 revalidation之后才真正到达服务器并修改了数据用户看到的就与服务端实际数据不一致了。不过这种情形被明确评估为极其罕见一条初始请求比打断它的新提交 该提交的 revalidation都更晚抵达服务器在任何常规网络与基础设施下都属于小概率意外。文档给出的结论是这些风险与使用纯 HTMLform触发默认浏览器行为时的风险完全一致属于低风险且不在 React Router 的职责范围内。如果在你的基础设施上这确属隐患可在 docs/explanation/concurrency.md 中找到对策建议随表单提交附带时间戳并编写服务端逻辑忽略过期提交。实战收益type-ahead 城市搜索框把上述机制落到一个典型场景中最能说明其价值。假设你要构建一个 type-ahead自动补全combobox用户每敲入一个字符就向服务器发一次请求。此时最关键的需求是——绝不能把某个旧输入值的搜索结果展示给用户。因为请求是异步的先发的请求完全可能比后发的晚返回把过时的结果塞进输入框。使用useFetcher时这完全不需要你手动管理同一个 fetcher 上每次fetcher.submit()都会自动取消该 fetcher 上尚未完成的请求。因此旧字符对应的请求会被取消UI 永远不会渲染属于其他输入值的结果。结合 loader 与组件两端路由部分如下route(/city-search, ./search-cities.ts) 这类loader路由仅负责按查询串返回城市列表export async function loader({ request }) { const { searchParams } new URL(request.url); return searchCities(searchParams.get(q)); }组件端只需声明一个 fetcher并在输入框每次变化时提交当前表单export function CitySearchCombobox() { const fetcher useFetcher(); return ( fetcher.Form action/city-search Combobox aria-labelCities ComboboxInput nameq onChange{(event) // 每次输入变化都提交表单以获取城市列表 fetcher.submit(event.target.form) } / {fetcher.data ? ( ComboboxPopover classNameshadow-popup {fetcher.data.length 0 ? ( ComboboxList {fetcher.data.map((city) ( ComboboxOption key{city.id} value{city.name} / ))} /ComboboxList ) : ( spanNo results found/span )} /ComboboxPopover ) : null} /Combobox /fetcher.Form ); }若使用 TypeScript 的类型推导体系还可以给 fetcher 挂上 loader 的类型以获得端到端类型安全const fetcher useFetchertypeof loader();同主题文档 docs/explanation/concurrency.md 中即以此种写法完整呈现了app/pages/city-search.tsx示例。整个应用的代码只需关心两件事——如何查询数据、如何渲染数据网络调度完全交给 React Router。使用提示当需要不改动 URL、在原页面原地完成数据变更/加载时更新单条记录、从列表删除、加载弹层/combobox 数据等优先选择useFetcher而不是FormuseNavigation后者适用于创建记录后跳转详情页、删除后跳转列表等需要改变 URL 的场景。详细的 API 对位与选型对比见 docs/explanation/form-vs-fetcher.md。小结把浏览器处理文档请求的直觉搬进客户端路由是 React Router 并发设计的核心思路场景规则源码佐证链接导航被打断取消旧导航的全部 in-flight loader 请求立即处理新导航startNavigation 中对pendingNavigationController.abort()表单提交被打断取消旧提交的数据请求优先最新提交待其完成后再触发 revalidation同上submission走startNavigation路径同一 fetcher 自我重入取消该 fetcher 上旧请求立即处理新请求fetchControllers Map abortFetcher多 fetcher 的并发 revalidation提交所有 fresh 响应取消启动更早的 stale 请求abortStaleFetchLoads 按id landedId中止需要记住的边界是React Router 只负责浏览器一侧的请求取消与结果提交纪律不能阻止已到达服务端的请求继续执行服务端竞态的处理不在其职责范围内。与此同时只要你的数据加载与变更都经由 React Router 的loader/action/useFetcher通道完成最常见的旧数据覆盖新 UI竞态就已被自动排除——这正是现代数据路由data router相比手动管理fetch的重要体验提升。延伸阅读useFetcher Hook 完整 API网络并发管理详解含更多时序图与过期数据极端场景Form、useFetcher、useNavigation 的选型与对照revalidation 相关集成测试【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表