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 参数处理、并行路由及路由守卫的实现。

相关推荐
AlienZHOU8 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc12 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师13 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen14 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒14 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow14 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81814 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007814 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明14 小时前
第 6 章 组件进阶
前端·vue.js
江华森14 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端