模块三 · 路由、测试与服务端
路由:嵌套、鉴权与按页懒加载
路由是应用的骨架。这一节讲清路由表配置、嵌套布局与 Outlet、两种鉴权写法、懒加载与 errorElement,以及 URL 该怎么设计。
读完这一节,你能做到
- 用
createBrowserRouter配出带布局与 404 的路由表。 - 用嵌套路由 +
Outlet组织后台布局。 - 写出"未登录"与"无权限"分开处理的鉴权。
- 按页面做懒加载,并用
errorElement接住路由级错误。
一、先选路由模式
| 模式 | URL 形态 | 部署要求 | 适用 |
|---|---|---|---|
createBrowserRouter | /orders/1 | 服务器需 fallback 到 index.html | 绝大多数 Web 应用 |
createHashRouter | /#/orders/1 | 无 | 静态托管、无法改服务器 |
createMemoryRouter | 不写 URL | 无 | 测试、非浏览器环境 |
history 模式刷新 404 是最常见的问题,根因不是路由,而是服务器把 /orders/1 当成文件去找。部署时必须配 try_files $uri /index.html。
二、路由表:从扁平到嵌套
import {
createBrowserRouter,
RouterProvider,
Navigate,
} from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <AppLayout />, // 布局路由:只渲染外壳
errorElement: <RouteError />, // 该分支下的错误边界
children: [
{ index: true, element: <Navigate to="/orders" replace /> },
{ path: "orders", element: <OrderList /> },
{ path: "orders/:id", element: <OrderDetail /> },
{ path: "login", element: <Login /> },
{ path: "*", element: <NotFound /> },
],
},
]);
function AppLayout() {
return (
<div className="app">
<Sidebar />
<main>
<Outlet /> {/* 子路由渲染在这里 */}
</main>
</div>
);
}
四个关键点:
index: true是"父路径的默认子路由",不要写成path: "/"。path: "*"放最后做 404,否则会吞掉其他路由。errorElement是路由级错误边界,能接住 loader、渲染与懒加载失败。- 布局路由只负责外壳,别把业务数据塞进去,否则切换子路由时会重新取数。
三、参数:params 与 searchParams
// /orders/:id
const { id } = useParams();
// /orders?status=open&page=2
const [searchParams, setSearchParams] = useSearchParams();
const status = searchParams.get("status") ?? "all";
const page = Number(searchParams.get("page") ?? 1);
setSearchParams({ status: "paid", page: "1" }); // 新增历史记录
setSearchParams(next, { replace: true }); // 替换当前记录
URL 设计原则:
| 数据 | 放哪 | 原因 |
|---|---|---|
| 资源标识 | params(/orders/:id) | 一个资源一个地址 |
| 筛选/分页/排序 | query | 可分享、可回退、可刷新还原 |
| 弹窗/临时态 | 组件 state | 不需要被分享 |
把筛选条件做成路径段,会导致每换一次筛选就多一个"页面",面包屑与回退全乱。
四、导航与相对路径
const navigate = useNavigate();
navigate("/orders"); // 绝对
navigate("..", { relative: "path" }); // 相对当前路由层级
navigate(-1); // 回退
navigate("/login", { state: { from: location.pathname } });
三条实践:
- 编程式导航只用于"动作完成后"(登录成功、保存成功),用户点击用
<Link>,保留中键新开标签的能力。 - 需要防止重复提交时用
replace: true,避免用户回退后重复触发。 - 跨页传"一次性提示"用
state,不要塞进 URL(刷新即丢是期望行为)。
五、鉴权:两种写法
写法一:组件包装
function RequireAuth({ children }: { children: ReactNode }) {
const user = useUser();
const location = useLocation();
if (!user) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
return children;
}
{ path: "orders", element: <RequireAuth><OrderList /></RequireAuth> }
优点:直观,能拿到组件上下文。缺点:权限判断发生在渲染期,页面会先渲染一次再跳转。
写法二:loader 重定向
{
path: "orders",
loader: async () => {
const user = await fetchUser();
if (!user) throw redirect("/login");
if (!user.roles.includes("ops")) throw redirect("/403");
return null;
},
element: <OrderList />,
}
优点:导航前完成判断,不会闪一下。缺点:依赖 data router,且 loader 里要能拿到用户信息(通常配合缓存)。
权限的三层结构:
- 路由级:
meta.roles式的声明,守卫查表,不做业务判断。 - 菜单级:由同一份路由表过滤生成,避免"菜单能点但页面 403"。
- 按钮级:权限码控制显隐;隐藏不等于安全,后端必须再校验一次。
未登录与无权限要跳不同页面。都跳登录页,用户会以为账号被登出。
六、懒加载:按页面切 chunk
const OrderList = lazy(() => import("@/pages/OrderList"));
{
path: "orders",
element: (
<Suspense fallback={<PageSkeleton />}>
<OrderList />
</Suspense>
),
}
更简洁的 data router 写法:
{ path: "orders", lazy: () => import("@/pages/OrderList") }
注意 lazy 属性要求模块导出 Component(或 element/loader 等约定字段),不是默认导出的组件。两种写法不要混。
三个优化点:
- 预加载:鼠标悬停菜单时
import()预热,点击时直接命中缓存。 - 避免过度拆分:把每个小组件都拆 chunk,请求数上升反而更慢。
- 骨架屏尺寸固定:避免加载完成后布局跳动(CLS)。
七、路由错误与滚动
function RouteError() {
const error = useRouteError() as any;
if (isRouteErrorResponse(error) && error.status === 404) return <NotFound />;
return <ErrorPage message={error?.message} />;
}
两个容易被忽略的体验点:
- 滚动恢复:切换路由默认不会回到顶部,需要在布局里监听
pathname变化并scrollTo(0, 0);浏览器回退时反而应该恢复位置,两者要分开处理。 - 路由级 loading:data router 的
loader期间可以用useNavigation()的state做顶部进度条,比白屏好。
八、与请求库配合
loader: ({ params }) =>
queryClient.ensureQueryData({
queryKey: ["order", params.id],
queryFn: () => fetchOrder(params.id!),
}),
ensureQueryData 的语义是"有缓存就返回,没有就请求",这样路由切换与缓存共用一套数据,不会重复请求。这也是把请求库与路由结合的价值:导航前取数 + 组件内命中缓存。
九、常见坑
- history 模式部署没配 fallback,刷新 404。
path: "*"写在最前面,吞掉所有路由。- 把
index: true写成path: "/",导致嵌套路由匹配异常。 - 鉴权只做前端,接口没有校验。
- 未登录与无权限都跳登录页,用户不知道发生了什么。
- 懒加载模块用默认导出,但 data router 要求具名
Component。 - 切换路由不重置滚动,用户停在上一个页面的底部。
- 布局路由里发请求,子路由切换时反复触发。
十、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 配一个后台路由:登录页、订单列表、订单详情、404,列表页带 query 筛选。
- 用
Outlet做侧边栏布局,验证切换子路由时侧边栏不重新挂载。 - 给订单路由加鉴权,要求未登录跳登录、无权限跳 403,并保留原地址回跳。
- 把三个页面改成懒加载,用 Network 面板确认 chunk 是按需加载的。
② 让 AI 写同一题
把第 3 题(鉴权)交给 AI,补一句 未登录跳登录页,登录后要回到原地址,刷新也要能回。
③ 对比要盯的点
- 鉴权它放在组件里还是
loader里?放在组件里会先渲染再跳,闪一下才走。 - 回跳地址它靠
state还是 query 参数?靠state的话刷新就丢了。 - 它把无权限和 404 混成同一个页了吗?这两种对用户是两回事。
④ 一句话取舍
它默认选了哪种鉴权?你这个场景该选哪种,代价是什么?
十一、自测
- 三种路由模式的部署差异是什么?
index路由与path: "/"有什么区别?- 组件包装鉴权与 loader 鉴权各自的优劣是什么?
- history 模式刷新 404 的根因是什么?
- 为什么菜单要从路由表生成?
参考答案要点:是否需要服务端 fallback;前者是父路径默认子路由;前者会先渲染再跳,后者导航前判断;服务器把路径当文件;避免两套权限配置不一致。
附:路由表组织与权限码
路由表超过二十条后,继续写在一个文件里会变成灾难。推荐按“布局 + 领域”拆文件,再合并:
router/
├─ index.tsx # createBrowserRouter,只做拼装
├─ guards.tsx # RequireAuth、RequireRole
├─ routes/
│ ├─ public.tsx # 登录、注册、404
│ ├─ order.tsx # 订单相关
│ └─ system.tsx # 用户、角色、配置
└─ paths.ts # 路径常量
三条收益:
- 路径常量化:
paths.orderDetail(id)比手写字符串安全,改路径只改一处。 - 权限就近声明:每个领域文件里写清
handle.roles,不用翻全局表。 - 按需加载:领域文件本身可以懒加载,后台系统的路由表也能分包。
权限码的设计要分两层:角色(role)决定“能不能进这个页面”,权限码(permission)决定“能不能点这个按钮”。两者混用会出现“运营能进订单页,但看不到导出按钮”这类需求无法表达的问题。
const ORDER_EXPORT = "order:export";
function ExportButton() {
const can = usePermission();
if (!can(ORDER_EXPORT)) return null;
return <button>导出</button>;
}
必须强调:前端权限只是体验层,后端必须对每个接口再校验一次。前端隐藏按钮能防误操作,防不了构造请求。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解