陌上人如玉
公子世无双

6.4 动态路由与参数

6.4 动态路由与参数

一句话:用「:param」在路径里声明动态段,用 useParams() 读取;参数变化时组件不会重新挂载(需要在 useEffect 里依赖参数,或给 element 加 key 强制重建)。

一、声明与读取

// ① 声明动态段(冒号开头)
<Route path="/users/:id" element={<UserDetail />} />
<Route path="/posts/:year/:month/:slug" element={<Post />} />

// ② 读取参数
import { useParams } from 'react-router-dom';

function UserDetail() {
  const { id } = useParams<{ id: string }>();       // ⭐ 泛型给出类型
  return <div>用户 ID:{id}</div>;
}

function Post() {
  const { year, month, slug } = useParams<{ year: string; month: string; slug: string }>();
  return <div>{year} 年 {month} 月:{slug}</div>;
}
⚠️ 参数【永远是字符串】:
  /users/123  →  params.id === '123'(string,不是 number)
  需要数字时要自己转换:const id = Number(params.id)

⚠️ 路径里不允许「斜杠」:
  /users/123/456 不会匹配 path="/users/:id"
  需要匹配「多段」时用通配符(见下文)

二、多个参数与嵌套参数

// ✅ 多参数
<Route path="/:category/:id" element={<Product />} />;
function Product() {
  const { category, id } = useParams();
  return <div>{category} / {id}</div>;
}
// ✅ 嵌套路由里的参数是「累积」的(子路由能拿到父路由的参数)
const router = createBrowserRouter([
  {
    path: 'users/:userId',
    element: <UserLayout />,
    children: [
      { path: 'orders/:orderId', element: <OrderDetail /> },
    ],
  },
]);

// 访问 /users/1/orders/99
function OrderDetail() {
  const { userId, orderId } = useParams();      // ⭐ 两个都能拿到
  return <div>用户 {userId} 的订单 {orderId}</div>;
}

// ⚠️ 父布局也能拿到子路由的参数(因为 params 是「整条链」的),
//    但「该不该用」是设计问题:父布局应只关心自己这一层。

三、可选参数

// v6.5+ 支持「可选段」语法(问号后缀)
<Route path="/posts/:lang?/:slug" element={<Post />} />;
// 匹配 /posts/hello 与 /posts/zh/hello

// ⚠️ 注意歧义:/posts/:lang?/:slug 在 /posts/hello 时
//    lang 会是 undefined,slug 是 'hello'
// ✅ 更常见的做法:用两个路由(更明确)
<Route path="/posts" element={<PostList />} />
<Route path="/posts/:slug" element={<Post />} />

// ✅ 或用「默认值」处理可选语义
function Post() {
  const { lang = 'zh', slug } = useParams();     // 参数缺失时用默认值
  return <div>{lang} / {slug}</div>;
}

四、通配符

// ① * 匹配「剩余所有段」(包括斜杠)
<Route path="/files/*" element={<FileBrowser />} />;
function FileBrowser() {
  const params = useParams();
  console.log(params['*']);            // ⭐ 通配符匹配到的部分(如 'a/b/c.txt')
  return <div>{params['*']}</div>;
}
// 访问 /files/a/b/c.txt → params['*'] === 'a/b/c.txt'

// ② 路径末尾的 * 常用于「404 兜底」或「嵌套子应用的基路径」
<Route path="/legacy/*" element={<LegacyApp />} />;

// ③ 也可以用在「中间」
<Route path="/docs/*/api" element={<ApiDocs />} />;
// ✅ 用通配符 + 自己解析,做「文件浏览器」或「博客的路由」
function FileBrowser() {
  const params = useParams();
  const segments = (params['*'] ?? '').split('/').filter(Boolean);
  // segments: ['a', 'b', 'c.txt']
  return <Breadcrumb segments={segments} />;
}

五、参数变化的处理(重点)

// ⚠️ 从 /users/1 到 /users/2:组件【不会重新挂载】
//    (因为 element 类型相同,React 复用了组件实例)
<Route path="/users/:id" element={<UserDetail />} />;
// ❌ 这样写:切换用户时数据不更新
function UserDetailBad() {
  const { id } = useParams();
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(id!).then(setUser);
  }, []);                              // ⚠️ 缺 id 依赖 → 只在挂载时请求一次

  return <div>{user?.name}</div>;
}
// ✅ 修复方式一:依赖参数(重新请求)
function UserDetailGood() {
  const { id } = useParams();

  useEffect(() => {
    let cancelled = false;
    fetchUser(id!).then((data) => { if (!cancelled) setUser(data); });
    return () => { cancelled = true; };
  }, [id]);                            // ⭐ 依赖 id

  return <div>{user?.name}</div>;
}

// ✅ 修复方式二:用查询库(自动按 key 重新请求)
function UserDetailBetter() {
  const { id } = useParams();
  const { data, isLoading } = useQuery({
    queryKey: ['user', id],            // ⭐ id 变化 → 自动重新请求
    queryFn: () => fetchUser(id!),
  });

  return isLoading ? <Skeleton /> : <div>{data?.name}</div>;
}
// ✅ 修复方式三:需要「重置内部状态」时,用 key 强制重建
function UserDetailRoute() {
  const { id } = useParams<{ id: string }>();
  return <UserDetailInner key={id} />;   // ⭐ id 变化 → 组件重建 → 内部状态全部重置
}

function UserDetailInner() {
  const { id } = useParams();
  const [tab, setTab] = useState('info');    // 切换用户时会重置为 'info'
  // ...
}
需求 做法
切换参数时重新请求数据 useEffect 依赖参数,或用查询库(queryKey 含参数)
切换参数时重置内部状态 给 element/组件加 key(强制卸载重建)
既要「保留部分状态」又要「重置部分状态」 把「需要重置的状态」下移到「带 key 的子组件」里

六、参数校验与 404

// ❌ 常见问题:参数对应的资源不存在时,页面显示空白或崩溃
function UserDetail() {
  const { id } = useParams();
  const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id!) });

  return <div>{data.name}</div>;         // ❌ data 为 undefined 时崩溃
}
// ✅ 方式一:组件内处理(声明式)
function UserDetailGood() {
  const { id } = useParams();
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id!),
  });

  if (isLoading) return <Skeleton />;
  if (error || !data) return <NotFound />;       // ⭐ 单页是 404(保留布局)
  return <div>{data.name}</div>;
}
// ✅ 方式二:在 loader 里抛 404(数据路由,推荐)
{
  path: 'users/:id',
  element: <UserDetail />,
  loader: async ({ params }) => {
    const user = await fetchUser(params.id!);
    if (!user) {
      throw new Response('Not Found', { status: 404 });    // ⭐ 交给 errorElement
    }
    return user;
  },
  errorElement: <UserError />,
}

function UserError() {
  const error = useRouteError();
  if (isRouteErrorResponse(error) && error.status === 404) {
    return <NotFound />;
  }
  return <div>出错了</div>;
}
// ✅ 方式三:参数格式校验(如 id 必须是数字)
function UserDetailValidated() {
  const { id } = useParams();
  const numId = Number(id);

  if (!Number.isInteger(numId) || numId <= 0) {
    return <NotFound />;                 // ⭐ 格式非法 → 404(而不是发无效请求)
  }
  // ...
}

七、匹配规则细节

① 静态段优先于动态段
   /users/me 与 /users/:id 同时存在时,/users/me 命中静态段

② 更具体的路径优先
   /users/:id/posts 优先于 /users/:id/*

③ 大小写:默认【敏感】(可通过 caseSensitive 配置)
   <Route path="/About" caseSensitive />   → /about 不匹配

④ 末尾斜杠:/users 与 /users/ 视为相同

⑤ 通配符 * 贪心匹配「剩余所有段」

八、常见坑

// ① 参数是字符串,直接当数字用
const id = params.id;                    // ⚠️ '123' 不是 123
if (id > 100) { }                        // ⚠️ 字符串比较会出错
const numId = Number(params.id);         // ✅

// ② 依赖数组漏掉参数(切换时不重新请求)
useEffect(() => { fetchUser(id); }, []); // ❌

// ③ 期望「参数变化时组件重新挂载」
// → 不会,需要 key

// ④ 用「路径里带斜杠的参数」
// /users/a/b 不会匹配 :id(一个段)
// ✅ 用通配符或拆成两段

// ⑤ 参数没有做「URL 编码/解码」
navigate(`/search/${keyword}`);          // ⚠️ keyword 含 / 或 ? 会破坏路径
navigate(`/search/${encodeURIComponent(keyword)}`);   // ✅
// 读取时也不用手动解码(React Router 已解码)

// ⑥ 多级参数名重复(父子路由用了同名 :id)
// ⑦ 在「父布局」读取子路由的参数(虽然能取到,但耦合了层级)
// ⑧ 一个参数用「中文/特殊字符」但没编码(URL 不合法)
// ⑨ 认为「params 是响应式的」(它随渲染返回新对象,可以正常用)
// ⑩ 在 loader 里没做「参数校验」就请求(无效请求)
// ⑪ 参数变化时「没有取消上一个请求」(竞态,见 7.2)
// ⑫ 用 useParams 在「非路由组件」里(拿不到)
// ⑥ 的说明:多级嵌套时参数名最好不同
{
  path: 'users/:userId',
  children: [{ path: 'orders/:orderId', element: <Order /> }],
}
// ✅ 用 userId / orderId 而非都用 id(避免覆盖歧义)

// ⑪ 的修复:用 AbortController 取消上一个请求
useEffect(() => {
  const controller = new AbortController();
  fetchUser(id!, { signal: controller.signal })
    .then(setUser)
    .catch((e) => { if (e.name !== 'AbortError') setError(e); });
  return () => controller.abort();         // ⭐ 参数变化时取消旧请求
}, [id]);

九、面试延伸

  1. 「动态路由怎么用?」
    在路径里用「冒号开头的段」声明参数(path="/users/:id"),在组件里用 useParams() 读取(返回「参数名 → 值」的对象)。注意三点:① 参数永远是字符串(要数字就自己 Number());② 参数不能包含斜杠(一个 :id 只匹配一个路径段,需要多段用通配符 *);③ 嵌套路由里的参数是「累积」的(子路由能拿到父路由的参数)。

  2. 「参数变化时组件会重新挂载吗?」
    不会。从 /users/1 到 /users/2,React Router 渲染的是同一个路由元素(element={<UserDetail />}),React 看到「元素类型相同」就复用组件实例——所以内部 state、滚动位置、ref 都不会重置。这带来两种处理方式:① 数据层面——useEffect 依赖参数(或用查询库把参数放进 queryKey);② 状态层面——如果希望「换一个参数 = 换一个实例」,给 element 加 key(key={id})强制重建。

  3. 「useParams 返回的对象是响应式的吗?」
    是。每次路由参数变化导致组件重新渲染时,useParams() 都会返回新的对象(包含最新参数)——所以「在渲染中使用 params.id」是安全且响应式的。唯一要注意的是「params 对象本身每次都是新的引用」,所以不要在「依赖数组」里直接放 params(会永远认为变了),而是依赖具体字段(params.id)。

  4. 「如何匹配多段路径?」
    用通配符 *:path="/files/*" 会匹配 /files/a/b/c,匹配到的剩余部分可以从 useParams()['*'] 中读取(值为 'a/b/c')。常见用途:① 404 兜底(path="*");② 嵌套子应用/旧系统迁移(把某个前缀下的所有路径交给一个组件处理);③ 文件浏览器(自己按 / 拆分 params['*'])。注意通配符是贪心的(匹配尽可能多)。

  5. 「怎么处理『参数对应的资源不存在』?」
    三种方式:① 组件内处理——请求回来后判断 !data 就渲染 <NotFound />(保留布局);② loader 里抛 404(数据路由,推荐)——if (!user) throw new Response('Not Found', { status: 404 }),由最近的 errorElement 处理(用 useRouteError + isRouteErrorResponse 读状态);③ 参数格式校验——如果 id 必须是数字,就在请求前校验(Number.isInteger),不合法直接返回 404(避免发无效请求)。

  6. 「/users/me 和 /users/:id 同时存在时会匹配哪个?」
    匹配静态段(/users/me)——React Router 的匹配优先级是「更具体/更静态的路径优先」:静态段 > 动态段 > 通配符。因此 /users/me 会命中静态路由而不是把 me 当作 id。这也是「个人中心」这类页面的常见写法(避免 id='me' 这种奇怪的参数)。

  7. 「动态参数需要 URL 编码吗?」
    需要——构造 URL 时要编码(encodeURIComponent(keyword)),否则参数里的 /、?、#、空格等字符会破坏路径结构(如 /search/a/b 会被解析成两段)。读取时不用手动解码(React Router 会解码)。此外「路径段」与「查询参数」的编码规则略有差异:路径段用 encodeURIComponent,查询参数可以用 URLSearchParams(它会自动编码)。

  8. 「参数变化时怎么避免请求竞态?」
    用 AbortController:在 useEffect 里创建 controller、把它传给 fetch({ signal }),并在清理函数里 controller.abort()。这样「参数变化导致 effect 重新执行」时,上一个请求会被取消,从而避免「旧请求后返回、覆盖新数据」的竞态问题。如果没有取消能力(如第三方 SDK),可以用「忽略标记」(let cancelled = false; ... return () => { cancelled = true; })来忽略过期响应。详见 7.2。

一句话速记

动态段用 :param,读取用 useParams()(参数永远是字符串、不能含斜杠,多段用 * 通配并从 params['*'] 读取);嵌套路由的参数是累积的;参数变化不会重新挂载组件(数据用 useEffect 依赖参数或查询库 queryKey;需要重置状态就加 key);静态段优先于动态段;资源不存在时「组件内渲染 404」或「loader 里 throw new Response(..., {status:404}) 交给 errorElement」,并在请求前做参数格式校验;构造 URL 时要 encodeURIComponent(读取时自动解码);参数变化时用 AbortController 避免竞态。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.4 动态路由与参数

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫