react-router 导航拦截深度解析:useBlocker 的设计决策、源码实现与表单守卫实战
在 React 应用中,用户填到一半的表单常常会在一次误点链接后丢失。react-router 通过 useBlocker Hook 提供了“拦截 SPA 内导航并让用户确认”的能力。本文基于仓库中的架构决策文档 decisions/0001-use-blocker.md,完整还原 useBlocker 从 v5 <Prompt> 到 v6 数据路由版本的演进背景、三条关键设计假设、三态模型,并结合 源码实现、官方操作指南 与 API 文档,给出可直接落地的表单守卫方案及其边界限制。
1. 背景:从 v5 的 <Prompt> 到 6.4 的 useBlocker
1.1 v5 时代的 <Prompt> 与 v6 beta 的反复
React Router v5 提供了 <Prompt> 组件:通过 when 属性声明何时拦截导航,并用 window.confirm 弹窗让用户确认。其最核心的用例是防止用户丢失半填写的表单数据。
v6 beta 初期曾提供两个替代 Hook(useBlocker 与 usePrompt),但在 beta 发布过程中被移除,官方当时的考量是:
“As for why it was removed in v6, we decided we'd rather ship with what we have than take even more time to nail down a feature that isn't 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.3 window.confirm 与浏览器行为的坑
window.confirm 虽然是同步的,但不会阻止用户继续点击前进/后退按钮。于是出现如下问题:
- 用户在
C,点击后退到B,弹出window.confirm; - 用户在未回答弹窗前再次点击后退(浏览器已到达
B,此次后退指向A); - 在 Chrome 中,
window.confirm返回false(即拦截 C->B),但浏览器却尊重了新的后退点击; - 最终用户停在
A,而路由库仍认为自己阻塞在C。
此外,popstate 级别的 blocker 无法拦截离开应用本身的导航(跨域跳转、整页刷新),这些需要自行监听 window 上的 beforeunload——好在 beforeunload 打开弹窗期间会阻塞进一步的前进后退点击,因此不受上述问题困扰。
3. 设计决策:三条假设与最终 API
3.1 三条可靠性假设
为了在 v6 中可靠地实现拦截,团队确立了以下前提:
- “是否拦截”的判定必须是即时且同步的,回答期间不允许用户发起任何额外导航。
- 这使得
popstate时能立刻决定是否需要回滚:非拦截导航是 no-op,被拦截导航则立即回滚,在任何其他导航发生之前重新与 URL 同步; - 该假设直接排除了
usePrompt的“开箱可用”地位——window.confirm虽是同步的,却不阻止用户发起新导航,且各浏览器对“弹窗打开时点击后退”的行为差异极大。
- 这使得
- Blocker 不能跨导航存活:一次成功导航完成后必须重置所有 blocker,因为其
retry函数本质上是陈旧的(stale),调用只会引发更多怪事。 - 同一时间只能有一个活跃的 blocker:多个表单各自半填写的状态会让拦截逻辑极其混乱;且 v5 中该限制本就存在,因此沿用。若未来出现 compelling 的多 blocker 用例再研究支持。
3.2 useBlocker 的三态模型
决策核心是:实现一个低层 useBlocker Hook,向用户暴露足够信息以(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 method="post" onChange={(e) => setFormIsDirty(true)}>
<label>
First name:
<input name="firstname" required />
</label>
<label>
Last name:
<input name="lastname" required />
</label>
<button type="submit">Submit</button>
{blocker.state === "blocked" ? (
<div>
<p>You have unsaved changes!<p>
<button onClick={() => blocker.reset()}>
Oh shoot - I need them keep me here!
</button>
<button onClick={() => blocker.proceed()}>
I know! They don't matter - let me out of here!
</button>
</div>
) : blocker.state === "proceeding" ? (
<p>Navigating away with unsaved changes...</p>
) : null}
</Form>
);
}
三态语义:
unblocked:空闲状态;blocked:用户尝试导航且拦截函数返回true,导航被阻止。此时暴露proceed()/reset():blocker.proceed():放行被拦截的导航(并放弃未保存的数据)。该放行导航不会再次执行拦截函数;blocker.reset():回到unblocked,用户留在当前页;
proceeding:blocker.proceed()触发的导航正在进行中,本质上反映该次导航期间非idle的navigation.state。
其他导航或对进行中导航的中断,都会把 blocker 重置回 unblocked。
3.3 Blocker 状态机
决策文档以状态图形式给出了完整迁移关系:
graph TD;
Unblocked -->|navigate| A{shouldBlock?};
A -->|false| Unblocked;
A -->|true| Blocked;
Blocked -->|blocker.proceed| Proceeding;
Blocked -->|Unblocked Navigation| Unblocked;
Blocked -->|blocker.reset| Unblocked;
Proceeding -->|Navigation Complete| Unblocked;
Proceeding -->|Navigation Interrupted| Unblocked;
3.4 顺带决定:为什么也提供 usePrompt
文档初稿曾划线划掉 usePrompt(打算只留给用户态实现),最终仍决定内置,理由包括:代码量只有几行;与 v5 体验更接近;GitHub 评论者并非完整样本,不清楚 v5 中有多少人依赖它;实现门槛低于自定义模态框。同时官方计划明确文档化:它会在更多场景下、以怪异且跨浏览器不一致的方式失效。
3.5 遗留问题(Open Questions)与结论
- 首版仅面向 data-router(6.4+);v5 应用可直接迁移到 6.4+ 的
RouterProvider,不必先落到 6.3 的BrowserRouter; - 拦截函数最终签定为接收
{ currentLocation, nextLocation, historyAction },命名与shouldRevalidate松散对齐,未来可扩展表单提交信息; - 是否提供
beforeUnload: boolean选项:结论是不内置——beforeunload本身也不可靠(不阻止额外前进后退),留给用户态实现。
3.6 用例调研:几乎所有人都绕开了 window.confirm
团队在 GitHub 讨论中调研 v5 <Prompt> 的实际用法,发现绝大多数用户通过 getUserConfirmation 自定义、history.block 手动实现或 message 属性函数等方式构建自定义确认弹窗(Material-UI 模态框、toast 等),仅极少数使用原生 window.confirm。这也解释了为何 useBlocker 定位为“低层 Hook”,把确认 UI 交给应用层实现。另有个别用户想借拦截做导航前埋点,官方认为这类“滥用场景”应由未来的 Events API 更精确地解决。
4. 实战:在框架模式下为表单加导航守卫
以下流程完整继承 官方导航拦截指南,适用于 framework 与 data 两种模式([MODES: framework, data])。
4.1 第 1 步:搭建带表单的根路由
新增一条 “contact” 路由(routes.ts):
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 method="post">
<p>
<label>
Email: <input name="email" type="email" />
</label>
</p>
<p>
<textarea name="message" />
</p>
<p>
<button type="submit">
{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
method="post"
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>
<p>Wait! You didn't send the message yet:</p>
<p>
<button type="button" onClick={() => blocker.proceed()}>
Leave
</button>{" "}
<button type="button" 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 = useRef<HTMLFormElement>(null);
<fetcher.Form
ref={formRef}
method="post"
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)环境,这正是决策中“首版仅面向 data-router”的落地体现; - 每个 blocker 拥有独立 key:通过
useEffect生成自增 key(String(++blockerId))并setBlockerKey,组件卸载时router.deleteBlocker(key)清理。这从实现层面保证了“blocker 不跨导航存活”——key 与组件生命周期绑定; - 容忍不稳定的函数身份:第二个 effect 在
blockerFunction变化时重新调用router.getBlocker(blockerKey, blockerFunction)注册,避免用户未用useCallback包裹时产生孤儿函数。仓库测试 use-blocker-test.tsx 中有专门的用例 “handles unstable blocker function identities” 验证这一点; - basename 剥离:若配置了
basename,会先stripBasename再交给用户函数,使currentLocation/nextLocation的行为与useLocation一致。测试 “strips basename from location provided to blocker function” 验证:在basename: "/base"下,函数收到的是pathname: "/"与pathname: "/about",并附historyAction: "PUSH"; - 返回值优先取
state.blockers:注释明确“Prefer the blocker fromstatenotrouter.statesince DataRouterContext is memoized”,确保 blocker 状态变化能触发更新;无活跃 blocker 时返回IDLE_BLOCKER(即state: "unblocked"的初始值,测试用例 “initializes an 'unblocked' blocker” 断言初始值恰为{ state: "unblocked", proceed: undefined, reset: undefined })。
5.2 最终 API 与 BlockerFunction 签名
结合 API 文档,当前版本的 Blocker 对象比决策草案多了 location 字段:
function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker
state:unblocked|blocked|proceeding;location:blocked时表示被拦截的目标位置;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 = useCallback<BlockerFunction>(
() => value !== "",
[value]
);
const blocker = useBlocker(shouldBlock);
return (
<form
onSubmit={(e) => {
e.preventDefault();
setValue("");
if (blocker.state === "blocked") {
blocker.proceed();
}
}}
>
<input
name="data"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button type="submit">Save</button>
{blocker.state === "blocked" ? (
<>
<p style={{ color: "red" }}>
Blocked the last navigation to
</p>
<button type="button" onClick={() => blocker.proceed()}>
Let me through
</button>
<button type="button" 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.3 unstable_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 中深入验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00