Next.js从入门到实战保姆级教程(第三章):项目结构与文件系统约定

本系列文章将围绕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.tsxlayout.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.tsxtemplate.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 方法:

  • GETPOSTPUTPATCHDELETE
  • HEADOPTIONS

同一文件中可导出多个函数,分别处理不同的 HTTP 方法。

3. 适用场景

虽然 Route Handlers 功能强大,但在 App Router 中,服务端数据操作有更推荐的方案------Server Actions (详见《表单处理与 Server Actions》)。Route Handlers 更适合以下场景:

  1. 第三方服务集成:移动 App、其他微服务需要 HTTP 接口
  2. Webhook 接收端:第三方支付回调、GitHub 事件通知
  3. 特定 HTTP 语义需求:需要返回特定的 HTTP 状态码、Headers
  4. 流式响应: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 社区有两种主流命名风格:

  • PascalCaseUserProfile.tsx(组件文件)
  • kebab-caseuser-profile.tsx(工具函数、配置文件)

选择哪种风格均可,关键是全项目保持一致。个人建议:

  • 组件文件使用 PascalCase
  • 其他文件(工具函数、类型定义、配置)使用 kebab-case

八、本章小结

通过本章学习,你应该掌握了:

  • 文件系统路由的核心约定:文件路径即 URL 路径
  • 特殊文件的用途:layout、loading、error、not-found、template
  • 路由组的价值:代码组织与 URL 解耦,支持差异化布局
  • 动态路由的实现:[param][...param] 语法
  • Route Handlers 的基本用法及适用场景
  • 推荐的项目代码组织结构与命名规范

下一章《Next.js的路由系统详解》将深入探讨路由系统的高级特性------导航机制、URL 参数处理、并行路由及路由守卫的实现。

相关推荐
水木流年追梦2 小时前
CodeTop Top 300 热门题目3-字符串相加
java·前端·算法
编码七号2 小时前
使用playwright做前端项目的端对端自动化测试
前端·功能测试·自动化
禅思院2 小时前
中篇:构建弹性的异步组件
前端·架构·前端框架
恋猫de小郭2 小时前
为什么 Github Copilot 要收集你数据,也是 AI 订阅以前便宜的原因
前端·人工智能·ai编程
我叫唧唧波2 小时前
【自动化部署】CI/CD 实战(三):让 Argo CD 接管 CD,Jenkins 镜像自动同步到集群
运维·前端·ci/cd·docker·自动化·jenkins·argocd
ZC跨境爬虫2 小时前
UI前端美化技能提升日志day1:矢量图片规范与自适应控制栏实战
前端·css·ui·状态模式
朱穆朗2 小时前
Cmder创建npm等项目中,使用CLI的BUG
前端·npm·bug
Z_Wonderful2 小时前
实现图片拖动、鼠标中心点缩放、文字层跟随功能
前端·javascript·计算机外设
|晴 天|2 小时前
前端项目多平台部署:GitHub Pages + Vercel + Cloudflare Pages 实战教程
前端·javascript·vue.js