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

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

2026-09-06 10:40:29作者:翟萌耘Ralph

在 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(useBlockerusePrompt),但在 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 导航的重试与历史栈当前位置强耦合,典型失败流程:

  1. 用户在 C,历史栈为 A -> B -> C
  2. 用户后退到 B,导航被拦截;
  3. 库把历史重置回 C,并提供 () => pop(-1) 的重试;
  4. 用户再次操作后 retry 被调用,最终落在 A,而不是原始被拦截导航本应到达的 B

2.3 window.confirm 与浏览器行为的坑

window.confirm 虽然是同步的,但不会阻止用户继续点击前进/后退按钮。于是出现如下问题:

  1. 用户在 C,点击后退到 B,弹出 window.confirm
  2. 用户在未回答弹窗前再次点击后退(浏览器已到达 B,此次后退指向 A);
  3. 在 Chrome 中,window.confirm 返回 false(即拦截 C->B),但浏览器却尊重了新的后退点击;
  4. 最终用户停在 A,而路由库仍认为自己阻塞在 C

此外,popstate 级别的 blocker 无法拦截离开应用本身的导航(跨域跳转、整页刷新),这些需要自行监听 window 上的 beforeunload——好在 beforeunload 打开弹窗期间会阻塞进一步的前进后退点击,因此不受上述问题困扰。

3. 设计决策:三条假设与最终 API

3.1 三条可靠性假设

为了在 v6 中可靠地实现拦截,团队确立了以下前提:

  1. “是否拦截”的判定必须是即时且同步的,回答期间不允许用户发起任何额外导航。
    • 这使得 popstate 时能立刻决定是否需要回滚:非拦截导航是 no-op,被拦截导航则立即回滚,在任何其他导航发生之前重新与 URL 同步;
    • 该假设直接排除了 usePrompt 的“开箱可用”地位——window.confirm 虽是同步的,却不阻止用户发起新导航,且各浏览器对“弹窗打开时点击后退”的行为差异极大。
  2. Blocker 不能跨导航存活:一次成功导航完成后必须重置所有 blocker,因为其 retry 函数本质上是陈旧的(stale),调用只会引发更多怪事。
  3. 同一时间只能有一个活跃的 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,用户留在当前页;
  • proceedingblocker.proceed() 触发的导航正在进行中,本质上反映该次导航期间非 idlenavigation.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,其机制与决策文档中的假设一一对应:

  1. 必须运行在数据路由上下文useDataRouterContext(DataRouterHook.UseBlocker)useDataRouterState(DataRouterStateHook.UseBlocker) 要求 Router 处于 RouterProvider(或 createBrowserRouter 等 data router)环境,这正是决策中“首版仅面向 data-router”的落地体现;
  2. 每个 blocker 拥有独立 key:通过 useEffect 生成自增 key(String(++blockerId))并 setBlockerKey,组件卸载时 router.deleteBlocker(key) 清理。这从实现层面保证了“blocker 不跨导航存活”——key 与组件生命周期绑定;
  3. 容忍不稳定的函数身份:第二个 effect 在 blockerFunction 变化时重新调用 router.getBlocker(blockerKey, blockerFunction) 注册,避免用户未用 useCallback 包裹时产生孤儿函数。仓库测试 use-blocker-test.tsx 中有专门的用例 “handles unstable blocker function identities” 验证这一点;
  4. basename 剥离:若配置了 basename,会先 stripBasename 再交给用户函数,使 currentLocation/nextLocation 的行为与 useLocation 一致。测试 “strips basename from location provided to blocker function” 验证:在 basename: "/base" 下,函数收到的是 pathname: "/"pathname: "/about",并附 historyAction: "PUSH"
  5. 返回值优先取 state.blockers:注释明确“Prefer the blocker from state not router.state since 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
  • stateunblocked | blocked | proceeding
  • locationblocked 时表示被拦截的目标位置;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 兜底。理解这三态状态机(unblockedblockedproceeding)与 proceed/reset 的语义后,配合 fetcher 脏状态追踪,即可在框架模式中为任何关键表单工作流加上可靠的导航守卫。相关实现与测试可继续在 packages/react-router/lib/hooks.tsxpackages/react-router/lib/dom/lib.tsxpackages/react-router/tests/dom/use-blocker-test.tsx 中深入验证。

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