首页 / React 学习笔记 / 31

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

动态路由与参数解析

实战#路由#参数

路径参数:useParams 就是前端的 @PathVariable

上一篇第 30 篇的路由表里出现了 users/:id,这个 :id 就是路径参数——同一条路由规则,配千变万化的 URL。你在后端早就天天用它:

UserController.java · 后端的路径参数
@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:

UserDetail.tsx · 前端的路径参数
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() 一把全拿到。

一条 URL 的两个参数区 /users/42/orders?tab=paid&page=2 路径参数 :id → useParams 到手是 "42",字符串 查询参数 → useSearchParams tab、page,可缺省 @PathVariable Long id 框架帮你抽参数、转类型 @RequestParam String tab 参数名 + 默认值,前端要自己兜 两路参数都是字符串进来:转不转、信不信,前端要自己把关
图 1一条 URL 的两个参数区:问号前是路径参数(useParams),问号后是查询参数(useSearchParams)
后端类比:丢了类型转换这一层

@PathVariable Long id 一步到位:抽取 + 类型转换都是框架的事,转不动还直接给你抛异常。前端 useParams 只负责抽取,返回的全是 string——类型转换和校验这两步从框架手里还给了你,这一站先记账,最后一站还账。

查询参数:useSearchParams 就是 @RequestParam

什么时候用查询参数?判断标准和后端一致:不改变「资源本身」、只是视图状态的,放查询参数。还是 /users/42 这个资源,但看第几页、筛哪个状态、开哪个 tab——?tab=orders&page=2。这些状态放 URL 里还有个好处:用户刷新、收藏、分享链接,状态都还在,和后端接口的翻页参数一个道理。

UserOrders.tsx · 读取与写入查询参数
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>
  );
}

三个使用要点:

  1. searchParams.get("page") 返回 string | null,null 的话用 ?? 兜默认值——@RequestParam(defaultValue = "1") 那行的活儿,前端要自己干。
  2. setSearchParams({...}) 对象写法是整体替换,会丢掉没写的参数;要保留其他参数就用上面的函数式写法,在 prevset
  3. 写就是导航setSearchParams 会触发重渲染并改 URL,组件不用再手动 setState——「URL 即状态」,你把状态的变化全权托付给了地址栏。
后端类比:筛选条件上不下不上 URL

你给列表接口写 @RequestParam page、size、status,从不把这些塞进 POST body——因为它们是「这一次查询」的描述,理应出现在 URL 里、能被书签复现。前端同理:页码、筛选、tab 放 useSearchParams(URL),而「选中了某一行」这种临时交互态放组件 state。判据和后端一模一样。

URL 之外的暗道:location.state

还有第三种传数据的方式:跳转时不写进 URL、只夹在这次导航里。后端也有这个机制——redirect attributes / flash attribute:重定向时捎带一次性的数据,用完即焚。useLocation 读到的 state 就是前端的这个暗道:

AuthGuard.tsx / LoginPage.tsx · 最典型场景:登录后回跳
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 还在(浏览器替你存着),但这是「赠品」不是契约,业务上别依赖它必须存在——所以取值时老老实实判空兜底。
三种通道怎么选?会出现在地址栏、要能被收藏分享的(页码、筛选、tab)→ useSearchParams;只服务「这一次跳转」的(回跳地址、来源)→ location.state;资源的身份本身(用户 id)→ 路径参数。和后端的 @PathVariable / @RequestParam / flash attribute 的分工完全同构。

参数变了,组件却不「重启」

这一站是后端思维最容易翻车的地方。从 /users/1 点到 /users/2,直觉上「换页面了」;但对 React Router 来说,两条 URL 命中的是同一条路由规则 → 组件根本不重新挂载,只是带着新参数重渲染了一次

类比一下:这不是「重新 new 一个 Controller」,而是同一个单例 Bean 的方法被再次调用——实例里的字段原样保留。映射到组件里:你的 useState 不会归零、useEffect 不会自动重跑。后果就是经典的「点了下一个用户,页面还显示上一个用户的数据」:

UserDetail.tsx · 两个解法
// 坑:没有依赖数组时,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 按路由层级解析:

UserDetail.tsx · 相对链接
<Link to="orders">他的订单</Link>  // → /users/42/orders,:id 自动拼上
<Link to="..">返回列表</Link>    // → /users,相对当前路由层级

相对链接写的是「路由结构」而不是「URL 字符串」——父子路由的层级就是链接的坐标系。哪天你把 users/:id 改名成 members/:id,这些链接一个都不用动,和 REST 风格里「资源间用相对引用」是一个味道。

实战:用户详情页全流程

把五站的知识焊成一个真实页面:/users/:id?tab=orders——路径参数定资源、查询参数定视图,外加参数校验:

UserDetailPage.tsx · /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 iduseParams()前端拿到的是 string,转换自己做
@RequestParam(defaultValue = "1")useSearchParams()get() 返回 string | null,默认值自己兜
redirect attributes / @SessionAttributeuseLocation().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 · 评论