Chapter 01

文件系统路由

起点用一句话点明本质:路由、渲染、数据、缓存都从"边界落在哪"推导。但边界要落在一棵组件树上——这一章先建立那棵树:App Router 如何把文件夹变成 URL、把 URL 变成嵌套的组件。

本章你将建立的 schema

  • app/ 目录里,文件夹 = URL 段;page.tsx 让一个段可被访问
  • layout 包裹子树、在导航之间持久存在、不重新渲染(对比 template 每次重挂)
  • 动态段 [id] / [...slug] / [[...slug]];路由组 (group) 只组织代码、不进 URL
  • 特殊文件 loading / error / not-found 把"加载中 / 出错 / 404"变成文件约定;平行路由 @slot 与拦截路由 (.)

1.1文件夹即 URL

在 app/ 目录下,每个文件夹是一个 URL 段,文件夹里的 page.tsx 决定这个段是否可访问、渲染成什么。

为什么需要它

集中式路由配置(一张 routes 表)随项目增长会和真实文件结构脱节:文件移动了,注册表忘了改,URL 与代码位置对不上。文件系统路由把这条同步关系交给目录本身——URL 结构 = 目录结构。新增一个路由就是新建一个文件夹加一个 page.tsx,不必改任何注册表。机制是:Next 在构建时扫描 app/ 整棵目录树,把每个含 page 的文件夹路径映射成一条路由;不含 page 的文件夹只是组织层级,不会产生可访问的页面。代价是 URL 不能再随意自定义——它被钉死在目录形状上,要改 URL 就得移动文件夹(路由组与下文的特殊约定正是用来在这层约束里腾挪的)。

对照关系最直观的看法是把目录树和 URL 并排放。下面三个文件分别落在三个位置,各自对应一条 URL。

app/ 目录 → URL 对应 tsx
app/
├─ page.tsx              // 渲染 "/"
├─ about/
│  └─ page.tsx           // 渲染 "/about"
└─ blog/
   ├─ page.tsx           // 渲染 "/blog"
   └─ [slug]/
      └─ page.tsx        // 渲染 "/blog/任意值",如 /blog/hello

// app/blog/[slug]/page.tsx —— 一个最简 page
export default async function PostPage(
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params      // 16 起 params 是 Promise,必须 await
  return <article>文章:{slug}</article>
}

app/page.tsx根目录的 page 对应站点首页 /。文件夹 app/ 自身是路由树的根,不进 URL。

app/blog/这个文件夹既有自己的 page(对应 /blog),又嵌套了子文件夹 [slug]。一个段可以同时是页面和父级。

app/ 目录 可访问的 URL page.tsx about/page blog/page blog/utils/ 无 page · 不出页面 / /about /blog (无对应 URL)
图 1.1文件夹映射成 URL 段,只有含 page 的文件夹才连出一条 URL。注意:底部 blog/utils/ 没有 page,它只是放工具代码的目录——既不可访问,也不产生页面。文件夹存在 ≠ 路由存在;page 才是开关。

1.2layout:嵌套、持久、不重渲染

layout.tsx 包裹同级及子级的所有 page,在子路由之间切换时不被卸载、不重新渲染,因此能持有不重置的 UI 状态。

为什么需要它(比文档深一层)

导航到同一 layout 下的另一个 page 时,React 只替换发生变化的那一段子树,layout 这一层作为持久边界被保留。结果是:layout 里的侧边栏滚动位置、菜单展开状态、输入框内容在导航后不会丢——因为承载它们的 DOM 与组件实例根本没被卸载。底层是 segment 级的 reconciliation:路由器按段比对新旧树,复用未变的 layout 段,只替换变化的 page 段。对比 template.tsx:它在每次导航时都重新挂载一个全新实例,内部 state 被重置、effect 重新跑——适合需要"每次进入都重来"的场景(进场动画、依赖挂载时机的埋点)。layout 与 template 的差别不是写法,而是这条复用规则:一个被复用,一个被重建。

下面是一个两层嵌套:根 layout 包整站,dashboard/layout 包仪表盘子树。再给出 template 做对照。

嵌套 layout + template 对照 tsx
// app/layout.tsx —— 根 layout,必须存在,要带 <html><body>
export default function RootLayout(
  { children }: { children: React.ReactNode }
) {
  return (
    <html lang="zh">
      <body>{children}</body>     {/* children = 当前匹配到的子树 */}
    </body>
  )
}

// app/dashboard/layout.tsx —— 仪表盘子树共享的 layout
export default function DashboardLayout(
  { children }: { children: React.ReactNode }
) {
  return (
    <div>
      <Sidebar />                 {/* 在 /dashboard/* 之间切换时保持不动 */}
      <main>{children}</main>
    </div>
  )
}

// app/onboarding/template.tsx —— 对照:每次导航重新挂载
export default function OnboardingTemplate(
  { children }: { children: React.ReactNode }
) {
  return <div className="fade-in">{children}</div>  // 进场动画每次重放
}

这条"不重渲染"的规则有个直接后果:layout 读不到随导航变化的 searchParams。因为查询串变化属于"同一段内的导航",不会触发 layout 重新执行,它拿到的会是过期值。需要响应查询串的逻辑要放在 page 里(page 每次导航都会重渲)——这一点与 03 章讲的动态数据直接相关:读 searchParams 会把该段转入动态渲染。

RootLayout · 保留 DashboardLayout · 保留 Sidebar 状态不丢 Page /dashboard/a 导航即替换 Page' /dashboard/b 仅换 page
图 1.2/dashboard/a → /dashboard/b 导航时,两层 layout(含 Sidebar)原地保留,只有朱红的 Page 被替换成 Page'。注意:替换发生在最内层变化的那一段,外层 layout 不参与——这就是侧边栏状态不重置的原因。
想一想

从 /dashboard/a 导航到 /dashboard/b(两者共享 dashboard/layout.tsx)。layout 里有个 const [count, setCount] = useState(0) 并已点到 3。导航后这个计数会被重置回 0 吗?

展开答案(先停 10 秒)

不会,仍是 3。dashboard/layout 在这次导航里属于被复用的段,它的组件实例没被卸载,useState 持有的值原样保留。被替换的只有 page 那一段。若把这段状态放进 template.tsx,答案就反过来——template 每次导航重新挂载,计数会回到 0。

(注:layout 默认是 Server Component,要在里面用 useState 需要把它拆出一个 'use client' 的子组件来持有状态,详见 02 章。这里讨论的是"持久 vs 重挂"这条规则本身。)

1.3动态段与路由组

[id] 捕获单个段,[...slug] 捕获其后所有段,[[...slug]] 连父段也可选;(group) 只为组织代码、不出现在 URL 里。

为什么需要它

真实站点的 URL 里有大量值是运行时才知道的:商品 id、文章 slug、任意深度的文档路径。把每个值建成一个文件夹既不现实也无法穷举,所以路由需要"占位段"。动态段就是带方括号的文件夹名:渲染时,匹配到的真实值通过 params 传进组件——16 起 params 是 Promise,必须 await。三种括号形态对应三种捕获范围(见下表)。配套的 generateStaticParams 能在构建时枚举这些段的取值,把动态路由预渲染成静态页面(呼应 03 章的静态渲染)。另一头,路由组 (folder) 解决的是相反的问题:你想给一组路由共享一个 layout、或把代码按业务分文件夹,但又不想这层文件夹挤进 URL。括号让这个文件夹只在磁盘上存在、在 URL 里消失。

动态段与路由组 tsx
// app/shop/[category]/[id]/page.tsx → /shop/keyboard/42
export default async function Product(
  { params }: { params: Promise<{ category: string; id: string }> }
) {
  const { category, id } = await params   // 多个动态段都在 params 里
  return <h1>{category} / {id}</h1>
}

// app/[...slug]/page.tsx → /a、/a/b、/a/b/c 都匹配
export default async function CatchAll(
  { params }: { params: Promise<{ slug: string[] }> }
) {
  const { slug } = await params           // slug 是数组:["a","b","c"]
  return <nav>{slug.join(' / ')}</nav>
}

// app/(marketing)/about/page.tsx → URL 仍是 /about
// (marketing) 只用于分组 + 共享 app/(marketing)/layout.tsx,
// 括号那层不进 URL
export default function About() {
  return <h1>关于我们</h1>
}
表 1.1 · 三种动态段的捕获范围
写法匹配params 形态典型用途
[id]恰好一个段{ id: string }商品 / 用户详情页
[...slug]一个或多个段{ slug: string[] }文档 / 多级分类
[[...slug]]零个或多个段(父段也可选){ slug?: string[] }同时处理 /docs 与 /docs/a/b
易混点 · [...] 与 [[...]] 差一个根

[...slug] 至少要匹配一个段,所以 app/docs/[...slug]/page.tsx 处理 /docs/a 但不处理裸 /docs(那需要另写 app/docs/page.tsx)。[[...slug]] 多套一层方括号,连"零个段"也匹配,于是单文件就能同时接住 /docs 和 /docs/a/b,此时 slug 在缺省时为 undefined,取用前要判空。

1.4特殊文件:loading / error / not-found

在一个段目录里放 loading.tsx / error.tsx / not-found.tsx,Next 自动把它们接成该段的加载态、错误边界、404 界面。

为什么需要它(比文档深一层)

这三个文件不是"约定俗成的命名",而是 Next 在构建时把它们编译进路由结构的接线点。loading.tsx 不是一个普通组件——Next 会自动用一个 <Suspense> 把同级 page 包起来,loading 就是那个 fallback。这意味着该段在等待数据时,框架自动渲染 loading,数据就绪后换成 page,整个过程是流式的(直接铺垫 03 章的 streaming)。error.tsx 则被编译成一个 React 错误边界包住同级 page:段内抛出的渲染错误被它捕获,而不会炸穿到上层。因为 React 的错误边界依赖客户端运行时(它要在浏览器里捕获错误并提供重试),error.tsx 必须是 Client Component——顶部要写 'use client';它接收 error(错误对象)和 reset(重试函数)两个 prop。not-found.tsx 在该段调用 notFound() 或匹配失败时渲染。三者都遵循就近原则:放在哪个段目录,就只管那个段及其子树。

高频报错 · error.tsx 忘了 'use client'

error.tsx 顶部漏写 'use client' 会直接报错——因为错误边界是客户端机制,框架要求这个文件是 Client Component。看到"error boundary 必须是 client component"之类提示,先检查第一行。

app/dashboard/loading.tsx + error.tsx tsx
// app/dashboard/loading.tsx —— 自动成为同级 page 的 Suspense fallback
export default function Loading() {
  return <div>加载仪表盘…</div>     // 数据就绪前显示这个
}

// app/dashboard/error.tsx —— 必须是 Client Component
'use client'                          // ← 漏了这行会报错

export default function Error(
  { error, reset }: { error: Error; reset: () => void }
) {
  return (
    <div>
      <p>出错了:{error.message}</p>
      <button onClick={reset}>重试</button>  {/* reset 重新渲染该段 */}
    </div>
  )
}
想一想

app/dashboard/page.tsx 是个 async 组件,要 await 一个慢查询。同目录放了 loading.tsx。用户访问 /dashboard 时,在数据返回前屏幕上显示的是什么?这等价于你手写了什么 React 结构?

展开答案(先停 10 秒)

显示 loading.tsx 的内容("加载仪表盘…"),数据就绪后自动换成 page。它等价于你手写了 <Suspense fallback={<Loading/>}><DashboardPage/></Suspense>——Next 把这层 <Suspense> 替你接上了。这正是为什么 loading.tsx 能开启流式渲染:<Suspense> 边界是 React 流式输出的切分单元,03 章会展开。

1.5进阶布局:平行路由与拦截路由

平行路由 @slot 让一个 layout 同时渲染多个独立子树(如仪表盘的多个面板);拦截路由 (.) 让一个路由在当前上下文里被"拦截"渲染(经典用途:列表点击弹出 modal,URL 仍可分享 / 刷新直达)。

为什么需要它(比文档深一层)

到这里,路由树是"一条路径对应一棵子树"。但有两类需求打破这个假设。其一:一个页面要同时、独立地渲染多块内容,每块有自己的加载与错误态(仪表盘里并排的 analytics 面板和 team 面板)。平行路由用 @slot 文件夹表达——@ 开头的文件夹不进 URL,而是作为具名 prop 传给同级 layout(app/dashboard/@analytics/page.tsx 的内容会以 props.analytics 传入 dashboard/layout)。其二:同一个 URL 在不同入口要有不同呈现——从列表点进图片,想要弹 modal 留在原页;但把那个 URL 直接分享 / 刷新,又要看到整页大图。拦截路由用 (.) 系列标记表达"在当前路由层把目标路由拦下来就地渲染",软导航走拦截版(modal),硬导航(刷新 / 直接访问)走真实路由(整页)。(.) 同级、(..) 上一级、(...) 根,括号里的点数表示从当前位置往上数几层去找被拦截的段。

版本约定(截至 Next 16)· 平行插槽必须有 default.tsx

每个平行路由插槽都要提供一个 default.tsx,作为该插槽在"当前 URL 未匹配到对应子路由"时的回退渲染。最容易踩到的是硬刷新:软导航时 Next 记得各插槽的活动状态,但刷新后这份状态丢失,未匹配的插槽若没有 default.tsx 就会渲染失败。这是 Next 16 收紧的约定——老教程里"省略 default 也能跑"的写法在 16 上会报错。

平行路由:@slot 作为 layout 的具名 prop tsx
// 目录:
// app/dashboard/layout.tsx
// app/dashboard/@analytics/page.tsx   + @analytics/default.tsx
// app/dashboard/@team/page.tsx        + @team/default.tsx

// app/dashboard/layout.tsx —— 具名插槽作为 prop 注入
export default function DashboardLayout({
  children,                 // 对应 app/dashboard/page.tsx
  analytics,                // 对应 @analytics 子树
  team,                     // 对应 @team 子树
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      {children}
      <section>{analytics}</section>   {/* 两块独立渲染 */}
      <section>{team}</section>         {/* 各有自己的 loading/error */}
    </div>
  )
}
图片列表页 /photos (.)photo/[id] 拦截 · modal 列表上方弹层 背景仍是 /photos photo/[id]/page 真实路由 整页大图 可分享 / 刷新直达 点缩略图 · 软导航 刷新 / 直接访问
图 1.3同一个 /photo/42,两条进入路径渲染成两种界面。注意:朱红那条是软导航触发的拦截版(modal 浮在列表上),黑色那条是硬导航走的真实整页路由——拦截路由让"弹窗体验"和"URL 可直达"同时成立,靠的就是区分这两种导航。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. app/(shop)/cart/page.tsx 对应的 URL 是什么?为什么括号那层不出现在 URL 里?
  2. 从 /dashboard/a 导航到 /dashboard/b(共享 dashboard/layout.tsx),layout 组件会重新挂载吗?这带来什么好处?
  3. loading.tsx 底层等价于什么 React 机制?(提示:和 03 章相关)
  4. (设计层)一个图片列表,点缩略图想弹 modal 看大图,但用户分享 / 刷新该 URL 时要能直接看到整页大图。该用哪种路由特性?为什么它能同时满足"弹窗"和"可直达"?
答案(先做完再展开)
  1. URL 是 /cart。(shop) 是路由组:括号文件夹只用于组织代码和共享 layout,不作为 URL 段。所以路径里只剩 cart 这一段。
  2. 不会重新挂载。这是同一 layout 下 page 之间的导航,路由器按段比对后复用 layout 段、只替换 page 段。好处是 layout 里的状态(侧边栏滚动位置、展开项、输入内容)和 DOM 全部保留,导航不会让它们重置或闪烁。
  3. 等价于 <Suspense fallback={<Loading/>}> 包住同级 page。Next 自动接上这层 <Suspense>,loading 就是 fallback;因此它天然开启该段的流式渲染(03 章展开)。
  4. 用拦截路由((.)photo/[id] 之类)。它能同时满足两点,是因为区分了两种导航:从列表点击属于软导航,命中拦截版、以 modal 就地渲染;分享链接后刷新 / 直接访问属于硬导航,绕过拦截、走真实的 photo/[id] 整页路由。同一个 URL,两条路径,两种呈现。
进阶挑战 · 刚好够不着

会话列表 + 详情:切换不重置左侧,右侧按 id 变化

做一个后台页面:左侧是会话列表,切换会话时列表的滚动位置和顶部搜索框的输入都要保留;右侧是当前会话的详情,要随选中的会话 id 变化而变化。用 layout + 平行路由 + 动态段,这棵树该怎么组织,才能让"切换会话不重置左侧状态、右侧详情按 [id] 刷新"?

提示(卡住再展开)

把持久和可变分到不同的段上。左侧会话列表(含搜索框)放进一个不随会话 id 变化的位置——要么直接放在 layout 里,要么作为一个 @list 平行插槽;只要它不在那条带 [id] 的路径上,导航切换会话时它就属于被复用的段,滚动位置和输入自然保留。右侧详情放在带动态段的 page 上,比如 app/chats/[id]/page.tsx(或一个 @detail 插槽下的 [id]):它在那条会变的路径上,切换 id 时只有这一段被替换。关键判断是"哪部分该落在变化的路径上"——落在上面就重渲,落在外面就保留。需要每块各自有加载态时,平行插槽(各带自己的 loading)比单纯 layout 更合适。