Chapter 05

动态路由:一个文件匹配无数 URL

app/products/[id].tsx 能接住 /products/1/products/abc/products/42——方括号就是占位符。再配上 Typed Routes,router.push('/products/...') 的参数能被 TS 编译器校验。

单段参数 [id]

app/
├── _layout.tsx
├── index.tsx
└── products/
    ├── index.tsx           ← /products
    └── [id].tsx            ← /products/:id
// app/products/[id].tsx
import { useLocalSearchParams, Stack } from 'expo-router';
import { View, Text, ActivityIndicator } from 'react-native';

export default function ProductDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const { data, isLoading } = useProduct(id);

  if (isLoading) return <ActivityIndicator />;

  return (
    <>
      <Stack.Screen options={{ title: data.name }} />
      <View>
        <Text>{data.name}</Text>
        <Text>¥{data.price}</Text>
      </View>
    </>
  );
}

导航过去

// 声明式
<Link href="/products/42">查看商品</Link>

// 对象写法,参数更清晰
<Link href={{ pathname: '/products/[id]', params: { id: '42' } }}>查看</Link>

// 命令式
router.push({ pathname: '/products/[id]', params: { id: product.id } });
pathname 写 [id] 而不是实际值
对象写法推荐 pathname: '/products/[id]',让 Typed Routes 知道这是哪个路由模板,然后在 params 里填具体值。字符串 /products/42 也能跑,但类型推导弱一些。

多段参数:多个 []

app/posts/[category]/[slug].tsx    ← /posts/tech/expo-router-v4
const { category, slug } = useLocalSearchParams<{
  category: string;
  slug: string;
}>();

Catch-all [...slug]

三点表示「贪婪匹配剩下所有段」,适合文档/层级内容:

app/docs/[...slug].tsx
  /docs/intro                  → slug = ['intro']
  /docs/api/core/client        → slug = ['api', 'core', 'client']
  /docs                        → 不匹配(至少 1 段)
const { slug } = useLocalSearchParams<{ slug: string[] }>();
// slug 是数组

可选 Catch-all [[...slug]]

双方括号表示「零段或多段都可以」,适合首页/空路径:

app/docs/[[...slug]].tsx
  /docs                        → slug = undefined
  /docs/intro                  → slug = ['intro']

useLocalSearchParams vs useGlobalSearchParams

useLocalSearchParams
只读当前路由段的参数。嵌套路由下,父路由的参数不返回。一般情况下用这个。
useGlobalSearchParams
读整个导航栈里所有路由的参数合集。父路由改了参数,子屏能拿到变化,但会多触发渲染。
// 场景:tab 切换时,当前 tab 的参数保留
// /products/42 下:
// useLocalSearchParams()  → { id: '42' }
// useGlobalSearchParams() → { id: '42', ...父级参数 }

Query 参数(?a=1&b=2)

// /products/42?variant=red&size=L
const { id, variant, size } = useLocalSearchParams<{
  id: string;
  variant: string;
  size: string;
}>();

// 导航带 query
router.push({
  pathname: '/products/[id]',
  params: { id: '42', variant: 'red', size: 'L' },
});

Expo Router 把 [id] 解析成路径段,剩下没匹配到的 key 当作 query——统一通过 useLocalSearchParams 拿,API 一致。

Typed Routes

// app.json
{
  "expo": {
    "experiments": { "typedRoutes": true }
  }
}

启用后,Expo 扫描 app/ 目录,生成 .expo/types/router.d.ts——所有 Href 类型都精确到每个可能的路由:

// ✅ 能编译
router.push('/products/42');
router.push({ pathname: '/products/[id]', params: { id: '42' } });

// ❌ TS 报错
router.push('/prodcuts/42');     // 拼错 → 报错
router.push({ pathname: '/products/[id]' });  // 缺 params → 报错
Typed Routes 是"静态的"
它基于文件系统扫描,不能处理运行时拼接的字符串。router.push(`/products/${id}`) 类型上就退化成 string——把 id 放 params 里才能拿到类型保护。

useSegments:当前路径的段数组

import { useSegments } from 'expo-router';

const segments = useSegments();
// /(tabs)/home/42 → ['(tabs)', 'home', '42']
// 用途:判断当前在哪个分组(/(auth)/* 或 /(app)/*)来做鉴权

usePathname:纯字符串路径

import { usePathname } from 'expo-router';

const pathname = usePathname();
// '/home/42'    (不含 query,不含分组)

路由参数变化触发刷新

const { id } = useLocalSearchParams<{ id: string }>();

// 关键:把 id 放 dependency 里,id 变屏幕不卸载,数据会刷新
const { data } = useQuery({
  queryKey: ['product', id],
  queryFn: () => fetchProduct(id),
});
Stack 里的 /products/42 → /products/43
默认走 push,相当于栈顶又加一屏(返回能回到 42)。如果你想就地替换,用 router.setParams({ id: '43' })router.replace

setParams:更新当前路由参数

import { router } from 'expo-router';

// 筛选条件变化,不跳转,只改 query
router.setParams({ variant: 'blue', size: 'M' });
// URL 从 /products/42?variant=red 变成 /products/42?variant=blue&size=M

参数都是 string

无论是 URL 路径还是 query,Expo Router 拿到的永远是 string——即便你 params 传了 number,到下一屏也变字符串。需要自己 parse:

const { id, page } = useLocalSearchParams<{ id: string; page: string }>();
const productId = Number(id);
const currentPage = parseInt(page ?? '1', 10);

Not Found 兜底

app/+not-found.tsx
import { Link, Stack } from 'expo-router';
import { View, Text } from 'react-native';

export default function NotFound() {
  return (
    <>
      <Stack.Screen options={{ title: '走丢了' }} />
      <View>
        <Text>这个页面不存在</Text>
        <Link href="/">返回首页</Link>
      </View>
    </>
  );
}

完整示例:商品列表 → 详情

// app/products/index.tsx
import { FlatList, Text, Pressable } from 'react-native';
import { Link } from 'expo-router';

export default function ProductList() {
  const { data } = useProducts();
  return (
    <FlatList
      data={data}
      keyExtractor={(p) => p.id}
      renderItem={({ item }) => (
        <Link
          href={{ pathname: '/products/[id]', params: { id: item.id } }}
          asChild
        >
          <Pressable style={{ padding: 16 }}>
            <Text>{item.name}</Text>
            <Text>¥{item.price}</Text>
          </Pressable>
        </Link>
      )}
    />
  );
}

本章小结