知

模块三 · 路由、测试与服务端

第 11 / 18 节19 分钟阅读型

路由:嵌套、鉴权与按页懒加载

路由是应用的骨架。这一节讲清路由表配置、嵌套布局与 Outlet、两种鉴权写法、懒加载与 errorElement,以及 URL 该怎么设计。

读完这一节,你能做到

  1. 用 createBrowserRouter 配出带布局与 404 的路由表。
  2. 用嵌套路由 + Outlet 组织后台布局。
  3. 写出"未登录"与"无权限"分开处理的鉴权。
  4. 按页面做懒加载,并用 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 里要能拿到用户信息(通常配合缓存)。

权限的三层结构:

  1. 路由级:meta.roles 式的声明,守卫查表,不做业务判断。
  2. 菜单级:由同一份路由表过滤生成,避免"菜单能点但页面 403"。
  3. 按钮级:权限码控制显隐;隐藏不等于安全,后端必须再校验一次。

未登录与无权限要跳不同页面。都跳登录页,用户会以为账号被登出。

六、懒加载:按页面切 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 的语义是"有缓存就返回,没有就请求",这样路由切换与缓存共用一套数据,不会重复请求。这也是把请求库与路由结合的价值:导航前取数 + 组件内命中缓存。

九、常见坑

  1. history 模式部署没配 fallback,刷新 404。
  2. path: "*" 写在最前面,吞掉所有路由。
  3. 把 index: true 写成 path: "/",导致嵌套路由匹配异常。
  4. 鉴权只做前端,接口没有校验。
  5. 未登录与无权限都跳登录页,用户不知道发生了什么。
  6. 懒加载模块用默认导出,但 data router 要求具名 Component。
  7. 切换路由不重置滚动,用户停在上一个页面的底部。
  8. 布局路由里发请求,子路由切换时反复触发。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 配一个后台路由:登录页、订单列表、订单详情、404,列表页带 query 筛选。
  2. 用 Outlet 做侧边栏布局,验证切换子路由时侧边栏不重新挂载。
  3. 给订单路由加鉴权,要求未登录跳登录、无权限跳 403,并保留原地址回跳。
  4. 把三个页面改成懒加载,用 Network 面板确认 chunk 是按需加载的。

② 让 AI 写同一题

把第 3 题(鉴权)交给 AI,补一句 未登录跳登录页,登录后要回到原地址,刷新也要能回。

③ 对比要盯的点

  • 鉴权它放在组件里还是 loader 里?放在组件里会先渲染再跳,闪一下才走。
  • 回跳地址它靠 state 还是 query 参数?靠 state 的话刷新就丢了。
  • 它把无权限和 404 混成同一个页了吗?这两种对用户是两回事。

④ 一句话取舍

它默认选了哪种鉴权?你这个场景该选哪种,代价是什么?

十一、自测

  1. 三种路由模式的部署差异是什么?
  2. index 路由与 path: "/" 有什么区别?
  3. 组件包装鉴权与 loader 鉴权各自的优劣是什么?
  4. history 模式刷新 404 的根因是什么?
  5. 为什么菜单要从路由表生成?

参考答案要点:是否需要服务端 fallback;前者是父路径默认子路由;前者会先渲染再跳,后者导航前判断;服务器把路径当文件;避免两套权限配置不一致。

附:路由表组织与权限码

路由表超过二十条后,继续写在一个文件里会变成灾难。推荐按“布局 + 领域”拆文件,再合并:

router/
├─ index.tsx        # createBrowserRouter,只做拼装
├─ guards.tsx       # RequireAuth、RequireRole
├─ routes/
│  ├─ public.tsx    # 登录、注册、404
│  ├─ order.tsx     # 订单相关
│  └─ system.tsx    # 用户、角色、配置
└─ paths.ts         # 路径常量

三条收益:

  1. 路径常量化:paths.orderDetail(id) 比手写字符串安全,改路径只改一处。
  2. 权限就近声明:每个领域文件里写清 handle.roles,不用翻全局表。
  3. 按需加载:领域文件本身可以懒加载,后台系统的路由表也能分包。

权限码的设计要分两层:角色(role)决定“能不能进这个页面”,权限码(permission)决定“能不能点这个按钮”。两者混用会出现“运营能进订单页,但看不到导出按钮”这类需求无法表达的问题。

const ORDER_EXPORT = "order:export";

function ExportButton() {
  const can = usePermission();
  if (!can(ORDER_EXPORT)) return null;
  return <button>导出</button>;
}

必须强调:前端权限只是体验层,后端必须对每个接口再校验一次。前端隐藏按钮能防误操作,防不了构造请求。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解