《Next.js 路由迷宫:动态路由与 Catch-all 的奇幻之旅》

187 阅读4分钟

开场白:路由,其实就像快递小哥

想象你家的门牌号就是 URL。
快递小哥(浏览器)拿着包裹(HTTP 请求)走到你家楼下,发现门牌号写着:

/shop/42/comment/7

他得知道:

  • 42 是谁家的店?
  • 7 是哪条评论?

在 Next.js 里,动态路由就是给快递小哥一张「万能地址簿」;而 Catch-all 路由则是一张「不管写了啥都能送到的魔法地图」。今天,咱们就把这两张地图拆开揉碎,再加点香料炒一炒。


一、动态路由:把门牌号变成变量

1.1 文件即路由,括号即变量

pagesapp 目录下,只要文件名用 [] 包起来,Next.js 就会把它当成变量。

pages/
└── product/
    └── [id].js     ← /product/42 会匹配到这儿

代码示例(pages/product/[id].js):

import { useRouter } from 'next/router';

export default function Product() {
  const router = useRouter();
  // 就像拆快递:router.query.id === '42'
  const { id } = router.query;

  return (
    <section>
      <h1>商品编号:{id}</h1>
      <p>商品描述:这是一只会写代码的咖啡杯,编号 {id}。</p>
      {/* 偷偷告诉你,编号 42 是宇宙终极答案 */}
    </section>
  );
}

1.2 多级动态路由:把门牌号拆成楼-单元-室

pages/
└── shop/
    └── [shopId]/
        └── comment/
            └── [commentId].js

匹配示例:

/shop/42/comment/7   → shopId = '42', commentId = '7'

底层小剧场:
Next.js 在构建时会把这些 [xxx] 编译成正则分组,运行时再用 RegExp 把 URL 拆成对象,存进 router.query
就像把 "42/7"/ 切成数组,再按位赋值,简单粗暴却十分高效。


二、Catch-all 路由:不管来多少层,我全接住

2.1 语法:三个点,法力无边

把文件名写成 [...slug].js,那它就是 Catch-all
slug 只是一个变量名,你爱叫 banana 也没人拦着。

pages/
└── docs/
    └── [...slug].js

匹配示例:

/docs                          → slug: []
/docs/intro                    → slug: ['intro']
/docs/intro/install            → slug: ['intro', 'install']
/docs/intro/install/docker     → slug: ['intro', 'install', 'docker']

代码示例(pages/docs/[...slug].js):

import { useRouter } from 'next/router';

export default function Docs() {
  const router = useRouter();
  const { slug = [] } = router.query; // 默认空数组,防止 404 时炸掉

  // 把路径数组拼成面包屑
  const breadcrumb = slug.join(' / ');

  return (
    <main>
      <h1>文档中心</h1>
      <nav style={{ color: '#888' }}>{breadcrumb}</nav>
      <article>
        <h2>当前页面:{slug[slug.length - 1] || '首页'}</h2>
        <p>这里是文档正文,懒得写了,假装有很多字。</p>
      </article>
    </main>
  );
}

2.2 可选 Catch-all:在末尾加 ?

文件名写成 [[...slug]].js(双层中括号 + 三个点)。
区别:URL 可以完全不提供 slug,也能匹配到。

pages/
└── blog/
    └── [[...slug]].js

匹配示例:

/blog                 → slug: undefined(不是空数组,而是直接没有)
/blog/2025            → slug: ['2025']
/blog/2025/08         → slug: ['2025', '08']

底层冷知识:
Next.js 在路由排序时,把可选 Catch-all 的优先级调到最低,防止它「吞噬」其他更具体的路由。
就像你排队买奶茶,可选 Catch-all 是那个永远说「我都可以」的佛系顾客,站在最后。


三、动态路由 vs Catch-all:一张对比表

特性动态路由 [id]Catch-all [...slug]可选 Catch-all [[...slug]]
匹配层级固定 1 级任意层级 ≥ 1任意层级 ≥ 0
额外参数slug 是数组slug 可能是 undefined
优先级
常见场景详情页文档、分类、多级菜单博客归档、文件浏览器

四、实战小彩蛋:用 Catch-all 做「伪静态」

需求:把 /blog/2025/08/15/hello-world 渲染成静态页面,但又不想写死每一年、月、日。

方案:

  1. pages/blog/[...slug].js 负责渲染。
  2. getStaticPaths 在构建时读取 Markdown 文件名,生成路径数组。

伪代码:

// pages/blog/[...slug].js
export async function getStaticPaths() {
  const posts = await getAllMarkdownPosts(); // ['2025/08/15/hello-world.md', ...]
  const paths = posts.map(file => ({
    params: { slug: file.replace('.md', '').split('/') }
  }));
  return { paths, fallback: 'blocking' };
}

export async function getStaticProps({ params }) {
  const filePath = params.slug.join('/');
  const content = await readMarkdown(filePath);
  return { props: { content } };
}

底层原理:
Next.js 会把 getStaticPaths 返回的数组序列化成 json 文件,放到 .next/server/pages 里。
运行时,当请求 /blog/2025/08/15/hello-world,Next.js 用哈希表直接查文件,O(1) 复杂度,快到飞起。


五、结语:路由的尽头,是哲学

动态路由像变量,Catch-all 像通配符,它们共同编织出 URL 的无限可能。
当你在浏览器地址栏敲下最后一个 / 时,服务器里正有无数正则表达式在赛跑,只为把最合适的组件送到你面前。

「路由即人生,条条大路通罗马,但 Catch-all 表示:罗马是啥?我先到了再说。」


附录:一张图看懂匹配优先级

(用 ASCII 画一下,凑合看)

URL: /shop/42/comment/7

Next.js 路由匹配顺序 ↓
├─ /shop/[shopId]/comment/[commentId].js   ← 命中!
├─ /shop/[...slug].js                      ← 没轮到
└─ /[...slug].js                           ← 更没戏

彩蛋问答

Q:为什么 [...slug].js 不能放在根目录 /[...slug].js
A:可以,但你会收获一个「什么都吃」的怪兽,连 /api/hello 都会被它吞掉,然后你的 API 就 404 了。
结论:Catch-all 也要讲武德,别把同事的路由吃光了。


全文完。祝你编码愉快,404 常伴左右,但永远能跳回来。