REACT · Vol.V · LESSON 31 · 路由与数据获取
动态路由与参数解析
路径参数:useParams 就是前端的 @PathVariable
上一篇第 30 篇的路由表里出现了 users/:id,这个 :id 就是路径参数——同一条路由规则,配千变万化的 URL。你在后端早就天天用它:
@GetMapping("/users/{id}")
public String detail(@PathVariable Long id, Model model) {
// Spring 把 URL 里的 "42" 抽出来,还顺手转成了 Long
model.addAttribute("user", userService.findById(id));
return "user/detail";
}
前端的用法几乎逐字对应,只是取参数从方法签名换成了一个 Hook:
import { useParams } from "react-router-dom";
function UserDetail() {
// 路由表注册的是 "users/:id",这里用同名 key 取
const { id } = useParams<{ id: string }>();
return <h2>用户 #{id} 的详情</h2>;
}
两个必须刻在脑子里的差异:第一,useParams 拿到的永远是字符串——URL 里的 42 到手是 "42",没有 Long 自动转换,想算术运算得自己 Number();第二,嵌套路由各层的参数会合并进同一个对象——路由表里父级是 users/:userId、子级是 orders/:orderId,在子组件里 useParams() 一把全拿到。
@PathVariable Long id 一步到位:抽取 + 类型转换都是框架的事,转不动还直接给你抛异常。前端 useParams 只负责抽取,返回的全是 string——类型转换和校验这两步从框架手里还给了你,这一站先记账,最后一站还账。
查询参数:useSearchParams 就是 @RequestParam
什么时候用查询参数?判断标准和后端一致:不改变「资源本身」、只是视图状态的,放查询参数。还是 /users/42 这个资源,但看第几页、筛哪个状态、开哪个 tab——?tab=orders&page=2。这些状态放 URL 里还有个好处:用户刷新、收藏、分享链接,状态都还在,和后端接口的翻页参数一个道理。
import { useSearchParams } from "react-router-dom";
function UserOrders() {
const [searchParams, setSearchParams] = useSearchParams();
// 读:get 返回 string | null,默认值要自己兜(对照 defaultValue)
const page = Number(searchParams.get("page") ?? "1");
const status = searchParams.get("status") ?? "all";
function selectStatus(next: string) {
// 写:函数式更新,prev 是当前的查询参数,改完整体替换
setSearchParams(prev => {
prev.set("status", next);
prev.set("page", "1"); // 筛选变了,页码回 1
return prev;
});
// 这一次 setSearchParams 就是一次客户端导航(默认压入历史)
// 翻页不想要历史记录的话:setSearchParams(prev => ..., { replace: true })
}
return (
<select value={status} onChange={e => selectStatus(e.target.value)}>
<option value="all">全部</option>
<option value="paid">已支付</option>
<option value="refunded">已退款</option>
</select>
);
}
三个使用要点:
- 读:
searchParams.get("page")返回string | null,null 的话用??兜默认值——@RequestParam(defaultValue = "1")那行的活儿,前端要自己干。 - 写:
setSearchParams({...})对象写法是整体替换,会丢掉没写的参数;要保留其他参数就用上面的函数式写法,在prev上set。 - 写就是导航:
setSearchParams会触发重渲染并改 URL,组件不用再手动setState——「URL 即状态」,你把状态的变化全权托付给了地址栏。
你给列表接口写 @RequestParam page、size、status,从不把这些塞进 POST body——因为它们是「这一次查询」的描述,理应出现在 URL 里、能被书签复现。前端同理:页码、筛选、tab 放 useSearchParams(URL),而「选中了某一行」这种临时交互态放组件 state。判据和后端一模一样。
URL 之外的暗道:location.state
还有第三种传数据的方式:跳转时不写进 URL、只夹在这次导航里。后端也有这个机制——redirect attributes / flash attribute:重定向时捎带一次性的数据,用完即焚。useLocation 读到的 state 就是前端的这个暗道:
import { useNavigate, useLocation } from "react-router-dom";
// 守卫发现没登录:带着「用户本来想去哪」跳去登录页(第 35 篇展开)
navigate("/login", {
state: { from: location.pathname },
replace: true,
});
// 登录页:登录成功,从 state 里取出原目的地
function LoginPage() {
const { state } = useLocation();
const from = (state as { from?: string } | null)?.from ?? "/dashboard";
navigate(from, { replace: true });
}
它的三条性质,决定了什么时候该用它:
- 不出现在 URL 里:用户复制链接发给别人,
state带不过去——所以只放「这一次跳转」需要的 UI 态(来源标记、回跳地址),别放业务数据。 - 可以传对象:不用像查询参数那样全是字符串,对象直接塞,
useLocation().state原样取回。 - 存在浏览器历史条目里:刷新后 state 还在(浏览器替你存着),但这是「赠品」不是契约,业务上别依赖它必须存在——所以取值时老老实实判空兜底。
参数变了,组件却不「重启」
这一站是后端思维最容易翻车的地方。从 /users/1 点到 /users/2,直觉上「换页面了」;但对 React Router 来说,两条 URL 命中的是同一条路由规则 → 组件根本不重新挂载,只是带着新参数重渲染了一次。
类比一下:这不是「重新 new 一个 Controller」,而是同一个单例 Bean 的方法被再次调用——实例里的字段原样保留。映射到组件里:你的 useState 不会归零、useEffect 不会自动重跑。后果就是经典的「点了下一个用户,页面还显示上一个用户的数据」:
// 坑:没有依赖数组时,effect 只在挂载时跑一次
// 之后 id 从 1 变 2,effect 假装不知道——页面停在旧数据
useEffect(() => {
loadUser(id);
}, [id]); // 解法一:把 id 放进依赖数组,id 变了就重跑(第 10 篇)
// 解法二:有时你就是要「每个 id 一个全新实例」——
// 重置滚动条、重置表单、重放动画。key 一变,React 直接
// 丢弃旧实例、重新挂载(第 7 篇 key 的老规则,用在路由上)
<UserDetail key={id} />
key 这个技巧值得多说一句:与其在组件里写一堆「参数变了就重置」的 if-else,不如让 React 把旧实例整个扔掉、当它是个新组件——就像与其在一个长生命周期对象里维护 reset 逻辑,不如直接 new 一个新的。代价是全部 state 归零、子树全部重建,所以只在「确实需要全新起点」时用。
顺便补上「嵌套参数与相对链接」。当前 URL 是 /users/42(父路由 users/:id),页面里的链接可以写相对路径,React Router 按路由层级解析:
<Link to="orders">他的订单</Link> // → /users/42/orders,:id 自动拼上
<Link to="..">返回列表</Link> // → /users,相对当前路由层级
相对链接写的是「路由结构」而不是「URL 字符串」——父子路由的层级就是链接的坐标系。哪天你把 users/:id 改名成 members/:id,这些链接一个都不用动,和 REST 风格里「资源间用相对引用」是一个味道。
实战:用户详情页全流程
把五站的知识焊成一个真实页面:/users/:id?tab=orders——路径参数定资源、查询参数定视图,外加参数校验:
import { useParams, useSearchParams, Link, Navigate } from "react-router-dom";
function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const tab = searchParams.get("tab") ?? "profile";
// 校验:id 必须是正整数。Number("12abc") 是 NaN,直接拦下
const userId = Number(id);
if (id === undefined || !Number.isInteger(userId) || userId <= 0) {
return <Navigate to="/404" replace />; // 非法参数,重定向走人
}
return (
<div>
<nav>
// 只改查询参数、路径参数原样保留的相对写法
<Link to="?tab=profile">资料</Link>
<Link to="?tab=orders">订单</Link>
</nav>
{tab === "orders"
? <UserOrders userId={userId} />
: <UserProfile userId={userId} />}
</div>
);
}
关于校验再补一句:手写 Number.isInteger 够用在简单场景;参数一多,用 Zod 声明式校验更清爽——z.coerce.number().int().positive() 一行把「字符串变数字 + 必须正整数」全说了,和你第 21 篇做表单校验是同一套思路、同一个库:入口数据不可信,先校验再使用,这条铁律不分前后端。
| 你在 Spring 里的 | 前端对应 | 关键差异 |
|---|---|---|
@PathVariable Long id | useParams() | 前端拿到的是 string,转换自己做 |
@RequestParam(defaultValue = "1") | useSearchParams() | get() 返回 string | null,默认值自己兜 |
redirect attributes / @SessionAttribute | useLocation().state | 一次性、不进 URL,复制链接带不走 |
| 每个请求一次新的方法调用 | 同路由复用组件实例 | 参数变了只重渲染,key 才重挂载 |
@Valid + 校验注解 | 手写判断 / Zod | 第 21 篇同款思路:入口数据先校验 |
- 路径参数
useParams对照@PathVariable,但永远是 string;嵌套路由各层参数合并返回。 - 查询参数
useSearchParams对照@RequestParam:get 要兜默认值,set 是一次导航,函数式写法才能保留其他参数。 - 跳转夹带数据用
location.state,对照 redirect attributes:一次性、不进 URL。 - 同路由参数变化只重渲染不重挂载:
useEffect依赖加id,要「全新实例」就上key。
本章回顾
这一篇把 URL 拆成了三个通道:路径参数定「哪个资源」(useParams / @PathVariable),查询参数定「怎么看它」(useSearchParams / @RequestParam),跳转暗道带「一次性的话」(state / redirect attributes)。外加两记后端思维的耳光:参数变了组件不重启,以及所有参数都是 string、校验自己做。现在参数全部到手,下一步自然是拿着它去找后端要数据——fetch、axios、useEffect 取数模板和竞态问题,下一篇第 32 篇见。
Comments · 评论