前置知识: Next.js

Next.js App Router 路由系统

7 min中级

布局嵌套、动态路由、导航预取、加载与错误状态——App Router 文件约定的完整入门。

知识点地图

  • 知识类别:App Router 路由系统——文件约定下的布局、动态路由、导航与状态页。
  • 解决什么问题:URL 结构怎么组织、页面间怎么导航与预取、加载/出错/404 三种状态怎么各归其位。
  • 什么时候用到:新建任何页面、设计站点信息架构、用户反馈”点过去白屏没反馈”或”404 页不生效”时。

0. 一句话理解

App Router 的文件约定:page.tsx 是页面、layout.tsx 是共享壳、[参数] 文件夹是动态路由、loading/error/not-found 是三种状态页面;文件夹嵌套即 URL 嵌套,不写任何路由表。

先给一张速查表,后面逐个展开:

文件职责备注
page.tsx页面内容,使路由可访问文件夹里没有 page.tsx 就不是一个可访问路由
layout.tsx共享壳,包裹子路由切换子页面时不重新渲染
template.tsx类似布局,但每次导航都会重新挂载需要入场动画等”每次都重来”的效果时用
loading.tsx路由级加载占位等价于包了一层 <Suspense>
error.tsx路由级错误边界必须是客户端组件
not-found.tsx404 兜底页面notFound() 也会触发它
route.ts后端 HTTP 接口与 page.tsx 同目录互斥,详见第 4 篇

1. 布局嵌套

tsx
// app/layout.tsx:根布局
import type { Metadata } from "next"

export const metadata: Metadata = {
  title: "我的站点",
  description: "Next.js 16 学习示例"
}

export default function RootLayout({
  children
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <html lang="zh-CN">
      <body>
        <header>全站导航</header>
        {children}
        <footer>页脚</footer>
      </body>
    </html>
  )
}

讲解:

  1. layout.tsx 接收 children(子路由渲染结果),布局本身在切换子页面时不会重新渲染,导航条写在布局里可以避免整页刷新、保持滚动位置与状态。
  2. 布局可以嵌套:app/blog/layout.tsx 只包裹博客区块的页面,且嵌套在根布局之内——子布局渲染进父布局的 {children} 里。
  3. metadata 导出对象用于设置页面标题与描述,Next.js 会自动注入 <head>,这是内置 SEO 能力;需要动态元数据(如按文章标题生成)则用 generateMetadata 异步函数。
  4. lang="zh-CN" 声明页面语言,有利于无障碍与搜索引擎。

类比:布局像会议室的四面墙与投影仪——换一批参会人(子页面)时房间不动,只有白板上的内容(children)在换。

2. 动态路由

创建文件夹 app/posts/[id]/page.tsx:

tsx
import { notFound } from "next/navigation"

export default async function PostPage({
  params
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  if (!/^\d+$/.test(id)) notFound()

  return <main>文章编号:{id}</main>
}

讲解:

  1. 文件夹名 [id] 表示动态段:/posts/42 会匹配并把 id 设为 "42"。
  2. Next.js 15 起 params 是 Promise,必须 await 后才能读取;同步访问在 16 中已直接报错。这是从”渲染前必须等请求”走向”能先渲染的先渲染”的架构铺垫。
  3. notFound() 会触发最近的 not-found.tsx,返回 404 页面;示例用正则校验”必须是数字”。
  4. 三种动态段写法:[id] 匹配单段;[...slug] 捕获 1 段以上(/a/b/c 全进数组);[[...slug]] 连 0 段也匹配(可选捕获-all,/docs 本身也能命中)。
  5. 配合 generateStaticParams 可以在构建期为已知参数预生成静态页:
tsx
// app/posts/[id]/page.tsx(追加导出)
export function generateStaticParams() {
  return [{ id: "1" }, { id: "2" }] // 构建期生成 /posts/1 与 /posts/2
}

未被列出的参数默认仍可在请求时按需渲染(dynamicParams 默认为 true);想只允许预生成的那几条,把它设为 false,其余参数直接 404。

3. 导航与预取

tsx
import Link from "next/link"

export default function Nav() {
  return (
    <nav>
      <Link href="/">首页</Link>
      <Link href="/about">关于</Link>
      <Link href={`/posts/${42}`}>文章 42</Link>
    </nav>
  )
}

讲解:

  1. next/link 是客户端导航:点击后不会整页刷新,共享布局与状态得以保留。
  2. 生产环境下 <Link> 进入视口会自动预取目标路由,点击时几乎零等待。Next.js 16 重写了预取:多个链接共享布局时布局只下载一次(布局去重),并且只预取缓存里还没有的部分(增量预取,离开视口会取消)。
  3. prefetch={false} 可以关停个别链接的预取,适合支付确认页这类”不希望用户拿着旧数据点进来”的页面。
  4. 编程式跳转与读取路由信息的 Hook 都来自 next/navigation,且必须在客户端组件中使用:useRouter().push("/login") 跳转、usePathname() 取路径、useSearchParams() 取查询串。
  5. 带查询参数的搜索表单可以直接用 next/form 的 <Form> 组件:它像 <Link> 一样带预取与客户端导航,JS 未加载时退化为原生表单提交。

4. 加载、错误与 404 三种状态

tsx
// app/posts/[id]/loading.tsx
export default function Loading() {
  return <p>加载中……</p>
}
tsx
// app/posts/[id]/error.tsx("use client" 是必须的)
"use client"

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <p>出错了</p>
      <button onClick={reset}>重试</button>
    </div>
  )
}

讲解:

  1. loading.tsx 在路由进入 Suspense 加载态时显示,配合流式渲染避免白屏:导航立即切到占位 UI,数据就绪后流式补齐。
  2. error.tsx 必须标注 "use client",因为错误边界需要在浏览器里捕获并交互;reset 函数由 Next.js 注入,点击后重新渲染出错的页面段。
  3. 生产环境里传给 error.tsx 的 error.message 会被抹除成笼统信息(只保留 digest 摘要用于关联服务端日志),防止把内部细节泄露给访客;调试信息应在服务端日志里看。
  4. not-found.tsx 渲染在最近的布局内;app/not-found.tsx 是全站兜底,用户访问任何不匹配的 URL 都会命中它。

5. 路由组织与路由段配置

三个实用技巧,覆盖 90% 的目录组织需求:

text
app/
  (marketing)/            # 圆括号 = 路由组:只影响分组,不出现在 URL 里
    about/page.tsx        # URL 是 /about,不是 /marketing/about
  dashboard/
    layout.tsx            # 只包住后台区块的子布局
    settings/page.tsx     # /dashboard/settings
  api/
    health/route.ts       # 接口路由,与 page.tsx 同目录互斥
tsx
// 路由段配置:写在 page.tsx / layout.tsx / route.ts 顶部
export const revalidate = 300 // 本路由每 5 分钟再生(ISR)
export const dynamic = "force-dynamic" // 强制每次请求动态渲染

讲解:

  1. 路由组 (marketing) 用于把若干路由归到同一布局下,圆括号目录本身不进入 URL;同一层级的两个路由组不能定义相同路径,否则冲突报错。
  2. 同目录 page.tsx 与 route.ts 互斥:一个路径要么渲染页面、要么响应接口,同时存在会在构建时报错。
  3. 路由段配置是写在文件顶部的导出常量,作用于该路由段及其子段:revalidate 定时再生、dynamic 强制静态/动态。它们是”传统模式”的控制手段;在 16 的 Cache Components 模型下被 use cache + cacheLife 的显式声明取代(见第 6、7 篇)。

6. 常见陷阱

  1. 同步读取 params/searchParams:两者都是 Promise,直接 params.id 得到 undefined;类型上也要写成 Promise<{ id: string }>。
  2. 以为布局能拿到查询参数:layout.tsx 的 props 只有 children 与 params,没有 searchParams——查询串属于请求信息,读取它会让页面转为动态渲染,因此布局不提供。
  3. 在静态页面里裸用 useSearchParams:静态渲染中它没有确定值,需要把使用它的客户端组件包进 <Suspense>,否则构建报错。
  4. 路由组路径冲突:(shop)/page.tsx 与 (web)/page.tsx 都会解析成 /,同时存在构建失败。

7. 动手实践

任务一:预渲染与按需渲染。 给 app/posts/[id]/page.tsx 配 generateStaticParams 生成两篇预渲染文章;访问一个未列出的 id,观察”按需渲染”仍然成功;再把 dynamicParams 设为 false,访问同样的 id 看它变成 404。提示:构建日志里每个路由前的符号标记(圆点/实心)区分静态与动态,npm run build 的输出就是验证材料。

任务二:两级 404。 在 app/posts/ 下新建 not-found.tsx;访问非数字 id 确认走文章区的 404,再访问一个不存在的整站路径确认走根级 404。提示:not-found.tsx 就近生效,两级页面文案要能区分。

任务三:动态导航与预取观察。 把导航改成写死 5 个 id 的文章列表(<Link> 循环渲染),npm run build && npm run start 后用 DevTools Network 面板观察滚动时触发的预取请求。提示:预取只发生在生产模式;Network 面板里筛 RSC 载荷类型看得最清楚。

任务四:路由组双布局。 用路由组给”营销页”(首页、关于)与”后台”(/dashboard)分别配一套布局,营销页带页脚、后台带侧栏。提示:两套布局都嵌在根布局之内,根布局只保留 html/body。

先自己写,再对照参考实现:

任务一参考实现
tsx
// app/posts/[id]/page.tsx
import { notFound } from "next/navigation"

const posts: Record<string, string> = {
  "1": "第一篇:环境搭建",
  "2": "第二篇:第一个页面",
}

export function generateStaticParams() {
  return Object.keys(posts).map((id) => ({ id }))
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const title = posts[id]
  if (!title) notFound()
  return <main>{title}</main>
}

验证路径:构建日志中 /posts/1、/posts/2 标记为静态(预生成);访问 /posts/3 首次请求时按需渲染成功——这是 dynamicParams 默认 true。追加 export const dynamicParams = false 后重新 build,再访问 /posts/3 直接 404。两种行为对应两种业务语义:“文章库只有这三篇”与”未来还会新增”。

任务二参考实现
tsx
// app/posts/not-found.tsx —— posts 区间内的 404
export default function PostsNotFound() {
  return <main>文章不存在,可能已被删除。<Link href="/">回首页</Link></main>
}
tsx
// app/not-found.tsx —— 全站兜底(脚手架已生成,改文案即可)
export default function NotFound() {
  return <main>页面不存在。<Link href="/">回首页</Link></main>
}

/posts/abc 命中 notFound() 时,Next.js 沿路由树向上找最近的 not-found.tsx——app/posts/not-found.tsx 更近,所以文案是”文章不存在”;/no-such-page 整段路由不匹配,落到根级兜底。两处 404 文案不同不是冗余,是给用户的位置信息。

任务四参考实现
text
app/
  layout.tsx                 # 根布局:只有 html/body
  (marketing)/
    layout.tsx               # 营销布局:导航 + 页脚
    page.tsx                 # /
    about/page.tsx           # /about
  dashboard/
    layout.tsx               # 后台布局:侧栏
    page.tsx                 # /dashboard
tsx
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header>站点导航</header>
      {children}
      <footer>页脚:备案号与联系方式</footer>
    </div>
  )
}
tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div style={{ display: "flex" }}>
      <aside>侧栏菜单</aside>
      <section>{children}</section>
    </div>
  )
}

注意 (marketing)/page.tsx 与根级 page.tsx 不能同时存在(都解析为 /),创建路由组版首页前先删掉旧文件。

8. 一句话记住

路由就是文件夹约定:page 管页面、layout 管外壳、[id] 管动态参数、loading/error/not-found 管三种状态;params 记得 await,路由组只分组不改 URL,预取在 16 已增量化和布局去重。