首页 / React 学习笔记 / 30

REACT · Vol.V · LESSON 30 · 路由与数据获取

React Router 与嵌套路由(对比 @RequestMapping)

实战核心#路由#Router

为什么 URL 变了,页面却不刷新

卷四到第 29 篇正式收官:目录结构定了、代码规范立了,一个干净的 React 工程骨架已经搭好。卷五「路由与数据获取」开篇,先解决单页应用(SPA)绕不开的问题——用户怎么在「页面之间」跳转

先回忆你在 Spring MVC 里写过的老三样。用户访问 /user/list,发生了什么:

UserController.java · 传统 MVC:一个路径一次整页请求
@Controller
public class UserController {

    @GetMapping("/user/list")
    public String list(Model model) {
        model.addAttribute("users", userService.findAll());
        return "user/list";     // 渲染一整页 Thymeleaf 模板
    }

    @GetMapping("/user/detail/{id}")
    public String detail(@PathVariable Long id, Model model) {
        model.addAttribute("user", userService.findById(id));
        return "user/detail";   // 又一整页,浏览器丢掉旧的重新画
    }
}

从列表跳详情,浏览器要干三件事:丢弃当前整个页面 → 发起新请求 → 从头渲染全新 HTML。顶栏、侧边栏这些根本没变的部分,也被推倒重来。用户看到的就是一次白屏闪烁。

SPA 的思路完全不同:浏览器只从服务器拿一次 index.html(外加 JS 包),之后路由这件事由前端 JavaScript 接管——URL 变了,JS 在内存里把对应的组件换上去,顶栏侧边栏原封不动。和后端的通信退化成纯粹的数据接口(JSON),服务器不再吐页面。

后端类比:路由从服务端搬进了浏览器

传统 MVC 里,DispatcherServlet 在服务器端把 URL 分发给 Controller;SPA 里,这张「URL → 处理器」的映射表整个搬进了浏览器,处理器从 Controller 方法换成了 React 组件。你不再是「每条路径一次页面请求」,而是「每条路径一个组件」。

有人会问:URL 干脆不变行不行?不行。刷新、收藏夹、分享链接、浏览器前进后退,全都依赖 URL。前端路由要保证的正是:URL 照常变化、照常能被收藏,但跳转过程不再整页刷新

路由表:createBrowserRouter 与 RouterProvider

React Router 6.4 之后的推荐写法是先声明一张路由表,再交给 RouterProvider 挂载——先集中注册,再启动分发,是不是很有 DispatcherServlet 装配 HandlerMapping 的味道:

main.tsx · 路由表:URL 到组件的映射
import { createRoot } from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import App from "./App";
import Home from "./pages/Home";
import UserList from "./pages/UserList";
import UserDetail from "./pages/UserDetail";
import NotFound from "./pages/NotFound";

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,                              // 根布局:顶栏 + 页脚
    children: [
      { index: true, element: <Home /> },          // 访问 / 时显示
      { path: "users", element: <UserList /> },    // 访问 /users
      { path: "users/:id", element: <UserDetail /> }, // 动态段,第 31 篇展开
      { path: "*", element: <NotFound /> },        // 兜底 404
    ],
  },
]);

createRoot(document.getElementById("root")!).render(
  <RouterProvider router={router} />
);

(备注:v6 的包名是 react-router-dom,v7 统一为 react-router,改个 import 就能迁移,写法不变。)

这张表里的三条路径规则值得单独记住:

写法含义你在 Spring 里的对应
users/:id动态段:匹配 /users/42,参数名是 id@GetMapping("/users/{id}")
index: true索引路由:父路径本身被访问时的默认子页面访问 / 时命中的首页方法
path: "*"通配:谁都没匹配上时兜底自定义 ErrorController 处理 404

匹配顺序也和 Spring 一样:精确优先、通配兜底,所以 * 永远放最后。注意动态段以 : 开头——参数怎么取出来、怎么校验,下一篇第 31 篇专门讲。

核心要点
  • createBrowserRouter 声明路由表,RouterProvider 挂载分发——对应 DispatcherServlet 加 HandlerMapping 的分工。
  • 三条基本规则::param 动态段、index 默认子页、* 404 兜底;精确优先,通配殿后。
  • 每个路由的 element 是组件,不是一次页面请求;跳转全程由 JS 在浏览器里完成。

嵌套路由与 Outlet:布局只写一次

回看上一站的路由表,根路由有个 children 字段——这就是嵌套路由。它解决的,正是你在后端用「类级 @RequestMapping + 布局模板」解决过的老问题:公共部分只写一次

需求长这样:后台页面共享同一个侧边栏,右侧内容区随 URL 变。不用嵌套的话,每个页面组件都得手动包一层 <AdminLayout>——十个页面写十遍,谁忘了一处哪页就「秃」了。这和 Thymeleaf 没上 layout decorator 时、每个页面手动 include 头尾片段的惨状一模一样。

嵌套的写法:父路由的组件里挖一个「坑」,子路由的渲染结果填进坑里。挖坑的标签就是 <Outlet />

AdminLayout.tsx · 布局路由:侧边栏常驻,Outlet 是坑位
import { Outlet, NavLink } from "react-router-dom";

function AdminLayout() {
  return (
    <div className="admin">
      <aside>
        {/* NavLink 命中当前路径时会自动加 active 类,高亮菜单 */}
        <NavLink to="/admin/dashboard">仪表盘</NavLink>
        <NavLink to="/admin/users">用户管理</NavLink>
        <NavLink to="/admin/settings">系统设置</NavLink>
      </aside>
      <main>
        <Outlet />  // 子路由渲染在这里;切菜单时只有这里变
      </main>
    </div>
  );
}

// 路由表:父路径 + children,路径自动拼接
{
  path: "/admin",
  element: <AdminLayout />,   // 切子页面时它不会重建
  children: [
    { index: true, element: <Dashboard /> },   // /admin
    { path: "users", element: <UserManage /> },    // /admin/users
    { path: "settings", element: <Settings /> },   // /admin/settings
  ],
}
切换前 /admin/users 仪表盘 用户管理 系统设置 Outlet UserManage 点击菜单 URL 变了 切换后 /admin/settings 仪表盘 用户管理 系统设置 Outlet Settings 侧边栏(AdminLayout)常驻,换的只是 Outlet 区域的组件
图 1嵌套路由:URL 从 /admin/users 变到 /admin/settings,AdminLayout 不重建,只有 Outlet 换了组件

这套结构你在后端全见过对应零件:类级 @RequestMapping("/admin") 拼接方法级路径,对应父子 path 自动拼接;HandlerInterceptor 统一做登录校验,对应把拦截逻辑集中在 AdminLayout(或路由 loader)里——守卫的完整玩法第 35 篇展开;Thymeleaf 的 layout decorate,对应 <Outlet /> 这个坑位。区别只有一个:这一切发生在浏览器里,切换时连一个 HTML 请求都不发。

Link 与 useNavigate:导航的两种姿势

先说一个反直觉的坑:站内跳转别用 <a href><a> 触发的是浏览器默认行为——向服务器要一个新页面,整个 SPA 被推倒重建:JS 重新下载执行,内存里的 state 全部清零。等于你精心维护的内存应用,用户每点一次菜单就重启一次。React Router 的 <Link> 渲染出来也是 <a> 标签(收藏、中键新开标签页都正常),但它拦截了默认事件,改用 History API 改 URL,再由路由表换组件——URL 照变,页面不刷新

UserList.tsx · 声明式跳转用 Link
import { Link } from "react-router-dom";

function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>
          <Link to={`/users/${u.id}`}>{u.name}</Link>
        </li>
      ))}
    </ul>
  );
}

第二种姿势是编程式跳转:跳转由逻辑触发,而不是用户点链接——登录成功后进首页、校验失败踢回登录页。这时用 useNavigate,对应你在 Controller 里写的 "redirect:/xxx"

LoginForm.tsx · 逻辑驱动的跳转用 useNavigate
import { useNavigate } from "react-router-dom";

function LoginForm() {
  const navigate = useNavigate();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const ok = await login(formData);
    if (ok) {
      navigate("/dashboard", { replace: true });
      // replace:替换当前历史记录,用户按「后退」回不到登录页
    } else {
      navigate(-1);  // 负数是在历史里后退,等价于浏览器的返回
    }
  }
  // ……表单渲染省略
}

replace: true 值得单独说:默认跳转会往浏览器历史栈里压一条记录(可后退);replace 则是替换当前记录。登录成功这种「过程页」就该 replace——别让用户后退回登录页,和后端 PRG 模式斩断「刷新重复提交」链路是同一个心思。

后端类比:forward、redirect 和 navigate

<Link> 像服务器内部 forward——同一份运行上下文里换视图,用户无感;navigate("/x") 像 redirect——发起一次新导航,历史栈可追溯;navigate("...", { replace: true }) 像 redirect 后清掉请求域——历史里不留痕。经验法则:用户能点的东西用 Link,逻辑驱动的跳转用 navigate。

一张表把两套路由焊在一起

学到这,把 Spring MVC 和 React Router 的概念摆在一起对一遍,后面几篇会反复用到这张表:

你在 Spring MVC 里的React Router 里的一句话对应
DispatcherServlet + HandlerMappingRouterProvider + 路由表集中注册,按 URL 分发
类级 + 方法级 @RequestMapping父路由 path + children路径分层自动拼接
Thymeleaf layout decorator布局组件 + <Outlet />公共壳子只写一次
HandlerInterceptor(登录校验)loader / 守卫(第 35 篇)进页面之前先拦一道
@PathVariable / @RequestParamuseParams / useSearchParams下一篇的主角
redirect:/xxxnavigate("/xxx")逻辑驱动的跳转
404 兜底页path: "*"谁都没匹配上时接住
整页跳转(传统 MVC)<Link> 客户端跳转URL 变了,应用不重启
核心要点
  • SPA 的路由在浏览器端:createBrowserRouter 注册「URL → 组件」,RouterProvider 负责分发;服务器只发一次 HTML。
  • 嵌套路由 + <Outlet /> 让布局只写一次——相当于类级 @RequestMapping、layout 模板、拦截器三件套的合体。
  • 站内跳转用 <Link>(拦下整页刷新),逻辑跳转用 navigate(对应 redirect);「过程页」记得 replace: true

本章回顾

卷五开篇,你把后端最熟的「URL 分发」心智整体平移进了浏览器:路由表就是前端的 HandlerMapping,嵌套路由就是父 @RequestMapping 加布局模板,Outlet 就是模板里挖好的那个坑。但眼下的路由表只会匹配「死」路径——用户详情页总要面对 /users/42/users/10086 这种千变万化的 URL。参数怎么取出来、怎么校验、跳转时怎么夹带数据,下一篇第 31 篇「动态路由与参数解析」见。

Comments · 评论