React 基础体系 · 第 10/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React Router 完整指南:嵌套路由、Loader、Action、错误与权限

React Router 不只是把 URL 映射到组件。现代 React Router 的数据路由模式还把路由变成了一个数据边界:

  • 路由决定渲染哪些布局和页面;
  • loader 在进入路由前读取数据;
  • action 处理该路由下的表单写操作;
  • errorElementErrorBoundary 处理加载、提交和渲染错误;
  • 路由层可以执行认证与授权检查;
  • 导航过程可以被取消、重新验证,并向 UI 暴露状态。

本文以 React 19、TypeScript 和 React Router 6.4+ / 7 的数据路由 API 为基础。React Router 7 仍然支持 createBrowserRouterloaderactionOutlet 等核心概念,但具体导入路径和框架模式可能因项目配置不同而变化。下面主要使用 react-router-dom 的浏览器数据路由写法。


一、先建立路由模型:URL、匹配结果与路由树

1. 路由不是平面表,而是一棵树

最简单的路由配置可以写成:

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    children: [
      {
        path: "dashboard",
        element: <DashboardPage />,
      },
      {
        path: "settings",
        element: <SettingsPage />,
      },
    ],
  },
]);

它对应以下 URL:

/             -> RootLayout
/dashboard    -> RootLayout + DashboardPage
/settings     -> RootLayout + SettingsPage

children 表示嵌套关系,而不是简单的配置分组。父路由组件必须通过 <Outlet /> 指定子路由的渲染位置:

import { Outlet } from "react-router-dom";

export function RootLayout() {
  return (
    <div className="app">
      <header>站点头部</header>

      <main>
        <Outlet />
      </main>

      <footer>站点底部</footer>
    </div>
  );
}

当访问 /dashboard 时,React Router 会先渲染 RootLayout,然后把 DashboardPage 放入 <Outlet />>

如果父组件忘记渲染 <Outlet />,路由匹配仍然可能成功,但子页面不会显示。这是嵌套路由中最常见的结构性错误之一。

2. 嵌套路由表达布局复用

例如,一个后台系统可以组织成:

/
├── login
└── app
    ├── dashboard
    ├── projects
    │   └── :projectId
    └── settings

对应路由:

const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <RootErrorBoundary />,
    children: [
      {
        path: "login",
        element: <LoginPage />,
      },
      {
        path: "app",
        element: <AppLayout />,
        children: [
          {
            index: true,
            element: <DashboardPage />,
          },
          {
            path: "projects",
            element: <ProjectsLayout />,
            children: [
              {
                index: true,
                element: <ProjectListPage />,
              },
              {
                path: ":projectId",
                element: <ProjectDetailPage />,
              },
            ],
          },
          {
            path: "settings",
            element: <SettingsPage />,
          },
        ],
      },
    ],
  },
]);

这里有两个重要规则:

  1. index: true 表示父路径的默认子页面。例如 /app 显示 DashboardPage
  2. path: ":projectId" 是动态参数路由。访问 /app/projects/p-123 时,params.projectId 的值是 "p-123"

AppLayoutProjectsLayout 都需要渲染 <Outlet />,否则更深层的页面无法出现。

function AppLayout() {
  return (
    <section>
      <AppSidebar />
      <div>
        <Outlet />
      </div>
    </section>
  );
}

function ProjectsLayout() {
  return (
    <section>
      <h1>项目</h1>
      <Outlet />
    </section>
  );
}

3. 路由匹配和参数

路由通常涉及三类输入:

路径参数:/projects/:projectId
查询参数:/projects?status=active&page=2
哈希片段:/projects#activity

在 loader 中:

import type { LoaderFunctionArgs } from "react-router-dom";

export async function projectLoader({
  params,
  request,
}: LoaderFunctionArgs) {
  const projectId = params.projectId;

  if (!projectId) {
    throw new Response("缺少 projectId", { status: 400 });
  }

  const url = new URL(request.url);
  const status = url.searchParams.get("status") ?? "all";

  return {
    projectId,
    status,
  };
}

路径参数属于路由匹配结果,查询参数则从 request.url 中解析。不要把查询参数误当作 params

// 错误:params 不包含 ?status=active
params.status;

// 正确
new URL(request.url).searchParams.get("status");

二、启动数据路由:createBrowserRouterRouterProvider

传统的 <BrowserRouter> 主要提供导航和匹配能力,而 createBrowserRouter 创建的是数据路由器,可以调度 loaderaction、错误边界和重新验证。

入口代码:

import React from "react";
import ReactDOM from "react-dom/client";
import {
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";
import { router } from "./router";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
);

典型的路由模块:

// router.tsx
import {
  createBrowserRouter,
  redirect,
} from "react-router-dom";
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "react-router-dom";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    loader: rootLoader,
    errorElement: <RootErrorBoundary />,
    children: [
      {
        path: "login",
        element: <LoginPage />,
        action: loginAction,
      },
      {
        path: "app",
        element: <AppLayout />,
        loader: appLoader,
        children: [
          {
            index: true,
            element: <DashboardPage />,
          },
        ],
      },
    ],
  },
]);

数据路由的核心执行顺序可以抽象为:

sequenceDiagram
    participant U as 用户
    participant R as Router
    participant L as Loader
    participant C as React组件
    participant S as 服务端API

    U->>R: 导航到 /app
    R->>R: 匹配父路由和子路由
    R->>L: 并行调用匹配路由的 loader
    L->>S: 使用 request.signal 请求数据
    S-->>L: 返回数据或错误
    L-->>R: 返回数据 / redirect / 抛出错误
    R->>C: 提交 loader 数据
    C-->>U: 渲染页面

匹配到的多个路由的 loader 通常会并行执行,而不是严格按照父子层级串行执行。因此,不能依赖“父 loader 一定先完成,子 loader 才开始”这一假设。如果子 loader 需要父 loader 的结果,应当:

  • 在同一个 loader 中组织依赖;
  • 通过共享服务函数获取当前用户;
  • 或让子 loader 自己独立验证所需条件。

三、Loader:路由进入前的数据读取

1. Loader 的职责

loader 是路由的数据读取函数。它通常用于:

  • 获取页面首屏数据;
  • 读取动态路径参数对应的资源;
  • 执行登录状态检查;
  • 根据查询参数加载筛选结果;
  • 在数据不存在时返回 404;
  • 在权限不足时返回 401 或 403;
  • 在导航被取消时终止底层请求。

一个最小 loader:

import type { LoaderFunctionArgs } from "react-router-dom";

export async function projectLoader({
  params,
  request,
}: LoaderFunctionArgs) {
  const projectId = params.projectId;

  if (!projectId) {
    throw new Response("项目 ID 缺失", { status: 400 });
  }

  const response = await fetch(
    `/api/projects/${encodeURIComponent(projectId)}`,
    {
      signal: request.signal,
      headers: {
        Accept: "application/json",
      },
    },
  );

  if (response.status === 404) {
    throw new Response("项目不存在", { status: 404 });
  }

  if (!response.ok) {
    throw new Response("读取项目失败", {
      status: response.status,
    });
  }

  return response.json() as Promise<Project>;
}

页面使用 useLoaderData 获取对应 loader 的结果:

import { useLoaderData } from "react-router-dom";

type Project = {
  id: string;
  name: string;
  description: string;
};

export function ProjectDetailPage() {
  const project = useLoaderData() as Project;

  return (
    <article>
      <h1>{project.name}</h1>
      <p>{project.description}</p>
    </article>
  );
}

路由配置:

{
  path: "projects/:projectId",
  loader: projectLoader,
  element: <ProjectDetailPage />,
  errorElement: <ProjectErrorBoundary />,
}

访问 /projects/p-123 时,React Router 会先执行 projectLoader。只有 loader 成功返回后,页面才会获得对应数据并渲染。

2. request.signal 与请求取消

导航存在竞争关系:

用户点击 /projects/a
请求 A 开始

用户马上点击 /projects/b
请求 B 开始

如果请求 A 在请求 B 之后完成,旧数据就可能覆盖新数据。数据路由器会根据导航状态识别过期导航,并通过 AbortSignal 通知 loader。

因此,底层请求必须传递:

fetch(url, {
  signal: request.signal,
});

不传递 signal 的后果是:

  • React Router 可能已经放弃这次导航;
  • 但底层 HTTP 请求仍然继续;
  • 浏览器仍消耗连接和带宽;
  • 自定义请求库可能继续执行无效的解析和副作用。

需要区分两件事:

  1. AbortController 通常可以取消浏览器侧等待和读取响应;
  2. 它不保证服务端已经停止处理请求。服务端是否取消数据库查询,要看服务端框架和数据库驱动是否支持取消。

3. Loader 返回值与跳转

loader 可以返回:

return { user, projects };

也可以返回 Response

return new Response(JSON.stringify({ ok: true }), {
  headers: {
    "Content-Type": "application/json",
  },
});

实际项目中更常见的是以下三种控制流:

// 成功返回数据
return data;

// 重定向
return redirect("/login");

// 抛出错误
throw new Response("Not Found", { status: 404 });

redirect 本质上是供路由器识别的响应控制信号,不应当在组件中通过 useEffect 作为首选的权限跳转方式,因为组件已经开始渲染,且可能出现页面闪烁。


四、Action:路由级写操作与表单提交

1. Action 处理改变数据的操作

loader 主要读取,action 主要处理写入:

  • 创建;
  • 更新;
  • 删除;
  • 登录;
  • 登出;
  • 发送评论;
  • 修改筛选条件之外的持久化状态。

示例:创建项目。

import {
  redirect,
} from "react-router-dom";
import type { ActionFunctionArgs } from "react-router-dom";

export async function createProjectAction({
  request,
}: ActionFunctionArgs) {
  const formData = await request.formData();

  const name = String(formData.get("name") ?? "").trim();
  const description = String(
    formData.get("description") ?? "",
  ).trim();

  if (name.length < 2) {
    return {
      fieldErrors: {
        name: "项目名称至少需要两个字符",
      },
    };
  }

  const response = await fetch("/api/projects", {
    method: "POST",
    signal: request.signal,
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      name,
      description,
    }),
  });

  if (response.status === 409) {
    return {
      formError: "项目名称已经存在",
    };
  }

  if (!response.ok) {
    throw new Response("创建项目失败", {
      status: response.status,
    });
  }

  const project = (await response.json()) as { id: string };

  return redirect(`/projects/${project.id}`);
}

对应表单:

import {
  Form,
  useActionData,
  useNavigation,
} from "react-router-dom";

type ActionData = {
  fieldErrors?: {
    name?: string;
  };
  formError?: string;
};

export function NewProjectPage() {
  const actionData = useActionData() as ActionData | undefined;
  const navigation = useNavigation();

  const submitting =
    navigation.state === "submitting";

  return (
    <Form method="post">
      <label>
        名称
        <input name="name" required />
      </label>

      {actionData?.fieldErrors?.name && (
        <p>{actionData.fieldErrors.name}</p>
      )}

      <label>
        描述
        <textarea name="description" />
      </label>

      {actionData?.formError && (
        <p role="alert">{actionData.formError}</p>
      )}

      <button type="submit" disabled={submitting}>
        {submitting ? "创建中..." : "创建项目"}
      </button>
    </Form>
  );
}

路由配置:

{
  path: "projects/new",
  element: <NewProjectPage />,
  action: createProjectAction,
}

2. Action 如何被选中

提交 <Form method="post"> 时,React Router 会根据表单所在的当前路由和目标 action 定位 action。若表单位于:

/app/projects/new

并且没有显式指定 action,通常会提交到当前路由,对应 projects/newaction

也可以显式指定:

<Form method="post" action="/app/projects/p-123/delete">
  <button type="submit">删除</button>
</Form>

一个 action 结束后,React Router 通常会重新验证相关 loader,使页面读取到服务端的新状态。这是数据路由的重要闭环:

用户提交表单
    ↓
action 执行写入
    ↓
action 返回结果或 redirect
    ↓
相关 loader 重新执行
    ↓
页面显示最新服务端数据

这比在组件中手工执行:

await save();
setItems(...);
navigate(...);

更容易保持路由数据和 URL 的一致性,但 action 仍然需要正确设计服务端事务和错误返回。

3. useActionData 与异常的区别

验证失败通常不是系统异常,可以返回结构化数据:

return {
  fieldErrors: {
    email: "邮箱格式不正确",
  },
};

而数据库不可用、服务端 500、响应格式损坏等情况应当抛出异常或错误响应:

throw new Response("服务暂时不可用", {
  status: 503,
});

这样可以让表单继续显示字段错误,同时让系统故障进入错误边界。把所有错误都 return { error: ... } 会导致错误边界失去作用,页面也可能继续使用不完整数据。


五、导航状态、Fetcher 与非导航操作

1. useNavigation 表示当前导航状态

React Router 的导航状态通常包括:

idle
loading
submitting

可以用它显示全局进度:

import { useNavigation } from "react-router-dom";

function GlobalProgress() {
  const navigation = useNavigation();

  if (navigation.state === "idle") {
    return null;
  }

  return <div className="progress">正在处理...</div>;
}

当用户点击链接并触发 loader 时,状态通常是 loading;提交表单到 action 时,状态通常先是 submitting,之后进入重新加载阶段。

不要仅凭一个组件自己的 isLoading 推断整个路由是否正在切换,因为 loader 可能属于父路由或兄弟路由。

2. useFetcher 不改变 URL 的情况下调用 loader 或 action

useFetcher 适合局部操作,例如:

  • 列表中的单行删除;
  • 点赞;
  • 自动补全;
  • 菜单中的启用/禁用;
  • 页面不跳转的局部表单。
import {
  useFetcher,
} from "react-router-dom";

export function DeleteProjectButton({
  projectId,
}: {
  projectId: string;
}) {
  const fetcher = useFetcher();

  const deleting =
    fetcher.state !== "idle";

  return (
    <fetcher.Form
      method="post"
      action={`/app/projects/${projectId}/delete`}
    >
      <button type="submit" disabled={deleting}>
        {deleting ? "删除中..." : "删除"}
      </button>
    </fetcher.Form>
  );
}

对应 action:

export async function deleteProjectAction({
  params,
  request,
}: ActionFunctionArgs) {
  const projectId = params.projectId;

  if (!projectId) {
    throw new Response("缺少项目 ID", { status: 400 });
  }

  const response = await fetch(
    `/api/projects/${encodeURIComponent(projectId)}`,
    {
      method: "DELETE",
      signal: request.signal,
    },
  );

  if (response.status === 404) {
    throw new Response("项目不存在", { status: 404 });
  }

  if (!response.ok) {
    throw new Response("删除失败", { status: response.status });
  }

  return { ok: true };
}

fetcher 不是绕过路由生命周期的普通 fetch。它仍会经过对应 action,并可能触发相关 loader 的重新验证,但不会把浏览器导航到新的 URL。


六、错误处理:错误边界、错误响应与错误传播

1. 路由错误边界处理哪些故障

数据路由中的 errorElementErrorBoundary 可处理:

  • loader 抛出的异常;
  • loader 返回或抛出的错误响应;
  • action 抛出的异常;
  • 路由组件渲染期间的错误。
import {
  isRouteErrorResponse,
  useRouteError,
} from "react-router-dom";

export function RootErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    if (error.status === 404) {
      return <NotFoundPage />;
    }

    if (error.status === 401) {
      return <p>请先登录</p>;
    }

    if (error.status === 403) {
      return <p>你没有权限访问此页面</p>;
    }

    return (
      <section>
        <h1>{error.status}</h1>
        <p>{error.statusText || "请求失败"}</p>
      </section>
    );
  }

  if (error instanceof Error) {
    return (
      <section>
        <h1>页面发生错误</h1>
        <p>{error.message}</p>
      </section>
    );
  }

  return <p>发生未知错误</p>;
}

路由配置:

{
  path: "/",
  element: <RootLayout />,
  errorElement: <RootErrorBoundary />,
  children: [...]
}

isRouteErrorResponse 用于识别 React Router 的错误响应,例如:

throw new Response("项目不存在", { status: 404 });

这与普通 JavaScript 异常不同。不要直接假定 error.status 一定存在:

// 不安全
const status = (error as any).status;

生产错误边界还应避免直接向用户展示数据库错误、内部堆栈或访问令牌。

2. 错误边界具有层级

可以为不同路由配置不同边界:

const routes = [
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <RootErrorBoundary />,
    children: [
      {
        path: "app",
        element: <AppLayout />,
        errorElement: <AppErrorBoundary />,
        children: [
          {
            path: "projects/:projectId",
            element: <ProjectDetailPage />,
            errorElement: <ProjectErrorBoundary />,
          },
        ],
      },
    ],
  },
];

访问 /app/projects/no-such-project 时,如果详情 loader 抛出 404,最近的 ProjectErrorBoundary 会优先处理。若该边界自身不存在,错误会向父路由边界传播。

这带来一个重要的 UI 结构能力:

整个应用错误       -> 显示全屏故障页
后台区域错误       -> 保留站点外壳,替换后台内容
项目详情错误       -> 保留侧栏和项目布局,只替换详情区域

3. 404、401 和 403 的语义不同

这三个状态不能混用:

  • 404 Not Found:资源不存在,或系统选择隐藏资源存在性;
  • 401 Unauthorized:当前请求没有经过有效认证;
  • 403 Forbidden:已经知道请求者是谁,但没有执行该操作的权限。

示例:

if (!user) {
  throw redirect(`/login?returnTo=${encodeURIComponent(path)}`);
}

if (!user.permissions.includes("project:read")) {
  throw new Response("Forbidden", { status: 403 });
}

是否对未授权用户返回 404,是安全策略问题。例如,多租户系统常常对不属于当前租户的资源统一返回 404,避免泄露资源是否存在;这必须由服务端统一决定,不能只由前端路由决定。


七、权限:认证、授权与路由保护

1. 认证和授权不是一回事

认证回答:

你是谁?

授权回答:

你是否可以执行这个操作?

只检查登录状态,不检查资源级权限是不完整的:

// 只证明用户登录,不证明用户能访问 projectId
if (user) {
  return project;
}

正确的资源读取通常应由服务端同时验证:

当前会话 -> 当前用户 -> 当前租户 -> projectId -> 操作权限

2. Loader 中执行页面级认证检查

可以在父路由 loader 中检查登录状态:

import {
  redirect,
} from "react-router-dom";
import type { LoaderFunctionArgs } from "react-router-dom";

type User = {
  id: string;
  name: string;
  permissions: string[];
};

async function getCurrentUser(
  request: Request,
): Promise<User | null> {
  const response = await fetch("/api/session", {
    signal: request.signal,
    credentials: "include",
  });

  if (response.status === 401) {
    return null;
  }

  if (!response.ok) {
    throw new Response("无法读取会话", {
      status: response.status,
    });
  }

  return response.json();
}

export async function requireUser({
  request,
}: LoaderFunctionArgs) {
  const user = await getCurrentUser(request);

  if (!user) {
    const url = new URL(request.url);
    const returnTo =
      url.pathname + url.search;

    throw redirect(
      `/login?returnTo=${encodeURIComponent(returnTo)}`,
    );
  }

  return user;
}

路由配置:

{
  path: "app",
  element: <AppLayout />,
  loader: requireUser,
  children: [
    {
      index: true,
      element: <DashboardPage />,
    },
    {
      path: "projects/:projectId",
      loader: projectLoader,
      element: <ProjectDetailPage />,
    },
  ],
}

父路由保护可以阻止未登录用户进入后台,但不能替代子资源的授权检查。projectLoader 仍应让服务端验证当前用户是否能读取该项目。

3. 权限检查必须在服务端成立

前端权限判断主要用于:

  • 隐藏不应显示的导航项;
  • 提前改善用户体验;
  • 避免用户点击后才看到错误。

它不能承担安全边界:

// 只能改善 UI,不能作为安全措施
{user.permissions.includes("project:delete") && (
  <DeleteButton />
)}

攻击者可以直接调用:

DELETE /api/projects/p-123

因此,删除接口必须在服务端重新检查:

请求身份有效
AND 用户属于项目所在租户
AND 用户拥有 project:delete
AND 项目状态允许删除

前端 action 也可以检查权限以改善反馈:

export async function deleteProjectAction({
  params,
  request,
}: ActionFunctionArgs) {
  const response = await fetch(
    `/api/projects/${params.projectId}`,
    {
      method: "DELETE",
      signal: request.signal,
      credentials: "include",
    },
  );

  if (response.status === 401) {
    throw redirect("/login");
  }

  if (response.status === 403) {
    throw new Response("没有删除权限", {
      status: 403,
    });
  }

  if (!response.ok) {
    throw new Response("删除失败", {
      status: response.status,
    });
  }

  return redirect("/app/projects");
}

但这仍然只是客户端对服务端结果的适配,真正的授权决策必须由 API 或服务端路由完成。

4. 按权限动态生成路由不是主要安全机制

不建议仅仅因为用户没有权限,就在客户端删除某条路由配置:

// 不足以保护资源
const routes = user
  ? authenticatedRoutes
  : publicRoutes;

原因是:

  • 用户仍可手动输入 URL;
  • 已下载的 JavaScript 中可能存在页面代码;
  • API 仍必须独立保护;
  • 权限可能在会话期间发生变化。

路由配置可以决定展示哪些页面,但服务端必须决定哪些数据和操作可用。


八、Loader、Action 与完整页面流程

下面把列表读取、创建和删除组合成一个较完整的结构。

1. 项目列表 loader

type ProjectSummary = {
  id: string;
  name: string;
};

export async function projectListLoader({
  request,
}: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const page = url.searchParams.get("page") ?? "1";
  const query = url.searchParams.get("q") ?? "";

  const apiUrl = new URL(
    "/api/projects",
    url.origin,
  );

  apiUrl.searchParams.set("page", page);
  if (query) {
    apiUrl.searchParams.set("q", query);
  }

  const response = await fetch(apiUrl, {
    signal: request.signal,
    credentials: "include",
    headers: {
      Accept: "application/json",
    },
  });

  if (!response.ok) {
    throw new Response("读取项目列表失败", {
      status: response.status,
    });
  }

  return response.json() as Promise<{
    items: ProjectSummary[];
    total: number;
  }>;
}

2. 页面读取 loader 数据

import {
  Link,
  useLoaderData,
} from "react-router-dom";

export function ProjectListPage() {
  const data = useLoaderData() as {
    items: ProjectSummary[];
    total: number;
  };

  return (
    <section>
      <header>
        <h1>项目列表</h1>
        <Link to="new">新建项目</Link>
      </header>

      <ul>
        {data.items.map((project) => (
          <li key={project.id}>
            <Link to={project.id}>
              {project.name}
            </Link>
          </li>
        ))}
      </ul>

      <p>共 {data.total} 个项目</p>
    </section>
  );
}

3. 路由配置

{
  path: "projects",
  element: <ProjectsLayout />,
  children: [
    {
      index: true,
      loader: projectListLoader,
      element: <ProjectListPage />,
    },
    {
      path: "new",
      action: createProjectAction,
      element: <NewProjectPage />,
    },
    {
      path: ":projectId",
      loader: projectLoader,
      element: <ProjectDetailPage />,
      errorElement: <ProjectErrorBoundary />,
    },
    {
      path: ":projectId/delete",
      action: deleteProjectAction,
    },
  ],
}

这里有一个容易忽略的点:projects/newprojects/:projectId 都可能匹配字符串 new。React Router 会进行路由排名,静态段通常优先于动态段,因此 new 会匹配静态路由,而不是把 "new" 当作 projectId。不过,实际项目中仍应避免设计过于模糊的路径,尤其是在存在通配符 * 时。


九、重新验证、缓存与并发

1. React Router 的数据不是全局服务端缓存

loader 返回的数据会被路由器保存并提供给当前匹配的路由组件,但它不等同于:

  • 持久化缓存;
  • 跨标签页缓存;
  • 自动请求去重系统;
  • 带时间策略的服务端状态缓存;
  • Redux Toolkit Query 或 TanStack Query 的完整缓存层。

默认情况下,导航、action 完成后等情况可能触发相关 loader 重新验证。重新验证的目标是让 UI 回到服务端真实状态,而不是长期缓存数据。

2. shouldRevalidate 控制重新验证

可以为路由定义:

{
  path: "projects",
  loader: projectListLoader,
  shouldRevalidate: ({
    currentUrl,
    nextUrl,
    formMethod,
    actionResult,
    defaultShouldRevalidate,
  }) => {
    if (
      currentUrl.pathname === nextUrl.pathname &&
      currentUrl.search === nextUrl.search &&
      formMethod === undefined
    ) {
      return false;
    }

    return defaultShouldRevalidate;
  },
  element: <ProjectListPage />,
}

这个函数可以减少不必要的读取,但它有一个风险:如果错误地返回 false,action 已经成功修改了服务端数据,列表仍可能显示旧内容。

因此,优化前要先回答:

这次 loader 是否依赖本次 action 修改的数据?
如果依赖,跳过重新验证会不会产生陈旧 UI?

除非能够证明数据不相关,否则保留 defaultShouldRevalidate 通常更安全。

3. 并发提交不是简单的“最后一次点击生效”

用户可能连续提交两个 action:

提交 A:修改名称为 Alpha
提交 B:修改名称为 Beta

网络完成顺序可能是:

B 先完成
A 后完成

服务端最终状态取决于服务端处理顺序、事务和版本控制,而不是前端点击顺序。React Router 可以管理导航和数据重新验证,但不能替业务系统解决写入冲突。

需要严格顺序的业务应在服务端使用:

  • 乐观锁;
  • 版本号;
  • updated_at 条件更新;
  • 幂等键;
  • 数据库事务;
  • 冲突响应,例如 409 Conflict

前端收到 409 后可以返回表单级错误:

if (response.status === 409) {
  return {
    formError: "数据已被其他用户修改,请刷新后重试",
  };
}

4. 与 React 19 的 Suspense 边界

普通 loader 默认会等待 Promise 完成,然后再渲染路由组件。这种方式适合首屏必须具备的数据。

对于可以延后显示的数据,可以考虑 Suspense 和 <Await> 等数据路由能力,但具体 API 在 React Router 版本和使用模式中存在差异。核心原则是:

关键数据:loader 等待完成后渲染
非关键数据:在可用的情况下延迟解析,并由 Suspense 显示局部 fallback

不要把所有请求都延迟,否则页面主体可能在数据未准备好时失去必要的结构;也不要把所有请求都阻塞,否则一个非关键推荐模块故障可能拖住整个页面。

React 19 的 Suspense、use 和 React Router 的 loader 不是同一套缓存机制。React 的渲染异步能力不能自动替代服务端数据缓存、请求去重和失效策略。


十、Loader 与组件请求的边界

1. 为什么首屏数据通常放在 loader

如果在页面组件中请求:

function ProjectPage() {
  const [project, setProject] = useState<Project | null>(null);

  useEffect(() => {
    fetch("/api/project").then(...);
  }, []);

  if (!project) {
    return <Loading />;
  }

  return <ProjectView project={project} />;
}

页面已经完成路由渲染后,才开始读取数据。这会产生:

  • 首屏先出现空页面或 loading;
  • 路由错误不能自然进入路由错误边界;
  • URL 参数、请求取消和导航生命周期需要自行处理;
  • action 完成后的重新验证需要手工编写。

使用 loader 后:

匹配路由
  -> loader 读取数据
  -> 成功才渲染页面
  -> 失败进入对应错误边界

2. 组件级请求仍然有适用场景

不是所有请求都应放在 loader。组件内部请求适用于:

  • 键盘输入驱动的自动补全;
  • 页面已显示后的轮询;
  • 鼠标悬停预览;
  • 不影响路由主要内容的实时数据;
  • 由独立客户端缓存库管理的数据。

判断标准不是“请求是否属于页面”,而是:

这个请求是否决定当前 URL 对应页面能否成立?

如果答案是“是”,loader 通常更合适;如果只是页面中的独立交互,组件请求或专用缓存库更合适。

3. 与 Redux Toolkit、Zustand 和服务端缓存的分工

可以按数据性质划分:

数据类型 合适的工具
URL、路径参数、查询条件 React Router
当前路由首屏数据 loader
路由表单写操作 action
跨组件客户端 UI 状态 Zustand 或 Redux Toolkit
复杂客户端业务状态、审计、可预测 reducer Redux Toolkit
跨页面服务端缓存、失效、轮询、去重 RTK Query、TanStack Query 等
认证和授权事实 服务端会话与 API

不要把同一份服务端数据同时无条件放进 loader、Redux 和组件本地 state。这样会产生多个事实源:

loader 中是旧名称
Redux 中是新名称
组件 state 中又有一个临时名称

如果使用 Redux Toolkit 的 RTK Query,应明确它承担服务端缓存职责;如果使用 loader,则应明确路由数据由路由生命周期管理。两者可以共存,但需要定义谁负责失效和刷新。


十一、表单验证与服务端边界

1. 客户端验证不是最终验证

表单可以做即时体验验证:

<input
  name="name"
  minLength={2}
  required
/>

但 action 仍必须验证,因为请求可能来自:

  • 手工构造的 HTTP 请求;
  • 被篡改的浏览器代码;
  • 旧版本客户端;
  • 其他客户端程序;
  • 自动化脚本。

服务端验证至少包括:

类型验证
格式验证
长度和范围验证
身份验证
资源归属验证
业务规则验证
事务一致性验证

2. 输入值不能直接信任

FormData.get() 返回的是 FormDataEntryValue | null,可能是字符串,也可能是文件:

const raw = formData.get("name");

if (typeof raw !== "string") {
  return {
    formError: "名称字段无效",
  };
}

const name = raw.trim();

对于数字也不能直接使用:

const page = Number(formData.get("page"));

if (!Number.isInteger(page) || page < 1) {
  return {
    formError: "页码无效",
  };
}

服务端 API 还必须防止 SQL 注入、越权访问、跨站请求伪造和重复提交。React Router 的 action 只是请求编排层,不会自动提供这些安全保证。


十二、浏览器端、服务端与 SSR 边界

1. createBrowserRouter 是客户端路由器

使用:

createBrowserRouter(...)

时,代码通常在浏览器中运行,loader 和 action 会通过 fetch 调用后端 API。服务端数据不会因为写在 loader 里,就自动变成服务端安全代码。

例如:

export async function loader() {
  return fetch("/api/private-data");
}

这个 loader 中不能直接假设存在:

process.env.DATABASE_URL;

也不能把数据库密码、服务端密钥打包到浏览器代码中。

2. 服务端渲染需要对应的服务端入口

如果使用 React Router 的 SSR 数据 API 或主流 React Router 框架模式,服务端通常需要:

  1. 接收 HTTP 请求;
  2. 使用请求 URL 匹配路由;
  3. 执行匹配路由的 loader;
  4. 将 loader 数据注入服务端渲染结果;
  5. 把 HTML 返回浏览器;
  6. 客户端 hydration;
  7. 后续导航在客户端继续运行。

具体 SSR API、文件约定和导入路径取决于使用的是数据路由、框架模式还是其他集成方式。不能把浏览器端 createBrowserRouter 示例直接当作完整 SSR 服务端实现。

3. Cookie 会话与令牌边界

如果采用 Cookie 会话,浏览器请求通常需要:

fetch("/api/session", {
  credentials: "include",
  signal: request.signal,
});

服务端必须正确设置:

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax

实际属性要根据部署域名、跨站场景和 CSRF 策略决定。

如果采用内存中的前端令牌:

const token = localStorage.getItem("token");

这会引入 XSS 暴露风险,且无法替代服务端权限检查。令牌存储方案属于认证系统设计,不是 React Router 的功能。


十三、常见失败表现与诊断方法

1. 页面为空但没有明显报错

优先检查父布局是否有:

<Outlet />

如果路由配置匹配成功、父组件也渲染成功,但没有 <Outlet />,子页面不会显示。

2. loader 返回数据却页面拿不到

检查三个条件:

loader 是否挂在当前实际匹配的路由上?
页面是否使用了 useLoaderData?
读取数据的组件是否就是该 loader 对应的路由组件?

如果页面是子路由组件,却试图用 useLoaderData 读取父路由 loader 的数据,结果可能与预期不符。读取其他匹配路由的数据时,应使用带 route id 的方式,并给路由配置稳定的 id

{
  id: "root",
  path: "/",
  loader: rootLoader,
  element: <RootLayout />,
}

然后根据当前 React Router 版本使用对应的跨路由数据读取 API。更简单的做法是让布局组件读取自己的 loader 数据,再通过 props 或 context 传给子组件。

3. 页面一直显示旧数据

检查:

  • action 是否真的成功;
  • action 是否修改了当前 loader 读取的数据;
  • 是否错误使用了 shouldRevalidate 返回 false
  • 是否同时存在 Redux、组件 state 和 loader 三份数据;
  • 服务端是否因为缓存返回旧响应;
  • 是否有竞态请求覆盖了页面状态。

可以观察:

const navigation = useNavigation();
console.log(navigation.state, navigation.location);

也可以在 loader 和 action 中记录:

请求 URL
请求方法
路由参数
响应状态
开始时间和结束时间

不要只在组件中打印渲染结果,因为问题可能发生在路由匹配、请求取消或重新验证阶段。

4. 404 错误边界没有显示

检查是否抛出了路由器能识别的错误:

throw new Response("Not Found", { status: 404 });

而不是仅仅:

return { status: 404 };

后者只是普通数据,React Router 不会把它当作错误流程处理。

5. 未登录用户先看到后台页面再跳转

通常说明认证检查写在了组件的 useEffect 中。将检查放到父路由 loader:

{
  path: "app",
  loader: requireUser,
  element: <AppLayout />,
}

这样路由在提交页面数据前就可以重定向。注意,这并不意味着 API 可以删除自己的认证检查;任何 API 请求都必须独立验证身份。

6. 请求取消后服务端仍然执行

这是正常可能现象。浏览器侧收到 AbortSignal 后会停止等待,但服务端是否停止执行取决于服务端实现。对于昂贵操作,需要在服务端设计:

  • 超时;
  • 幂等;
  • 事务回滚;
  • 数据库查询取消;
  • 后台任务状态查询。

不能仅凭前端 signal 保证业务操作已经撤销。


十四、一个可执行的最小安装与启动示例

以 Vite 项目为例:

npm create vite@latest router-demo -- --template react-ts
cd router-demo
npm install
npm install react-router-dom
npm run dev

入口:

// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import {
  createBrowserRouter,
  RouterProvider,
} from "react-router-dom";
import "./index.css";

function Layout() {
  return (
    <>
      <nav>
        <a href="/">首页</a>
      </nav>
      <main>
        <Outlet />
      </main>
    </>
  );
}

完整导入应包括 Outlet,例如:

import {
  createBrowserRouter,
  Link,
  Outlet,
  RouterProvider,
  useLoaderData,
} from "react-router-dom";

一个最小可运行路由:

type Message = {
  message: string;
};

async function homeLoader({
  request,
}: LoaderFunctionArgs): Promise<Message> {
  await new Promise((resolve) => {
    const timeout = setTimeout(resolve, 300);

    request.signal.addEventListener(
      "abort",
      () => clearTimeout(timeout),
      { once: true },
    );
  });

  if (request.signal.aborted) {
    throw new DOMException(
      "请求已取消",
      "AbortError",
    );
  }

  return {
    message: "loader 已成功执行",
  };
}

function HomePage() {
  const data = useLoaderData() as Message;

  return (
    <>
      <h1>{data.message}</h1>
      <Link to="/about">关于</Link>
    </>
  );
}

function AboutPage() {
  return <h1>关于页面</h1>;
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    children: [
      {
        index: true,
        loader: homeLoader,
        element: <HomePage />,
      },
      {
        path: "about",
        element: <AboutPage />,
      },
    ],
  },
]);

ReactDOM.createRoot(
  document.getElementById("root")!,
).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>,
);

上例的运行过程是:

  1. 打开 /
  2. 路由器匹配根路由和 index 路由;
  3. 执行 homeLoader
  4. 等待约 300 毫秒;
  5. 将返回对象提供给 HomePage
  6. 点击“关于”后进入 /about
  7. 因为 URL 发生变化,页面切换为 AboutPage

示例中的延迟只是为了观察状态,不应在生产环境中人为添加。真正的 loader 应使用后端 API,并传递 request.signal


十五、如何选择路由数据方案

可以用以下决策过程:

情况一:数据决定页面是否可以显示

例如项目详情、订单详情、用户设置:

URL /projects/p-123
    -> 读取 p-123
    -> 不存在显示 404
    -> 无权限显示 403
    -> 成功渲染详情

优先使用路由 loader。

情况二:操作属于当前路由的标准表单写入

例如创建、编辑、删除:

Form
  -> action
  -> 验证
  -> 调用 API
  -> 返回字段错误或 redirect
  -> 重新验证 loader

优先使用 action。

情况三:数据需要跨页面缓存和复杂失效

例如:

  • 多页面共享相同实体;
  • 轮询;
  • 请求去重;
  • 后台刷新;
  • 细粒度缓存失效;
  • 离线支持。

可以使用 RTK Query 等服务端缓存工具。React Router 仍然负责 URL、布局、页面入口和权限边界。

情况四:数据只是本地交互状态

例如:

  • 侧栏是否展开;
  • 弹窗是否打开;
  • 当前拖拽目标;
  • 未提交的 UI 草稿。

使用组件 state、Zustand 或 Redux Toolkit,而不是为了这些状态创建 loader。


十六、核心边界总结

一个稳定的 React Router 数据流通常可以表示为:

URL
  ↓
路由匹配
  ↓
父级认证 loader
  ↓
页面 loader
  ↓
组件渲染
  ↓
Form / fetcher
  ↓
action
  ↓
服务端验证与写入
  ↓
redirect 或结构化错误
  ↓
loader 重新验证
  ↓
错误边界或最新页面

其中每个边界的责任不同:

  • 嵌套路由负责布局和页面组合;
  • loader负责路由进入所需的数据读取;
  • action负责路由相关的写操作;
  • 错误边界负责把失败转换成可恢复的 UI;
  • 权限 loader负责前端路由层的访问控制体验;
  • 服务端 API负责最终认证、授权、校验和事务安全;
  • Redux Toolkit、Zustand 或服务端缓存库负责路由之外的状态需求。

最重要的因果关系是:路由器可以协调客户端的导航、数据读取、写操作和错误恢复,但它不会自动替代服务端安全、业务事务、缓存系统或全局状态管理。理解这些边界后,嵌套路由不再只是组件层级,loader 和 action 也不再只是“把请求放到配置里”,而是形成了与 URL、服务端数据和错误恢复相互一致的应用流程。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。