首页
/ React Router 导航拦截(Navigation Blocking)实战:用 useBlocker 保护未提交的表单

React Router 导航拦截(Navigation Blocking)实战:用 useBlocker 保护未提交的表单

2026-09-07 19:00:52作者:鲍丁臣Ursa

当用户正处于一个重要的工作流中间,比如正在填写一份联系表单,你往往不希望他们不小心点击某个链接、按下浏览器返回键就离开当前页面、丢掉已经输入的内容。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 / proceedundefinedlocationundefined
blocked 已拦截一次导航 reset()proceed(),且 location 指向被拦截的目标位置
proceeding 正在从一次被拦截的导航中放行(用户已点了「离开」) 放行过程中,location 为正在前往的目标

代码中这三种形态对应 BlockerBlockedBlockerUnblockedBlockerProceeding 三个接口,组合导出为 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)与 historyActionPOP / 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 / nextLocationpathname 剥掉 basename 前缀后再传给用户的拦截函数(hooks.tsx),保证传给用户的位置信息与 useLocation 观察到的一致。
  • 合法的状态迁移updateBlocker 中通过 invariant 约束合法迁移(见 router.ts):unblocked → blockedblocked → blockedblocked → proceedingblocked → unblockedproceeding → 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,相关内容可参考 路由模块类型安全),并使用 useFetcherfetcher.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:读取当前表单中 emailmessage 字段的值,只要有任意内容就置为脏。

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 → unblockedblocked → 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)。

源码索引

如果你想深入验证本文中的机制描述,可重点关注以下文件与代码位置:

综上,useBlocker 配合脏状态追踪,可以让「正在填写重要表单的用户」不再被意外导航打断。只需记住一条主线:isDirty 决定何时拦截(shouldBlock),blocked 状态渲染确认 UI,proceed()/reset() 决定被拦住的导航去向,action 成功后用 reset()(留下清表单)或 proceed()(放行到目标页)收敛状态机——这套模式即可平滑复用到向导页、编辑器、多步骤下单等各类「离开需确认」的业务场景。

登录后查看全文
热门项目推荐
相关项目推荐