Skip to content
工程与部署
Next.js 基本概念:文件系统路由与导航
Next.js 基本概念:文件系统路由与导航 的概念、用法、示例和注意点
2024/01/137 分钟工程与部署
页面即路由:page.tsx 的映射规则
Next.js 的路由系统建立在文件系统的目录结构之上。在 app 目录下,每个文件夹对应一个 URL 段,而具体的 UI 则由特定的文件约定来创建。
page.tsx 是最基础的文件约定——它定义了一个路由的终端 UI。只有包含 page.tsx 的目录才会对外暴露为可访问的路由。这个文件需要默认导出一个 React 组件:
typescript
// app/page.tsx
export default function HomePage() {
return <h1>Home</h1>
}这个组件会被渲染在 http://localhost:3000/ 的根路径上。嵌套目录遵循同样的规则:
bash
app/
blog/
page.tsx # 对应 /blog
about/
page.tsx # 对应 /about目录本身不会被映射为路由——只有包含导出组件的 page 文件才会。这意味着可以在 app 下放置仅作为内部模块使用、不暴露路由的文件夹,Next.js 只会对包含 page.tsx 的目录生成路由。
路由段的概念:路径 /blog/hello 由三个段组成——/(根段)、blog(段)、hello(叶段)。每个文件夹映射一个段,page.tsx 负责输出该段上的 UI。
在实际运行中,当请求匹配到某个路由时,Next.js 会渲染该路由对应的 page.tsx。如果有布局文件(layout.tsx),则页面会被作为 children 传递给布局组件。
共享与持久:layout.tsx 的嵌套
layout.tsx 负责定义跨多个页面共享的 UI 结构。它的核心行为是:在路由切换时保持自身状态不变,仅重新渲染内部的 children。
typescript
// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<nav>Blog Navigation</nav>
<main>{children}</main>
</div>
)
}这里的 children 可能是子布局,也可能是最终的 page.tsx。当用户从 /blog/post-1 导航到 /blog/post-2 时,BlogLayout 组件本身不会卸载重挂载——只有 children 部分会根据目标路由重新渲染。
这种持久化机制对交互状态有一定影响。如果布局组件内维护了用户输入或滚动位置,这些状态在子页面间跳转时不会丢失——因为 React 的组件实例没有被销毁。
布局的嵌套遵循目录层级的自然嵌套。如果在 app 的根目录和一个子目录下都定义了 layout.tsx,它们会自动形成父子关系:
bash
app/
layout.tsx # 根布局,包裹所有页面
blog/
layout.tsx # blog 布局,嵌套在根布局内
page.tsx根布局必须包含 html 和 body 标签:
typescript
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
)
}如果缺少 html 或 body,Next.js 在构建时会报错,这是硬性约束。
路由组:绕过布局嵌套
当目录结构上的嵌套关系与实际 UI 需求不一致时,路由组提供了一种解耦方式。
路由组使用括号包裹文件夹名,例如 (marketing) 或 (shop)。括号内的名称不会出现在 URL 路径中,它的唯一作用是在文件系统中对路由进行逻辑分组。
bash
app/
(marketing)/
layout.tsx # marketing 独立的布局
about/
page.tsx # /about
pricing/
page.tsx # /pricing
(shop)/
layout.tsx # shop 独立的布局
products/
page.tsx # /products/about 和 /pricing 的 URL 中不包含 (marketing)。两组页面共享同一个根 URL 层级,但各自使用独立的布局,互不影响。如果没有路由组,要实现这种“不同功能的页面使用不同布局但共享同一 URL 层级”的需求,就需要在根布局里编写条件渲染逻辑。
动态参数:从 [slug] 到 catch-all 路由
固定的 URL 段由文件夹名直接映射,但实际项目中经常需要在路径中携带动态参数。Next.js 通过特殊的文件夹命名约定来支持这一点。
[slug]:单段动态参数
用方括号包裹文件夹名,就创建了一个动态路由段:
bash
app/
blog/
[slug]/
page.tsx这个结构会匹配 /blog/hello、/blog/nextjs-intro 等路径,但不会匹配 /blog 或 /blog/hello/world。
参数通过 params 传入页面组件。在 App Router 中,params 是一个 Promise 类型,需要 await 解包:
typescript
// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>Post: {slug}</h1>
}params 被设计成 Promise 与 App Router 的渲染模型有关——服务端组件在请求处理阶段接收 params,而这个阶段本身是异步的。把 params 设计为 Promise 使得框架能在不阻塞渲染管线的情况下完成参数解析。
如果使用 TypeScript,Next.js 还提供了 PageProps 工具类型,可以基于路由路径直接推断参数类型:
typescript
import type { PageProps } from 'next'
export default async function BlogPostPage(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
// slug 被自动推断为 string
}类型推导在重构路由结构时比较有用——改了路由路径,如果忘记同步修改参数类型,TypeScript 会直接报错。
[...slug]:catch-all 路由
当一个路由需要匹配任意深度的路径时(例如 /docs/getting-started/quick-start 或 /docs/api-reference/components),可以使用 [...folderName] 语法:
bash
app/
docs/
[...slug]/
page.tsx这个路由会匹配 /docs 之后的所有段:
| 请求路径 | 参数值 |
|---|---|
/docs/getting-started | ['getting-started'] |
/docs/api/components/button | ['api', 'components', 'button'] |
注意 /docs 本身不会被匹配(因为 page.tsx 在 [...slug] 内,不在 docs 下)。参数类型变为 string[]:
typescript
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
// slug 是 ['getting', 'started'] 这样的数组
return <h1>Section: {slug.join(' / ')}</h1>
}[[...slug]]:可选的 catch-all
[...slug] 要求至少有一层路径。如果希望根路径也能命中(比如 /docs 同时也显示内容),可以升级为 [[...slug]]:
bash
app/
docs/
[[...slug]]/
page.tsx这时 /docs 会被匹配,且 slug 的值为 undefined。参数类型相应地变为 string[] | undefined:
typescript
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
return <h1>{slug ? slug.join(' / ') : 'Docs Home'}</h1>
}客户端获取动态参数
在客户端组件中获取动态段参数有两种方式。一种是使用 useParams hook:
typescript
'use client'
import { useParams } from 'next/navigation'
export default function PostTitle() {
const { slug } = useParams<{ slug: string }>()
return <h1>{slug}</h1>
}另一种是用 React 的 use() 解包:
typescript
'use client'
import { use } from 'react'
export default function PostTitle({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = use(params)
return <h1>{slug}</h1>
}两种方式在功能上没有本质区别。useParams 更简洁,use() 则遵循 React 的 Suspense 集成模式——参数未就绪时可以触发最近的 Suspense 边界。
Link 导航
Link 组件用于处理页面间的跳转,它会在客户端完成导航,并自动预加载目标页面的代码分块。
typescript
import Link from 'next/link'
export default function Nav() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/blog">Blog</Link>
<Link href="/blog/hello">Hello Post</Link>
</nav>
)
}与直接使用 <a> 标签不同,Link 不会触发完整的页面刷新。当用户点击 Link 时,Next.js 仅获取目标页面的 JavaScript 和数据,然后在客户端完成渲染。
预加载:当 Link 进入视口时,Next.js 会在后台预取目标路由的代码分块。到用户实际点击时,数据往往已经就绪,导航几乎瞬时完成。这个行为在生产构建(next build && next start)中生效;开发模式下 Link 不会预取,因此开发时的导航延迟不能代表上线后的体验。
Link 默认预取整个页面及其子数据。如果页面上有大量链接同时进入视口(例如博客目录),会导致大量的后台请求。可以通过 prefetch 属性控制:
typescript
<Link href="/blog/deep-dive" prefetch={false}>
Deep Dive
</Link>设置为 false 后,该链接只在 hover 时才开始预取,避免不必要的带宽占用。
编程式跳转:useRouter
当跳转需要发生在事件处理函数中,而不是用户直接点击链接时,useRouter 暴露了 push 和 replace 方法,用于从逻辑代码中触发导航。
typescript
'use client'
import { useRouter } from 'next/navigation'
export default function SearchForm() {
const router = useRouter()
function handleSearch(term: string) {
router.push(`/search?q=${term}`)
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
handleSearch(e.currentTarget.term.value)
}}
>
<input name="term" />
<button type="submit">Search</button>
</form>
)
}push 会在浏览器历史栈中新增一条记录,用户可以点后退返回。replace 直接替换当前历史记录,用户无法回退到替换前的页面——这个区别在重定向类场景下很重要。比如登录成功后跳转至首页,用 replace 更合理,否则点击后退会回到登录页。
浅路由(shallow routing)允许在不重新运行数据获取逻辑的情况下修改 URL。在 App Router 中,这个能力通过 router.push 配合服务端组件的缓存策略实现,而不像 Pages Router 那样直接暴露 shallow 参数。相关缓存策略的细节不属于本章范围。
loading.tsx 与 error.tsx
page.tsx 负责正常的 UI 输出,Next.js 还为异常的渲染状态提供了两个约定文件:loading.tsx 和 error.tsx。
loading.tsx 在页面或布局的 JavaScript 资源正在加载时显示,利用了 React 的 Suspense 机制:
typescript
// app/blog/loading.tsx
export default function Loading() {
return <p>Loading blog posts...</p>
}当 page.tsx(或其依赖的异步组件)尚未就绪时,Next.js 会在这个位置渲染 loading.tsx 的内容。这个文件可以被放在任意有 page.tsx 的目录中,并且只影响它所在层级及其子路由。
error.tsx 用来捕获渲染过程中的错误。它必须是一个 'use client' 组件:
typescript
'use client'
// app/blog/error.tsx
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
)
}reset() 会尝试重新渲染 error.tsx 所包裹的路由段。如果重试后仍然出错,error 边界会再次显示。
需要注意的是,error.tsx 及其子组件中的错误会被捕获,但同一层级的 layout.tsx 中的错误不会被同一层的 error.tsx 捕获。布局的错误需要由上层(父级)的 error.tsx 来处理。这是 React 错误边界机制的限制——错误边界只能捕获其子组件的错误,而布局和 error.tsx 处于同一层级。
应用:多页面博客骨架
以下综合运用上述概念,构建一个博客的基本路由结构。
目录结构
bash
app/
layout.tsx # 根布局
page.tsx # 首页 /
blog/
layout.tsx # 博客共享布局
page.tsx # 博客首页 /blog
[slug]/
page.tsx # 文章页 /blog/:slug
loading.tsx # 博客加载态
error.tsx # 博客错误边界
about/
page.tsx # /about根布局
typescript
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<header>
<nav>
<a href="/">Home</a> | <a href="/blog">Blog</a> | <a href="/about">About</a>
</nav>
</header>
<main>{children}</main>
</body>
</html>
)
}博客布局
typescript
// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return (
<div>
<aside>Blog Sidebar (persistent across posts)</aside>
<article>{children}</article>
</div>
)
}首页
typescript
// app/page.tsx
import Link from 'next/link'
const posts = [
{ slug: 'hello-world', title: 'Hello World' },
{ slug: 'nextjs-routing', title: 'Next.js Routing' },
]
export default function HomePage() {
return (
<div>
<h1>My Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</div>
)
}博客首页——使用 useRouter 跳转
typescript
// app/blog/page.tsx
'use client'
import { useRouter } from 'next/navigation'
export default function BlogPage() {
const router = useRouter()
return (
<div>
<h1>Blog Home</h1>
<button onClick={() => router.push('/blog/hello-world')}>
Read Hello World
</button>
</div>
)
}文章页
typescript
// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>{slug}</h1>
}加载态和错误边界
typescript
// app/blog/loading.tsx
export default function Loading() {
return <p>Loading post...</p>
}typescript
// app/blog/error.tsx
'use client'
export default function Error({ error }: { error: Error }) {
return <p>Failed to load: {error.message}</p>
}这个结构运行之后,页面间的跳转行为如下:
- 从首页点击
Link到文章页——客户端导航,不刷新整页,博客布局的aside保持状态 - 在博客首页点击按钮,
useRouter触发同样的客户端跳转 - 文章页的数据加载阶段,
loading.tsx短暂出现 - 如果文章页渲染出错,
error.tsx接管,不影响博客布局和整页的其他部分
参考链接
- 文件系统路由定义、page 与 layout 文件约定:Next.js 官方文档,Layouts and Pages
https://nextjs.org/docs/app/getting-started/layouts-and-pages - 动态路由段、catch-all 与可选 catch-all:Next.js 官方文档,Dynamic Routes
https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes - 布局嵌套与路由组概念:Next.js 官方 GitHub 讨论
https://github.com/vercel/next.js/discussions/70909 - 文件系统路由、嵌套、共置与部分渲染:Next.js 学习课程
https://nextjs.org/learn/dashboard-app/creating-layouts-and-pages - Linking and Navigating 文档入口
https://nextjs.org/docs/pages/building-your-application/routing
参考链接
- [1] https://nextjs.org/docs/app/getting-started/layouts-and-pages
- [10] https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes
- [14] https://github.com/vercel/next.js/discussions/70909
- [16] https://nextjs.org/learn/dashboard-app/creating-layouts-and-pages
- [17] https://nextjs.org/docs/pages/building-your-application/routing
相关推荐
2024/01/19 · 5 分钟Next.js 关键 API:路由处理程序、中间件与 Server Actions
Next.js 关键 API:路由处理程序、中间件与 Server Actions 的概念、用法、示例和注意点
2024/01/15 · 7 分钟Next.js 渲染策略:服务端组件与客户端组件Next.js 渲染策略:服务端组件与客户端组件 的概念、用法、示例和注意点
2024/01/11 · 6 分钟Next.js 概述:从 React 到全栈框架Next.js 概述:从 React 到全栈框架 的概念、用法、示例和注意点
2025/03/09 · 7 分钟Nuxt.js 渲染模式与部署Nuxt.js 渲染模式与部署 的概念、用法、示例和注意点
