ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

react-router 导航拦截深度解析:useBlocker 的设计决策、源码实现与表单守卫实战

react-router 导航拦截深度解析:useBlocker 的设计决策、源码实现与表单守卫实战 react-router 导航拦截深度解析useBlocker 的设计决策、源码实现与表单守卫实战【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router在 React 应用中用户填到一半的表单常常会在一次误点链接后丢失。react-router 通过useBlockerHook 提供了“拦截 SPA 内导航并让用户确认”的能力。本文基于仓库中的架构决策文档 decisions/0001-use-blocker.md完整还原useBlocker从 v5Prompt到 v6 数据路由版本的演进背景、三条关键设计假设、三态模型并结合 源码实现、官方操作指南 与 API 文档给出可直接落地的表单守卫方案及其边界限制。1. 背景从 v5 的Prompt到 6.4 的useBlocker1.1 v5 时代的Prompt与 v6 beta 的反复React Router v5 提供了Prompt组件通过when属性声明何时拦截导航并用window.confirm弹窗让用户确认。其最核心的用例是防止用户丢失半填写的表单数据。v6 beta 初期曾提供两个替代 HookuseBlocker与usePrompt但在 beta 发布过程中被移除官方当时的考量是“As for why it was removed in v6, we decided wed rather ship with what we have than take even more time to nail down a feature that isnt fully baked.”移除后社区开始通过UNSAFE_NavigationContext手动调用navigator.block来补齐这一能力以便从 v5 平滑迁移到 v6。然而在 6.4 的数据路由data routing改造中react-router 大幅精简并内联了history库移除了block方法导致上述 workaround 失效当时仅能通过unstable_HistoryRouter迂回实现。1.2 社区反馈哪些场景 localStorage 方案不够用早期官方立场是“把表单数据存到localStorage优于拦截导航”。但持续的用户反馈表明拦截在以下场景中不可替代取消长时间运行的任务long-running processes等待 API 调用完成等待文件上传结束离开页面本身意味着用户想清空表单数据敏感表单信息无法写入localStorage。基于这些反馈团队决定在明确已知限制的前提下重新引入拦截能力避免用户因拦截问题而无法享受 6.4 及后续版本的新特性。2. 为什么“拦截”这件事如此困难决策文档用相当篇幅分析了 POP 导航拦截的本质难题这也是理解整个 API 设计的钥匙。2.1 PUSH/REPLACE 简单POP 难拦截PUSH/REPLACE相对直接这些导航经过history可以在调用window.history.pushState之前先评估 blocker若被拦截则直接跳过调用URL 与 UI 保持同步拦截POP则不同popstate事件触发时 URL 已经变了我们立即处于“URL 与 UI 不同步”的状态。例如已导航A - B - C用户点后退时UI 还显示C而 URL 已是B。v5 的解法是在location.state中记录index判断popstate的 delta被拦截时把历史回滚到原位置。2.2 真正的难点retry的时机失控暴露给用户态retry()函数后路由库就失去了对“何时重试”的控制权而被拦截的 POP 导航的重试与历史栈当前位置强耦合典型失败流程用户在C历史栈为A - B - C用户后退到B导航被拦截库把历史重置回C并提供() pop(-1)的重试用户再次操作后retry被调用最终落在A而不是原始被拦截导航本应到达的B。2.3window.confirm与浏览器行为的坑window.confirm虽然是同步的但不会阻止用户继续点击前进/后退按钮。于是出现如下问题用户在C点击后退到B弹出window.confirm用户在未回答弹窗前再次点击后退浏览器已到达B此次后退指向A在 Chrome 中window.confirm返回false即拦截 C-B但浏览器却尊重了新的后退点击最终用户停在A而路由库仍认为自己阻塞在C。此外popstate级别的 blocker无法拦截离开应用本身的导航跨域跳转、整页刷新这些需要自行监听window上的beforeunload——好在beforeunload打开弹窗期间会阻塞进一步的前进后退点击因此不受上述问题困扰。3. 设计决策三条假设与最终 API3.1 三条可靠性假设为了在 v6 中可靠地实现拦截团队确立了以下前提“是否拦截”的判定必须是即时且同步的回答期间不允许用户发起任何额外导航。这使得popstate时能立刻决定是否需要回滚非拦截导航是 no-op被拦截导航则立即回滚在任何其他导航发生之前重新与 URL 同步该假设直接排除了usePrompt的“开箱可用”地位——window.confirm虽是同步的却不阻止用户发起新导航且各浏览器对“弹窗打开时点击后退”的行为差异极大。Blocker 不能跨导航存活一次成功导航完成后必须重置所有 blocker因为其retry函数本质上是陈旧的stale调用只会引发更多怪事。同一时间只能有一个活跃的 blocker多个表单各自半填写的状态会让拦截逻辑极其混乱且 v5 中该限制本就存在因此沿用。若未来出现 compelling 的多 blocker 用例再研究支持。3.2useBlocker的三态模型决策核心是实现一个低层useBlockerHook向用户暴露足够信息以1展示自定义确认弹窗/对话框2在用户接受时放行导航。组件树中仅允许一个活跃 blocker若检测到第二个useBlocker会报错或告警。决策文档中给出的原始类型草案后经演进最终 API 增加了location字段见第 5 节type Blocker | { state: unblocked; reset: undefined; proceed: undefined; } | { state: blocked; reset(): void; proceed(): void; } | { state: proceeding; reset: undefined; proceed: undefined; }; declare function useBlocker(shouldBlock: boolean | () boolean): Blocker; function MyFormComponent() { let [formIsDirty, setFormIsDirty] React.useState(false); let blocker useBlocker(formIsDirty); return ( Form methodpost onChange{(e) setFormIsDirty(true)} label First name: input namefirstname required / /label label Last name: input namelastname required / /label button typesubmitSubmit/button {blocker.state blocked ? ( div pYou have unsaved changes!p button onClick{() blocker.reset()} Oh shoot - I need them keep me here! /button button onClick{() blocker.proceed()} I know! They dont matter - let me out of here! /button /div ) : blocker.state proceeding ? ( pNavigating away with unsaved changes.../p ) : null} /Form ); }三态语义unblocked空闲状态blocked用户尝试导航且拦截函数返回true导航被阻止。此时暴露proceed()/reset()blocker.proceed()放行被拦截的导航并放弃未保存的数据。该放行导航不会再次执行拦截函数blocker.reset()回到unblocked用户留在当前页proceedingblocker.proceed()触发的导航正在进行中本质上反映该次导航期间非idle的navigation.state。其他导航或对进行中导航的中断都会把 blocker 重置回unblocked。3.3 Blocker 状态机决策文档以状态图形式给出了完整迁移关系3.4 顺带决定为什么也提供usePrompt文档初稿曾划线划掉usePrompt打算只留给用户态实现最终仍决定内置理由包括代码量只有几行与 v5 体验更接近GitHub 评论者并非完整样本不清楚 v5 中有多少人依赖它实现门槛低于自定义模态框。同时官方计划明确文档化它会在更多场景下、以怪异且跨浏览器不一致的方式失效。3.5 遗留问题Open Questions与结论首版仅面向>import { type RouteConfig, index, route, } from react-router/dev/routes; export default [ index(routes/home.tsx), route(contact, routes/contact.tsx), ] satisfies RouteConfig;路由模块中用 fetcher 提交表单注意fetcher 提交是异步的表单成功后不离开页面这正是需要脏状态追踪的原因import { useFetcher } from react-router; import type { Route } from ./types/contact; export async function action({ request }: Route.ActionArgs) { let formData await request.formData(); let email formData.get(email); let message formData.get(message); console.log(email, message); return { ok: true }; } export default function Contact() { let fetcher useFetcher(); return ( fetcher.Form methodpost p label Email: input nameemail typeemail / /label /p p textarea namemessage / /p p button typesubmit {fetcher.state idle ? Send : Sending...} /button /p /fetcher.Form ); }4.2 第 2 步跟踪 dirty 状态用一个布尔值加onChange处理器追踪脏状态export default function Contact() { let [isDirty, setIsDirty] useState(false); let fetcher useFetcher(); return ( fetcher.Form methodpost onChange{(event) { let email event.currentTarget.email.value; let message event.currentTarget.message.value; setIsDirty(Boolean(email || message)); }} {/* existing code */} /fetcher.Form ); }4.3 第 3 步用useBlocker拦截导航import { useBlocker } from react-router; export default function Contact() { let [isDirty, setIsDirty] useState(false); let fetcher useFetcher(); let blocker useBlocker(useCallback(() isDirty, [isDirty])); // ... existing code }此时导航会被拦截但用户还没有确认的途径。4.4 第 4 步展示确认 UI官方示例用了一个简单div生产环境建议改用模态对话框{blocker.state blocked ( div pWait! You didnt send the message yet:/p p button typebutton onClick{() blocker.proceed()} Leave /button{ } button typebutton onClick{() blocker.reset()} Stay here /button /p /div )}点击 “Leave” 调用blocker.proceed()继续导航点击 “Stay here” 调用blocker.reset()保持当前页。4.5 第 5 步action 成功后重置 blocker若用户不点“离开/留下”而是直接提交表单blocker 仍会保持活跃。用 effect 在 action 解析后重置useEffect(() { if (fetcher.data?.ok) { if (blocker.state blocked) { blocker.reset(); } } }, [fetcher.data]);4.6 第 6 步action 成功后清空表单可选与拦截本身无关但完整流程通常还需要用 ref 清空表单let formRef useRefHTMLFormElement(null); fetcher.Form ref{formRef} methodpost onChange{(event) { // ... existing code }} {/* existing code */} /fetcher.Form useEffect(() { if (fetcher.data?.ok) { formRef.current?.reset(); if (blocker.state blocked) { blocker.reset(); } } }, [fetcher.data]);也可以换成继续放行被拦截的导航用户填表后忘了点“Send”而去点别的链接 → 导航被拦截弹出确认 → 用户没点按钮而是提交了表单 → 提交成功后自动完成那次被拦截的跳转useEffect(() { if (fetcher.data?.ok) { if (blocker.state blocked) { // proceed with the blocked navigation blocker.proceed(); } else { formRef.current?.reset(); } } }, [fetcher.data]);5. 源码深潜useBlocker在 react-router 中的实现5.1 Hook 层注册、basename 剥离与状态读取useBlocker的实现位于 packages/react-router/lib/hooks.tsx其机制与决策文档中的假设一一对应必须运行在数据路由上下文useDataRouterContext(DataRouterHook.UseBlocker)与useDataRouterState(DataRouterStateHook.UseBlocker)要求 Router 处于RouterProvider或createBrowserRouter等 data router环境这正是决策中“首版仅面向>function useBlocker(shouldBlock: boolean | BlockerFunction): Blockerstateunblocked|blocked|proceedinglocationblocked时表示被拦截的目标位置proceeding时表示blocker.proceed()之后正在前往的位置proceed()/reset()仅blocked态可用shouldBlock布尔值或函数。函数形式接收{ currentLocation, nextLocation, historyAction }// Boolean version let blocker useBlocker(value ! ); // Function version let blocker useBlocker( ({ currentLocation, nextLocation, historyAction }) value ! currentLocation.pathname ! nextLocation.pathname );完整的三态消费示例源自 API 文档展示了proceeding态的 UI 呈现与“提交时若处于 blocked 态则自动放行”的组合技巧import { useCallback, useState } from react; import { BlockerFunction, useBlocker } from react-router; export function ImportantForm() { const [value, setValue] useState(); const shouldBlock useCallbackBlockerFunction( () value ! , [value] ); const blocker useBlocker(shouldBlock); return ( form onSubmit{(e) { e.preventDefault(); setValue(); if (blocker.state blocked) { blocker.proceed(); } }} input namedata value{value} onChange{(e) setValue(e.target.value)} / button typesubmitSave/button {blocker.state blocked ? ( p style{{ color: red }} Blocked the last navigation to /p button typebutton onClick{() blocker.proceed()} Let me through /button button typebutton onClick{() blocker.reset()} Keep me here /button / ) : blocker.state proceeding ? ( p style{{ color: orange }} Proceeding through blocked navigation /p ) : ( p style{{ color: green }} Blocker is currently unblocked /p )} /form ); }5.3unstable_usePrompt在useBlocker之上的薄封装v5 兼容的window.confirm体验由 packages/react-router/lib/dom/lib.tsx 中的usePrompt导出名unstable_usePrompt提供export function usePrompt({ when, message, }: { when: boolean | BlockerFunction; message: string; }): void { let blocker useBlocker(when); React.useEffect(() { if (blocker.state blocked) { let proceed window.confirm(message); if (proceed) { // This timeout is needed to avoid a weird race on POP navigations // between the window.history revert navigation and the result of // window.confirm setTimeout(blocker.proceed, 0); } else { blocker.reset(); } } }, [blocker, message]); React.useEffect(() { if (blocker.state blocked !when) { blocker.reset(); } }, [blocker, when]); }源码注释直接印证了决策文档第 2.3 节分析的跨浏览器问题setTimeout(blocker.proceed, 0)专门用于规避POP 导航中window.history回滚导航与window.confirm结果之间的竞态。JSDoc 也明确警告unstable_前缀不会移除因为“用户在确认框打开时又点了额外前进/后退该技术在多数浏览器上行为差异很大且有时不正确使用风险自负”——这与决策中“we plan to document that it breaks in more cases, in weird ways, and even differently across browsers”完全一致。6. 边界与限制务必知晓仅拦截 SPA 内导航useBlocker不处理硬刷新hard-reloads或跨源cross-origin导航。需要覆盖整页关闭/刷新场景时应自行结合beforeunload监听仅适用于 data router必须运行在RouterProvider等数据路由上下文中Hook 内部对DataRouterHook.UseBlocker上下文的依赖即是硬性前提单一活跃 blocker组件树中同时存在两个useBlocker会触发错误/告警同步判定约束shouldBlock的求值必须是即时同步的这正是排除把window.confirm内嵌进拦截判定流程的原因unstable_usePrompt风险自负跨浏览器行为不一致官方长期保留 unstable 标记。7. 小结useBlocker的设计史浓缩了 react-router 对“可靠性优先”的取舍接受“同步判定、单次活跃、不跨导航存活”三条假设换取 POP 导航下 URL 与 UI 的即时重同步把确认 UI 完全交给应用层同时用unstable_usePrompt为 v5 迁移者保留window.confirm兜底。理解这三态状态机unblocked→blocked→proceeding与proceed/reset的语义后配合 fetcher 脏状态追踪即可在框架模式中为任何关键表单工作流加上可靠的导航守卫。相关实现与测试可继续在 packages/react-router/lib/hooks.tsx、packages/react-router/lib/dom/lib.tsx 与 packages/react-router/tests/dom/use-blocker-test.tsx 中深入验证。【免费下载链接】react-routerDeclarative routing for React项目地址: https://gitcode.com/GitHub_Trending/re/react-router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表