Next.js

布局与页面

深入理解 Next.js 的布局和页面系统,掌握文件系统路由和组件组织的最佳实践。

概述

Next.js 使用基于文件系统的路由,通过文件夹和文件定义路由。布局(Layout)和页面(Page)是构建应用的核心概念。

创建页面

页面是在特定路由上渲染的 UI。通过在 app 目录中添加 page 文件并默认导出 React 组件来创建页面。

基础页面

// app/page.tsx
export default function Page() {
  return <h1>Hello Next.js!</h1>
}

嵌套页面

page.tsx # /about
page.tsx # /blog
page.tsx # /blog/:slug

页面的关键特性:

  • 支持 .js.jsx.tsx 文件扩展名
  • 页面始终是路由子树的叶子节点
  • 需要 page 文件才能使路由段公开访问
  • 默认为服务器组件,可设置为客户端组件

创建布局

布局是在多个页面之间共享的 UI。在导航时,布局保持状态、保持交互性且不会重新渲染。

基础布局

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <section>
      <nav>Dashboard Navigation</nav>
      {children}
    </section>
  )
}

根布局

根布局是 app 目录中最顶层的布局,用于定义 <html><body> 标签以及其他全局共享的 UI。

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh">
      <body>{children}</body>
    </html>
  )
}

根布局要求:

  • 必须包含 <html><body> 标签
  • 是必需的,不能删除
  • 可以使用内置的 SEO 支持管理 <head> 元素

嵌套布局

布局可以嵌套,形成布局层次结构。

layout.tsx # 根布局
page.tsx
layout.tsx # 仪表板布局
page.tsx
page.tsx
// app/layout.tsx (根布局)
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh">
      <body>
        <header>全局头部</header>
        {children}
        <footer>全局底部</footer>
      </body>
    </html>
  )
}

// app/dashboard/layout.tsx (嵌套布局)
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="dashboard">
      <aside>侧边栏</aside>
      <main>{children}</main>
    </div>
  )
}

渲染结果:

<html lang="zh">
  <body>
    <header>全局头部</header>
    <div class="dashboard">
      <aside>侧边栏</aside>
      <main>
        <!-- 页面内容 -->
      </main>
    </div>
    <footer>全局底部</footer>
  </body>
</html>

页面 Props

params

包含从根段到该页面的动态路由参数的 Promise 对象。

// app/shop/[slug]/page.tsx
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <h1>产品:{slug}</h1>
}

路由参数示例:

路由URLparams
app/shop/[slug]/page.js/shop/1Promise<{ slug: '1' }>
app/shop/[category]/[item]/page.js/shop/shoes/nikePromise<{ category: 'shoes', item: 'nike' }>
app/shop/[...slug]/page.js/shop/clothes/topsPromise<{ slug: ['clothes', 'tops'] }>

searchParams

包含当前 URL 搜索参数的 Promise 对象。

// app/shop/page.tsx
export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const { page = '1', sort = 'asc', query = '' } = await searchParams

  return (
    <div>
      <h1>产品列表</h1>
      <p>搜索查询:{query}</p>
      <p>当前页:{page}</p>
      <p>排序:{sort}</p>
    </div>
  )
}

URL 示例:

/shop?page=2&sort=desc&query=shoes

searchParams 值:

{
  page: '2',
  sort: 'desc',
  query: 'shoes'
}

布局 Props

children(必需)

布局组件必须接受并使用 children prop。在渲染时,children 将填充布局包裹的路由段。

export default function Layout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section>{children}</section>
}

params(可选)

包含从根段到该布局的动态路由参数的 Promise 对象。

// app/dashboard/[team]/layout.tsx
export default async function DashboardLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ team: string }>
}) {
  const { team } = await params

  return (
    <section>
      <header>
        <h1>欢迎来到 {team} 的仪表板</h1>
      </header>
      <main>{children}</main>
    </section>
  )
}

在客户端组件中使用 Props

客户端组件不能是 async 的,需要使用 React 的 use 函数读取 Promise。

在页面中使用

'use client'

import { use } from 'react'

export default function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const { slug } = use(params)
  const { query } = use(searchParams)

  return (
    <div>
      <h1>{slug}</h1>
      <p>搜索:{query}</p>
    </div>
  )
}

在布局中使用

'use client'

import { use } from 'react'

export default function Layout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ team: string }>
}) {
  const { team } = use(params)

  return (
    <section>
      <h1>{team} 团队</h1>
      {children}
    </section>
  )
}

路由段配置

可以通过导出特殊变量来配置页面或布局的行为。

dynamic

控制路由段的动态行为。

// app/blog/[slug]/page.tsx
export const dynamic = 'auto' // 默认
// export const dynamic = 'force-dynamic'
// export const dynamic = 'error'
// export const dynamic = 'force-static'

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <h1>{slug}</h1>
}

选项:

  • auto(默认):尽可能缓存,不阻止组件选择动态行为
  • force-dynamic:强制动态渲染,每次请求时重新获取数据
  • force-static:强制静态渲染,缓存数据
  • error:如果组件使用动态函数或未缓存的数据,则报错

dynamicParams

控制访问未通过 generateStaticParams 生成的动态段时的行为。

// app/blog/[slug]/page.tsx
export const dynamicParams = true // 默认

export async function generateStaticParams() {
  return [{ slug: 'post-1' }, { slug: 'post-2' }]
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  return <h1>{slug}</h1>
}

选项:

  • true(默认):未包含在 generateStaticParams 中的动态段按需生成
  • false:未包含的动态段返回 404

revalidate

设置路由段的默认重新验证时间。

// app/blog/page.tsx
export const revalidate = 3600 // 每小时重新验证

export default async function Page() {
  const posts = await fetchPosts()
  return <div>{/* 渲染文章 */}</div>
}

选项:

  • false:无限期缓存(默认)
  • 0:始终动态渲染
  • number:以秒为单位的重新验证时间

fetchCache

控制路由段的 fetch 缓存行为。

// app/blog/page.tsx
export const fetchCache = 'auto' // 默认
// export const fetchCache = 'default-cache'
// export const fetchCache = 'only-cache'
// export const fetchCache = 'force-cache'
// export const fetchCache = 'default-no-store'
// export const fetchCache = 'only-no-store'
// export const fetchCache = 'force-no-store'

runtime

指定路由段的运行时。

// app/api/route.tsx
export const runtime = 'nodejs' // 默认
// export const runtime = 'edge'

preferredRegion

指定路由段的首选区域。

// app/api/route.tsx
export const preferredRegion = 'auto' // 默认
// export const preferredRegion = 'global'
// export const preferredRegion = 'home'
// export const preferredRegion = ['iad1', 'sfo1']

生成元数据

可以通过导出 metadata 对象或 generateMetadata 函数来定义页面或布局的元数据。

静态元数据

// app/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: '我的网站',
  description: '欢迎来到我的网站',
}

export default function Page() {
  return <h1>首页</h1>
}

动态元数据

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await fetchPost(slug)

  return {
    title: post.title,
    description: post.excerpt,
  }
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await fetchPost(slug)
  return <article>{/* 渲染文章 */}</article>
}

布局中的元数据

// app/dashboard/layout.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    template: '%s | 仪表板',
    default: '仪表板',
  },
}

export default function Layout({
  children,
}: {
  children: React.ReactNode
}) {
  return <section>{children}</section>
}

子页面继承:

// app/dashboard/settings/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: '设置', // 渲染为 "设置 | 仪表板"
}

生成静态参数

generateStaticParams 函数与动态路由段结合使用,在构建时静态生成路由。

基础用法

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetchPosts()

  return posts.map((post) => ({
    slug: post.slug,
  }))
}

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await fetchPost(slug)
  return <article>{/* 渲染文章 */}</article>
}

多个动态段

// app/products/[category]/[product]/page.tsx
export async function generateStaticParams() {
  const products = await fetchProducts()

  return products.map((product) => ({
    category: product.category,
    product: product.id,
  }))
}

从父段生成

// app/products/[category]/[product]/page.tsx
export async function generateStaticParams({
  params,
}: {
  params: Promise<{ category: string }>
}) {
  const { category } = await params
  const products = await fetchProductsByCategory(category)

  return products.map((product) => ({
    product: product.id,
  }))
}

链接和导航

使用 Link 组件在页面之间导航。

基础链接

import Link from 'next/link'

export default function Page() {
  return (
    <nav>
      <Link href="/">首页</Link>
      <Link href="/about">关于</Link>
      <Link href="/blog">博客</Link>
    </nav>
  )
}

动态链接

import Link from 'next/link'

export default function BlogList({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  )
}

编程式导航

'use client'

import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()

  return (
    <button onClick={() => router.push('/dashboard')}>
      前往仪表板
    </button>
  )
}

TypeScript 支持

Next.js 提供了全局类型助手,用于页面和布局的 props。

PageProps

// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  return <h1>博客文章:{slug}</h1>
}

LayoutProps

// app/dashboard/layout.tsx
export default function Layout(props: LayoutProps<'/dashboard'>) {
  return (
    <section>
      {props.children}
      {/* 如果有 app/dashboard/@analytics,它会作为类型化的插槽出现 */}
      {/* {props.analytics} */}
    </section>
  )
}

TypeScript 类型助手:

  • 静态路由将 params 解析为 {}
  • PagePropsLayoutProps 是全局助手,无需导入
  • 类型在 next devnext buildnext typegen 期间生成

实际应用示例

博客应用

layout.tsx # 根布局
page.tsx # 首页
layout.tsx # 博客布局
page.tsx # 博客列表
page.tsx # 博客文章
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh">
      <body>
        <header>
          <nav>
            <Link href="/">首页</Link>
            <Link href="/blog">博客</Link>
          </nav>
        </header>
        {children}
        <footer>© 2025</footer>
      </body>
    </html>
  )
}

// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="blog-container">
      <aside>
        <h2>分类</h2>
        {/* 分类列表 */}
      </aside>
      <main>{children}</main>
    </div>
  )
}

// app/blog/page.tsx
export default async function BlogPage() {
  const posts = await fetchPosts()

  return (
    <div>
      <h1>博客文章</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetchPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await fetchPost(slug)

  return {
    title: post.title,
    description: post.excerpt,
  }
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await fetchPost(slug)

  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

电商应用

layout.tsx
page.tsx
layout.tsx
page.tsx
page.tsx
page.tsx
// app/products/[category]/[product]/page.tsx
export async function generateStaticParams() {
  const products = await fetchProducts()

  return products.map((product) => ({
    category: product.category,
    product: product.id,
  }))
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ category: string; product: string }>
}) {
  const { product } = await params
  const productData = await fetchProduct(product)

  return {
    title: productData.name,
    description: productData.description,
  }
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ category: string; product: string }>
}) {
  const { category, product } = await params
  const productData = await fetchProduct(product)

  return (
    <div>
      <h1>{productData.name}</h1>
      <p>分类:{category}</p>
      <p>价格:¥{productData.price}</p>
      <button>加入购物车</button>
    </div>
  )
}

最佳实践

1. 合理使用布局层次

将共享的 UI 放在适当的布局层级中。

layout.tsx # 全局导航和底部
layout.tsx # 营销页面布局

2. 利用路由段配置优化性能

// 静态页面
export const dynamic = 'force-static'
export const revalidate = 3600

// 动态页面
export const dynamic = 'force-dynamic'

3. 使用 generateStaticParams 预渲染

export async function generateStaticParams() {
  // 在构建时生成所有可能的路由
  return await fetchAllSlugs()
}

4. 正确处理元数据

// 布局中设置模板
export const metadata = {
  title: {
    template: '%s | 网站名',
    default: '网站名',
  },
}

// 页面中设置具体标题
export const metadata = {
  title: '页面标题', // 渲染为 "页面标题 | 网站名"
}

5. 使用 TypeScript 类型助手

export default async function Page(props: PageProps<'/blog/[slug]'>) {
  // 自动获得类型安全的 params
  const { slug } = await props.params
}

总结

Next.js 的布局和页面系统提供了强大而灵活的方式来组织应用:

  • 页面定义特定路由的 UI
  • 布局在多个页面间共享 UI,保持状态
  • 支持嵌套布局,形成布局层次
  • 通过 paramssearchParams 访问路由参数
  • 使用路由段配置优化性能
  • 通过元数据 API 管理 SEO
  • 使用 generateStaticParams 预渲染动态路由
  • TypeScript 提供完整的类型支持

理解并掌握这些概念,可以构建高性能、可维护的 Next.js 应用。

在 GitHub 上编辑

上次更新于