React Router
单页应用(SPA)没有"换页面"这回事:URL 变了,只是换了一批组件渲染。React Router 是做这件事最流行的库——它把 URL 映射到组件树,让"前进后退""刷新保持""可分享链接"这些浏览器原生能力在 SPA 里继续成立。
第 15 章把状态分成四类,其中一类是 URL 状态。这一章讲的就是它:路由不只是"页面跳转",更是一套把界面状态存进地址栏的机制。理解了"URL 是组件树的一条路径",嵌套路由、动态参数、loader 都会顺理成章。
1. 声明式路由与匹配原理
1.1 路由表
用一张配置表声明"哪个路径渲染哪个界面":
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{ path: "/", element: <Home /> },
{ path: "/users/:id", element: <UserProfile /> },
{ path: "/files/*", element: <FileBrowser /> },
{ path: "*", element: <NotFound /> },
]);
function App() {
return <RouterProvider router={router} />;
}三种路径段:静态段(users)、动态段(:id,匹配一段任意内容)、通配段(*,匹配剩余全部)。访问 /users/42 时组件里用 useParams() 拿到参数对象,id 的值是字符串 "42"。
1.2 匹配是怎么做的
React Router 把模式编译成正则:/users/:id 变成 ^/users/([^/]+)$,再按参数名把捕获组回填。亲手写一个最小版本就一目了然:
type Params = Record<string, string>;
// 把 "/users/:id" 这种模式编译成正则 + 参数名列表
function compile(pattern: string): { regex: RegExp; keys: string[] } {
const keys: string[] = [];
const source = pattern
.split("/")
.map((seg) => {
if (seg.startsWith(":")) { keys.push(seg.slice(1)); return "([^/]+)"; }
if (seg === "*") { keys.push("*"); return "(.*)"; }
return seg;
})
.join("/");
return { regex: new RegExp("^" + source + "$"), keys };
}
function match(pattern: string, path: string): Params | null {
const built = compile(pattern);
const m = path.match(built.regex);
if (m === null) return null;
const params: Params = {};
built.keys.forEach((k, i) => { params[k] = m[i + 1]; });
return params;
}
console.log("== 基本匹配 ==");
console.log("/users/:id vs /users/42 -> " + JSON.stringify(match("/users/:id", "/users/42")));
console.log("/posts/:pid/comments/:cid -> " + JSON.stringify(match("/posts/:pid/comments/:cid", "/posts/3/comments/7")));
console.log("/posts/:pid vs /users/42 -> " + JSON.stringify(match("/posts/:pid", "/users/42")));
console.log("/files/* vs /files/a/b.txt -> " + JSON.stringify(match("/files/*", "/files/a/b.txt")));
// 多个模式都能匹配时怎么选?按"具体程度"打分:静态段 > 动态段 > 通配段
function score(pattern: string): number {
return pattern.split("/").filter((s) => s !== "").reduce((sum, seg) => {
if (seg === "*") return sum + 1;
if (seg.startsWith(":")) return sum + 3;
return sum + 10;
}, 0);
}
const patterns = ["/users/*", "/users/:id", "/users/new"];
console.log("== 访问 /users/new,三个模式都能匹配 ==");
patterns.forEach((p) => { console.log(" " + p + " score=" + score(p) + " match=" + JSON.stringify(match(p, "/users/new"))); });
const winner = patterns
.filter((p) => match(p, "/users/new") !== null)
.sort((a, b) => score(b) - score(a))[0];
console.log("胜出的是: " + winner + " <- 静态段最具体,不会被 :id 抢走");打分排序这一步很关键:路由表的书写顺序不重要,React Router 会按具体程度排序后再匹配。这就是为什么 /users/new 不会被 /users/:id 误吞——你不需要小心翼翼地调整顺序(这一点和 Express 等按顺序匹配的服务端框架相反)。
2. 嵌套路由:URL 是一条路径
真实应用有公共外壳:顶栏、侧边栏、面包屑。与其每页重复,不如让子路由"嵌"进布局的留白处:
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />, // 外壳,里面有 <Outlet />
children: [
{ index: true, element: <Home /> }, // 父路径自身
{
path: "users",
element: <UsersLayout />, // 二级外壳(如用户列表侧栏)
children: [
{ index: true, element: <UserList /> },
{ path: ":id", element: <UserProfile /> },
],
},
{ path: "about", element: <About /> },
],
},
]);布局组件里用 <Outlet /> 占位,子路由的渲染结果出现在这个位置:
function RootLayout() {
return (
<div>
<Sidebar />
<main>
<Outlet />
</main>
</div>
);
}所以一个 URL 匹配出的不是"一个组件",而是一条组件链:
// 嵌套路由:URL 匹配出的不是"一个组件",而是"一条组件链"
type Route = { path: string; component: string; children?: Route[] };
const routes: Route[] = [
{
path: "/", component: "RootLayout",
children: [
{ path: "", component: "Home" }, // index 路由
{
path: "users", component: "UsersLayout",
children: [
{ path: "", component: "UserList" },
{ path: ":id", component: "UserProfile" },
],
},
{ path: "about", component: "About" },
],
},
];
type Matched = { chain: string[]; params: Record<string, string> };
// 深度优先匹配:逐段消费 URL,命中就把组件压入链
function matchRoutes(rs: Route[], segments: string[], params: Record<string, string>): Matched | null {
for (const r of rs) {
const own = r.path === "" ? [] : r.path.split("/").filter((s) => s !== "");
if (own.length > segments.length) continue;
const next = { ...params };
let ok = true;
for (let i = 0; i < own.length; i++) {
const pat = own[i];
if (pat.startsWith(":")) next[pat.slice(1)] = segments[i];
else if (pat !== segments[i]) { ok = false; break; }
}
if (!ok) continue;
const rest = segments.slice(own.length);
if (r.children !== undefined) {
const sub = matchRoutes(r.children, rest, next);
if (sub !== null) return { chain: [r.component, ...sub.chain], params: sub.params };
continue;
}
if (rest.length === 0) return { chain: [r.component], params: next };
}
return null;
}
function visit(url: string): void {
const segs = url.split("/").filter((s) => s !== "");
const m = matchRoutes(routes, segs, {});
if (m === null) { console.log(url + " -> 404"); return; }
// 链条就是嵌套渲染顺序:外层组件的 Outlet 里放下一个
console.log(url + " -> " + m.chain.join(" > ") + " params=" + JSON.stringify(m.params));
}
visit("/");
visit("/about");
visit("/users");
visit("/users/42");
visit("/nope");
console.log("== 切换 /users/42 -> /users/7 时会发生什么 ==");
const a = matchRoutes(routes, ["users", "42"], {});
const b = matchRoutes(routes, ["users", "7"], {});
if (a !== null && b !== null) {
console.log("组件链是否相同: " + String(a.chain.join(">") === b.chain.join(">")));
console.log("变化的只有 params: " + JSON.stringify(a.params) + " -> " + JSON.stringify(b.params));
console.log("外层 RootLayout / UsersLayout 状态保留,UserProfile 拿到新 id 更新");
}最后那段输出点出了嵌套路由最大的实用价值:外层复用。从 /users/42 切到 /users/7,组件链完全相同,React 按第 2 章的协调规则复用这些节点——侧栏的滚动位置、展开状态全部保留,只有 UserProfile 因为 params 变化而更新。
上面这个复用是双刃剑:UserProfile 组件实例没被销毁,它内部的 useState(比如"编辑中的草稿")会带到下一个用户身上。需要"换 id 就彻底重来"时,给它一个 key:<UserProfile key={params.id} />——这是第 8 章"用 key 重置状态"技巧在路由场景的标准应用。
3. 导航:Link、NavLink 与命令式
绝不要用 <a href> 做站内跳转——那会触发整页刷新,React 状态全丢、白屏一次。
import { Link, NavLink, useNavigate } from "react-router-dom";
function Nav() {
const navigate = useNavigate();
return (
<nav>
<Link to="/about">关于</Link>
{/* NavLink 自带激活态,适合导航菜单 */}
<NavLink to="/users" className={({ isActive }) => (isActive ? "on" : "")}>
用户
</NavLink>
{/* 命令式:在事件回调、请求成功后跳转 */}
<button onClick={() => navigate("/users/42")}>看用户</button>
<button onClick={() => navigate(-1)}>返回上一页</button>
<button onClick={() => navigate("/login", { replace: true })}>去登录(不留历史)</button>
</nav>
);
}replace: true 的意义:登录跳转、重定向这类场景如果用 push,用户按后退会回到"刚被踢走的页面",然后又被踢一次,陷入循环。这类导航必须 replace。
4. searchParams:把筛选条件放进 URL
路径参数适合表达"看哪个资源",查询参数适合表达"怎么看"——筛选、排序、分页、关键词。第 15 章说过:能进 URL 的状态就别放 useState,因为 URL 里的状态天然可分享、可刷新、可后退。
import { useSearchParams } from "react-router-dom";
function TodoList() {
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "all";
const page = Number(params.get("page") ?? "1");
return (
<>
<select value={status} onChange={(e) => setParams({ status: e.target.value, page: "1" })}>
<option value="all">全部</option>
<option value="active">未完成</option>
</select>
<Pager page={page} onChange={(p) => setParams({ status, page: String(p) })} />
</>
);
}setParams 默认是 push(进历史);输入框实时搜索这种高频更新要传 { replace: true },否则用户敲十个字符就在历史里塞了十条记录,后退键彻底不可用:
// URL 就是状态:把筛选条件放进 search params,而不是 useState
// 这里手写一个 mini history + searchParams,体会"可分享、可后退"的含义
type Entry = { path: string; search: string };
class MiniHistory {
private stack: Entry[] = [];
private cursor = -1;
push(url: string): void {
// 新导航会截断"前进"的分支——和浏览器一致
this.stack = this.stack.slice(0, this.cursor + 1);
const [path, search = ""] = url.split("?");
this.stack.push({ path, search });
this.cursor = this.stack.length - 1;
}
replace(url: string): void {
const [path, search = ""] = url.split("?");
this.stack[this.cursor] = { path, search };
}
back(): void { if (this.cursor > 0) this.cursor--; }
forward(): void { if (this.cursor < this.stack.length - 1) this.cursor++; }
get current(): Entry { return this.stack[this.cursor]; }
get url(): string {
const e = this.current;
return e.search === "" ? e.path : e.path + "?" + e.search;
}
}
// 从 search 字符串里读状态(对应 useSearchParams)
function readParams(search: string): Record<string, string> {
const out: Record<string, string> = {};
for (const pair of search.split("&")) {
if (pair === "") continue;
const [k, v = ""] = pair.split("=");
out[k] = v;
}
return out;
}
function render(h: MiniHistory): void {
const p = readParams(h.current.search);
const status = p.status ?? "all";
const page = p.page ?? "1";
console.log(" URL " + h.url);
console.log(" 界面渲染: 状态=" + status + " 第" + page + "页");
}
const history = new MiniHistory();
console.log("用户打开列表页");
history.push("/todos");
render(history);
console.log("点了筛选 active(push:进历史,可后退)");
history.push("/todos?status=active&page=1");
render(history);
console.log("翻到第 2 页");
history.push("/todos?status=active&page=2");
render(history);
console.log("按浏览器后退");
history.back();
render(history);
console.log("再后退");
history.back();
render(history);
console.log("前进");
history.forward();
render(history);
console.log("输入框实时搜索用 replace(不污染历史,避免后退要按十几次)");
history.replace("/todos?status=active&page=1&q=re");
render(history);
history.replace("/todos?status=active&page=1&q=react");
render(history);
console.log("后退一次就回到列表初始态,而不是逐字符回退:");
history.back();
render(history);这个 mini history 就是浏览器 history.pushState / replaceState 的行为模型:一个带游标的栈,push 会截断前进分支。React Router 在其上包了一层订阅机制,URL 变化时通知组件重渲染。
5. loader 与 action:数据跟着路由走
组件里 useEffect 拉数据有个固有顺序问题:必须先渲染组件,才能开始请求。父组件请求完渲染子组件、子组件再请求——瀑布就是这么来的(第 16 章的第四个缺陷)。
React Router 的 loader 把数据获取提前到导航阶段:
{
path: "users/:id",
element: <UserProfile />,
errorElement: <RouteError />, // loader 抛错时渲染它
loader: async ({ params, request }) => {
const res = await fetch(`/api/users/${params.id}`, { signal: request.signal });
if (!res.ok) throw new Response("Not Found", { status: 404 });
return res.json();
},
}function UserProfile() {
const user = useLoaderData() as User; // 数据已经在手,没有 loading 分支
return <h1>{user.name}</h1>;
}三个连带好处,都源自"URL 一确定,需要哪些数据就确定了":
- 并行:一条 URL 上多层路由的 loader 同时发起,不再层层等待;
- 无闪烁:数据没到之前停在旧页面(可用
useNavigation()显示顶部进度条),而不是先白屏再填充; - 可预取:鼠标悬停在
Link上时就能提前跑 loader。
写操作对应 action,配合 <Form method="post"> 使用,提交后自动重新运行当前路由的 loader(相当于自动 invalidate)——这套设计明显借鉴了传统服务端渲染的表单模型,也和第 16 章 mutation 后失效缓存是同一个思想。
loader 解决"何时开始请求",Query 解决"数据缓存与共享"。生产项目常见组合是在 loader 里调用 queryClient.ensureQueryData(...):导航时预取进 Query 缓存,组件里照常 useQuery 读——既消除瀑布,又保留缓存与后台刷新。
6. 路由级代码分割
第 13 章讲过 lazy + Suspense。路由是代码分割最自然的切点——用户没进后台管理页,就不该下载那几百 KB:
const Dashboard = lazy(() => import("./pages/Dashboard"));
{
path: "dashboard",
element: (
<Suspense fallback={<PageSkeleton />}>
<Dashboard />
</Suspense>
),
}配合 errorElement 处理 chunk 加载失败(网络差或发版后旧 chunk 404),是生产环境必备的兜底。
/users/42 是前端路由,服务器上并没有这个文件。直接刷新这个地址,Nginx 会返回 404——必须配置"所有未匹配请求都回退到 index.html"(try_files $uri /index.html)。这是 SPA 部署第一坑,本地开发服务器默认帮你做了,上线才暴露。
useParams() 返回的每一段都是 string:/users/42 拿到的是 "42" 不是 42。直接拿去比较(user.id === params.id)或做数组索引必踩坑,先 Number(params.id) 并校验 Number.isNaN。同理 searchParams.get() 也总是字符串或 null。
小结
- 路由把 URL 映射到组件链;三种段:静态、动态
:id、通配*;参数取值永远是字符串 - 匹配按"具体程度"排序而非书写顺序,静态段优先级最高,不会被动态段吞掉
- 嵌套路由用
<Outlet />;切换同层级路由时外层复用、状态保留,需要重置就加key - 导航用
Link/NavLink/useNavigate;登录重定向类跳转要replace: true - 筛选排序分页放
searchParams(可分享、可后退);高频输入用 replace 避免污染历史 loader把请求提前到导航阶段,消除瀑布与闪烁;action+ 自动重跑 loader 对应写操作- 路由是代码分割的天然切点;SPA 部署必须配置回退到 index.html
- 下一章讲并发特性:useTransition 与自动批处理 →
一个后台系统有:登录页(无外壳)、带侧边栏的首页、文章列表、文章详情(详情页内还有"评论"和"版本历史"两个 tab 子路由)。写出 createBrowserRouter 的嵌套配置,标出每层 <Outlet /> 的位置,并说明访问 /posts/9/comments 时组件链是什么。
给 Playground 1 的 compile 增加可选段支持:模式 /files/:name? 既能匹配 /files 也能匹配 /files/a.txt。再给 score 函数加上可选段的打分规则,说明它应该排在静态段和动态段的什么位置。
以下六个场景各该用 push 还是 replace,说明理由:点击导航菜单、搜索框输入联想、切换列表筛选、表单提交成功后跳详情、未登录被重定向到登录页、分页翻页。
现有代码:Layout 里 useEffect 拉取当前用户,UserProfile 里 useEffect 拉取用户详情,PostList 里再拉他的文章。画出三个请求的时间线,然后改写成 loader 方案,说明新时间线快在哪、快多少个 RTT。