客户端路由不是“点击链接后切换组件”,而是一套让 URL、浏览器历史、匹配结果、页面数据和错误边界保持一致的导航协议。设计正确时,刷新、前进后退、复制链接和直接访问都应指向同一个可恢复页面。
一、为什么客户端路由不只是条件渲染
一个订单后台可能包含以下地址:
/orders?status=pending&page=2
/orders/ord_1024
/orders/ord_1024/edit
/settings/members如果只根据组件内的 currentPage 切换界面,会立即失去浏览器已经提供的能力:
- 地址栏不能表达当前页面;
- 刷新后回到默认视图;
- 链接无法复制、收藏和分享;
- 浏览器前进、后退与应用状态脱节;
- 页面层级、数据加载和错误恢复没有统一边界;
- 埋点、访问控制和代码分割只能散落在组件中。
客户端路由的核心工作,是把一个 Location 映射为一组匹配的 Route Branch,再由这条分支共同决定布局、数据、提交行为和错误 UI。
图中的关键不是某个组件,而是整条匹配分支。父路由可提供外壳和父级数据,子路由提供具体页面;任一阶段失败时,错误由分支上最近的路由错误边界接管。
本文以浏览器 History API 和现代 React Router 的 Data Router 公开 API 为例。React Router 的包入口、类型名称和部分默认行为会随主版本演进,项目应以锁定版本的官方文档为准。本文讨论的是稳定的路由模型,不把某个版本的内部排序算法或缓存实现当成业务契约。
核心结论
- URL 是可分享、可恢复的页面状态,不只是渲染后的附属文本。
pushState、replaceState只修改当前文档的历史记录,不会自动匹配路由、请求数据或触发 React Render。popstate主要对应历史遍历;调用pushState或replaceState本身不会触发它。- 路由匹配得到的是一条父子分支,不只是一个叶子组件。
- 静态路径、动态参数和通配符的含义不同;参数匹配成功不代表业务参数有效。
- Nested Route 描述 URL 与 UI 层级,Layout Route 可只提供布局而不增加 URL Segment。
- Path Params 标识资源,Search Params 表达筛选、排序、分页等可选视图状态,但两者都属于不可信外部输入。
- Navigation State 至少要区分 History Entry State、当前 Location 和路由器的 Pending Navigation,不能都叫
state。 - Loader 负责导航读取,Action 负责路由写入;写入成功后通常还需要重新验证受影响的读取。
- 路由错误边界应靠近可恢复区域,并区分 404、权限错误、业务失败和未知异常。
二、底座:History API 与 Location
浏览器当前地址由 window.location 暴露,历史栈由 window.history 管理。单页应用通常使用:
history.pushState(state, '', url):增加一条历史记录;history.replaceState(state, '', url):替换当前历史记录;history.back()、forward()、go(delta):遍历历史记录;popstate:监听当前活动历史记录发生变化。
2.1 push 与 replace 是产品语义
假设用户从订单列表进入详情:
/orders -> /orders/ord_1024这通常应使用 Push,因为用户预期“后退”回到列表。登录后把 /login 替换为 /dashboard 则更适合 Replace,否则后退会再次进入已经失效的登录页。
history.pushState({ source: 'orders' }, '', '/orders/ord_1024');
history.replaceState(null, '', '/dashboard');错误地把所有导航都设为 Replace,会破坏历史轨迹;把临时规范化跳转全部 Push,则可能让用户在两个地址之间反复后退。
2.2 pushState 不等于完成导航
以下代码只更新地址和历史栈:
history.pushState(null, '', '/orders/ord_1024');浏览器不会因此自动:
- 派发
popstate; - 解析 React Route;
- 执行 Loader;
- 更新页面标题;
- 滚动到顶部;
- 向服务器请求新 HTML。
路由库需要封装 History,主动发布 Location 变化,再执行匹配和渲染。不要一边让 React Router 管理 History,一边在业务代码直接调用 window.history.pushState,否则路由器可能无法及时感知状态变化。
2.3 History Entry State 不是持久存储
pushState 的第一个参数会成为当前 History Entry 的关联状态。它适合携带“从哪个列表进入”“关闭弹窗后回到哪里”一类导航上下文,但不适合作为关键业务事实:
- 用户复制 URL 到新标签页时,该状态通常不会随 URL 传递;
- 数据必须可被结构化克隆,且浏览器可能限制大小;
- 不能替代服务端、URL、Session Storage 或应用 Store;
- 不能存放 Token、密码等敏感数据。
如果页面刷新或直接访问时仍必须恢复某个值,应把它放入 URL、持久化存储或通过资源 ID 从服务端读取。
2.4 服务器仍必须认识客户端地址
客户端路由只能在 JavaScript 已经加载后工作。用户直接访问 /orders/ord_1024 时,请求首先到达 CDN 或服务器。纯 SPA 部署通常需要把未知的前端路由回退到 index.html,但不能把真正的静态资源和 API 404 也错误回退成 HTML。
这属于部署契约,不是 React Router 能在浏览器内补救的问题。SSR 或框架路由则由服务器先匹配同一地址并返回对应 HTML。
三、Route Matching:从 URL 找到匹配分支
路由配置可以看作一棵树:
const router = createBrowserRouter([
{
path: '/',
Component: AppLayout,
children: [
{ index: true, Component: HomePage },
{
path: 'orders',
Component: OrdersLayout,
children: [
{ index: true, Component: OrdersPage },
{ path: ':orderId', Component: OrderDetailPage },
{ path: ':orderId/edit', Component: EditOrderPage },
],
},
{ path: '*', Component: NotFoundPage },
],
},
]);访问 /orders/ord_1024/edit 时,匹配结果不是只有 EditOrderPage,而是类似:
AppLayout -> OrdersLayout -> EditOrderPage这条分支决定了哪些 Layout、Loader 和 Error Boundary 参与本次导航。
3.1 常见路径类型
| 类型 | 示例 | 语义 |
|---|---|---|
| 静态 Segment | /orders/new |
精确业务路径 |
| 动态 Segment | /orders/:orderId |
捕获一个路径参数 |
| 可选 Segment | /:lang?/docs |
Segment 可出现或省略,支持度看版本 |
| Splat / 通配符 | /files/* |
捕获剩余路径,通常用于兜底或文件层级 |
| Index Route | 父路由的 index: true |
父路径完全匹配时的默认子页面 |
不要依赖“配置顺序一定决定优先级”的直觉。现代路由器通常会对候选分支做确定性排名,让静态 Segment 比动态 Segment 更具体;但不同库的精确评分规则属于实现细节。配置中仍应避免含义重叠、难以解释的路径。
3.2 匹配成功不代表参数有效
/orders/:orderId 会匹配 /orders/anything。Route Matcher 只知道结构符合,不知道 anything 是否是合法订单 ID,更不知道订单是否存在。
function parseOrderId(value: string | undefined): string {
if (!value || !/^ord_[a-zA-Z0-9]+$/.test(value)) {
throw new Response('Invalid order id', { status: 400 });
}
return value;
}参数处理至少包含三层判断:
- Segment 是否存在;
- 格式和取值范围是否合法;
- 对应资源是否存在且当前用户有权访问。
前两项可在解析层完成,第三项通常需要 Loader 请求服务端。客户端隐藏页面不能替代服务端授权。
3.3 404 有两种来源
- 没有 Route Branch 匹配:进入全局
*Route; - Route 匹配成功,但资源不存在:Loader 返回或抛出 404 Response。
前者表示“应用没有这个页面”,后者表示“页面类型存在,但指定资源不存在”。两者可以显示相似 UI,但日志、返回按钮和监控维度不应完全相同。
四、Nested Route 与 Layout Route
4.1 Nested Route 同时表达 URL 和 UI 层级
父组件使用 <Outlet /> 渲染匹配的子路由:
function OrdersLayout() {
return (
<section>
<OrdersNavigation />
<main>
<Outlet />
</main>
</section>
);
}当用户从订单列表进入详情时,顶层应用壳和订单导航可以保留,只替换 Outlet 中的子页面。路由树因此也成为布局树、数据边界树和错误恢复树。
4.2 Layout Route 不一定增加 URL Segment
有些布局只用于分组,不应出现在 URL 中。例如多个设置页面共享侧边栏:
{
Component: SettingsLayout,
children: [
{ path: 'profile', Component: ProfileSettingsPage },
{ path: 'security', Component: SecuritySettingsPage },
],
}这种没有 path 的父 Route 可提供 Layout、Error Boundary 或上下文,但不消费 URL Segment。不要为了复用 UI 强行制造 /settings-layout/... 这类暴露实现细节的地址。
4.3 路由嵌套不等于任意组件嵌套
只有具备以下需求时,才值得增加一层 Route:
- 需要独立 URL 层级;
- 需要共享 Layout 或 Loader;
- 需要独立错误恢复边界;
- 需要独立代码分割和导航 Pending UI。
普通视觉容器仍应是普通 React Component。路由树过深会增加 Loader 协调、相对链接理解和错误边界设计成本。
五、Route Params 与 Search Params
5.1 Path Params 通常标识资源或层级
/organizations/:organizationId/orders/:orderIdorganizationId 和 orderId 决定当前页面的主要资源身份。切换它们通常意味着进入另一个页面实体,应产生明确的导航与数据重新加载。
function OrderPage() {
const { orderId } = useParams();
// useParams 返回的值仍可能是 undefined,也未经业务校验。
}TypeScript 类型声明不能验证地址栏中的运行时字符串。应在 Loader 边界解析后,再把经过验证的 ID 交给领域层。
5.2 Search Params 适合可选视图状态
/orders?status=pending&sort=createdAt&page=2筛选、排序、分页、Tab 和搜索词通常适合放入 Query String,因为用户刷新、分享或后退时希望恢复同一视图。
type OrderListQuery = {
status: 'all' | 'pending' | 'paid';
page: number;
};
function parseOrderListQuery(url: URL): OrderListQuery {
const rawStatus = url.searchParams.get('status');
const rawPage = Number(url.searchParams.get('page') ?? '1');
return {
status:
rawStatus === 'pending' || rawStatus === 'paid' ? rawStatus : 'all',
page: Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1,
};
}解析策略必须显式选择:
- 缺失值使用默认值;
- 非法值回退、规范化跳转,或直接返回 400;
- 多值参数使用
getAll,不能假设只有一个; - 写回 URL 时规定稳定顺序和空值删除策略。
5.3 不要原地修改共享的 URLSearchParams
某些 Router Hook 返回的 Search Params 对象可被读取,但直接 set 后如果没有调用 Router Setter,并不会形成正式导航;原地修改还会让引用和值的变化难以推理。
const [searchParams, setSearchParams] = useSearchParams();
function changePage(page: number) {
const next = new URLSearchParams(searchParams);
next.set('page', String(page));
setSearchParams(next);
}高频输入如搜索框不应每个按键都无条件 Push 新历史记录。可以先保留本地 Draft,Debounce 后用 Replace 写入 URL;用户确认搜索时再根据产品语义决定是否 Push。
六、Navigation State:先区分三个概念
“导航状态”经常同时指三种不同事物:
| 状态 | 示例 | 生命周期 |
|---|---|---|
| Current Location | pathname、search、hash |
当前地址 |
| History Entry State | location.state、返回来源 |
当前历史条目,不能通过纯 URL 分享 |
| Pending Navigation | idle、loading、submitting |
一次导航或提交过程 |
6.1 声明式链接优先
用户可见的普通页面跳转应优先使用 <Link> 或 <NavLink>,而不是给 <div> 绑定点击事件:
<Link to={`/orders/${order.id}`}>查看订单</Link>链接保留了新标签页打开、复制地址、键盘访问和浏览器状态栏等原生能力。navigate() 更适合命令完成后的跳转、超时跳转或无法自然表示为链接的流程控制。
6.2 Pending UI 不能遮蔽旧页面语义
一次导航可能经历:
全局进度条可以反映整个导航;局部骨架屏应放在真正变化的 Outlet 区域。不要在每次后台重新验证时清空旧页面,否则会造成闪烁和上下文丢失。
6.3 导航可被替代和取消
用户快速从订单 A 切到 B 时,A 的 Loader 可能仍在进行。现代 Data Router 通常会通过 request.signal 传递取消信号。Fetcher 必须继续把信号交给底层请求:
async function orderLoader({ params, request }: LoaderFunctionArgs) {
const orderId = parseOrderId(params.orderId);
const response = await fetch(`/api/orders/${orderId}`, {
signal: request.signal,
});
if (response.status === 404) {
throw new Response('Order not found', { status: 404 });
}
if (!response.ok) {
throw new Response('Failed to load order', { status: response.status });
}
return parseOrder(await response.json());
}Abort 只能取消支持 AbortSignal 的工作,且服务端可能已经收到请求。写操作不能仅依赖客户端取消保证一致性,仍需幂等键、事务或版本检查。
七、Loader:让数据依赖属于 Route
组件内 useEffect 请求通常在组件首次 Commit 后才开始,父子组件各自请求还可能形成 Waterfall。Loader 把“进入该 Route 前需要什么数据”提升到路由匹配阶段。
const router = createBrowserRouter([
{
path: '/orders/:orderId',
loader: orderLoader,
Component: OrderPage,
ErrorBoundary: OrderRouteErrorBoundary,
},
]);
function OrderPage() {
const order = useLoaderData() as Order;
return <OrderDetail order={order} />;
}7.1 Loader 的职责边界
Loader 适合:
- 读取和校验 Route Params、Search Params;
- 请求页面进入时必需的数据;
- 把 HTTP 404、401、403 等转换为明确 Route Error;
- 执行重定向;
- 将 AbortSignal 传到请求层;
- 返回组件渲染所需的稳定数据结构。
Loader 不应:
- 操作 DOM 或依赖组件已挂载;
- 把所有服务端数据永久复制到本地 State;
- 静默吞掉错误并伪造空数据;
- 仅靠客户端检查执行最终授权;
- 无边界地重复实现 Query Cache 已经负责的缓存策略。
路由 Loader 与 TanStack Query 等缓存库可以协作:Loader 负责导航时机和路由错误语义,Query Client 负责资源缓存、共享与失效。但必须明确谁发请求、谁拥有 Freshness,避免 Loader 和组件对同一 Key 重复 Fetch。
7.2 父子 Loader 与并行性
匹配分支上的多个 Loader 通常可以并发启动,因此数据依赖应尽量按资源拆分,不要人为制造父请求完成后子请求才开始的 Waterfall。如果子数据确实依赖父 Loader 的结果,应重新考虑 API 是否能从 URL Params 独立请求,或使用框架当前版本提供的上下文和中间件能力。
“通常并发”不是所有路由库和所有模式的永久承诺。SSR、Lazy Route Discovery、Middleware 和自定义数据层可能改变时序,关键流程应按项目版本集成测试。
八、Action:让写操作进入路由数据流
Action 处理与 Route 关联的写操作,例如创建、编辑和删除。它通常接收 Request,读取 FormData,执行校验和 Mutation,再返回结果或 Redirect。
async function updateOrderAction({ params, request }: ActionFunctionArgs) {
const orderId = parseOrderId(params.orderId);
const formData = await request.formData();
const result = parseOrderUpdate(formData);
if (!result.success) {
return {
ok: false as const,
fieldErrors: result.fieldErrors,
};
}
const response = await fetch(`/api/orders/${orderId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result.command),
signal: request.signal,
});
if (response.status === 409) {
return { ok: false as const, formError: '订单已被其他人修改' };
}
if (!response.ok) {
throw new Response('Failed to update order', {
status: response.status,
});
}
return redirect(`/orders/${orderId}`);
}8.1 预期业务错误与异常错误分流
以下结果通常应作为可渲染 Action Data 返回:
- 字段校验失败;
- 乐观锁冲突;
- 优惠码不可用;
- 用户可修复的业务约束。
以下情况更适合进入 Route Error Boundary:
- 未处理异常;
- 服务不可用且当前区域无法继续;
- 响应结构损坏;
- 不应由当前表单自行恢复的权限或路由错误。
不要把所有失败都 Throw 到整页 Error,也不要把未知 500 伪装成普通字段错误。
8.2 Action 后为什么需要 Revalidation
写入成功后,当前页面的 Loader Data 可能已经过期。Data Router 通常会重新验证相关 Loader,使页面回到服务端确认状态:
重新验证保证“写后读”回到权威快照,但也有网络成本。可以通过路由器提供的 Revalidation Policy 缩小范围,不过优化前必须确认不会留下陈旧权限、计数或关联列表。
Action 不是服务端事务本身。在纯 SPA 中它仍运行于浏览器,真正的认证、授权、校验、幂等和事务必须由 API 服务端完成。
九、Route Error Boundary:按路由层级恢复
路由错误边界可处理 Loader、Action 和 Route Component 渲染阶段的错误,并由匹配分支上最近的边界接管。
function OrderRouteErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return <OrderNotFound />;
}
if (error.status === 403) {
return <PermissionDenied />;
}
}
reportUnexpectedRouteError(error);
return <UnexpectedError retryHref={window.location.href} />;
}9.1 边界应放在哪里
- App Root:处理无法局部恢复的未知错误;
- Orders Layout:保留全局导航,只替换订单工作区;
- Order Detail:单个订单失败时保留订单列表或侧栏;
- 独立 Widget:如果它不是 Route,仍可能需要普通 React Error Boundary。
路由错误边界和普通 React Error Boundary 有交集,但不完全相同。普通边界主要捕获后代渲染错误;Data Router 的 Route Boundary 还参与 Loader、Action 和 Response Error 协议。事件处理器和任意异步回调中的错误不会自动被普通 React Boundary 捕获,必须在对应流程显式处理。
9.2 不要在错误 UI 中泄露细节
生产环境不要直接渲染 Stack Trace、SQL 错误、内部 URL 或原始 Response Body。用户看到可操作信息,监控系统记录经过脱敏的 Route ID、Status、Trace ID 和异常上下文。
错误页还应提供符合场景的恢复方式:重试当前 Loader、返回列表、重新登录或联系支持,而不是所有错误都只显示“刷新页面”。
十、完整工程示例:订单详情路由
下面把匹配、Loader、Action 和 Error Boundary 放在同一条路由中:
const router = createBrowserRouter([
{
path: '/',
Component: AppLayout,
ErrorBoundary: RootRouteErrorBoundary,
children: [
{
path: 'orders',
Component: OrdersLayout,
children: [
{
index: true,
loader: ordersLoader,
Component: OrdersPage,
},
{
path: ':orderId',
id: 'order-detail',
loader: orderLoader,
action: updateOrderAction,
Component: OrderPage,
ErrorBoundary: OrderRouteErrorBoundary,
},
],
},
{ path: '*', Component: NotFoundPage },
],
},
]);
createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />,
);一次访问 /orders/ord_1024?panel=history 的执行路径为:
- Router 从 History 读取 Location;
- Matcher 得到
AppLayout -> OrdersLayout -> OrderPage; - Loader 校验
orderId,读取 Search Params,并携带 AbortSignal 请求数据; - Loader 成功后,React 渲染匹配分支并把详情放入 Outlet;
- 表单提交时,Action 解析输入并请求服务端;
- Action 成功后 Redirect 或触发 Loader Revalidation;
- Loader、Action 或 Render 失败时,由
OrderRouteErrorBoundary优先接管; - 用户后退时,History 激活旧 Entry,Router 重新匹配并恢复对应页面。
路由配置是应用级基础设施,最好集中表达路径和数据边界;页面内部的纯 UI 逻辑仍留在 Feature Module,避免把所有业务都堆进一个 Routes 文件。
十一、常见误区与错误案例
11.1 用普通按钮模拟所有链接
错误做法:
<button onClick={() => navigate(`/orders/${order.id}`)}>
查看订单
</button>如果动作本质是访问资源,应改为 <Link>。Button 留给“提交、删除、切换”等命令行为。
11.2 把 location.state 当作页面唯一数据源
navigate('/checkout/success', { state: { order } });直接刷新成功页或从外部打开 URL 时,order 可能不存在。应让 URL 携带 orderId,页面通过 Loader 获取可授权的订单结果;History State 只能作为非关键优化或返回上下文。
11.3 在组件 Effect 中重复 Route Loader 请求
Loader 已读取订单,组件又按相同 ID useEffect(fetchOrder),会产生重复请求、两个 Loading State 和竞争写入。应统一资源所有者,或让 Loader 调用 Query Client 的 ensureQueryData,组件订阅同一个 Cache Key。
11.4 捕获所有错误并返回空数组
try {
return await fetchOrders();
} catch {
return [];
}这会把“服务器故障”伪装成“没有订单”。正确做法是区分可接受的空结果与请求失败,让错误边界或局部 Error State 显示真实状态。
11.5 用前端 Route Guard 代替授权
客户端可以基于已有权限隐藏入口或重定向,但用户仍能构造 URL 和 API 请求。真正的资源授权必须发生在服务端,Loader 只负责把 401/403 映射为合适 UI。
11.6 给每次筛选变化都 Push History
滑块、输入框或分页器的每个细微变化都 Push,会让后退键需要执行很多次。应根据交互意图选择 Replace、Debounce 或提交时 Push。详细治理属于下一篇“路由工程”的范围。
十二、工程选型与边界
12.1 什么时候原生 History 足够
只有极小型页面、嵌入式 Widget,且不存在嵌套布局、参数解析、数据加载和错误边界时,才可能手写一个很薄的 History Adapter。完整业务应用通常不应自行实现 Matcher、相对链接、并发导航和无障碍语义。
12.2 Declarative Router 与 Data Router
- 只需组件级页面映射时,声明式 Route 模式更简单;
- 需要 Loader、Action、Pending Navigation、Redirect 和 Route Error Boundary 时,Data Router 更适合;
- 已使用 Next.js、Remix 或其他 React Framework 时,应优先采用框架路由,因为服务器渲染、构建和数据协议已经与它绑定。
不要在同一个 URL Scope 内叠加两个路由器。微前端确实可能需要路由分区,但必须明确 History 的唯一写入者、Basename、事件同步和销毁协议。
12.3 Browser URL 与 Hash URL
Browser History 路由地址自然,例如 /orders/1,但部署端需要正确回退。Hash Router 使用 /#/orders/1,Hash 不会作为 HTTP Request Target 的路径发送给服务器,适合无法配置服务器回退的静态环境,但 URL、美观性、服务端处理和部分平台集成会受限。
选型是部署约束,不是个人风格偏好。
十三、测试与验证方法
13.1 路由集成测试优先
使用 Memory Router 或 createMemoryRouter 构造真实 Entry,验证整条 Route Branch:
it('renders a route-level 404 for a missing order', async () => {
const router = createMemoryRouter(routes, {
initialEntries: ['/orders/ord_missing'],
});
render(<RouterProvider router={router} />);
expect(
await screen.findByRole('heading', { name: '订单不存在' }),
).toBeInTheDocument();
});测试应覆盖:
- 静态、动态、Index、Splat Route 是否命中预期分支;
- Path Params 和 Search Params 的缺失、非法、多值与编码输入;
- Push、Replace、Back、Forward 后的页面与 URL;
- Loader Success、404、403、500 和 Response Schema Error;
- 快速连续导航时旧请求是否收到 Abort;
- Action 字段错误、成功 Redirect 和 Revalidation;
- 最近 Error Boundary 是否接管,同时保留上层 Layout;
- 直接访问深层 URL 时部署环境是否返回应用 HTML 或 SSR 页面。
13.2 不要只 Mock Router Hook
把 useParams、useNavigate 全部 Mock 掉,只能证明组件对几个伪造返回值作出反应,无法验证 Route Config、相对路径、History 和 Loader 协作。纯展示组件可单测,路由行为应通过真实 Memory Router 集成测试。
13.3 观察导航性能
路由性能应测完整用户路径,而不是只测 Matcher 函数:
- Navigation Start 到可交互内容出现的时间;
- Loader 网络瀑布和重复请求;
- Route Chunk 下载、解析与执行时间;
- 导航期间的 Long Task 和 INP;
- 返回访问是否复用合理缓存;
- Abort 后请求和解析工作是否真正停止。
在目标网络、CPU Throttling 和接近真实数据量下测量,并比较 P50/P95,而不是依据开发机一次点击下结论。
十四、总结
客户端路由的最小模型是:History 产生 Location,Matcher 找到 Route Branch,分支协调 Layout、Loader、Action、Navigation State 和 Error Boundary,React 最终提交对应 UI。
真正需要记住的是:
- URL 必须足以恢复页面的关键身份和可分享状态;
- Push 与 Replace 决定用户能否获得合理的前进后退体验;
- Params 是外部输入,匹配、格式、存在性和授权是不同检查;
- Nested Route 是 UI、数据和错误边界的共同层级;
- Loader 让数据请求提前进入导航阶段,Action 让写操作进入同一数据流;
- 取消只能减少无用客户端工作,不能替代服务端一致性;
- 路由错误边界应尽可能局部恢复,同时保留必要上下文;
- 部署回退、服务端授权、资源缓存和表单状态仍有各自的工程边界。
一个可靠的路由系统,不是让组件“看起来切换成功”,而是让直接访问、分享、刷新、提交、失败和前进后退都得到一致结果。
问答复盘
Q1:调用 history.pushState 后,为什么 React 页面不会自动更新?
答: pushState 只增加 History Entry 并修改地址,不会触发 popstate,也不知道 React Route。路由器必须封装这次写入、发布新的 Location,再完成匹配、加载和渲染。
Q2:Nested Route 与 Layout Route 有什么区别?
答: Nested Route 是父子匹配关系,可同时形成 URL 和 UI 层级;Layout Route 通常是没有 path 的父 Route,只提供布局、数据或错误边界,不增加 URL Segment。
Q3:/orders/:orderId 已经匹配成功,是否可以直接请求订单?
答: 不能直接信任。匹配只证明路径结构符合;仍需检查参数是否存在、格式是否合法、资源是否存在,以及服务端是否授权当前用户读取。
Q4:筛选条件应该放 Path Params 还是 Search Params?
答: 决定主要资源身份和层级的值通常放 Path Params;可选的筛选、排序、分页和视图模式通常放 Search Params。最终标准是该值是否应参与页面身份、分享和恢复。
Q5:location.state 能否用来传递结算成功后的完整订单?
答: 可以作为临时导航上下文,但不能作为唯一数据源。刷新、复制链接或新标签页打开时它不可靠;URL 应保存订单 ID,再由 Loader 读取经过授权的服务端结果。
Q6:Loader 与组件里的 Query Hook 是否一定冲突?
答: 不一定。Loader 可负责导航时机,Query Cache 负责资源缓存;关键是共享同一资源 Key 和请求所有者,例如 Loader 调用 ensureQueryData。如果双方独立 Fetch,就会产生重复请求和两套状态。
Q7:Action 被 Abort 后,是否可以确认服务端没有执行写入?
答: 不可以。AbortSignal 只能请求取消客户端和支持取消的传输工作,服务端可能已经接收并提交命令。关键写操作仍需幂等键、事务、版本检查和结果查询能力。
Q8:字段校验错误应该进入 Route Error Boundary 吗?
答: 通常不应该。用户可修复的字段错误应作为 Action Data 返回给表单;未知异常、页面无法继续的服务故障或路由级 404/403 更适合交给最近的 Route Error Boundary。
Q9:为什么不能把所有导航都设为 replace?
答: Replace 会覆盖当前 History Entry。若用于普通页面跳转,用户将无法后退到之前页面;它更适合规范化 URL、登录后替换失效页面等不应保留旧 Entry 的场景。
Q10:如何验证客户端路由在生产环境真的可用?
答: 除了 Memory Router 集成测试,还必须在实际部署环境直接访问深层 URL,验证服务器回退或 SSR、静态资源 404、刷新、前进后退、Loader 取消和错误边界。只从首页点击进入不能覆盖部署契约。
延伸知识
- 路由工程:Authentication Guard、Permission、Deep Link 与 Redirect Loop;
- 离页治理:Unsaved Changes、Blocker 与浏览器关闭提示;
- 导航体验:Scroll Restoration、Route-level Code Splitting 与 Prefetch;
- 并发导航:Navigation Race、AbortSignal 与写操作幂等;
- URL State:Search Params Schema、Canonical URL 与缓存 Key;
- 服务端渲染:Hydration、Streaming、Server Router 与 HTTP Status。