React 基础体系 · 第 10/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Router 完整指南:嵌套路由、Loader、Action、错误与权限
React Router 不只是把 URL 映射到组件。现代 React Router 的数据路由模式还把路由变成了一个数据边界:
- 路由决定渲染哪些布局和页面;
loader在进入路由前读取数据;action处理该路由下的表单写操作;errorElement或ErrorBoundary处理加载、提交和渲染错误;- 路由层可以执行认证与授权检查;
- 导航过程可以被取消、重新验证,并向 UI 暴露状态。
本文以 React 19、TypeScript 和 React Router 6.4+ / 7 的数据路由 API 为基础。React Router 7 仍然支持 createBrowserRouter、loader、action、Outlet 等核心概念,但具体导入路径和框架模式可能因项目配置不同而变化。下面主要使用 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 />,
},
],
},
],
},
]);
这里有两个重要规则:
index: true表示父路径的默认子页面。例如/app显示DashboardPage。path: ":projectId"是动态参数路由。访问/app/projects/p-123时,params.projectId的值是"p-123"。
AppLayout 和 ProjectsLayout 都需要渲染 <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");
二、启动数据路由:createBrowserRouter 与 RouterProvider
传统的 <BrowserRouter> 主要提供导航和匹配能力,而 createBrowserRouter 创建的是数据路由器,可以调度 loader、action、错误边界和重新验证。
入口代码:
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 请求仍然继续;
- 浏览器仍消耗连接和带宽;
- 自定义请求库可能继续执行无效的解析和副作用。
需要区分两件事:
AbortController通常可以取消浏览器侧等待和读取响应;- 它不保证服务端已经停止处理请求。服务端是否取消数据库查询,要看服务端框架和数据库驱动是否支持取消。
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/new 的 action。
也可以显式指定:
<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. 路由错误边界处理哪些故障
数据路由中的 errorElement 或 ErrorBoundary 可处理:
- 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/new 和 projects/: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 框架模式,服务端通常需要:
- 接收 HTTP 请求;
- 使用请求 URL 匹配路由;
- 执行匹配路由的 loader;
- 将 loader 数据注入服务端渲染结果;
- 把 HTML 返回浏览器;
- 客户端 hydration;
- 后续导航在客户端继续运行。
具体 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>,
);
上例的运行过程是:
- 打开
/; - 路由器匹配根路由和 index 路由;
- 执行
homeLoader; - 等待约 300 毫秒;
- 将返回对象提供给
HomePage; - 点击“关于”后进入
/about; - 因为 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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Context 与 Reducer:跨层状态、更新边界和可测试设计
- 下一篇:React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
- 延伸:React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论