本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
Next.js 与传统 React 项目的最大差异在于其基于文件系统的路由机制。在传统 React 项目中,开发者可以自由组织文件结构,路由需要单独配置;而 Next.js 通过文件系统的目录结构直接定义 URL 路由。这种设计虽然初看起来具有约束性,但一旦掌握,将大幅减少繁琐的路由配置工作。
本章将系统性地讲解这套约定体系。
一、核心原则:文件路径映射 URL 路径
这是 App Router 的核心约定,必须深刻理解:
bash
src/app/page.tsx → /
src/app/about/page.tsx → /about
src/app/blog/page.tsx → /blog
src/app/blog/[slug]/page.tsx → /blog/任意值
src/app/dashboard/settings/page.tsx → /dashboard/settings
文件与路由映射的基本规则如下:
- 每个"路由段"(URL 中两个斜杠之间的部分)对应一个目录
- 目录中的
page.tsx文件即为该路由的页面组件 - 仅
page.tsx会成为可访问的页面,其他文件不会暴露为路由
这一设计允许开发者在 app/ 目录中存放组件、工具函数甚至测试文件,无需担心用户直接访问到它们。
二、特殊文件:Next.js 的约定系统
在每个路由目录中,Next.js 识别若干特殊文件名,这些文件承担不同的职责:
- layout.tsx:共享布局(持久存在),目录下的所有路由共享
- page.tsx:页面内容(必需),每个路由的页面组件
- loading.tsx:页面处于加载状态时展示
- error.tsx:错误处理界面,当发生错误时就会替代page组件展示
- notfound:404 页面,当路由片段对应的路由不存在时展示
- template.tsx:每次导航重置的布局
当用户访问 /dashboard/settings 时,Next.js 按以下顺序组合页面:
bash
app/layout.tsx ← 最外层,包裹所有页面
app/dashboard/layout.tsx ← dashboard 专属布局
app/dashboard/settings/loading.tsx ← 数据加载时的占位
app/dashboard/settings/page.tsx ← 实际页面内容
若数据加载出错,error.tsx 将替代 page.tsx 显示;若路由不存在,not-found.tsx 将接管。这套机制被称为分层错误边界,提供了优雅的错误处理方案。
三、特殊文件详解
1. layout.tsx --- 持久化布局
布局文件是 App Router 中最重要的概念之一。
(1)核心特性
用户在同一个布局下的子路由间切换时,布局组件不会重新渲染。这意味着布局内的状态、滚动位置、动画等都会被保留。
(2)典型应用场景
管理后台的左侧导航栏不应在点击不同菜单项时重新加载。layout.tsx 正是为实现这种"稳定的外壳"而设计。
typescript
// src/app/dashboard/layout.tsx
// 此布局将在所有 /dashboard/* 页面中持续存在
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex h-screen">
{/* 左侧导航:切换页面时不会重新渲染 */}
<aside className="w-64 bg-gray-900 text-white p-4">
<nav className="space-y-2">
<a href="/dashboard">概览</a>
<a href="/dashboard/analytics">数据分析</a>
<a href="/dashboard/settings">设置</a>
</nav>
</aside>
{/* 右侧内容区:每次路由变化时更新 */}
<main className="flex-1 overflow-auto p-8">
{children}
</main>
</div>
)
}
(3)根布局的特殊性
根布局(src/app/layout.tsx) 必须包含 <html> 和 <body> 标签,因为它是整个应用的 HTML 骨架。此处适合放置:
- 全局字体配置
- 全局样式导入
- 全局状态 Provider(如 Redux、Context)
- 全局的Meta数据
typescript
// src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'My Application',
description: 'Built with Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className={inter.className}>
{children}
</body>
</html>
)
}
2. template.tsx --- 重置布局
template.tsx 与 layout.tsx 非常相似,都可以包裹子路由,但两者的核心区别在于渲染行为。
(1)核心特性
模板文件在导航时会被重新挂载。
当用户在不同路由间切换时,即使它们共享同一个 template.tsx,Next.js 也会销毁旧的模板实例并创建一个全新的实例。这意味着:
- 状态不保留:模板内的 React 状态会被重置。
- 副作用重新执行 :
useEffect等副作用钩子会重新运行。 - 动画重置:CSS 动画或过渡效果会从头开始播放。
(2)典型应用场景
template.tsx 适用于那些需要"每次进入都重新开始"的场景,比如进入动画、表单重置、埋点统计等。最典型的就是页面切换动画。
如果你希望每次进入页面时都有一个"淡入"或"滑入"的动画,使用 layout.tsx 是很难实现的(因为它不会重新渲染),而 template.tsx 则能完美解决。
typescript
// src/app/dashboard/template.tsx
// 每次导航到 /dashboard 下的页面时,此组件都会重新挂载
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode
}) {
return (
// 利用 key 或组件重新挂载特性触发动画
<div className="animate-fade-in">
{children}
</div>
)
}
(3)layout.tsx 与 template.tsx 对比
为了更直观地理解,我们可以通过下表对比两者的区别:
| 特性 | layout.tsx | template.tsx |
|---|---|---|
| 导航行为 | 持久化(不重新渲染) | 重置(重新挂载) |
| 状态保持 | 保持状态 | 重置状态 |
| 副作用 | 不重新运行 | 重新运行 |
| CSS 动画 | 仅在初次加载时触发 | 每次导航都会触发 |
| 性能 | 更高(复用 DOM) | 稍低(重建 DOM) |
| 适用场景 | 导航栏、侧边栏、页脚 | 页面过渡动画、重置表单状态 |
(4)共存规则
你可以在同一个路由层级同时拥有 layout.tsx 和 template.tsx。在这种情况下,template.tsx 会包裹在 layout.tsx 内部。
文件结构示例:
text
src/app/
├── layout.tsx <-- 根布局 (始终存在)
└── dashboard/
├── layout.tsx <-- 持久化侧边栏
├── template.tsx <-- 页面切换动画容器
└── page.tsx <-- 实际页面内容
渲染层级关系:
text
RootLayout
└── DashboardLayout (持久化)
└── DashboardTemplate (每次导航重新创建)
└── PageContent
选择建议: 默认使用 layout.tsx,只有当需要"每次进入页面都重新执行"的逻辑时,才考虑使用 template.tsx。
3. loading.tsx --- 优雅的加载状态
当页面需要从服务器获取数据时,loading.tsx 提供等待期间的视觉反馈。
typescript
// src/app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="space-y-4">
{/* 骨架屏:用灰色方块模拟内容形状 */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-100 rounded w-full" />
<div className="h-4 bg-gray-100 rounded w-5/6 mt-1" />
</div>
))}
</div>
)
}
(1) 工作原理
Next.js 自动将 page.tsx 包裹在 React 的 <Suspense> 组件中,使用 loading.tsx 作为 fallback。
(2)最佳实践
骨架屏(Skeleton Screen)的体验显著优于旋转 Loading 图标。骨架屏让用户预知内容即将呈现及其大致布局,有效降低等待焦虑。设计时应尽量模拟真实内容的布局比例。
4. error.tsx --- 错误边界处理
任何页面都可能因网络请求失败、数据库异常或代码错误而出错。error.tsx 提供安全网,确保用户看到友好的错误提示,而非白屏或浏览器默认错误页。
typescript
// src/app/blog/error.tsx
// 注意:error.tsx 必须是客户端组件
'use client'
import { useEffect } from 'react'
export default function BlogError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void // 重试函数,尝试重新渲染此路由段
}) {
useEffect(() => {
// 将错误上报至监控系统(如 Sentry)
console.error('Blog section error:', error)
}, [error])
return (
<div className="text-center py-16">
<h2 className="text-2xl font-bold text-gray-800 mb-2">
内容加载失败
</h2>
<p className="text-gray-500 mb-6">
可能是网络问题,请尝试刷新
</p>
<button
onClick={reset}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
重试
</button>
</div>
)
}
关键要求
error.tsx必须是客户端组件 (需添加'use client')- 原因:需捕获客户端渲染错误,且通常涉及事件处理(如重试按钮)
5. not-found.tsx --- 404 页面
当调用 notFound() 函数或路由不存在时,Next.js 将显示此组件。
typescript
// src/app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-300">404</h1>
<p className="mt-4 text-xl text-gray-600">页面未找到</p>
<Link
href="/"
className="mt-6 inline-block px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
返回首页
</Link>
</div>
</div>
)
}
四、路由组:代码组织与 URL 解耦
随着项目规模扩大,app/ 目录会变得复杂,通常我们希望能够对路由进行分组,这就有了路由组(Route Groups) 。路由组通过括号命名目录,实现代码组织与 URL 结构的解耦------括号目录不会出现在最终 URL 中。
1. 目录结构示例
bash
src/app/
├── (auth)/ ← 括号!不出现在 URL 中
│ ├── login/
│ │ └── page.tsx → /login
│ └── register/
│ └── page.tsx → /register
├── (marketing)/
│ ├── about/
│ │ └── page.tsx → /about
│ └── pricing/
│ └── page.tsx → /pricing
└── (app)/
├── layout.tsx ← 此布局仅应用于 (app) 组的页面
├── dashboard/
│ └── page.tsx → /dashboard
└── settings/
└── page.tsx → /settings
2. 核心价值:差异化布局
路由组最实用的能力是为不同页面组应用不同的布局。例如:
- 认证页面(登录、注册)采用居中卡片的极简布局
- 应用页面包含侧边栏导航
- 营销页面使用品牌化的导航栏
通过路由组,三套布局互不干扰:
typescript
// src/app/(auth)/layout.tsx
// 仅 login、register 页面使用此布局
export default function AuthLayout({
children
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
{children}
</div>
</div>
)
}
⚠️ 注意:由于路由分组分组名不会体现在最终 URL 中。如果在不同分组里创建了相同路径的页面(如
/(groupA)/user/page.tsx和/(groupB)/user/page.tsx将会造成路由冲突。请确保跨分组的页面路径唯一。
五、动态路由:URL 参数化处理
动态路由允许路由包含可变的部分,比如博客文章、用户主页、商品详情等页面的id参数。动态路由使用方括号匹配这些可变段。
1. 基础动态路由
bash
src/app/blog/[slug]/page.tsx → /blog/hello-world
/blog/my-first-post
/blog/anything-here
在页面组件中,通过 params 获取动态值:
typescript
// src/app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// 使用 slug 从数据库或 API 获取文章数据
const post = await getPostBySlug(slug)
if (!post) {
// 找不到文章,显示 404
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
Next.js 15 重要变更 :
params现在是 Promise 类型,需要使用await解包。旧版本教程中直接解构{ params }的写法已不适用。
2. 捕获所有路由段
对于不确定数量的路径段(如文档系统),使用 [...slug] 语法,最终的params会被处理成一个数组:
bash
/docs/getting-started
/docs/api/components/button
/docs/guides/authentication/jwt
typescript
// src/app/docs/[...slug]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
// slug 为数组:['getting-started'] 或 ['api', 'components', 'button']
return <div>文档内容</div>
}
六、API 路由:Route Handlers
除了页面组件,Next.js 支持在同一项目中编写 API 接口。在 app/ 目录下的route.ts 文件(而非 page.tsx)定义 HTTP 端点。
1. 基本用法
基本的使用实在route.ts中导出指定HTTP方法名的函数:
typescript
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server'
// 处理 GET /api/posts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = searchParams.get('page') || '1'
const posts = await getPosts({ page: parseInt(page) })
return NextResponse.json(posts)
}
// 处理 POST /api/posts
export async function POST(request: Request) {
const body = await request.json()
const post = await createPost(body)
return NextResponse.json(post, { status: 201 })
}
2. 支持的 HTTP 方法
Route Handlers 支持所有标准 HTTP 方法:
GET、POST、PUT、PATCH、DELETEHEAD、OPTIONS
同一文件中可导出多个函数,分别处理不同的 HTTP 方法。
3. 适用场景
虽然 Route Handlers 功能强大,但在 App Router 中,服务端数据操作有更推荐的方案------Server Actions (详见《表单处理与 Server Actions》)。Route Handlers 更适合以下场景:
- 第三方服务集成:移动 App、其他微服务需要 HTTP 接口
- Webhook 接收端:第三方支付回调、GitHub 事件通知
- 特定 HTTP 语义需求:需要返回特定的 HTTP 状态码、Headers
- 流式响应:SSE(Server-Sent Events)、AI 流式输出
反模式警示 :避免在服务端组件中
fetch自己编写的 Route Handler。应直接在服务端组件中调用数据库或业务逻辑。
七、推荐的项目代码组织结构
上述内容聚焦于 app/ 目录的约定。完整的项目还需考虑组件、工具函数、类型定义的组织方式。
1. 通用项目结构
以下是被广泛采用的目录结构:
bash
src/
├── app/ ← Next.js 路由(仅存放路由相关文件)
│ ├── (auth)/ ← 认证相关页面
│ ├── (main)/ ← 主应用页面
│ └── api/ ← API 路由
├── components/ ← 可复用的 React 组件
│ ├── ui/ ← 纯 UI 组件(Button、Input、Modal 等)
│ └── features/ ← 业务功能组件(PostCard、UserAvatar 等)
├── lib/ ← 工具函数和业务逻辑
│ ├── db.ts ← 数据库客户端配置
│ ├── auth.ts ← 认证相关逻辑
│ └── utils.ts ← 通用工具函数
├── hooks/ ← 自定义 React Hooks
├── types/ ← TypeScript 类型定义
└── styles/ ← 全局样式文件(可选)
2. 设计原则
此结构遵循一个简单原则:app/ 目录仅负责路由,具体逻辑和 UI 组件置于外部。这样设计的优势:
- 便于未来迁移至其他框架
- 方便提取功能为独立库
- 最小化改动范围
3. 文件命名规范
React 社区有两种主流命名风格:
- PascalCase :
UserProfile.tsx(组件文件) - kebab-case :
user-profile.tsx(工具函数、配置文件)
选择哪种风格均可,关键是全项目保持一致。个人建议:
- 组件文件使用 PascalCase
- 其他文件(工具函数、类型定义、配置)使用 kebab-case
八、本章小结
通过本章学习,你应该掌握了:
- 文件系统路由的核心约定:文件路径即 URL 路径
- 特殊文件的用途:layout、loading、error、not-found、template
- 路由组的价值:代码组织与 URL 解耦,支持差异化布局
- 动态路由的实现:
[param]和[...param]语法 - Route Handlers 的基本用法及适用场景
- 推荐的项目代码组织结构与命名规范
下一章《Next.js的路由系统详解》将深入探讨路由系统的高级特性------导航机制、URL 参数处理、并行路由及路由守卫的实现。