REACT · Vol.V · LESSON 30 · 路由与数据获取
React Router 与嵌套路由(对比 @RequestMapping)
为什么 URL 变了,页面却不刷新
卷四到第 29 篇正式收官:目录结构定了、代码规范立了,一个干净的 React 工程骨架已经搭好。卷五「路由与数据获取」开篇,先解决单页应用(SPA)绕不开的问题——用户怎么在「页面之间」跳转。
先回忆你在 Spring MVC 里写过的老三样。用户访问 /user/list,发生了什么:
@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 组件。你不再是「每条路径一次页面请求」,而是「每条路径一个组件」。
路由表:createBrowserRouter 与 RouterProvider
React Router 6.4 之后的推荐写法是先声明一张路由表,再交给 RouterProvider 挂载——先集中注册,再启动分发,是不是很有 DispatcherServlet 装配 HandlerMapping 的味道:
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 />:
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
],
}
这套结构你在后端全见过对应零件:类级 @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 照变,页面不刷新。
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":
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 模式斩断「刷新重复提交」链路是同一个心思。
<Link> 像服务器内部 forward——同一份运行上下文里换视图,用户无感;navigate("/x") 像 redirect——发起一次新导航,历史栈可追溯;navigate("...", { replace: true }) 像 redirect 后清掉请求域——历史里不留痕。经验法则:用户能点的东西用 Link,逻辑驱动的跳转用 navigate。
一张表把两套路由焊在一起
学到这,把 Spring MVC 和 React Router 的概念摆在一起对一遍,后面几篇会反复用到这张表:
| 你在 Spring MVC 里的 | React Router 里的 | 一句话对应 |
|---|---|---|
| DispatcherServlet + HandlerMapping | RouterProvider + 路由表 | 集中注册,按 URL 分发 |
类级 + 方法级 @RequestMapping | 父路由 path + children | 路径分层自动拼接 |
| Thymeleaf layout decorator | 布局组件 + <Outlet /> | 公共壳子只写一次 |
| HandlerInterceptor(登录校验) | loader / 守卫(第 35 篇) | 进页面之前先拦一道 |
@PathVariable / @RequestParam | useParams / useSearchParams | 下一篇的主角 |
redirect:/xxx | navigate("/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 · 评论