Next.js App Router 项目结构、路由系统、特殊文件详解与实践教程
约 4316 字大约 14 分钟
Next.jsReact
2025-08-05
顶级文件夹
npx create-next-app@latest next-app-demo --app
cd next-app-demo
npm run dev
--app
代表我们使用 App Router 模式。Next.js 会自动创建好顶级文件夹结构。
在 app/about/page.tsx
中添加一个页面,之后这个页面会对应 /about
路由:
// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>关于我们</h1>
<p>这是一个测试页面,用来学习 Next.js 路由系统。</p>
</main>
);
}
只要在
app/
下放一个xxx/page.tsx
文件,就会自动生成/xxx
路由!
添加一个公共布局 layout.tsx(页面外壳)
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh">
<head />
<body>
<header style={{ backgroundColor: '#eee', padding: '10px' }}>
<a href="/">首页</a> | <a href="/about">关于</a>
</header>
<hr />
{children}
</body>
</html>
);
}
所有页面都会被包裹在这个 layout.tsx
里,实现统一导航和结构。
你可以把任意一张图片拖进 public/
目录,例如 public/logo.png
,然后我们在首页引用它:
// app/page.tsx
export default function HomePage() {
return (
<main>
<h1>欢迎来到首页</h1>
<img src="/logo.png" alt="Logo" width={150} />
</main>
);
}
{ children: React.ReactNode; }
和 Readonly<{ children: React.ReactNode; }>
的区别:
区别详解
{ children: React.ReactNode; }
- 这个类型声明定义了一个普通的 JavaScript 对象字面量类型。
- 它表示
props
对象中有一个名为children
的属性,其值可以是任何React.ReactNode
类型(包括 React 元素、字符串、数字、布尔值、null、undefined 或这些类型的数组)。 - 可变性: 从 TypeScript 的角度来看,这个对象及其属性在理论上是可变的。这意味着如果你在组件内部尝试重新分配
props.children
(尽管在 React 中这是不推荐且通常没有意义的),TypeScript 默认不会报错。
Readonly<{ children: React.ReactNode; }>
- 这个类型声明使用了 TypeScript 的
Readonly<T>
工具类型。 Readonly<T>
的作用是创建一个新类型,该类型的所有属性都变为readonly
(只读)。- 因此,
Readonly<{ children: React.ReactNode; }>
意味着props
对象本身是只读的,并且它的children
属性也是只读的。 - 不可变性: 在编译时,TypeScript 会强制执行这种只读性。如果你在组件内部尝试重新分配
props.children
或者props
本身(例如props.children = 'new content';
),TypeScript 将会抛出编译错误,因为children
被声明为readonly
。
Readonly
的重要性:
- 强化 React 原则: 在 React 中,一个核心原则是组件的 props 应该是不可变的(Immutable)。组件不应该修改它们接收到的 props。
Readonly
工具类型在 TypeScript 层面显式地强制和表达了这一原则。 - 类型安全: 它提供了一个额外的类型安全层。即使开发者不小心尝试修改了 props,TypeScript 也会在编译时捕获到这个错误,而不是在运行时才发现问题。这有助于避免潜在的 bug。
- 代码意图清晰: 使用
Readonly
明确地向其他开发者传达了该对象或其属性不应该被修改的意图,提高了代码的可读性和可维护性。
总结
在 Next.js 的根布局中,children
属性通常是 React 传递给组件的内容,你几乎永远不会需要修改它。
- 功能上: 两者在运行时行为上没有区别,因为 React 本身就期望 props 是不可变的。
- 类型安全和规范上: 使用
Readonly<{ children: React.ReactNode; }>
是更推荐的做法。它提供了更严格的类型检查,并明确地表达了 React 中 props 应该保持只读的约定。这是一种更好的工程实践。
因此,虽然 { children: React.ReactNode; }
也能工作,但 Readonly<{ children: React.ReactNode; }>
是一个更健壮、更符合 React 和 TypeScript 最佳实践的选择。
路由文件
App Router 的路由文件角色,也就是在 src/app/
里,不同文件名的组件扮演不同的功能角色。
文件名 | 核心职责 | 关键特性 | 何时使用? |
---|---|---|---|
layout.js | 共享UI布局 | 状态保持、不重渲染、可嵌套、必须包含 children | 当你需要为多个页面添加相同的导航栏、页脚或侧边栏时。 |
page.js | 页面独有UI | 定义可公开访问的路由、可异步获取数据、路由树的叶子节点 | 创建一个路由的具体页面内容,例如网站首页、文章详情页。 |
loading.js | 加载状态UI | Suspense集成、自动应用、即时显示、优化用户体验 | 当页面需要时间加载数据时,提供一个临时的加载动画或骨架屏。 |
not-found.js | 404未找到UI | 捕获notFound() 调用或无效URL、比error.js 优先级高 | 当用户访问不存在的页面或内容时,显示一个友好的“未找到”提示。 |
error.js | 运行时错误UI | 必须是客户端组件、隔离错误、可尝试恢复 (reset ) | 捕获页面或其子组件的数据获取失败或其他运行时错误,防止整个应用崩溃。 |
global-error.js | 全局根布局错误UI | 必须是客户端组件、捕获根layout.js 的错误、应用的最终防线 | layout.js 本身发生错误时(如获取全局用户状态失败),提供一个全局的回退页面。 |
route.js | API服务端点 | 不渲染UI、处理HTTP请求 (GET, POST等)、返回Response 对象 | 当你需要创建后端API接口,供前端或其他服务调用时。 |
template.js | 重新渲染的布局 | 状态不保留、每次导航都重新挂载、适用于进入/离开动画 | 当你希望页面在每次切换时都执行某些逻辑(如动画、日志记录)时。 |
default.js | 并行路由回退UI | 仅用于并行路由、避免部分404、提供默认视图 | 在并行路由设置中,当某个路由插槽没有匹配的活动状态时,为其提供一个默认显示内容。 |
layout.tsx - 全局布局
用途:定义网站的整体结构,如导航栏、页脚,它不会因为页面切换而重新渲染,保持状态。 场景:为整个博客添加统一的导航和版权信息。
文件路径:app/layout.tsx
import Link from 'next/link';
import './globals.css';
export const metadata = {
title: 'My Awesome Blog',
description: 'A Next.js 14 blog example.',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<header style={{ padding: '1rem', borderBottom: '1px solid #ccc', background: '#f0f0f0' }}>
<nav>
<Link href="/" style={{ marginRight: '1rem' }}>首页</Link>
<Link href="/posts">文章</Link>
</nav>
</header>
<main style={{ padding: '1rem' }}>{children}</main>
<footer style={{ padding: '1rem', borderTop: '1px solid #ccc', marginTop: '2rem', textAlign: 'center' }}>
© 2024 My Awesome Blog. All rights reserved.
</footer>
</body>
</html>
);
}
page.tsx - 页面内容
用途:定义特定路由路径下的UI内容,是用户访问URL时看到的主体。 场景:创建博客首页和文章列表页。
文件路径:app/page.tsx
(首页),就是 http://localhost:3000/ 和 app/posts/page.tsx
(文章列表页),就是 http://localhost:3000/posts
app/
├── layout.tsx 全局布局
├── page.tsx 首页
├── about/
| └── page.tsx
├── contact/ 联系我们
| └── page.tsx
├── posts/ 文章列表
| └── page.tsx
| └── [id]/
app/page.tsx
:
export default function HomePage() {
return (
<div>
<h1>欢迎来到我的博客!</h1>
<p>在这里你可以找到最新的技术文章和个人思考。</p>
</div>
);
}
app/posts/page.tsx
:
interface Post {
id: string;
title: string;
}
async function getPosts(): Promise<Post[]> {
// 模拟网络延迟和数据获取(假设我们有一个API来获取文章列表)
const res = await fetch('http://localhost:3000/api/posts', { cache: 'no-store' }); // 禁用缓存以便每次都能看到loading
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
<h2>所有文章</h2>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
loading.tsx - 加载UI
用途:当包含异步数据获取的页面或布局正在加载数据时,显示回退UI。 场景:当 app/posts/page.tsx
正在加载数据时,显示一个骨架屏。
文件路径:app/posts/loading.tsx
export default function PostsLoading() {
return (
<div style={{ padding: '1rem', border: '1px dashed #ccc', background: '#f9f9f9' }}>
<h3>正在努力加载文章列表...</h3>
<p>请稍候,精彩内容即将呈现!</p>
{/* 模拟骨架屏 */}
<div style={{ height: '20px', width: '80%', background: '#eee', marginBottom: '10px' }}></div>
<div style={{ height: '20px', width: '90%', background: '#eee', marginBottom: '10px' }}></div>
<div style={{ height: '20px', width: '70%', background: '#eee' }}></div>
</div>
);
}
loading.tsx
的执行机制,App Router 专属
当你访问的路由是 异步组件 + 页面级别,Next.js 会:
- 显示当前路径下的
loading.tsx
(如果存在),否则在上一级目录找loading.tsx
- 等待
page.tsx
的组件异步加载完成 - 渲染实际页面
触发条件总结:
条件 | 会不会触发 loading.tsx |
---|---|
页面是同步组件 | 不会 |
页面组件中使用了 fetch() 、await (服务器组件) | 会 |
页面使用了 React.lazy 、Suspense 加载子组件 | 不会 |
子目录有异步 page.tsx(例如 src/app/contact/form/page.tsx ) | 会 |
示例结构:
src/
├── app/
│ └── contact/
│ ├── loading.tsx 会自动显示
│ ├── form/
│ │ └── page.tsx 这个页面异步加载
当然上述的/form/page.tsx
内部也得是异步组件,否则不会触发loading.tsx
// src/app/contact/form/page.tsx
export default async function FormPage() {
await new Promise(resolve => setTimeout(resolve, 2000));
return <div>表单页面</div>;
}
错误示例(不会触发):
// src/app/contact/page.tsx
import { Suspense } from 'react';
import SlowComponent from './SlowComponent';
export default function Page() {
return (
<Suspense fallback={null}>
<SlowComponent />
</Suspense>
);
}
在加载异步组件的页面中,loading.tsx
不会自动显示。你必须用 <Suspense fallback={<Loading />}>
手动处理。
页面级异步加载 → 用 loading.tsx
组件级懒加载或异步组件 → 用 <Suspense fallback={...}>
not-found.tsx - 404页面
用途:当用户访问了不存在的路径,或者在 RSC/Client Component 中调用 notFound()
函数时,渲染此页面。 场景:为整个博客提供一个自定义的404页面。
文件路径:app/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h1>404 - 页面未找到</h1>
<p>抱歉,您要查找的页面可能已被移动或删除,或者您输入了错误的地址。</p>
<Link href="/" style={{ display: 'inline-block', marginTop: '1.5rem', padding: '0.8rem 1.5rem', background: '#0070f3', color: 'white', borderRadius: '5px', textDecoration: 'none' }}>
返回首页
</Link>
</div>
);
}
可以在 app/posts/[id]/page.tsx
中模拟调用 notFound()
来测试,比如当文章ID不存在时:
// app/posts/[id]/page.tsx 摘录
import { notFound } from 'next/navigation';
async function getPost(id: string) {
const res = await fetch(`http://localhost:3000/api/posts/${id}`);
if (!res.ok) {
if (res.status === 404) {
notFound(); // 如果API返回404,触发not-found.tsx
}
throw new Error('Failed to fetch post');
}
return res.json();
}
error.tsx - 运行时错误UI
用途:为同级或子级的客户端组件或服务器组件捕获运行时错误,并提供一个错误边界。它必须是客户端组件。 场景:当请求单个文章详情数据失败时,局部显示错误信息和重试按钮。
文件路径:app/posts/[id]/error.tsx
'use client'; // 🚩 必须是客户端组件
import { useEffect } from 'react';
export default function PostError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optionally log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div style={{ padding: '1rem', border: '2px solid red', background: '#ffebeb', color: 'red' }}>
<h2>文章加载出错了!</h2>
<p>错误详情: {error.message}</p>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
style={{ marginTop: '1rem', padding: '0.5rem 1rem', cursor: 'pointer' }}
>
尝试重新加载
</button>
</div>
);
}
要测试这个错误页面,你可以修改 app/api/posts/[id]/route.ts
暂时抛出一个错误。
// app/api/posts/[id]/route.ts
export async function GET(request: Request, { params }: { params: { id: string } }) {
throw new Error('Failed to fetch post');
}
global-error.tsx - 全局根布局错误UI
用途:捕获并处理所有未被其他 error.tsx
捕获的、尤其是根 layout.tsx
中发生的错误。它会取代生产环境下的默认错误页面。 场景:当网站加载时发生一些不可恢复的全局性错误(例如根布局中的数据获取失败),提供一个最终的错误回退页面。
文件路径:app/global-error.tsx
'use client'; // 必须是客户端组件
import { useEffect } from 'react';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 这是捕获根布局错误的最后一道防线,应上报到错误监控服务
console.error('全局错误:', error);
}, [error]);
return (
<html>
<body>
<div style={{ textAlign: 'center', padding: '2rem', background: '#f8d7da', color: '#721c24', border: '1px solid #f5c6cb' }}>
<h1>抱歉,网站遇到了一些问题!</h1>
<p>错误详情: {error.message}</p>
<p>请尝试刷新页面,或稍后再试。</p>
<button
onClick={() => reset()}
style={{ marginTop: '1.5rem', padding: '0.8rem 1.5rem', background: '#dc3545', color: 'white', borderRadius: '5px', border: 'none', cursor: 'pointer' }}
>
刷新页面
</button>
</div>
</body>
</html>
);
}
如果要测试这个,你需要在一个更上层、例如 app/layout.tsx 中的数据获取逻辑里,故意抛出一个错误,但是这个错误页面只会显示在开发环境,生产环境会显示默认的错误页面。
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
throw new Error('Failed to fetch layout data');
}
route.ts - API端点
用途:创建后端API接口,不渲染任何UI,只处理HTTP请求并返回数据。 场景:提供获取文章列表和单篇文章详情的API。
文件路径:app/api/posts/route.ts
(文章列表) 和 app/api/posts/[id]/route.ts
(单篇文章)
app/api/posts/route.ts
:
import { NextResponse } from 'next/server';
const dummyPosts = [
{ id: '1', title: '理解 Next.js App Router', content: '这是一篇关于Next.js App Router的深入解析。' },
{ id: '2', title: 'React Server Components 实践', content: '探索React Server Components的强大功能。' },
{ id: '3', title: 'TypeScript 在前端开发中的应用', content: '如何在项目中高效使用TypeScript。' },
];
export async function GET() {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 1000));
return NextResponse.json(dummyPosts);
}
export async function POST(request: Request) {
const newPost = await request.json();
// ... 保存到数据库逻辑
console.log('Received new post:', newPost);
return NextResponse.json({ message: 'Post created', post: newPost }, { status: 201 });
}
app/api/posts/[id]/route.ts
:
import { NextResponse } from 'next/server';
const dummyPosts = [
{ id: '1', title: '理解 Next.js App Router', content: '这是一篇关于Next.js App Router的深入解析。' },
{ id: '2', title: 'React Server Components 实践', content: '探索React Server Components的强大功能。' },
{ id: '3', title: 'TypeScript 在前端开发中的应用', content: '如何在项目中高效使用TypeScript。' },
];
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 500));
const { id } = params;
const post = dummyPosts.find(p => p.id === id);
if (!post) {
return new NextResponse('Post not found', { status: 404 });
}
return NextResponse.json(post);
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const { id } = params;
// ... 删除逻辑
console.log(`Deleting post with ID: ${id}`);
return new NextResponse(null, { status: 204 }); // No Content
}
多个动态参数:
src/app/api/user/[userId]/posts/[postId]/route.ts
对应的访问路径是:
/api/user/42/posts/123
处理函数中:
export async function GET(req: NextRequest, { params }: { params: { userId: string; postId: string } }) {
const { userId, postId } = params;
// ...
}
template.tsx - 重新渲染的布局
用途:每次导航到它的子路由时,它都会重新挂载,使得其内部的状态、副作用和CSS动画能够重新运行。 场景:添加一个全局的页面切换淡入动画,或者在每次页面加载时清除一些临时状态。
文件路径:app/template.tsx
'use client';
import { motion, AnimatePresence } from 'framer-motion'; // 假设安装了framer-motion
import { usePathname } from 'next/navigation';
const pageVariants = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
};
export default function Template({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); // 获取当前路径
return (
// AnimatePresence确保在组件离开时也能执行动画
<AnimatePresence mode="wait">
<motion.div
key={pathname} // 关键:pathname变化时强制重新渲染 motion.div
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
注意:这个例子使用了第三方动画库 framer-motion
。你需要先安装它:npm install framer-motion
default.tsx - 并行路由回退页面
用途:仅用于并行路由 (Parallel Routes)。当一个并行路由插槽没有被URL激活匹配时,它会渲染该插槽的 default.tsx
作为回退内容。 场景:在一个管理面板中,有两个并行区域:@team
(团队列表)和 @metrics
(数据指标)。当用户访问 /dashboard
但没有指定具体的团队或指标视图时,这两个插槽可以显示一个默认的介绍信息。
文件结构:
app/dashboard/
├── layout.tsx # 仪表盘布局
├── page.tsx # 仪表盘主页(可以为空或简单欢迎)
├── @team/
│ ├── page.tsx # 团队列表页
│ └── default.tsx # 团队插槽的默认回退
└── @metrics/
├── page.tsx # 数据指标页
└── default.tsx # 数据指标插槽的默认回退
代码实现:
app/dashboard/layout.tsx
:
import React from 'react';
export default function DashboardLayout({
children,
team, // @team slot
metrics, // @metrics slot
}: {
children: React.ReactNode;
team: React.ReactNode;
metrics: React.ReactNode;
}) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem', padding: '2rem' }}>
<aside style={{ borderRight: '1px solid #eee', paddingRight: '2rem' }}>
<h2>仪表盘导航</h2>
<ul>
<li><a href="/dashboard">总览</a></li>
<li><a href="/dashboard/team">团队视图</a></li>
<li><a href="/dashboard/metrics">数据视图</a></li>
</ul>
<hr style={{ margin: '1rem 0' }} />
{/* 并行路由插槽 */}
<section>
<h3>团队区</h3>
{team}
</section>
<section style={{ marginTop: '1rem' }}>
<h3>指标区</h3>
{metrics}
</section>
</aside>
<main>
{children} {/* 主内容区域,如 app/dashboard/page.tsx */}
</main>
</div>
);
}
app/dashboard/page.tsx
:
export default function DashboardOverviewPage() {
return (
<div>
<h1>仪表盘总览</h1>
<p>欢迎来到您的管理面板。请选择左侧的视图以查看详情。</p>
</div>
);
}
app/dashboard/@team/page.tsx
:
export default function TeamPage() {
return (
<div style={{ border: '1px solid blue', padding: '1rem' }}>
<h4>团队列表</h4>
<p>这里展示所有团队成员详细信息...</p>
<ul>
<li>Alice (Project A)</li>
<li>Bob (Project B)</li>
</ul>
</div>
);
}
app/dashboard/@metrics/page.tsx
:
export default function MetricsPage() {
return (
<div style={{ border: '1px solid green', padding: '1rem' }}>
<h4>核心指标</h4>
<p>这里展示销售数据、用户活跃度等关键指标...</p>
<p>总用户: 12,345</p>
<p>今日活跃: 8,765</p>
</div>
);
}
app/dashboard/@team/default.tsx
:
export default function DefaultTeamSlot() {
return (
<div style={{ border: '1px dashed blue', padding: '1rem', opacity: 0.7 }}>
<p>点击“团队视图”查看团队详情。</p>
</div>
);
}
app/dashboard/@metrics/default.tsx
:
export default function DefaultMetricsSlot() {
return (
<div style={{ border: '1px dashed green', padding: '1rem', opacity: 0.7 }}>
<p>点击“数据视图”查看核心指标。</p>
</div>
);
}
运行效果:
- 访问
http://localhost:3000/dashboard
。dashboard/layout.tsx
会加载,并且由于URL没有激活@team
或@metrics
页面,app/dashboard/@team/default.tsx
和app/dashboard/@metrics/default.tsx
将会被渲染在它们的各自插槽中。 - 访问
http://localhost:3000/dashboard/team
。@team/page.tsx
会被渲染,替换掉default.tsx
。@metrics
仍然显示其default.tsx
。 - 访问
http://localhost:3000/dashboard/metrics
。类似地,@metrics/page.tsx
替换掉其default.tsx
。@team
仍然显示其default.tsx
。
贡献者
更新日志
7eec2
-Document organization于9de0b
-全局优化于b1c4a
-文档迁移于