React Router 导航拦截(Navigation Blocking)实战:用 useBlocker 保护未提交的表单
当用户正处于一个重要的工作流中间,比如正在填写一份联系表单,你往往不希望他们不小心点击某个链接、按下浏览器返回键就离开当前页面、丢掉已经输入的内容。React Router 为此提供了基于「路由状态机」的导航拦截能力:本文以「联系人(contact)」路由的脏表单为完整示例,讲解如何在路由中配置由 fetcher.Form + action 驱动的表单,用脏状态(dirty state)结合 useBlocker 阻止 SPA 内的导航,并渲染「离开 / 留下」确认 UI,在用户提交成功后再正确复位或放行被拦截的导航。读完你将能在自己的表单、向导页或任何需要「离开前确认」的场景中,直接复用这套可运行的完整方案。
本指南对应仓库中的官方 How-To 文档 navigation-blocking.md,其适用模式标记为 [MODES: framework, data],即 Framework 模式(react-router.config.ts + routes.ts 声明式路由)与 数据路由模式(createBrowserRouter + RouterProvider)均支持。下方示例以 Framework 模式的文件路由写法呈现,数据模式下等价于在 RouterProvider 内的任意组件中调用 useBlocker。
1. 导航拦截的底层机制:Blocker 状态机
在动手写代码之前,先理解 useBlocker 返回对象的行为,这对后续每一个步骤都至关重要。从源码 router.ts 可以看出,Blocker 是一个可辨识联合类型,永远处于以下三种状态之一:
blocker.state |
含义 | 可用 API |
|---|---|---|
unblocked |
空闲状态,尚未拦截任何导航 | reset / proceed 为 undefined,location 为 undefined |
blocked |
已拦截一次导航 | reset()、proceed(),且 location 指向被拦截的目标位置 |
proceeding |
正在从一次被拦截的导航中放行(用户已点了「离开」) | 放行过程中,location 为正在前往的目标 |
代码中这三种形态对应 BlockerBlocked、BlockerUnblocked、BlockerProceeding 三个接口,组合导出为 Blocker 联合类型。proceed() 只能解决当前这一次被拦截的导航,点击后 blocker 进入 proceeding 状态,路由继续完成这次导航;reset() 则把 blocker 拉回 unblocked,用户留在当前页面。
shouldBlock 参数的两种写法
useBlocker 接受 boolean | BlockerFunction:
// 布尔版:dirty 时为 true 即拦截
let blocker = useBlocker(isDirty);
// 函数版:可以拿到每次导航的上下文做更精细的判断
let blocker = useBlocker(
({ currentLocation, nextLocation, historyAction }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
);
其中 BlockerFunction 的类型定义在 router.ts:参数对象包含 currentLocation(当前所在 Location)、nextLocation(即将前往的 Location)与 historyAction(POP / PUSH / REPLACE)。函数版很适合做条件拦截,例如「只要还在本路由内跳转就不拦截」。函数签名相关的 JSDoc 与完整可运行示例可在 useBlocker API 文档 中查看。
从源码看:hook 的注册与生命周期
useBlocker 的实现位于 hooks.tsx,内部机制可以归纳为几点:
- 每个挂载实例有独立 key:模块级计数器
blockerId自增生成 key,组件卸载时通过 effect 清理函数调用router.deleteBlocker(key),因此离开页面后不会再误拦截。 - shouldBlock 以函数形式注册到 router:hook 内部把布尔值包装成函数,并把它交给
router.getBlocker(blockerKey, blockerFunction)。真实导航到来时,router 会调用shouldBlockNavigation(见 router.ts)执行注册的拦截函数。 - 当前 router 同一时间只支持一个活跃 blocker:源码中有明确注释
We only support a single active blocker at the moment,若存在多个会给出警告并使用最后注册的那个。实际应用中请确保同一时刻只有一个页面组件在调用useBlocker。 - basename 处理:如果应用配置了 basename 且拦截条件为函数形式,源码会把
currentLocation/nextLocation的pathname剥掉 basename 前缀后再传给用户的拦截函数(hooks.tsx),保证传给用户的位置信息与useLocation观察到的一致。 - 合法的状态迁移:
updateBlocker中通过invariant约束合法迁移(见 router.ts):unblocked → blocked、blocked → blocked、blocked → proceeding、blocked → unblocked、proceeding → unblocked。这解释了为什么「被拦截后取消(reset)」与「被拦截后放行(proceed 完成后回到 unblocked)」都能正确收敛。 - 导航成功后自动复位:在导航成功完成时,router 会断言「既然已经顺利通过,说明所有 blocker 都放行了」,并把所有 blocker 重置回
IDLE_BLOCKER(见 router.ts)。
边界须知:
useBlocker只负责 SPA 内部、由 React Router 控制的导航(点击<Link>、调用navigate()、后退/前进按钮等)。它无法拦截浏览器硬刷新(hard reload)与跨域(cross-origin)导航,这两类场景需要配合beforeunload事件处理,详见本文第 7 节。
2. 第一步:搭建带表单与 action 的路由
先声明一条 /contact 路由。在 Framework 模式中,路由配置集中在 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;
然后在联系人路由模块中定义一个 action(由框架自动生成的类型 Route.ActionArgs 来自 ./+types/contact,相关内容可参考 路由模块类型安全),并使用 useFetcher 的 fetcher.Form 提交,这样表单提交不会触发整页导航,而是通过 fetcher.state 驱动按钮文案:
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>
);
}
useFetcher 的完整语义可参考 useFetcher API 文档,action 在 Framework 模式下的约定可参考 actions 指南。这里 action 返回 { ok: true },稍后页面正是依据 fetcher.data?.ok 判断提交是否成功。
3. 第二步:记录表单的脏状态
为了判断「是否允许离开」,需要追踪表单是否被修改过。最简单的方式是用一个布尔值配合表单的 onChange:读取当前表单中 email 与 message 字段的值,只要有任意内容就置为脏。
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>
);
}
官方文档也注明:你可以根据业务需要采用更复杂的脏状态追踪方案(例如与初始值做深度对比、为每个字段单独标记 touched 等),这里只是为了演示而选用了最直接的写法。
4. 第三步:用 useBlocker 在表单脏时阻止导航
接下来把 useBlocker 接入组件。由于拦截条件依赖 isDirty,请像下方这样用 useCallback 包一层,以保持回调身份稳定(源码中 hook 对 shouldBlock 有依赖追踪,身份稳定可避免不必要的重复注册):
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
}
到这一步,当表单为脏时导航确实会被「拦住」(可以打开控制台观察),但用户界面上没有任何提示,用户会以为导航失灵了。所以下一步必须给出确认 UI。
5. 第四步:渲染确认 UI(离开 / 留下)
当 blocker.state === "blocked" 时渲染一个确认区域。官方示例用普通 div 展示,并提示你完全可以换成 modal 对话框组件。两个按钮分别调用:
blocker.proceed():用户选择「离开」,放行刚才那次被拦截的导航;blocker.reset():用户选择「留下」,清除拦截状态,继续停留在当前页面。
export default function Contact() {
let [isDirty, setIsDirty] = useState(false);
let fetcher = useFetcher();
let blocker = useBlocker(
useCallback(() => isDirty, [isDirty]),
);
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 */}
{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>
)}
</fetcher.Form>
);
}
得益于 Blocker 的可辨识联合类型,在 blocker.state === "blocked" 的代码分支里 TypeScript 会收窄类型,使 blocker.proceed()、blocker.reset() 与 blocker.location 均可安全访问;而在 unblocked / proceeding 分支调用这些 API 会被类型系统阻止——这正是第 1 节介绍的状态机结构带来的类型安全收益。如果你需要更丰富的提示(比如在确认框里展示目标地址),可以在 blocked 状态下读取 blocker.location。
6. 第五步:action 成功后复位 blocker
细心的读者会发现一个缺口:如果用户被拦截后两个按钮都没点,而是直接点了表单里的「Send」提交,此时 blocker.state 仍停留在 blocked。表单提交(fetcher 驱动)本身不是一次需要过拦截的导航,所以拦截状态不会自动消失。官方文档在第五步用 useEffect 监听 fetcher.data 来解决:action 返回 { ok: true } 后,把 blocked 状态的 blocker reset() 掉。
useEffect(() => {
if (fetcher.data?.ok) {
if (blocker.state === "blocked") {
blocker.reset();
}
}
}, [fetcher.data]);
7. 第六步:提交成功后清空表单(及另一种收尾策略)
「重置 blocker」与「清空表单」本质上是两件事:只有用户决定「留下」时清空表单才有意义。官方文档用一个 formRef 把表单 DOM 引用交给组件,在 action 成功后同时完成两件事:
let formRef = useRef<HTMLFormElement>(null);
// put it on the form
<fetcher.Form
ref={formRef}
method="post"
onChange={(event) => {
// ... existing code
}}
>
{/* existing code */}
</fetcher.Form>;
useEffect(() => {
if (fetcher.data?.ok) {
// clear the form in the effect
formRef.current?.reset();
if (blocker.state === "blocked") {
blocker.reset();
}
}
}, [fetcher.data]);
另一种选择:直接放行到被拦截的导航
不过别忘了状态机里还有 proceed()。官方文档给出另一个视角:如果当前正有一次导航被拦截,而用户又恰好提交成功,那么你不一定要 reset——可以顺势放行这次被拦截的导航,把用户带到他们原本想去的页面。只有未处于 blocked 时才清空表单:
useEffect(() => {
if (fetcher.data?.ok) {
if (blocker.state === "blocked") {
// proceed with the blocked navigation
blocker.proceed();
} else {
formRef.current?.reset();
}
}
}, [fetcher.data]);
这段代码对应的完整用户动线是:
- 用户填写表单;
- 用户忘记点「Send」,转而点击了某个链接;
- 导航被拦截,页面出现确认文案;
- 用户没有点「Leave / Stay here」,而是直接提交了表单;
- action 成功后
proceed(),用户被带到最初请求的页面。
什么时候用
reset()、什么时候用proceed()?取决于产品预期:前者代表「提交完成,留在当前页并清空表单」,适合多段填写、成功后继续在同一页工作的场景;后者代表「提交完成后,去往用户原本想去的目标页」。两者都在第 1 节列出的合法状态迁移范围内(blocked → unblocked与blocked → proceeding → unblocked),这也是为什么 router 源码用状态机而非简单的if/else来管理拦截生命周期。
8. 适用模式、边界与配套能力
- 适用模式:
[MODES: framework, data]。Framework 模式下可直接在路由模块组件中使用(如本文示例);数据路由模式下,在createBrowserRouter+RouterProvider树内任意组件中调用同样生效,可参考 RouterProvider 文档。 - 覆盖范围:能拦截
<Link>点击、useNavigate编程式导航、浏览器前进/后退等 SPA 内部导航;基于上述状态机与shouldBlockNavigation的实现,放行或被拦截的导航最终都会由 router 统一处理。 - 不覆盖:硬刷新与跨域跳转不在 React Router 的管辖范围内。若需要在这些场景下给出系统级离开确认,可搭配
beforeunload事件——React Router 提供了便捷封装useBeforeUnload(见 useBeforeUnload API 文档),它同时支持布尔条件与函数形式。常见做法是:useBlocker管 SPA 内部导航、useBeforeUnload管刷新/关闭标签页,二者职责互补。 - 历史背景:导航拦截曾经还以
<Prompt>/usePrompt这类命令式 API 的面貌存在,useBlocker是当前推荐的数据路由方案;更完整的 API 参考与签名见 useBlocker.md 及其背后的 JSDoc 源码(hooks.tsx)。
源码索引
如果你想深入验证本文中的机制描述,可重点关注以下文件与代码位置:
useBlockerhook 实现、注册/清理生命周期与 basename 处理:packages/react-router/lib/hooks.tsxBlocker联合类型与BlockerFunction定义、IDLE_BLOCKER:packages/react-router/lib/router/router.ts- 合法状态迁移约束
updateBlocker、拦截判定shouldBlockNavigation:packages/react-router/lib/router/router.ts - 导航成功后把所有 blocker 复位为
IDLE_BLOCKER的逻辑:packages/react-router/lib/router/router.ts - 配套的
useBeforeUnload实现:packages/react-router/lib/dom/lib.tsx
综上,useBlocker 配合脏状态追踪,可以让「正在填写重要表单的用户」不再被意外导航打断。只需记住一条主线:isDirty 决定何时拦截(shouldBlock),blocked 状态渲染确认 UI,proceed()/reset() 决定被拦住的导航去向,action 成功后用 reset()(留下清表单)或 proceed()(放行到目标页)收敛状态机——这套模式即可平滑复用到向导页、编辑器、多步骤下单等各类「离开需确认」的业务场景。
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