Next.js 16 从入门到实战:一篇文章掌握全栈开发核心技术
Next.js 16 从入门到实战:一篇文章掌握全栈开发核心技术
本文基于一个真实的 Next.js 学习项目,涵盖路由系统、数据获取策略、API 路由、中间件、图片优化、国际化、性能监控、Server Actions、错误处理、SWC 编译器、Dynamic Import、SSE 流式通信、第三方 SDK 集成、高级路由(Interception/Parallel/Catch-all)、Preview Mode、Automatic Static Optimization、MDX 等核心知识点,适合新手循序渐进地学习。
目录
基础篇
进阶篇
- 九、性能监控与分析
- 十、Server Actions(进阶)
- 十一、错误处理体系
- 十二、动态元数据 generateMetadata
- 十三、Next.js Compiler(SWC 编译器)
- 十四、Dynamic Import 动态导入
- 十五、SSE 流式通信
- 十六、第三方 SDK 集成
- 十七、嵌套 Layout 与加载状态
- 十八、面包屑导航
高级篇
- 十九、高级路由(Interception / Parallel / Catch-all)
- 二十、Preview Mode 草稿预览
- 二十一、Automatic Static Optimization
- 二十二、MDX 支持
- 二十三、Absolute Imports 模块路径别名
- 二十四、API 路由进阶(CORS / Response Helpers / Segment Config)
- 总结
一、环境搭建
1.1 技术栈
| 技术 | 版本 | 用途 |
|---|---|---|
| Next.js | 16.3.0 | 全栈框架(默认 SWC 编译器) |
| React | 19.2.8 | UI 框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化样式 |
| next-intl | 4.x | 国际化 |
| @next/mdx | 16.3.1 | MDX 支持(Markdown + JSX) |
1.2 创建项目
# 使用 create-next-app 创建项目
npx create-next-app@latest my-next-app \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
# 安装 i18n 依赖
npm install next-intl
# 安装 MDX 依赖
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
# 启动开发服务器
npm run dev
1.3 项目目录结构
src/
├── app/ # App Router 目录
│ ├── layout.tsx # 根布局
│ ├── page.tsx # 首页
│ ├── loading.tsx # 全局 Loading
│ ├── error.tsx # 全局错误边界
│ ├── not-found.tsx # 404 页面
│ ├── globals.css # 全局样式
│ ├── mdx-components.tsx # 全局 MDX 组件注册(必需)
│ │
│ ├── about/ # /about
│ ├── login/ # /login
│ ├── dashboard/ # /dashboard(受认证保护)
│ │ ├── page.tsx
│ │ └── loading.tsx
│ │
│ ├── users/ # /users, /users/[id]
│ │ ├── page.tsx
│ │ ├── loading.tsx
│ │ └── [id]/
│ │ ├── page.tsx
│ │ └── loading.tsx
│ │
│ ├── blog/ # /blog, /blog/[slug]
│ │ ├── layout.tsx # ⭐ 嵌套布局(侧边栏常驻)
│ │ ├── loading.tsx
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ ├── page.tsx # ISR: revalidate=60
│ │ └── loading.tsx
│ │
│ ├── performance/ # 性能监控演示
│ ├── api-demo/ # API 演示入口
│ │
│ ├── preview/ # ⭐ Preview Mode 演示
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ │
│ ├── compiler/ # ⭐ SWC 编译器详解
│ │ ├── page.tsx
│ │ ├── CompilerOverview.tsx
│ │ ├── CompilerPipeline.tsx
│ │ ├── CompilerBuildDemo.tsx
│ │ ├── CompilerConfig.tsx
│ │ └── CompilerTransform.tsx
│ │
│ ├── dynamic-import/ # ⭐ Dynamic Import 演示
│ │ └── page.tsx
│ │
│ ├── static-optimization/ # ⭐ 静态优化对比
│ │ ├── page.tsx
│ │ ├── static/page.tsx # 自动静态
│ │ ├── dynamic/page.tsx # cookies() 强制动态
│ │ └── isr/page.tsx # revalidate=10
│ │
│ ├── mdx/ # ⭐ MDX 支持
│ │ ├── page.tsx # 入口
│ │ ├── demo/
│ │ │ ├── page.tsx # 渲染 .mdx
│ │ │ └── content.mdx
│ │ └── guide/
│ │ ├── page.tsx
│ │ └── guide-content.mdx
│ │
│ ├── playground/ # ⭐ 实验场
│ │ ├── page.tsx
│ │ ├── auth/ # 认证演示
│ │ ├── router/ # 路由演示
│ │ ├── middleware/ # 中间件演示
│ │ ├── breadcrumb/ # 面包屑演示
│ │ ├── sse-streaming/ # SSE 流式聊天
│ │ ├── map-demo/ # 高德地图集成
│ │ ├── script-optimization/ # 脚本优化
│ │ └── interception/ # ⭐ 高级路由
│ │ ├── layout.tsx # Parallel Routes 布局
│ │ ├── page.tsx
│ │ ├── photos/
│ │ │ ├── page.tsx # 完整列表页
│ │ │ └── [id]/page.tsx # 完整详情页
│ │ └── @modal/ # ⭐ Parallel Route 插槽
│ │ ├── default.tsx
│ │ └── (..)photos/[id]/page.tsx # ⭐ 拦截路由
│ │
│ ├── actions/ # Server Actions
│ │ └── auth.ts
│ │
│ └── api/ # API Routes(详见第四章)
│ ├── auth/{login,logout,me}/route.ts
│ ├── chat/stream/route.ts # SSE 流式 API
│ ├── users/[{id}]/route.ts # 动态 API 路由
│ ├── hello/route.ts # Hello World
│ ├── cors-demo/route.ts # CORS 跨域演示
│ ├── response-helpers/route.ts # NextResponse 助手
│ ├── segment-config-demo/route.ts # Segment Config
│ ├── files/[...slug]/route.ts # Catch-all 路由
│ ├── middleware-demo/route.ts # 中间件演示
│ ├── preview/{enter,exit}/route.ts # 预览模式 API
│ └── segment-config-demo/route.ts
│
├── components/ # 组件目录
│ ├── Header.tsx / Footer.tsx # 布局组件
│ ├── Sidebar.tsx # ⭐ 中后台风格侧边栏
│ ├── LanguageSwitcher.tsx # 语言切换
│ ├── WebVitalsMonitor.tsx # 性能监控
│ ├── OptimizedAvatar.tsx # 图片优化
│ ├── Breadcrumb.tsx # ⭐ 面包屑导航
│ ├── Skeleton.tsx # ⭐ 加载骨架屏
│ ├── SSEChatDemo.tsx # ⭐ SSE 流式聊天组件
│ ├── AMapLoader.tsx # ⭐ 高德地图加载器
│ ├── MapDemo.tsx # 地图演示
│ ├── ChatWidget.tsx # 客服聊天
│ ├── PreviewToggle.tsx # ⭐ 预览模式切换
│ ├── AuthStatus.tsx # 认证状态
│ ├── PhotoModalContent.tsx # ⭐ 拦截路由弹窗
│ ├── Counter.tsx / Card.tsx # 通用组件
│ ├── AdSense.tsx / AdUnit.tsx # 广告组件
│ ├── Analytics.tsx / PageTracker.tsx # GA 分析
│ ├── PerformanceTestClient.tsx # 性能测试
│ ├── UserListClient.tsx # 用户列表
│ ├── mdx-components.tsx # MDX 自定义组件
│ └── demos/ # Demo 组件
│ ├── HeavyChart.tsx # Dynamic Import 重型组件
│ ├── ClientOnlyComponent.tsx # ssr:false 演示
│ └── MarkdownViewer.tsx
│
├── i18n/ # 国际化配置
│ ├── config.ts
│ ├── request.ts
│ └── messages/{zh,en}.json
│
├── lib/ # 工具库
│ ├── auth.ts # 客户端认证
│ ├── auth-server.ts # 服务端认证
│ ├── data.ts # 数据源
│ ├── cms-data.ts # ⭐ CMS 草稿数据(Preview Mode)
│ └── photos.ts # ⭐ 拦截路由共享数据
│
└── proxy.ts # 中间件(认证 + i18n)
二、路由系统
2.1 文件路由约定
Next.js 采用文件系统路由,每个文件夹对应一个 URL 路径:
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── users/
│ ├── page.tsx → /users
│ └── [id]/
│ └── page.tsx → /users/1, /users/2 ...
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/nextjs-tutorial
└── dashboard/
└── page.tsx → /dashboard
2.2 动态路由
用方括号 [param] 表示动态参数,在组件中通过 useParams() 获取:
// app/users/[id]/page.tsx
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
// Next.js 16 中 params 是 Promise,需要 await
const { id } = await params;
const user = await getUser(parseInt(id));
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
2.3 嵌套路由与 Layout
每一级目录可以放一个 layout.tsx,它会包裹该目录下所有页面:
app/
├── layout.tsx ← 根布局(Header + Footer)
├── users/
│ ├── layout.tsx ← 用户列表专属布局(可选)
│ ├── page.tsx ← 被 users/layout.tsx 包裹
│ └── [id]/
│ └── page.tsx ← 也被 users/layout.tsx 包裹
2.4 路由文件种类
| 文件 | 作用 | 渲染时机 |
|---|---|---|
page.tsx | 页面内容 | URL 匹配时渲染 |
layout.tsx | 布局包裹 | 该目录及子目录所有页面 |
loading.tsx | 加载骨架屏 | 页面加载时自动显示 |
error.tsx | 错误边界 | 子树抛出运行时错误 |
not-found.tsx | 404 页面 | 路由不存在或调用 notFound() |
default.tsx | Parallel Route 默认 | 插槽没有匹配内容时 |
mdx-components.tsx | MDX 全局组件注册 | 启用 MDX 时必需 |
2.5 客户端导航 Hook(useRouter / usePathname / useParams / useSearchParams)
"use client";
import { useRouter, usePathname, useParams, useSearchParams } from "next/navigation";
export default function RouterDemo() {
const router = useRouter(); // 命令式导航
const pathname = usePathname(); // 当前路径
const params = useParams(); // 动态参数
const searchParams = useSearchParams(); // 查询参数
return (
<div>
<p>当前路径: {pathname}</p>
<button onClick={() => router.push("/users")}>跳转</button>
<button onClick={() => router.back()}>返回</button>
</div>
);
}
| Hook | 返回值 | 典型场景 |
|---|---|---|
useRouter() | router 对象 | 命令式导航(push/replace/back) |
usePathname() | 当前路径字符串 | 导航高亮、面包屑 |
useParams() | 动态路由参数 | 获取 [id]、[slug] |
useSearchParams() | URLSearchParams | 读取查询字符串 |
2.6 学习答疑:路由参数与跳转常见误区 ✍️
本节整理学习过程中容易混淆的几个点,避免以后踩坑。
🔍 疑惑 1:Link href="/blog/${post.slug}" 真的能匹配到 blog/[slug]/page.tsx 吗?
完全正确。 这就是 Next.js 文件系统路由的核心约定:
Link href="/blog/hello-world"
↓
匹配目录 src/app/blog/[slug]/
↓
动态段 [slug] 被赋值为 "hello-world"
↓
页面组件通过 params.slug 拿到 "hello-world"
只要你的目录名是 [slug](方括号),Link 里填什么 URL,就会把对应位置的内容塞进去。
🔍 疑惑 2:两种参数——什么情况下用 params,什么情况下用 searchParams?
这是最容易混淆的点,两者完全独立,互不干扰:
| 类型 | 来源 URL 位置 | 声明方式 | 典型用途 |
|---|---|---|---|
| 动态路由 params | 问号 前 的路径段 | 目录名写 [slug] | 主键、slug 等唯一标识资源的参数 |
| 查询参数 searchParams | 问号 后 的键值对 | 不用声明,自由传递 | 排序、筛选、页码、追踪来源等附加信息 |
例子:
/blog/hello-world?utm=wechat&sort=new
↑ params.slug ↑ searchParams
记忆口诀:路径里的用 params,问号后的用 searchParams
🔍 疑惑 3:一个页面能不能同时拿到 params 和 searchParams?两种接收方式分别是什么?
可以!完全能同时拿。 但 Server Component 和 Client Component 的写法不同:
✅ Server Component(默认,推荐,不写 use client)
通过函数的两个入参直接拿到:
// src/app/blog/[slug]/page.tsx — 默认就是 Server Component
export default async function BlogDetailPage({
params, // 第 1 个入参:动态路由参数
searchParams, // 第 2 个入参:查询参数
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ utm?: string; sort?: string }>;
}) {
// ⚠️ Next.js 16 / React 19 起:两者都是 Promise,必须 await
const { slug } = await params;
const { utm, sort } = await searchParams;
// 同时用两个参数
const post = await fetchPost(slug);
if (utm === "hotlist") trackFromHotList();
if (sort === "new") { /* 重新排序评论 */ }
}
也适用于 generateMetadata:
export async function generateMetadata({ params, searchParams }) {
const { slug } = await params;
// 一样的用法
}
⚠️ Client Component(写 use client,需要交互时才用)
通过 hook 拿,而且需要 React.use() 解包(Next.js 16 / React 19 异步 API 新约定):
"use client";
import { useParams, useSearchParams } from "next/navigation";
import { use } from "react";
export default function InteractivePage() {
// 动态路由参数
const params = use(useParams<{ slug: string }>());
// 查询参数
const searchParams = use(useSearchParams());
console.log(params.slug); // "hello-world"
console.log(searchParams.get("utm")); // "wechat"
}
🔍 疑惑 4:拿到参数后需要做进一步处理,就必须写 use client 吗?
错!判断标准不是"有没有处理数据",而是"用不用浏览器能力"。 看下面这个分类:
👇 这些事情 Server Component 都能干(不写 use client):
// ❌ 没有 use client,照样做各种"处理"
export default async function BlogDetailPage({ params, searchParams }) {
const { slug } = await params;
const { sort, theme } = await searchParams;
const post = await fetchPost(slug); // 查数据库
const related = await fetchRelated(slug); // 查关联
const filtered = related.filter(p => sort === "new" ? p.new : true); // 过滤
const readingTime = Math.ceil(post.content.length / 500); // 计算
const bg = theme === "dark" ? "#000" : "#fff"; // 条件判断
return <article style={{ background: bg }}>...</article>;
}
拿到参数后做数据查询、计算、条件渲染、拼 JSX——再复杂都不用写 use client。
👇 只有这些情况才必须写 use client:
- 使用 React Hook:
useState、useEffect、useRef、useRouter、useSearchParams、useParams… - 使用 浏览器 API:
window、document、localStorage、navigator.geolocation… - 有 用户交互事件:
onClick、onChange、受控表单输入的value绑定…
一句话判断标准:
🎯 拿数据、算数据、画页面 → Server Component 直接干;点一下、改个值、读浏览器 → use client。
🔍 疑惑 5:最佳实践——既需要拿两种参数,又需要交互怎么办?
强烈推荐:Page 保持 Server Component,交互部分拆成独立 Client 子组件。
src/app/blog/[slug]/
├── page.tsx ← Server Component:拿 params/searchParams、查数据、渲染主体
└── components/
└── LikeButton.tsx ← Client Component(写 use client):只做点赞按钮的交互
示例代码:
// page.tsx — 保持 Server,不写 use client
export default async function Page({ params, searchParams }) {
const { slug } = await params;
const { utm } = await searchParams;
const post = await fetchPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* 交互部分拆出去,需要的数据通过 props 传入 */}
<LikeButton initialCount={post.likes} postSlug={slug} />
</article>
);
}
// LikeButton.tsx — 只有这一个文件写 use client
"use client";
import { useState } from "react";
export function LikeButton({ initialCount, postSlug }) {
const [count, setCount] = useState(initialCount);
return <button onClick={() => setCount(c => c + 1)}>👍 {count}</button>;
}
好处:
- SEO 友好(服务端渲染文章主体)
- 首屏速度快(Server 直出 HTML,不用等客户端 JS)
- 体积小(只有点赞按钮那一小块客户端代码)
🔍 疑惑 6:generateStaticParams / generateMetadata / revalidate 这些名字能随便写吗?
绝对不能!必须严格按约定名字写。
它们是 Next.js 的约定式具名导出(Convention Exports)。Next.js 在构建和运行时会扫描 page.tsx / layout.tsx 文件的具名导出,只有匹配到特定名字才会自动调用对应逻辑。
打个比方:考试答题纸
Next.js 只会按题号(generateMetadata / generateStaticParams)位置找答案
你把答案随便写在空白处叫 myBuildParams(),老师(Next.js)直接略过不看
如果改错名字会怎样:
// ❌ 错误:自己起的名字 buildParamsList — Next.js 不认识,永远不会被调用
export async function buildParamsList() {
return posts.map(p => ({ slug: p.slug }));
}
// ❌ 错误:自己起的名字 genMeta — 自定义 SEO 标签不会被注入
export async function genMeta({ params }) {
return { title: "文章标题" };
}
// ✅ 正确:必须用约定名字
export async function generateStaticParams() { ... }
export async function generateMetadata() { ... }
export const revalidate = 60;
结果(踩坑实录):
generateStaticParams改错名 → 动态路由不会预生成任何静态页面,每次访问走 SSRgenerateMetadata改错名 → 博客详情页的<title>回退到layout.tsx的默认标题,SEO 崩了revalidate改错名 → ISR 失效,页面永远不重新校验
同类的约定式导出还有哪些(一整套机制):
| 导出名 | 写在哪 | 作用 |
|---|---|---|
generateStaticParams() | page.tsx | 动态路由 SSG:告诉 Next.js 预渲染哪些 slug/id |
generateMetadata() | page.tsx / layout.tsx | 生成 <title>、<meta>、OG 图等 SEO 元数据 |
generateViewport() | page.tsx / layout.tsx | 生成 viewport / theme-color 元数据 |
generateSitemaps() | app/sitemap.ts | 生成 sitemap.xml |
generateRobots() | app/robots.ts | 生成 robots.txt |
generateImageMetadata() | app/favicon.ts / opengraph-image.tsx | 生成 favicon / OG 图片清单 |
revalidate | page.tsx / layout.tsx | ISR 重生成秒数(博客详情页的 export const revalidate = 60) |
dynamic | page.tsx / layout.tsx | 强制渲染模式:'force-static' / 'force-dynamic' |
runtime | page.tsx / layout.tsx / route.ts | 运行时:'nodejs' / 'edge' |
一句话记忆:
🎯 约定式导出认名字不认功能。写错名字代码逻辑再对也不会被 Next.js 调用。
🔍 疑惑 7:layout.tsx / page.tsx 里的组件函数名(比如 InterceptionLayout)也必须叫这个名字吗?
不需要!可以随便起。
这个和疑惑 6 的 generateStaticParams / generateMetadata 是两套完全不同的机制,千万别混。
Next.js 里的「名字」分两类:
| 类型 | 名字要求 | 例子 |
|---|---|---|
| 约定式具名导出 | ❌ 必须一字不差 | generateStaticParams / generateMetadata / revalidate / dynamic / runtime |
| 默认导出组件 | ✅ 随便起 | page.tsx / layout.tsx / loading.tsx / error.tsx / not-found.tsx / template.tsx 里的 export default function XXX() |
关键判断标准:看有没有 default 关键字。
// layout.tsx —— 默认导出,名字随便起
export default function InterceptionLayout({ children, modal }) { ... }
export default function MyLayout({ children, modal }) { ... } // ✅ 也行
export default function Layout({ children, modal }) { ... } // ✅ 也行
export default function ({ children, modal }) { ... } // ✅ 匿名也行
Next.js 只关心三件事:
- ✅ 文件名是不是
layout.tsx(按文件名识别角色) - ✅ 是不是
export default(按导出方式取组件) - ✅ props 接收对不对(
children/modal等)
Next.js 不关心:
- ❌ 函数叫什么名字
项目里的验证(都是合法的):
src/app/layout.tsx的函数叫RootLayoutsrc/app/playground/interception/layout.tsx的函数叫InterceptionLayout
这些名字只是给开发者看的,方便阅读代码时理解意图。换成 Layout、Foo、Bar 也能跑,只是不利于阅读。
对比记忆:
| 机制 | 比喻 |
|---|---|
约定式具名导出(generateMetadata 等) | 考试答题纸:必须写在「第 3 题第 2 行」对应位置,老师才改 |
默认导出(page / layout 等组件) | 寄快递:只要贴对「快递单」(export default + 正确文件名),里面东西叫什么名字快递员根本不管 |
一句话判断标准:
🎯 有
default关键字 → 函数名随便起;没有default(比如export const revalidate、export function generateMetadata)→ 名字必须一字不差。
🔍 疑惑 8:访问 /playground/interception/photos/1 时,进入的是哪个文件?
关键认知:同一个 URL,根据「进入方式」不同,进入的文件完全不同!
URL: /playground/interception/photos/1
↓
你是怎么进来的?
↓
┌───────────┴───────────┐
↓ ↓
软导航 硬导航
(<Link> 点击) (地址栏输入/刷新/新标签页)
↓ ↓
@modal/(..)photos/ photos/
[id]/page.tsx [id]/page.tsx
(弹窗版本) (完整详情页)
两种情况详细对比:
| 进入方式 | 触发文件 | 渲染内容 | layout.tsx 此时收到 |
|---|---|---|---|
<Link> 点击(软导航) | @modal/(..)photos/[id]/page.tsx | 弹窗 + 背景列表 | children=列表页 + modal=弹窗 |
| 地址栏输入/刷新(硬导航) | photos/[id]/page.tsx | 完整详情页 | children=详情页 + modal=null |
为什么 <Link> 能触发拦截?
<Link>是客户端路由(软导航),Next.js 知道用户"从哪来"(有来源上下文)<a>或地址栏输入是浏览器原生跳转(硬导航),Next.js 拿不到来源信息
亲自验证 3 种操作:
- 列表页点照片 → 弹窗(软导航 →
@modal/...) - 弹窗状态按 F5 刷新 → 完整详情页(刷新变成硬导航 →
photos/[id]/...) - 弹窗状态右键复制链接 → 新标签页打开 → 完整详情页(新标签页是硬导航)
🎯 URL 决定"去哪",进入方式决定"看哪个文件"。
🔍 疑惑 9:用 <a> 跳转是不是就不需要 @modal 了?
技术上对,但思路错位了。 这不是"<a> 让 @modal 失效",而是"你主动放弃了弹窗功能"。
核心认知升级:从机制导向翻转到产品导向
❌ 错误的思考路径(机制导向):
“我用了
<Link>所以需要@modal,用<a>就不需要@modal了”
✅ 正确的思考路径(产品导向):
“我的产品要不要弹窗体验?”
- 要弹窗 →
<Link>+@modal+layout.tsx(一套组合拳,缺一不可)- 不要弹窗 →
<Link>+photos/[id]/page.tsx(直接走完整页,删掉@modal)
为什么永远别用 <a> 跳转内部页面?
| 对比项 | <Link> | <a> |
|---|---|---|
| 页面刷新 | ❌ 不刷新(客户端路由) | ✅ 完整刷新 |
| 跳转速度 | ⚡ 秒开(只加载变化的组件) | 🐢 慢(重新加载所有 JS/CSS) |
| 预取(prefetch) | ✅ 鼠标悬停就预取 | ❌ 无 |
| 状态保留 | ✅ 全局状态/Context 不丢 | ❌ 全部重置 |
| 用户体验 | ✅ 类似 SPA,丝滑 | ❌ 传统多页,白屏闪烁 |
🎯
<Link>和@modal是配套关系,不是"用谁决定要不要谁"。决定权在你想要什么产品体验,而不是用什么标签。
🔍 疑惑 10:InterceptionLayout 看起来没必要,能不能删掉?
绝对不能删!它是整个拦截路由机制的「承重墙」。
路由匹配的理解是对的:
/playground/interception → page.tsx ✅
/playground/interception/photos → photos/page.tsx ✅
/playground/interception/photos/1 → photos/[id]/page.tsx(硬导航时)✅
但漏掉的关键:点击照片(软导航)时,实际进入的是另一个文件!
点击照片(软导航) → @modal/(..)photos/[id]/page.tsx ← 弹窗版本
直接输入 URL(硬导航) → photos/[id]/page.tsx ← 完整详情页
问题来了:弹窗内容要怎么同时显示在屏幕上?列表页又怎么保持不消失?
答案就是 InterceptionLayout。看 [layout.tsx](file:///d:/11-练习/my-next-app/src/app/playground/interception/layout.tsx#L37-L53):
export default function InterceptionLayout({
children, // ← 主内容:列表页 / 介绍页 / 完整详情页
modal, // ← 弹窗内容:@modal 插槽里渲染的组件
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children} {/* 👈 背景内容(列表页保持可见) */}
{modal} {/* 👈 弹窗内容(overlay 在上面) */}
</div>
);
}
modal 这个 prop 就是 Parallel Routes 的核心机制:
@modal目录是 Parallel Route 的「插槽」- Next.js 渲染时,会把这个插槽的内容作为
modalprop 注入到 layout.tsx - 如果没有 layout.tsx 接收
modalprop,这个插槽根本无处可去
删掉 InterceptionLayout 的后果:
@modal插槽没有地方渲染 → Next.js 报错或忽略插槽- 点击照片 → 弹窗组件永远不会显示在页面上
- 拦截路由机制完全失效 → 退化成普通路由跳转
- 整个
@modal/(..)photos/[id]/page.tsx全废了
图解三个文件的协作:
URL: /playground/interception/photos/1(从列表点击进入)
┌─────────────────────────────────────────────┐
│ InterceptionLayout (layout.tsx) │
│ ┌─────────────────────────────────────┐ │
│ │ children ← photos/page.tsx │ │ ← 背景列表页保持可见
│ │ (列表页内容) │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ modal ← @modal/(..)photos/[id] │ │ ← 弹窗浮在上面
│ │ (PhotoModalContent 组件) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
🎯
InterceptionLayout不是可选装饰,是拦截路由机制的承重墙。它通过modalprop 接收@modal插槽内容,把弹窗和背景页面同时渲染到屏幕上。
🔍 疑惑 11:什么是「弹窗体验」?跳转后页面不刷新就是弹窗吗?
不是!「不刷新页面」不等于「弹窗体验」。 完整详情页跳转也不刷新,但那不是弹窗。
弹窗体验 = 当前页面不消失 + 一个浮层弹出来盖在上面。
两个关键词:
- 当前页面不消失(背景还在)
- 浮层盖在上面(弹出来的东西)
区分三个容易混的概念:
| 概念 | 是否刷新页面 | 背景页面是否保留 | 是不是弹窗 |
|---|---|---|---|
| 完整详情页跳转 | 不刷新(用 <Link>) | ❌ 列表页消失 | ❌ 不是弹窗 |
| 弹窗体验 | 不刷新 | ✅ 列表页保留 | ✅ 是弹窗 |
window.alert() | 不刷新 | ✅ 保留但卡住 | ❌ 浏览器原生弹窗 |
弹窗体验的视觉效果:
点击照片 #1 之前:
┌─────────────────────────────────┐
│ 📸 照片列表 │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ #1 │ │ #2 │ │ #3 │ │
│ └─────┘ └─────┘ └─────┘ │
└─────────────────────────────────┘
点击照片 #1 之后(弹窗体验):
┌─────────────────────────────────┐
│ 📸 照片列表 ┌──────┤ ← 弹窗浮在列表上面
│ ┌─────┐ ┌─────┐ ┌─────┐ │ 照片1 │ 背景列表还在!
│ │ #1 │ │ #2 │ │ #3 │ │ 详情 │
│ └─────┘ └─────┘ └─────┘ └──────┤
│ 半透明遮罩 │
└─────────────────────────────────┘
URL: /photos/1 ← 变了!
但列表页没消失!弹窗盖在上面!
「秒开」的真正原因(两层):
第一层:不刷新
传统跳转: Next.js 软导航 + 弹窗:
浏览器重新加载 只请求弹窗需要的数据
HTML/CSS/JS 列表页 DOM 完全保留
白屏 → 渲染 直接渲染弹窗
~500ms ~50ms
第二层:列表页的 DOM 状态完全保留
场景:列表页你滚到了底部,然后点击照片
传统跳转:
├── 弹窗关闭后
└── 回到列表页顶部(滚动位置丢了)❌
弹窗体验:
├── 弹窗关闭后
└── 回到列表页你刚才滚动的位置 ✅(DOM 完全保留,滚动位置也在)
这才是弹窗体验的真正价值:用户上下文不丢失。
完整对比表:
| 指标 | 完整详情页跳转 | 弹窗体验 |
|---|---|---|
| 页面刷新 | ❌ 不刷新 | ❌ 不刷新 |
| 列表页 DOM | ❌ 销毁重建 | ✅ 完全保留 |
| 滚动位置 | ❌ 丢失 | ✅ 保留 |
| 输入框内容 | ❌ 丢失 | ✅ 保留 |
| 数据请求 | 详情页数据 | 弹窗数据 |
| 渲染耗时 | 重建整个页面 | 只渲染弹窗 |
| 用户体验 | 跳转感强 | 平滑过渡 |
为什么有了弹窗还要保留完整详情页?
因为弹窗体验有失效场景,需要兜底:
- 用户在弹窗状态按 F5 刷新 → URL 还是
/photos/1,但弹窗没了 - 用户复制链接 → 新标签页打开 → 新标签是硬导航,不触发拦截
- 用户把链接发给朋友 → 朋友打开是硬导航
- 搜索引擎爬虫抓取 → 爬虫不点 Link,直接抓 URL
所以必须有「兜底方案」:当弹窗失效时,完整详情页接管,保证刷新不白屏、链接可分享、SEO 可抓取。
这就是为什么项目里有两个文件共享同一个 URL:
photos/[id]/page.tsx ← 完整详情页(兜底)
@modal/(..)photos/[id]/page.tsx ← 弹窗版本(软导航时)
一句话总结(升级版):
🎯 弹窗体验 = 旧页面不消失 + 新内容浮层覆盖 + DOM 状态全保留。
- 用户体验:秒开 + 无白屏 + 滚动位置/输入状态全保留
- 技术实现:
<Link>软导航 +@modal插槽 +layout.tsx同屏渲染children + modal- 工程兜底:两个文件共享同一 URL,硬导航时自动降级为完整详情页,保证可分享/可刷新/可 SEO
这就是 Next.js Interception Routes 的精髓:兼顾用户体验和工程健壮性。
🔍 疑惑 12:Catch-all 路由 [...slug] 会拦截所有 GET 请求吗?
绝对不是! [...slug] 不是「全局拦截器」,它只拦截特定 URL 前缀下的所有子路径。
❌ 常见错误理解:
以为:任何 GET 请求都会被 [...slug]/route.ts 捕获
/、/users、/blog/xxx、/api/chat、/api/files/xxx
↓
全部进入 [...slug]/route.ts ❌ 错!
如果真这样,其他 page.tsx / route.ts 全都失效了,整个项目就乱套了。
✅ 正确理解:前缀由文件位置决定,只捕获该前缀下的子路径
文件路径:src/app/api/files/[...slug]/route.ts
↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
文件位置决定了能捕获的 URL 前缀
✅ 能捕获的 URL(必须以 /api/files/ 开头):
✅ /api/files/a
✅ /api/files/a/b
✅ /api/files/a/b/c/d/e/f/g/h (无限层)
✅ /api/files/images/2024/jan/photo.jpg
❌ 不能捕获的 URL(前缀不对):
❌ / (不是 /api/files/ 开头)
❌ /users (不是 /api/files/ 开头)
❌ /blog/hello (不是 /api/files/ 开头)
❌ /api/chat (是 /api/chat,不是 /api/files)
❌ /api/files ([...slug] 要求至少一层,根路径不匹配)
前缀是固定的,只有后面部分才是 catch-all 的通配范围。
图解:固定前缀 + 通配部分
固定前缀 通配部分
┌──────┐ ┌──────────────┐
URL: /api/files/ images/2024/jan
└──────┘ └──────────────┘
↑ ↑
必须完全匹配 任意层级都行
(由文件位置决定) [...slug] 收集这部分
项目里的路由分工(各管各的,互不干扰):
app/
├── api/
│ ├── files/
│ │ └── [...slug]/route.ts ← 只管 /api/files/*
│ ├── chat/
│ │ └── ai/route.ts ← 只管 /api/chat/ai
│ └── users/
│ └── [id]/route.ts ← 只管 /api/users/{id}
├── blog/
│ └── [slug]/page.tsx ← 只管 /blog/{slug}
└── page.tsx ← 只管 /
类比记忆:
把 [...slug] 想象成小区门口的保安:
| 比喻 | 对应代码 |
|---|---|
| 保安站岗的位置 | 文件在 app/ 目录下的位置 |
| 小区访客 | 以该前缀开头的 URL 请求 |
| 登记所有楼层访客 | [...slug] 收集所有子路径段 |
| 不管其他小区访客 | 不捕获其他前缀的 URL |
不是"全市所有保安管所有访客",是"指定小区的保安管本小区所有楼层访客"。
🔍 疑惑 13:[...slug] 和 [[...slug]] 有什么区别?
两种 catch-all 写法,差别在于**「根路径能不能匹配」**:
| 写法 | 名称 | /api/files 能匹配吗 | 典型用法 |
|---|---|---|---|
[...slug] | 必填 catch-all | ❌ 不能(至少要有一层) | 文件浏览、多级分类 |
[[...slug]] | 可选 catch-all | ✅ 能(slug=[] 空数组) | 列表 + 详情合一页 |
具体对比:
[...slug] (单括号,必填)
├── /api/files/a ✅ slug=["a"]
├── /api/files/a/b ✅ slug=["a","b"]
└── /api/files ❌ 不匹配!(至少要有一层)
[[...slug]] (双括号,可选)
├── /api/files/a ✅ slug=["a"]
├── /api/files/a/b ✅ slug=["a","b"]
└── /api/files ✅ slug=[] (空数组也匹配)
本项目用的是 [...slug](单括号),所以 /api/files(不带子路径)不会被捕获。
params.slug 的类型也不同:
// 普通 [slug]
{ params: { slug: string } } // 字符串
// Catch-all [...slug] / [[...slug]]
{ params: { slug: string[] } } // 字符串数组!注意是数组
🔍 疑惑 14:Catch-all 路由到底解决什么问题?什么时候该用?
核心价值:用一个文件处理「深度不确定」的 URL 路径。
❌ 不用 catch-all 的痛苦场景(实现文件系统浏览):
假设要支持这些 URL(文件夹深度不确定):
/files/images/2024/jan/photo.jpg (4层)
/files/documents/work/report.pdf (4层)
/files/videos/2023/summer/vacation (4层)
用普通 [slug] 路由要这样写:
app/files/
├── [category]/page.tsx ← 匹配 /files/images
└── [category]/[year]/page.tsx ← 匹配 /files/images/2024
└── [category]/[year]/[month]/page.tsx ← 匹配 /files/images/2024/jan
└── [category]/[year]/[month]/[name]/page.tsx ← 匹配 4 层
└── ... 还要 5 层?6 层?无穷无尽
问题: 每多一层路径,就要多建一层文件夹,永远写不完。
✅ 用 catch-all 一个文件搞定:
app/api/files/[...slug]/route.ts ← 一个文件匹配所有层级!
典型应用场景:
| 场景 | URL 例子 | 为什么用 catch-all |
|---|---|---|
| 文件管理器 | /files/docs/2024/q1/report.pdf | 文件夹深度不确定,可能 3 层可能 7 层 |
| 电商分类 | /shop/electronics/laptops/gaming | 分类层级是动态的,不能写死 |
| WordPress 风格 URL | /blog/tech/frontend/react/hooks | 文章路径可能是任意层级 |
| 代理转发 | /api/proxy/service-a/users/123 | 把 /api/proxy/* 全部转发到另一个服务 |
| 文档系统 | /docs/api/authentication/oauth2/google | 文档目录树层级不定 |
项目里的实际代码([route.ts L78-L123](file:///d:/11-练习/my-next-app/src/app/api/files/[…slug]/route.ts#L78-L123)):
function generateMockData(slug: string[]) {
const firstSegment = slug[0]; // 取第一段做分类判断
switch (firstSegment) {
case "images": // /api/files/images/... → 返回图片数据
return { type: "image-folder", files: [...] };
case "documents": // /api/files/documents/... → 返回文档数据
return { type: "document-folder", files: [...] };
case "products": // /api/files/products/... → 返回产品数据
return { type: "product-category", ... };
}
}
一个文件就能响应所有这些 URL:
GET /api/files/images → slug = ["images"]
GET /api/files/images/2024 → slug = ["images", "2024"]
GET /api/files/images/2024/jan → slug = ["images", "2024", "jan"]
GET /api/files/documents/report → slug = ["documents", "report"]
GET /api/files/products/electronics/laptops → slug = ["products","electronics","laptops"]
一句话总结:
🎯 Catch-all 路由 = 「在固定前缀下,用一个文件通配任意层级」的路由模式。
- 前缀:由文件在
app/目录下的位置决定- 通配:
[...slug]收集前缀后面所有路径段,组成数组- 不是全局拦截器:只捕获该前缀下的请求,其他 URL 各走各的路由
- 适用场景:路径深度不确定的功能(文件系统、多级分类、代理转发)
类比:指定小区的保安管本小区所有楼层访客,不是全市保安管所有访客。
🔍 疑惑 15:Preview Mode 草稿预览是通过 cookie 判断作者/用户身份吗?
完全正确! 本项目就是用自定义 cookie 实现的,用真实代码走一遍。
1️⃣ 约定字段:preview_mode cookie
看 [enter/route.ts L18-L24](file:///d:/11-练习/my-next-app/src/app/api/preview/enter/route.ts#L18-L24):
response.cookies.set("preview_mode", "true", {
httpOnly: true,
maxAge: 60 * 60, // 1 小时有效
});
// ↑
// 约定的字段名 约定的值
约定的字段:字段名 preview_mode,值 true = 作者模式,没有这个 cookie(或 false)= 普通用户。
2️⃣ 判断身份:读取 cookie
看 [preview/[slug]/page.tsx L25-L26](file:///d:/11-练习/my-next-app/src/app/preview/[slug]/page.tsx#L25-L26):
const cookieStore = await cookies();
const isPreview = cookieStore.get("preview_mode")?.value === "true";
// ↑
// cookie 是 "true" → 作者(预览模式)
// cookie 不存在 → 普通用户
3️⃣ 根据身份返回不同内容
看 [cms-data.ts L74-L81](file:///d:/11-练习/my-next-app/src/lib/cms-data.ts#L74-L81):
export async function getPostBySlug(slug: string, preview?: boolean) {
const allPosts = preview
? [...PUBLISHED_POSTS, ...DRAFT_POSTS] // ← 作者:已发布 + 草稿
: PUBLISHED_POSTS; // ← 用户:只看已发布
return allPosts.find((post) => post.slug === slug);
}
权限差异:作者能看到草稿,用户只能看已发布。
完整流程图:
场景一:作者点击「进入预览模式」
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
作者访问 /api/preview/enter
↓
设置 cookie:preview_mode=true(1小时)
↓
重定向到 /preview
↓
此时作者访问任意文章
↓
page.tsx 读取 cookie → isPreview=true
↓
getPostBySlug(slug, true)
↓
返回:已发布 + 草稿 ✅(作者能看到草稿)
场景二:普通用户访问
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
用户直接访问 /preview/getting-started
↓
page.tsx 读取 cookie → 没有 preview_mode
↓
isPreview=false
↓
getPostBySlug(slug, false)
↓
返回:只有已发布 ❌(草稿看不到)
场景三:作者退出预览模式
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
作者访问 /api/preview/exit
↓
删除 cookie:preview_mode
↓
恢复成普通用户身份
真实效果对比:
| 访问 URL | 普通用户(无 cookie) | 作者(有 cookie) |
|---|---|---|
/preview/getting-started | ✅ 看到「已发布」标签 | ✅ 看到「已发布」标签 |
/preview/upcoming-features | ❌ 404(草稿看不到) | ✅ 看到「🟣 草稿文章」+ 紫色提示框 |
/preview/preview-mode-deep-dive | ❌ 404 | ✅ 看到「🟣 草稿文章」+ 紫色提示框 |
两种实现方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 自定义 cookie(本项目用的) | 简单直观,容易理解原理 | 需要自己管理 cookie 安全性 |
官方 draftMode() | 安全性高,官方维护,自动加密 | API 比较黑盒,不利于学习 |
本项目选择自定义 cookie 方案,看 [preview/[slug]/page.tsx L6](file:///d:/11-练习/my-next-app/src/app/preview/[slug]/page.tsx#L6) 的注释:
/**
* 通过自定义 cookie 判断预览状态
* 不依赖 next/headers 的 draftMode()
*/
一句话总结:
🎯 Preview Mode = 通过 cookie 区分「作者」和「用户」身份,同一 URL 返回不同内容。
- 约定字段:
preview_mode=true(作者)/ 不存在(用户)- 判断身份:
cookies().get("preview_mode")- 权限差异:作者能看到草稿,用户只能看已发布
- 典型场景:CMS 编辑预览、草稿审核、灰度发布
不是"作者/用户看同一个页面的不同 UI",而是"作者能拿到草稿数据,用户拿不到"——数据层面的权限控制。
🔍 疑惑 16:Route Segment Config 是干什么的?
给「单个路由」单独设置运行规则的开关。 在 page.tsx / route.ts 里 export 几个固定名字的常量,单独控制这个路由的缓存、渲染模式、运行时等行为。
这是疑惑 6「约定式具名导出」机制的具体应用——名字必须一字不差,Next.js 按名字识别。
五个核心配置项:
| 配置项 | 作用 | 可选值 | 项目里用在哪 |
|---|---|---|---|
dynamic | 渲染模式 | auto / force-dynamic / force-static / error | [chat/ai/route.ts L18](file:///d:/11-练习/my-next-app/src/app/api/chat/ai/route.ts#L18) |
revalidate | 缓存秒数 | 0 / false / 数字 | [blog/[slug]/page.tsx L17](file:///d:/11-练习/my-next-app/src/app/blog/[slug]/page.tsx#L17) |
runtime | 运行时环境 | nodejs / edge | [chat/stream/route.ts L26](file:///d:/11-练习/my-next-app/src/app/api/chat/stream/route.ts#L26) |
fetchCache | fetch 缓存策略 | auto / force-no-store / force-cache 等 | — |
preferredRegion | 数据中心区域 | home / iad1 / sfo1 等 | — |
项目里的 3 个真实例子:
例子 1:博客详情页用 ISR(之前学过的)
看 [blog/[slug]/page.tsx L17](file:///d:/11-练习/my-next-app/src/app/blog/[slug]/page.tsx#L17):
export const revalidate = 60;
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// 构建时生成静态页,60 秒后过期重新生成
一个配置让博客详情页变成 ISR 模式:用户访问时返回缓存的静态 HTML,60 秒后下次访问触发后台重新生成。不用改全局配置,只影响这一篇文章。
例子 2:AI 聊天接口必须实时
看 [chat/ai/route.ts L17-L18](file:///d:/11-练习/my-next-app/src/app/api/chat/ai/route.ts#L17-L18):
export const runtime = "nodejs"; // 用 Node.js 运行时(Edge 不支持某些 API)
export const dynamic = "force-dynamic"; // 每次请求都执行,不缓存
AI 聊天必须每次实时响应,不能缓存。这两个配置保证了:
- 用 Node.js 运行时(而不是 Edge,因为调用 AI API 需要 Node 完整能力)
- 强制动态(每次请求都执行,绝不返回缓存)
例子 3:SSE 流式响应需要 Node.js
看 [chat/stream/route.ts L26](file:///d:/11-练习/my-next-app/src/app/api/chat/stream/route.ts#L26):
export const runtime = "nodejs";
// ↑↑↑↑↑↑↑↑↑↑
// SSE 需要 Node.js 运行时(Edge 不完全支持 ReadableStream)
注释里写得很清楚:Edge Runtime 对 ReadableStream 支持不全,所以必须用 Node.js 运行时。
Route Segment Config 解决什么问题?
❌ 不用它的问题:
假设全局配置:所有路由都用 SSG(静态生成)。但你的 /api/chat 必须实时响应,怎么办?
- 方案 A:改全局配置 → 但其他页面也被影响,SEO 和性能全崩
- 方案 B:每个路由单独配置 → ✅ 这就是 Route Segment Config
✅ 用它的好处:
全局默认:SSG 静态生成
↓
单独调整某个路由:
├── /blog/[slug] → revalidate=60 (ISR)
├── /api/chat/ai → force-dynamic (实时)
├── /api/chat/stream → runtime=nodejs (Node 运行时)
└── / → 默认 SSG
每个路由各走各的路,互不干扰,这就是 Route Segment Config 的价值。
和疑惑 6 的联系:
所有这些 export const 都是 Next.js 按名字找的,改名字 = 配置失效:
// 名字必须一字不差!这就是约定式具名导出
export const revalidate = 60; // ← Next.js 按名字识别
export const dynamic = "force-dynamic"; // ← 改成 myDynamic 就失效
export const runtime = "nodejs"; // ← 改成 myRuntime 就失效
完整配置选项速查表:
| 配置项 | 类型 | 作用 | 典型场景 |
|---|---|---|---|
dynamic | string | 渲染模式 | force-dynamic 用于实时 API |
revalidate | number | false | 缓存秒数 | 60 用于博客 ISR |
runtime | string | 运行时环境 | nodejs 用于需要完整 Node API |
fetchCache | string | fetch 缓存策略 | force-no-store 不缓存 fetch |
preferredRegion | string | array | 部署区域 | iad1 部署到美东 |
dynamicParams | boolean | 是否允许动态参数 | true 允许任意 slug |
一句话总结:
🎯 Route Segment Config = 给单个路由单独配置规则的开关,通过约定名字的
export const实现。
- 作用:不用改全局配置,就能让某个路由单独用 ISR / 动态渲染 / Node 运行时
- 写法:
export const 配置项 = 值(必须用约定名字)- 本质:疑惑 6 学的「约定式具名导出」机制的具体应用
- 项目里用了 3 处:博客 ISR(revalidate)、AI 接口实时(force-dynamic)、SSE 流(runtime=nodejs)
配套记忆:疑惑 6 讲的是「为什么名字不能改」,疑惑 16 讲的是「名字不改能干什么具体的事」。
三、数据获取策略
Next.js 提供了多种数据获取方式,核心区别在于何时何地获取数据。
3.1 四种数据获取策略
| 策略 | 时机 | 适用场景 | 代码位置 |
|---|---|---|---|
| SSR | 每次请求 | 数据频繁变化、需要实时性 | Server Component 中 fetch() |
| SSG | 构建时 | 内容固定、不常变化 | Server Component 中 fetch() |
| ISR | 构建 + 定时重生成 | 需要更新但不需要实时 | fetch({ next: { revalidate: 3600 } }) |
| CSR | 客户端挂载后 | 交互性强、个性化数据 | Client Component 中 useEffect + fetch |
3.2 Server Component 中的 fetch
// app/users/page.tsx — Server Component(默认)
// SSR 模式:每次请求都获取最新数据
export const dynamic = "force-dynamic";
export default async function UsersPage() {
const res = await fetch("https://api.example.com/users");
const users = await res.json();
return (
<div>
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
3.3 ISR:增量静态再生成
// 每 3600 秒(1 小时)重新生成一次
// 期间访问会返回缓存的静态页面
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 3600 },
});
// 也可以用页面级的 revalidate
// app/blog/[slug]/page.tsx
export const revalidate = 60; // 项目里博客详情页用的是这个
3.4 CSR:客户端获取
// Client Component 中用 useEffect 获取
"use client";
import { useState, useEffect } from "react";
export default function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/stats")
.then((res) => res.json())
.then(setData);
}, []);
return <div>{data?.total}</div>;
}
3.5 generateStaticParams
动态路由中,如果使用 SSG/ISR,需要告诉 Next.js 预生成哪些路径:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
// Next.js 构建时会为每个 slug 生成静态页面
}
export const revalidate = 60; // ISR
export default async function BlogPost({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}
四、API 路由(Route Handlers)
4.1 基本概念
API Route 是 Next.js 提供的轻量级后端,文件放在 app/api/ 目录下,每个 route.ts 文件对应一个 API 端点。
4.2 项目中的完整 API 清单
| 路径 | 方法 | 用途 |
|---|---|---|
/api/hello | GET/POST | Hello World 示例 |
/api/users | GET/POST | 用户列表/创建用户 |
/api/users/[id] | GET/PUT/PATCH/DELETE | 动态路由 CRUD |
/api/auth/login | POST | 登录(设置 cookie) |
/api/auth/logout | POST | 登出(清除 cookie) |
/api/auth/me | GET | 获取当前用户 |
/api/chat/stream | POST | SSE 流式聊天回复 |
/api/cors-demo | GET/OPTIONS | CORS 跨域演示 |
/api/response-helpers | GET | NextResponse 助手演示 |
/api/segment-config-demo | GET | Route Segment Config |
/api/files/[...slug] | GET | Catch-all 路由 |
/api/middleware-demo | GET | 中间件演示 |
/api/preview/enter | GET | 开启预览模式 |
/api/preview/exit | GET | 退出预览模式 |
4.3 基本示例:GET/POST
// app/api/hello/route.ts
import { NextResponse } from "next/server";
// GET /api/hello?name=World
export async function GET(request: NextRequest) {
const name = request.nextUrl.searchParams.get("name") || "World";
return NextResponse.json({
message: `Hello, ${name}!`,
timestamp: new Date().toISOString(),
});
}
// POST /api/hello
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ receivedData: body }, { status: 200 });
}
4.4 实战:登录 API(设置 httpOnly Cookie)
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { authenticate, AUTH_COOKIE_NAME } from "@/lib/auth";
export async function POST(request: NextRequest) {
const { username, password } = await request.json();
const result = authenticate(username, password);
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 401 });
}
// 设置 httpOnly cookie(安全)
const response = NextResponse.json({ success: true, user: result.user });
response.cookies.set(AUTH_COOKIE_NAME, result.token!, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
return response;
}
4.5 动态 API 路由:CRUD
// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
// GET /api/users/1
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const user = await getUser(parseInt(id));
if (!user) return NextResponse.json({ error: "用户不存在" }, { status: 404 });
return NextResponse.json(user);
}
// PUT /api/users/1 — 全量更新
export async function PUT(request: Request, { params }) {
const { id } = await params;
const body = await request.json();
return NextResponse.json({ id, ...body });
}
// PATCH /api/users/1 — 部分更新
export async function PATCH(request: Request, { params }) {
const { id } = await params;
const body = await request.json();
return NextResponse.json({ id, updated: body });
}
// DELETE /api/users/1 — 删除
export async function DELETE(request: Request, { params }) {
const { id } = await params;
return NextResponse.json({ success: true, deletedId: id });
}
五、样式方案
5.1 Tailwind CSS v4 配置
/* src/app/globals.css */
@import "tailwindcss";
/* 可以添加自定义主题变量 */
@theme {
--color-brand: #3b82f6;
}
5.2 在组件中使用
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Hello Next.js
</h1>
<button className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
点击
</button>
</div>
5.3 next/font 字体优化
// app/layout.tsx
import { Geist, Geist_Mono } from "next/font/google";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export default function RootLayout({ children }) {
return (
<html className={geistSans.variable}>
<body>{children}</body>
</html>
);
}
Next.js 会自动:
- 将字体文件内联到 CSS(零网络请求)
- 使用
font-display: swap防止文字闪烁 - 提供自托管能力(无需外部域名)
六、中间件实战
6.1 什么是中间件
Next.js 中间件在请求到达页面/API 之前执行,可以做路由拦截、重定向、设置 cookie 等操作。
6.2 项目中的中间件:i18n 路由 + 认证
// src/proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { locales, defaultLocale } from "@/i18n/config";
// 公开路径(不需要登录)
const PUBLIC_PATHS = ["/", "/about", "/users", "/blog", "/login", "/performance", "/api/chat"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get("auth-token");
// 1️⃣ 国际化路由处理(/en/xxx → 重写到 /xxx + 设置 locale cookie)
const pathSegments = pathname.split("/").filter(Boolean);
const hasLocale = pathSegments.length > 0 && locales.includes(pathSegments[0]);
if (hasLocale) {
const locale = pathSegments[0];
const restOfPath = "/" + pathSegments.slice(1).join("/");
const response = NextResponse.rewrite(new URL(restOfPath, request.url));
response.cookies.set("locale", locale, { path: "/", maxAge: 31536000 });
return response;
}
// 2️⃣ 认证拦截
const isPublicPath = PUBLIC_PATHS.includes(pathname);
const isAuthenticated = !!token;
// 已登录用户访问登录页 → 首页
if (pathname === "/login" && isAuthenticated) {
return NextResponse.redirect(new URL("/", request.url));
}
// 未登录访问受保护页面 → 登录页
if (!isPublicPath && !isAuthenticated) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
6.3 中间件流程图
用户请求 /dashboard
↓
proxy.ts 执行
↓
┌───────────────────┐
│ 检查 locale 前缀? │── 是 → 设置 cookie,rewrite
└───────────────────┘
↓ 否
┌───────────────────┐
│ 检查 auth-token? │── 无 → 重定向到 /login
└───────────────────┘
↓ 有
渲染 /dashboard 页面 ✅
七、图片优化(next/image)
7.1 配置允许的远程域名
// next.config.ts
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "api.dicebear.com", pathname: "/**" },
],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
formats: ["image/avif", "image/webp"],
},
};
7.2 使用原生 img 处理 SVG
⚠️
next/image对 SVG 支持不佳(会返回 400),SVG 头像建议用原生<img>。
// components/OptimizedAvatar.tsx
export default function OptimizedAvatar({ src, alt, size = 40 }) {
return (
<img
src={src}
alt={alt}
width={size}
height={size}
loading="lazy"
decoding="async"
className="rounded-full bg-gray-100"
/>
);
}
7.3 next/image vs 原生 img
| 图片类型 | 推荐方案 |
|---|---|
| SVG(图标、头像生成器) | 原生 <img> |
| 位图(JPG/PNG/WebP) | next/image |
| 大量小图标 | <Image> + 合理的 sizes |
八、国际化(i18n)
8.1 安装与配置
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);
// src/i18n/config.ts
export const locales = ["zh", "en"] as const;
export const defaultLocale = "zh";
8.2 翻译消息
// src/i18n/messages/zh.json
{
"nav": { "home": "首页", "about": "关于", "users": "用户列表" },
"home": { "title": "欢迎来到 Next.js 学习项目" }
}
8.3 在 Server / Client Component 中使用
// Server Component
import { getTranslations } from "next-intl/server";
export default async function HomePage() {
const t = await getTranslations("home");
return <h1>{t("title")}</h1>;
}
// Client Component
"use client";
import { useTranslations } from "next-intl";
export default function LanguageSwitcher() {
const t = useTranslations("common");
return <button>{t("switchLang")}</button>;
}
8.4 语言切换流程
用户点击 "EN" 按钮
↓
document.cookie = "locale=en"
router.push("/en/about")
↓
proxy.ts 检测 /en 前缀 → rewrite + 设置 cookie
↓
页面显示英文 ✅
8.5 两种 Hook 的区别
| Hook | 适用场景 | 注意事项 |
|---|---|---|
getTranslations() | Server Component | 需要 await |
useTranslations() | Client Component | 不需要 await |
九、性能监控与分析
9.1 核心 Web Vitals 指标
| 指标 | 全称 | 含义 | 目标值 |
|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间 | < 2.5s |
| INP | Interaction to Next Paint | 交互响应时间 | < 200ms |
| CLS | Cumulative Layout Shift | 累积布局偏移 | < 0.1 |
| FCP | First Contentful Paint | 首次内容绘制 | < 1.8s |
| TTFB | Time To First Byte | 首字节时间 | < 0.8s |
9.2 useReportWebVitals
// components/WebVitalsMonitor.tsx
"use client";
import { useReportWebVitals } from "next/web-vitals";
import { useState } from "react";
export default function WebVitalsMonitor() {
const [visible, setVisible] = useState(false); // 默认隐藏,点击显示
const [metrics, setMetrics] = useState<Record<string, number>>({});
useReportWebVitals((metric) => {
setMetrics(prev => ({ ...prev, [metric.name]: metric.value }));
});
return (
<>
<button onClick={() => setVisible(!visible)}>📊 显示性能</button>
{visible && (
<div className="fixed bottom-4 right-4 ...">
<p>LCP: {metrics.LCP?.toFixed(2)}ms</p>
<p>INP: {metrics.INP?.toFixed(2)}ms</p>
<p>CLS: {metrics.CLS?.toFixed(3)}</p>
</div>
)}
</>
);
}
9.3 性能优化检查清单
| 优化项 | 状态 | 说明 |
|---|---|---|
| 图片优化 | ✅ | next/image 或原生 lazy loading |
| 字体优化 | ✅ | next/font 内联字体 |
| 代码分割 | ✅ | App Router 自动按路由分割 |
| 懒加载 | ✅ | loading.tsx 骨架屏 |
| 缓存策略 | ✅ | revalidate 控制 ISR |
| Dynamic Import | ✅ | 按需加载重组件 |
十、Server Actions(进阶)
10.1 什么是 Server Actions
Server Actions 是 Next.js 提供的直接在客户端调用服务端函数的能力,不需要手动处理 HTTP 请求。
10.2 对比:API Route vs Server Action
| 对比项 | API Route | Server Action |
|---|---|---|
| HTTP 请求 | 手动 fetch() | 自动处理 |
| 数据序列化 | JSON.stringify | 自动处理 FormData |
| 错误处理 | 手动检查 res.ok | 返回值直接判断 |
| 安全 | 需要 CORS/CSRF 配置 | Next.js 自动处理 |
| Cookie 操作 | response.cookies.set() | cookies().set() |
10.3 创建 Server Action
// src/app/actions/auth.ts
"use server"; // ⭐ 顶部指令,标记此文件为 Server Action 文件
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { authenticate } from "@/lib/auth";
export async function loginAction(prevState, formData: FormData) {
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const result = authenticate(username, password);
if (!result.success) {
return { error: result.error };
}
// 直接设 cookie(不需要 response)
const cookieStore = await cookies();
cookieStore.set("auth-token", result.token!, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 7,
});
redirect("/dashboard");
}
export async function logoutAction() {
const cookieStore = await cookies();
cookieStore.delete("auth-token");
redirect("/");
}
10.4 在客户端调用(useActionState)
"use client";
import { useActionState } from "react";
import { loginAction } from "@/app/actions/auth";
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(loginAction, null);
return (
<form action={formAction}>
<input name="username" defaultValue="admin" />
<input name="password" type="password" defaultValue="123456" />
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>
{isPending ? "登录中..." : "登录"}
</button>
</form>
);
}
10.5 useActionState 解析
const [state, formAction, isPending] = useActionState(loginAction, null);
// ↑ ↑ ↑
// 状态 绑定 form 的 是否正在提交
// 返回值 action 属性 (用于禁用按钮)
// 初始值
十一、错误处理体系
11.1 三层错误处理
app/
├── not-found.tsx ← 404 错误(预期行为)
├── error.tsx ← 运行时错误(页面级)
└── layout.tsx ← 根布局
11.2 not-found.tsx(404 页面)
// app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div className="text-center py-24">
<h1 className="text-6xl font-bold mb-4">404</h1>
<p className="text-gray-600 mb-8">页面不存在</p>
<Link href="/" className="px-6 py-3 bg-blue-600 text-white rounded-lg">
返回首页
</Link>
</div>
);
}
触发方式:
- 用户直接访问不存在的路由
- 代码中调用
notFound()
11.3 error.tsx(错误边界)
// app/error.tsx
"use client"; // ⭐ 必须是 Client Component
import { useEffect } from "react";
export default function Error({ error, reset }) {
useEffect(() => {
console.error("【错误边界】", error);
}, [error]);
return (
<div className="text-center py-24">
<h1 className="text-6xl font-bold mb-4">500</h1>
<p className="text-gray-600 mb-8">{error.message}</p>
<button
onClick={() => reset()} // ⭐ 重试函数
className="px-6 py-3 bg-blue-600 text-white rounded-lg"
>
重试
</button>
</div>
);
}
十二、动态元数据 generateMetadata
12.1 静态 vs 动态
// ❌ 静态 metadata(所有页面共用)
export const metadata: Metadata = {
title: "Blog",
description: "Blog posts",
};
// ✅ 动态 metadata(根据参数生成)
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
type: "article",
},
};
}
12.2 继承机制
layout.tsx metadata(全局默认)
└── blog/layout.tsx metadata(覆盖父级)
└── blog/[slug]/page.tsx generateMetadata(动态覆盖)
十三、Next.js Compiler(SWC 编译器)
📁 项目路径:[src/app/compiler/](file:///d:/11-练习/my-next-app/src/app/compiler/page.tsx)
13.1 什么是 Next.js Compiler
Next.js Compiler 是 Next.js 12+ 默认使用的编译器,基于 SWC(Speedy Web Compiler)实现,用 Rust 编写。
一句话总结:把"时髦代码"翻译成"浏览器能跑的代码",速度比 Babel 快 17 倍。
13.2 SWC vs Babel
| 对比项 | Babel(旧) | SWC(新) |
|---|---|---|
| 实现语言 | JavaScript | Rust |
| 速度 | 慢 | 快 17 倍 🚀 |
| 是否需配置 | 需要手动配插件 | 默认启用,开箱即用 |
| Next.js 中状态 | 12 以后废弃 | 12+ 默认 |
13.3 编译流水线
你写的代码 (.tsx/.ts/.jsx)
↓
1️⃣ 解析 (Parsing) — 把源码解析成 AST
↓
2️⃣ 转换 (Transform) — JSX → React.createElement,TS → JS
↓
3️⃣ 压缩 (Minification) — 移除空格、注释、缩短变量名
↓
浏览器运行的代码 (.js)
13.4 配置
// next.config.ts(默认配置即可,无需手动启用)
const nextConfig: NextConfig = {
// SWC 已默认启用
// 可选:启用 React Compiler(Next.js 15+)
// reactCompiler: true,
};
13.5 大白话类比
Next.js Compiler 就像一个高速翻译官,你写的 TypeScript、JSX 它都能秒翻译成浏览器看得懂的 JavaScript,速度比以前的 Babel 翻译官快得多。
十四、Dynamic Import 动态导入
📁 项目路径:[src/app/dynamic-import/page.tsx](file:///d:/11-练习/my-next-app/src/app/dynamic-import/page.tsx)
14.1 什么是 Dynamic Import
按需加载组件或模块,而不是一开始就把所有代码都加载。
14.2 大白话类比
| 方式 | 类比 |
|---|---|
| 普通 import | 搬家时把所有家具一次性全搬进去(重) |
| Dynamic Import | 搬家时先搬必需的,其他需要时再搬(轻) |
14.3 四种用法
import dynamic from "next/dynamic";
// 1️⃣ 基本懒加载(组件首次用到时才加载)
const HeavyChart = dynamic(() => import("@/components/demos/HeavyChart"));
// 2️⃣ 带 loading 状态
const HeavyChartWithLoading = dynamic(
() => import("@/components/demos/HeavyChart"),
{ loading: () => <div>加载中...</div> }
);
// 3️⃣ 禁用 SSR(仅客户端渲染)
const ClientOnly = dynamic(
() => import("@/components/demos/ClientOnlyComponent"),
{ ssr: false }
);
// 4️⃣ 动态 import 表达式(按变量加载不同模块)
const module = await import(`/locales/${lang}.json`);
14.4 应用场景
- 重型组件(图表、编辑器)懒加载
- 仅客户端运行的组件(如访问 window)
- 多语言文件按需加载
- 减小首屏 JS 体积
十五、SSE 流式通信
📁 项目路径:[src/app/api/chat/stream/route.ts](file:///d:/11-练习/my-next-app/src/app/api/chat/stream/route.ts) + [src/components/SSEChatDemo.tsx](file:///d:/11-练习/my-next-app/src/components/SSEChatDemo.tsx)
15.1 什么是 SSE
SSE(Server-Sent Events) 是一种 HTTP 单向推送技术,服务器持续向客户端推送数据,客户端通过 fetch + ReadableStream 接收。
典型应用:AI 流式回复、实时通知、进度推送。
15.2 SSE vs WebSocket vs 普通 API
| 方式 | 方向 | 复杂度 | 适用场景 |
|---|---|---|---|
| 普通 API | 单次请求→单次响应 | 低 | CRUD |
| SSE | 服务端→客户端(单向) | 中 | AI 流式回复、通知 |
| WebSocket | 双向 | 高 | 聊天室、游戏 |
15.3 后端实现(Route Handler 返回 ReadableStream)
// app/api/chat/stream/route.ts
export const runtime = "nodejs"; // SSE 需要 Node.js 运行时
export async function POST(request: Request) {
const { message } = await request.json();
const fullReply = generateReply(message);
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// 按字符逐个推送(模拟打字机效果)
for (let i = 0; i < fullReply.length; i++) {
const payload = JSON.stringify({
type: "char",
content: fullReply[i],
index: i,
total: fullReply.length,
});
// ⭐ SSE 协议格式:data: {JSON}\n\n
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
await new Promise(resolve => setTimeout(resolve, 30));
}
// 推送完成事件
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "done" })}\n\n`));
controller.close();
},
});
// ⭐ Content-Type 必须是 text/event-stream
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}
15.4 前端消费(fetch + getReader)
"use client";
import { useState, useRef } from "react";
export default function SSEChatDemo() {
const [reply, setReply] = useState("");
const initFiredRef = useRef(false); // ⭐ 防 React 18 StrictMode 重复请求
async function sendMessage(message: string) {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按 \n\n 分割 SSE 事件
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const event of events) {
const data = JSON.parse(event.replace("data: ", ""));
if (data.type === "char") {
setReply(prev => prev + data.content); // 逐字拼接
} else if (data.type === "done") {
console.log("回复完成");
}
}
}
}
// ...
}
15.5 踩坑经验
| 问题 | 原因 | 解决 |
|---|---|---|
| StrictMode 下请求两次 | React 18 开发模式 useEffect 执行两次 | 用 useRef 做初始化标记 |
| PowerShell curl 看不到流 | PowerShell 的 curl 不是真 curl | 用 curl.exe 或浏览器 |
| API 被认证拦截 | 中间件拦截了 /api/chat | 把 /api/chat 加入公开路径白名单 |
十六、第三方 SDK 集成
📁 项目路径:[src/components/AMapLoader.tsx](file:///d:/11-练习/my-next-app/src/components/AMapLoader.tsx)
16.1 next/script 三种加载策略
| 策略 | 加载时机 | 适用 |
|---|---|---|
beforeInteractive | 页面交互前(阻塞) | 字体加载器、必须的 SDK |
afterInteractive | 交互后立即(默认) | GA、AdSense |
lazyOnload | 浏览器空闲时 | 客服 widget、广告 |
16.2 ⚠️ 坑:next/script + 条件渲染导致 hydration 错误
错误:Encountered a script tag while rendering React component
原因:<Script> 在 SSR 阶段输出 <script>,但配合条件渲染时
SSR 和客户端的 DOM 结构不一致
16.3 正确做法:useEffect + 手动注入 script
// components/AMapLoader.tsx
"use client";
import { useState, useEffect } from "react";
export default function AMapLoader() {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const script = document.createElement("script");
script.src = `https://webapi.amap.com/maps?v=2.0&key=${process.env.NEXT_PUBLIC_AMAP_KEY}`;
script.onload = () => setLoaded(true);
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}, []);
return loaded ? <div id="map-container" /> : <div>地图加载中...</div>;
}
16.4 环境变量配置
# .env.local
NEXT_PUBLIC_AMAP_KEY=你的高德地图Key # 客户端可访问(NEXT_PUBLIC_ 前缀)
NEXT_PUBLIC_ADSENSE_ID=ca-pub-xxxx # Google AdSense ID
NEXT_PUBLIC_GA_ID=G-XXXXXX # Google Analytics ID
💡
NEXT_PUBLIC_前缀的变量会被打包进客户端代码,浏览器可见;不加前缀的只在服务端可见。
十七、嵌套 Layout 与加载状态
📁 项目路径:[src/app/blog/layout.tsx](file:///d:/11-练习/my-next-app/src/app/blog/layout.tsx)
17.1 嵌套 Layout 的价值
app/
├── layout.tsx ← 根布局(全站)
└── blog/
├── layout.tsx ← ⭐ 嵌套布局(被下面所有路由继承)
├── page.tsx ← /blog (继承 blog/layout.tsx)
└── [slug]/
└── page.tsx ← /blog/:slug (同样继承)
核心价值:
- 状态持久化:在 /blog → /blog/getting-started 之间切换时,侧边栏不会重新渲染
- 代码复用:所有博客页面共享的导航、侧边栏只写一次
- 数据共享:layout 里 fetch 的数据可以传给子页面
17.2 嵌套 Layout 实战
// app/blog/layout.tsx
import { getPosts } from "@/lib/data";
export const metadata = {
title: { default: "博客", template: "%s | 博客" },
};
export default async function BlogLayout({ children }: { children: React.ReactNode }) {
// 在 layout 层获取数据,路由切换时不会重复请求
const posts = await getPosts();
const recentPosts = [...posts].slice(0, 3);
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<div className="flex gap-8 flex-col lg:flex-row">
{/* 侧边栏:在路由切换时保持不销毁 */}
<aside className="w-full lg:w-64">
<div className="lg:sticky lg:top-24">
<h3>🔥 热门文章</h3>
{recentPosts.map(post => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
{post.title}
</Link>
))}
</div>
</aside>
{/* 主内容区(由子路由 page.tsx 注入) */}
<div className="flex-1 min-w-0">{children}</div>
</div>
</div>
);
}
17.3 loading.tsx 层级继承
📁 项目路径:[src/components/Skeleton.tsx](file:///d:/11-练习/my-next-app/src/components/Skeleton.tsx)
核心规则:子路由没有 loading.tsx 时,会自动继承父目录的 loading.tsx。
app/
├── loading.tsx ← 全局 loading
├── users/
│ ├── loading.tsx ← users 专用 loading
│ └── [id]/
│ └── loading.tsx ← 用户详情专用 loading(没有则继承父级)
└── blog/
├── loading.tsx ← blog 专用 loading
└── [slug]/
└── loading.tsx ← 文章详情专用 loading
17.4 可复用的 Skeleton 组件
// components/Skeleton.tsx
"use client";
type SkeletonVariant = "list" | "detail" | "card" | "dashboard";
export default function Skeleton({ variant = "list" }: { variant?: SkeletonVariant }) {
switch (variant) {
case "list":
return (
<div className="space-y-3">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl p-4 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2" />
<div className="h-3 bg-gray-200 rounded w-2/3" />
</div>
))}
</div>
);
case "detail":
return /* ... */;
// ...
}
}
// app/users/loading.tsx
import Skeleton from "@/components/Skeleton";
export default function Loading() {
return <Skeleton variant="list" />;
}
十八、面包屑导航
📁 项目路径:[src/components/Breadcrumb.tsx](file:///d:/11-练习/my-next-app/src/components/Breadcrumb.tsx)
18.1 usePathname 的典型应用
面包屑是 usePathname() 最经典的使用场景,把"URL 路径"自动渲染为"层级导航"。
18.2 实现思路
1. usePathname() 获取当前路径:/playground/middleware
2. 按 / 分割:["playground", "middleware"]
3. 逐级累加 URL:/playground → /playground/middleware
4. 渲染为:首页 / 实验场 / 中间件
18.3 关键代码
// components/Breadcrumb.tsx
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const DEFAULT_LABEL_MAP: Record<string, string> = {
users: "用户",
blog: "博客",
playground: "实验场",
middleware: "中间件",
router: "路由",
"sse-streaming": "SSE 流式",
// ...
};
export default function Breadcrumb({ labels = {} }: { labels?: Record<string, string> }) {
const pathname = usePathname();
const labelMap = { ...DEFAULT_LABEL_MAP, ...labels };
// 去掉 locale 前缀(/en/xxx → /xxx)
const cleanPath = pathname.replace(/^\/(en|zh)(?=\/|$)/, "");
const segments = cleanPath.split("/").filter(Boolean);
if (segments.length === 0) return null;
const items = segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/");
let label: string;
if (labelMap[segment]) {
label = labelMap[segment];
} else if (/^\d+$/.test(segment)) {
label = `#${segment}`; // 动态参数显示为 #123
} else {
label = segment.charAt(0).toUpperCase() + segment.slice(1);
}
return { label, href, isLast: index === segments.length - 1 };
});
return (
<nav aria-label="面包屑">
<Link href="/">首页</Link>
{items.map(item => (
<span key={item.href}>
<span> / </span>
{item.isLast ? (
<span className="font-medium">{item.label}</span>
) : (
<Link href={item.href}>{item.label}</Link>
)}
</span>
))}
</nav>
);
}
十九、高级路由(Interception / Parallel / Catch-all)
📁 项目路径:[src/app/playground/interception/](file:///d:/11-练习/my-next-app/src/app/playground/interception/layout.tsx)
这是 Next.js 独有的高级路由特性,同一个 URL 根据访问来源不同,显示不同的 UI。
19.1 Interception Routes(拦截路由)
一句话:URL 变了,但页面不跳转(弹窗形式打开)。
大白话解释
场景:图片列表 → 点击图片 → 图片详情
普通做法:跳转新页面(列表消失)
拦截路由:URL 变成 /photos/1,但显示为弹窗(列表保留在背景)
目录结构(用 (..) 表示拦截)
app/playground/interception/
├── photos/
│ ├── page.tsx ← 照片列表
│ └── [id]/
│ └── page.tsx ← 完整详情页(直接访问时)
└── @modal/ ← Parallel Route 插槽(@开头)
├── default.tsx ← 默认(没有 modal 时返回 null)
└── (..)photos/
└── [id]/
└── page.tsx ← ⭐ 拦截路由(弹窗形式)
(..) 的含义
类似文件系统的相对路径:
| 写法 | 含义 | 拦截对象 |
|---|---|---|
(.)photos | 同级 | 当前目录下的 photos |
(..)photos | 上一级 | 父目录的 photos |
(..)(..)photos | 上两级 | 祖父目录的 photos |
(...)photos | 根级 | app 根目录的 photos |
19.2 Parallel Routes(并行路由)
一句话:同一个布局里同时渲染多个页面,通过 @文件夹 命名约定实现。
layout.tsx 接收多个插槽
// app/playground/interception/layout.tsx
export default function InterceptionLayout({
children, // 主内容
modal, // ⭐ 弹窗插槽(@modal)
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal} {/* 没有 modal 时由 default.tsx 返回 null */}
</div>
);
}
19.3 Catch-all 路由(捕获所有路径段)
📁 项目路径:[src/app/api/files/[…slug]/route.ts](file:///d:/11-练习/my-next-app/src/app/api/files/[…slug]/route.ts)
| 写法 | 含义 | 示例 |
|---|---|---|
[slug] | 单段动态 | /files/a |
[...slug] | 捕获所有 | /files/a/b/c → slug = [“a”,“b”,“c”] |
[[...slug]] | 可选捕获 | /files 也能匹配(slug = []) |
// app/api/files/[...slug]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string[] }> }
) {
const { slug } = await params;
// /api/files/images/2024/jan → slug = ["images", "2024", "jan"]
return NextResponse.json({ path: slug.join("/") });
}
应用场景:文件系统路由、多级分类、通配 API。
19.4 三种高级路由对比
| 路由类型 | 语法 | 作用 |
|---|---|---|
| Interception | (..)folder | 拦截路由,URL 变但不跳转 |
| Parallel | @folder | 同一布局渲染多个内容 |
| Catch-all | [...slug] | 捕获多级路径 |
二十、Preview Mode 草稿预览
📁 项目路径:[src/app/preview/](file:///d:/11-练习/my-next-app/src/app/preview/page.tsx)
20.1 什么是 Preview Mode
一句话:内容编辑者通过 cookie 在自己的浏览器里预览未发布的草稿,其他访客看不到。
20.2 大白话解释
正常访客访问 /preview:
→ 看到 3 篇已发布文章(绿色)
编辑者开启 Preview Mode 后访问 /preview:
→ 看到 3 篇已发布 + 2 篇草稿(紫色虚线边框 + "[草稿]" 前缀)
→ 页面不被 SSG/ISR 缓存
20.3 实现原理(通过自定义 cookie)
// app/preview/page.tsx
import { cookies } from "next/headers";
import { getPosts } from "@/lib/cms-data";
export default async function PreviewDemoPage() {
// ⭐ 通过 cookie 判断预览状态
const cookieStore = await cookies();
const isPreview = cookieStore.get("preview_mode")?.value === "true";
// 根据模式获取不同数据
const posts = await getPosts(isPreview);
return (
<div>
{posts.map(post => (
<article className={post.status === "draft" ? "border-dashed border-purple-300" : ""}>
{post.status === "draft" && <span>草稿</span>}
<h3>{post.title}</h3>
</article>
))}
</div>
);
}
20.4 开启/退出 Preview Mode
// app/api/preview/enter/route.ts
export async function GET(request: NextRequest) {
const response = NextResponse.redirect(new URL("/preview", request.url));
response.cookies.set("preview_mode", "true", { path: "/" });
return response;
}
// app/api/preview/exit/route.ts
export async function GET(request: NextRequest) {
const response = NextResponse.redirect(new URL("/preview", request.url));
response.cookies.delete("preview_mode");
return response;
}
20.5 实际应用:CMS Webhook 集成
编辑在 CMS 点击"预览"
↓
CMS 调用 GET /api/preview/enter?slug=upcoming-features
↓
API 设置 cookie 并重定向到 /preview/upcoming-features
↓
页面读取 cookie,返回草稿内容
↓
预览完成 → 点击"退出预览" → GET /api/preview/exit
二十一、Automatic Static Optimization
📁 项目路径:[src/app/static-optimization/](file:///d:/11-练习/my-next-app/src/app/static-optimization/page.tsx)
21.1 什么是 Automatic Static Optimization
Next.js 自动判断页面是否可以静态生成,无需手动配置。
21.2 大白话类比
| 渲染方式 | 餐厅类比 |
|---|---|
| SSG(静态) | 预制套餐(构建时做好,秒上) |
| SSR(动态) | 现点现做(每次请求都做) |
| ISR | 预制+定期更新(每小时刷新菜单) |
21.3 自动判断规则
| 代码特征 | 渲染方式 |
|---|---|
没用 cookies() / headers() / searchParams | ✅ 自动静态 |
用了 cookies() / headers() | ❌ 动态 |
export const dynamic = "force-dynamic" | ❌ 强制动态 |
export const dynamic = "force-static" | ✅ 强制静态 |
export const revalidate = 60 | 🔄 ISR |
21.4 项目中的三个对比页面
// 1. 静态页面(app/static-optimization/static/page.tsx)
// 没有动态函数 → 自动静态
export default function StaticPage() {
return <div>构建时间:{new Date().toLocaleString()}</div>;
}
// 2. 动态页面(app/static-optimization/dynamic/page.tsx)
// 使用 cookies() → 强制动态
import { cookies } from "next/headers";
export default async function DynamicPage() {
const cookieStore = await cookies();
return <div>当前时间:{new Date().toLocaleString()}</div>;
}
// 3. ISR 页面(app/static-optimization/isr/page.tsx)
export const revalidate = 10; // 每 10 秒重新生成
export default function ISRPage() {
return <div>生成时间:{new Date().toLocaleString()}</div>;
}
二十二、MDX 支持
📁 项目路径:[src/app/mdx/](file:///d:/11-练习/my-next-app/src/app/mdx/page.tsx)
22.1 什么是 MDX
MDX = Markdown + JSX,让你在 Markdown 文章里嵌入 React 组件。
22.2 大白话类比
| 类型 | 类比 |
|---|---|
| 普通 Markdown | 纯文本邮件(只有文字) |
| MDX | 带交互按钮的邮件(文字+投票+计数器) |
22.3 配置步骤
1. 安装依赖
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
2. 修改 next.config.ts
// next.config.ts
import createMDX from "@next/mdx";
import createNextIntlPlugin from "next-intl/plugin";
const withMDX = createMDX({
extension: /\.(md|mdx)$/,
});
const withNextIntl = createNextIntlPlugin();
export default withMDX(withNextIntl(nextConfig));
3. 创建 mdx-components.tsx(必需!)
⚠️ 这个文件必须放在
src/根目录(不是src/app/),且函数不接受参数。
// src/mdx-components.tsx
import type { MDXComponents } from "mdx/types";
import { Callout, Counter, CodeBlock, VoteButtons } from "@/components/mdx-components";
const components: MDXComponents = {
Callout,
Counter,
CodeBlock,
VoteButtons,
};
export function useMDXComponents(): MDXComponents {
return components;
}
4. 创建 .mdx 文件并 import
// app/mdx/demo/page.tsx
import DemoMDX from "./content.mdx";
export default function DemoPage() {
return (
<div>
<Link href="/mdx">← 返回</Link>
<DemoMDX />
</div>
);
}
<!-- app/mdx/demo/content.mdx -->
# 我的文章
这是普通文字。
<Callout type="warning">
⚠️ 这是嵌入的 React 组件!
</Callout>
<Counter initial={10} step={5} />
22.4 ⚠️ 常见坑
| 问题 | 原因 | 解决 |
|---|---|---|
| 页面 404 | page.mdx 和 page.tsx 重名 | MDX 文件改名(如 content.mdx),用 page.tsx import |
useMDXComponents 报错 | 文件位置错误 / 签名错误 | 放在 src/ 根目录,函数不接受参数 |
<p> 嵌套 <p> 警告 | MDX 自动包 <p>,组件又用了 <p> | 组件内部用 <div> 代替 <p> |
二十三、Absolute Imports 模块路径别名
23.1 什么是 Absolute Imports
用 @/ 代替冗长的相对路径,让 import 语句更清晰。
23.2 对比
// ❌ 相对路径(痛苦)
import { auth } from "../../../lib/auth";
import { UserCard } from "../../../components/UserCard";
// ✅ 绝对路径(清爽)
import { auth } from "@/lib/auth";
import { UserCard } from "@/components/UserCard";
23.3 配置(tsconfig.json)
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
@ 映射到 src 目录,项目里所有组件、lib、i18n 都可以用 @/ 引用。
二十四、API 路由进阶(CORS / Response Helpers / Segment Config)
24.1 NextResponse 响应助手
📁 项目路径:[src/app/api/response-helpers/route.ts](file:///d:/11-练习/my-next-app/src/app/api/response-helpers/route.ts)
import { NextResponse } from "next/server";
// 1. 返回 JSON(最常用)
return NextResponse.json({ data: "hello" });
// 2. 重定向(地址栏会变)
return NextResponse.redirect(new URL("/login", request.url));
// 3. 重写(地址栏不变,内部转发)
return NextResponse.rewrite(new URL("/secret", request.url));
// 4. 透传请求 + 修改 headers
const response = NextResponse.next();
response.headers.set("X-Custom", "hello");
return response;
// 5. Cookie 操作
response.cookies.set("token", "xxx", { httpOnly: true });
response.cookies.delete("token");
24.2 CORS 跨域处理
📁 项目路径:[src/app/api/cors-demo/route.ts](file:///d:/11-练习/my-next-app/src/app/api/cors-demo/route.ts)
// 允许的源(生产环境用具体域名,不要用 *)
const ALLOWED_ORIGINS = [
"http://localhost:3000",
"https://myapp.com",
];
const CORS_HEADERS = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
function handleCors(request: NextRequest): NextResponse | null {
const origin = request.headers.get("origin");
// 预检请求 OPTIONS
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 204 });
if (origin && ALLOWED_ORIGINS.includes(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
Object.entries(CORS_HEADERS).forEach(([k, v]) =>
response.headers.set(k, v)
);
}
return response;
}
return null;
}
export async function GET(request: NextRequest) {
const corsResponse = handleCors(request);
if (corsResponse) return corsResponse;
const response = NextResponse.json({ data: "hello" });
const origin = request.headers.get("origin");
if (origin && ALLOWED_ORIGINS.includes(origin)) {
response.headers.set("Access-Control-Allow-Origin", origin);
}
return response;
}
export { handleCors as OPTIONS };
24.3 Route Segment Config
📁 项目路径:[src/app/api/segment-config-demo/route.ts](file:///d:/11-练习/my-next-app/src/app/api/segment-config-demo/route.ts)
通过 export const 配置路由行为:
// app/api/segment-config-demo/route.ts
// 1. 渲染模式
export const dynamic = "force-dynamic"; // 'auto' | 'force-dynamic' | 'force-static' | 'error'
// 2. 缓存时间(秒)
export const revalidate = 0; // 0=不缓存,false=永久缓存,数字=秒数
// 3. 运行时
export const runtime = "nodejs"; // 'nodejs' | 'edge'
// 4. 数据中心区域
export const preferredRegion = "home"; // 'home' | 'iad1' | 'sfo1'
export async function GET(request: NextRequest) {
return NextResponse.json({
dynamic, // "force-dynamic"
revalidate, // 0
runtime, // "nodejs"
time: new Date().toISOString(),
});
}
24.4 Segment Config 配置一览
| 配置 | 可选值 | 作用 |
|---|---|---|
dynamic | auto / force-dynamic / force-static / error | 控制动态/静态 |
revalidate | 0 / false / 数字 | 缓存时间 |
runtime | nodejs / edge | 运行时环境 |
preferredRegion | home / iad1 / sfo1 | 部署区域 |
fetchCache | auto / force-no-store / force-cache | fetch 缓存策略 |
总结
本文通过一个完整的 Next.js 学习项目,涵盖了 24 个核心知识点:
基础篇(1-8)
| 序号 | 知识点 | 核心要点 |
|---|---|---|
| 1 | 环境搭建 | create-next-app、TypeScript、Tailwind、MDX |
| 2 | 路由系统 | 文件路由、动态路由、layout、loading、error、客户端 Hook |
| 3 | 数据获取 | SSR、SSG、ISR、CSR、generateStaticParams |
| 4 | API 路由 | GET/POST/PUT/DELETE、NextResponse、Cookie |
| 5 | 样式方案 | Tailwind、next/font |
| 6 | 中间件 | 路由拦截、认证、i18n 路由 |
| 7 | 图片优化 | next/image、remotePatterns、SVG 处理 |
| 8 | 国际化 | next-intl、Server/Client Component |
进阶篇(9-18)
| 序号 | 知识点 | 核心要点 |
|---|---|---|
| 9 | 性能监控 | Web Vitals、useReportWebVitals |
| 10 | Server Actions | useActionState、form action |
| 11 | 错误处理 | not-found.tsx、error.tsx、reset() |
| 12 | 动态元数据 | generateMetadata、Open Graph |
| 13 | SWC 编译器 | Rust 编写,比 Babel 快 17 倍 |
| 14 | Dynamic Import | next/dynamic、ssr:false、按需加载 |
| 15 | SSE 流式通信 | ReadableStream、打字机效果 |
| 16 | 第三方 SDK 集成 | next/script、useEffect 注入、避坑 |
| 17 | 嵌套 Layout | 状态持久化、loading 继承、Skeleton |
| 18 | 面包屑导航 | usePathname、路径段映射 |
高级篇(19-24)
| 序号 | 知识点 | 核心要点 |
|---|---|---|
| 19 | 高级路由 | Interception(拦截)、Parallel(并行)、Catch-all |
| 20 | Preview Mode | 草稿预览、cookie 标记、CMS 集成 |
| 21 | Automatic Static Optimization | 自动判断静态/动态、ISR |
| 22 | MDX 支持 | Markdown + JSX、mdx-components.tsx |
| 23 | Absolute Imports | @/ 路径别名、tsconfig paths |
| 24 | API 路由进阶 | CORS、Response Helpers、Segment Config |
推荐学习路径(递进关系图)
Next.js 的知识点之间有明确的前置依赖关系。下面的图展示了从零基础到精通的完整路径,每一层都建立在前一层之上。
完整递进路线图
┌─────────────────────────────────────────────────────────┐
│ 第一层:基础地基(不学写不了代码) │
│ 预计学习时间:1-2 周 │
├─────────────────────────────────────────────────────────┤
│ │
│ ① 环境搭建 │
│ ↓ │
│ ② 文件路由系统 ← 基础中的基础 │
│ ↓ │
│ ③ Server/Client Component ← 决定代码在哪运行 │
│ ↓ ↓ │
│ ④ 数据获取 ⑤ layout.tsx 嵌套 │
│ (SSR/SSG/ISR) (状态持久化) │
│ ↓ ↓ │
│ ⑥ 动态路由 [param] + generateStaticParams │
│ ↓ │
│ ⑦ API Routes (GET/POST/PUT/DELETE) │
│ │
└──────────────────────────┬──────────────────────────────┘
│
│ 基础打牢后才能学进阶
↓
┌─────────────────────────────────────────────────────────┐
│ 第二层:进阶提升(让功能更完整) │
│ 预计学习时间:2-3 周 │
├─────────────────────────────────────────────────────────┤
│ │
│ ⑧ loading.tsx ← 依赖 ⑤ layout │
│ ↓ │
│ ⑨ error.tsx + not-found.tsx ← 错误边界 │
│ ↓ │
│ ⑩ 中间件 (Middleware) ← 依赖 ② 路由 │
│ ↓ ↓ │
│ ⑪ 认证鉴权 ⑫ 国际化 i18n │
│ (proxy.ts) (next-intl) │
│ ↓ ↓ │
│ ⑬ Server Actions ← 替代 API Route 的更简洁方案 │
│ ↓ │
│ ⑭ 动态元数据 generateMetadata ← 依赖 ⑥ 动态路由 │
│ ↓ │
│ ⑮ 图片优化 next/image │
│ ↓ │
│ ⑯ 样式方案 (Tailwind + CSS Modules) │
│ │
└──────────────────────────┬──────────────────────────────┘
│
│ 功能完整后优化性能
↓
┌─────────────────────────────────────────────────────────┐
│ 第三层:高级特性(提升体验和性能) │
│ 预计学习时间:2-3 周 │
├─────────────────────────────────────────────────────────┤
│ │
│ ⑰ SWC 编译器 ← 理解构建原理 │
│ ↓ │
│ ⑱ Dynamic Import ← 依赖 ③ Server/Client │
│ ↓ │
│ ⑲ SSE 流式通信 ← 依赖 ⑦ API Routes │
│ ↓ │
│ ⑳ 第三方 SDK 集成 ← 依赖 ③ (useEffect + script) │
│ ↓ │
│ ㉑ 嵌套 Layout + Skeleton ← 依赖 ⑤ ⑧ │
│ ↓ │
│ ㉒ 面包屑导航 (usePathname) │
│ ↓ │
│ ㉓ 性能监控 (Web Vitals) │
│ │
└──────────────────────────┬──────────────────────────────┘
│
│ 掌握高级特性后挑战顶级难度
↓
┌─────────────────────────────────────────────────────────┐
│ 第四层:顶级特性(App Router 精髓) │
│ 预计学习时间:1-2 周 │
├─────────────────────────────────────────────────────────┤
│ │
│ ㉔ Interception Routes ← 依赖 ② ⑤ (拦截路由) │
│ ↓ │
│ ㉕ Parallel Routes ← 依赖 ㉔ (并行插槽) │
│ ↓ │
│ ㉖ Catch-all Routes ← 依赖 ⑥ (全匹配路由) │
│ ↓ │
│ ㉗ Preview Mode ← 依赖 ⑩ 中间件 (草稿预览) │
│ ↓ │
│ ㉘ Automatic Static Optimization ← 依赖 ④ (静态判断) │
│ ↓ │
│ ㉙ MDX 支持 ← 依赖 ⑯ 样式 + ⑭ 元数据 │
│ ↓ │
│ ㉚ Absolute Imports ← 配置层,随时可学 │
│ ↓ │
│ ㉛ API 路由进阶 (CORS/Segment Config) │
│ │
└──────────────────────────┬──────────────────────────────┘
│
│ 所有知识学完后准备上线
↓
┌─────────────────────────────────────────────────────────┐
│ 第五层:上线部署(Going to Production) │
│ 预计学习时间:3-5 天 │
├─────────────────────────────────────────────────────────┤
│ │
│ ㉜ 环境变量配置 (.env.local / NEXT_PUBLIC_) │
│ ↓ │
│ ㉝ CORS 跨域处理 │
│ ↓ │
│ ㉞ Vercel 部署 (Git 推送自动部署) │
│ ↓ │
│ ㉟ Core Web Vitals 监控 (线上性能) │
│ ↓ │
│ ㊱ 持续集成 CI/CD │
│ │
└─────────────────────────────────────────────────────────┘
知识点依赖关系详解
| 层级 | 知识点 | 前置依赖 | 为什么需要先学前置 | 项目文件 |
|---|---|---|---|---|
| 第一层 | ① 环境搭建 | 无 | 一切的起点 | package.json |
| ② 文件路由系统 | ① | 路由是 Next.js 的骨架 | src/app/ | |
| ③ Server/Client Component | ② | 决定代码在哪运行,影响一切后续 | 全项目 | |
| ④ 数据获取 (SSR/SSG/ISR) | ③ | 必须理解组件类型才能正确获取数据 | blog/[slug]/page.tsx | |
| ⑤ layout.tsx 嵌套 | ② | 路由系统的一部分,状态持久化基础 | blog/layout.tsx | |
| ⑥ 动态路由 [param] | ② ④ | 需要理解路由和数据获取 | users/[id]/page.tsx | |
| ⑦ API Routes | ② | 后端接口,路由系统的延伸 | src/app/api/ | |
| 第二层 | ⑧ loading.tsx | ⑤ | 需要理解 layout 才能理解层级继承 | blog/loading.tsx |
| ⑨ error.tsx | ⑤ ⑧ | 和 loading.tsx 同属特殊文件 | error.tsx | |
| ⑩ 中间件 Middleware | ② | 拦截路由请求,必须先懂路由 | proxy.ts | |
| ⑪ 认证鉴权 | ⑩ | 中间件的具体应用 | proxy.ts | |
| ⑫ 国际化 i18n | ⑩ | 中间件 + Cookie 实现 | i18n/ | |
| ⑬ Server Actions | ⑦ | API Route 的进化版 | login/page.tsx | |
| ⑭ 动态元数据 | ⑥ | 需要动态路由参数 | 各 page.tsx | |
| ⑮ 图片优化 | ③ | 需要理解组件渲染时机 | next.config.ts | |
| ⑯ 样式方案 | ③ | Tailwind 在 Client/Server 中行为不同 | 全项目 | |
| 第三层 | ⑰ SWC 编译器 | 无 | 理解构建原理,独立知识点 | next.config.ts |
| ⑱ Dynamic Import | ③ | 需要理解组件类型和加载时机 | dynamic-import/ | |
| ⑲ SSE 流式通信 | ⑦ | 基于 API Route 的 ReadableStream | api/chat/stream/ | |
| ⑳ 第三方 SDK | ③ | 需要理解 useEffect 和 hydration | AMapLoader.tsx | |
| ㉑ 嵌套 Layout 进阶 | ⑤ ⑧ | layout + loading 的综合运用 | blog/layout.tsx | |
| ㉒ 面包屑导航 | ② | 基于 usePathname 路由 Hook | Breadcrumb.tsx | |
| ㉓ 性能监控 | ③ | useReportWebVitals 是 Client Hook | WebVitalsMonitor.tsx | |
| 第四层 | ㉔ Interception Routes | ② ⑤ | 需要深入理解路由和布局 | interception/layout.tsx |
| ㉕ Parallel Routes | ㉔ | 基于 Interception 的并行插槽 | interception/layout.tsx | |
| ㉖ Catch-all Routes | ⑥ | 动态路由的进阶版 | api/[...slug]/ | |
| ㉗ Preview Mode | ⑩ | 依赖中间件设置 Cookie | preview/page.tsx | |
| ㉘ Static Optimization | ④ | 需要理解 SSR/SSG/ISR | static-optimization/ | |
| ㉙ MDX 支持 | ⑯ ⑭ | 需要样式和元数据知识 | mdx/ | |
| ㉚ Absolute Imports | 无 | 配置层,随时可学 | tsconfig.json | |
| ㉛ API 路由进阶 | ⑦ | API Route 的延伸 | api/cors-demo/ | |
| 第五层 | ㉜ 环境变量 | 无 | 配置层,随时可学 | .env.local |
| ㉝ CORS 跨域 | ⑦ | API Route 安全配置 | api/cors-demo/ | |
| ㉞ Vercel 部署 | 全部 | 需要项目完整才能部署 | — | |
| ㉟ Web Vitals 线上 | ㉓ | 性能监控的线上版 | WebVitalsMonitor.tsx | |
| ㊱ CI/CD | ㉞ | 部署的自动化 | — |
学习节奏建议
第 1-2 周 ─── 第一层:基础地基
│
│ 目标:能独立写出一个带路由、数据获取、API 的页面
│ 检验标准:能说出 Server/Client Component 的区别
│ 关键练习:创建一个博客列表页 + 详情页
│
第 3-5 周 ─── 第二层:进阶提升
│
│ 目标:能让项目功能完整(认证、错误处理、SEO、多语言)
│ 检验标准:能给页面加 loading 状态、error 边界、metadata
│ 关键练习:给博客加登录鉴权 + 中英文切换
│
第 6-8 周 ─── 第三层:高级特性
│
│ 目标:掌握性能优化和高级交互
│ 检验标准:能实现 SSE 流式回复、Dynamic Import、地图集成
│ 关键练习:给博客加 AI 聊天助手 + 懒加载图表
│
第 9-10 周 ── 第四层:顶级特性
│
│ 目标:掌握 App Router 最复杂的路由机制
│ 检验标准:能说出 Interception Routes 的原理和使用场景
│ 关键练习:实现点击图片弹出模态框(不跳转页面)
│
第 11 周 ─── 第五层:上线部署
│
│ 目标:把项目部署到生产环境
│ 检验标准:能在 Vercel 上访问到自己的项目
│ 关键练习:配置环境变量 + 部署 + 性能监控
难度星级参考
| 知识点 | 难度 | 出错频率 | 学习建议 |
|---|---|---|---|
| 文件路由 | ⭐ | 低 | 多写几个页面就懂了 |
| Server/Client Component | ⭐⭐⭐ | 极高 | 这是最容易踩坑的,务必理解透彻 |
| 数据获取 (SSR/SSG/ISR) | ⭐⭐⭐ | 中 | 三种方式对比着学 |
| API Routes | ⭐⭐ | 低 | 和写 Express 差不多 |
| 中间件 | ⭐⭐⭐ | 中 | 注意匹配规则和执行顺序 |
| Server Actions | ⭐⭐⭐ | 中 | 新概念,需要转变思维 |
| Interception Routes | ⭐⭐⭐⭐⭐ | 高 | 最难的路由特性,多看官方文档 |
| SSE 流式通信 | ⭐⭐⭐⭐ | 中 | ReadableStream 的 API 要熟悉 |
| 缓存机制 | ⭐⭐⭐⭐⭐ | 极高 | Next.js 最难理解的部分 |
| MDX | ⭐⭐ | 中 | 配置容易出错,按步骤来 |
| 部署上线 | ⭐⭐ | 低 | Vercel 基本是傻瓜式 |
常用命令
npm run dev # 启动开发服务器(HMR 热更新)
npm run build # 生产构建(SWC 编译,比 Babel 快 17 倍)
npm run start # 启动生产服务器
npm run lint # ESLint 代码检查
性能优化清单
| 优化方向 | 项目中的实现 |
|---|---|
| 编译速度 | SWC 编译器(默认启用) |
| 代码分割 | App Router 自动 + Dynamic Import |
| 图片优化 | next/image / 原生 lazy loading |
| 字体优化 | next/font 内联 |
| 缓存策略 | ISR revalidate |
| 加载体验 | loading.tsx + Skeleton 组件 |
| 流式响应 | Route Handler + ReadableStream |
| 第三方脚本 | next/script 三种策略 / useEffect 注入 |
💡 项目源码:Gitee 搜索
git@gitee.com:huihui-999/my-next-app.git即可找到完整项目代码,包含本文所有示例的完整实现。
关于作者
这是一个用于系统学习 Next.js 16 的实战项目,覆盖从入门到高级的 24 个核心知识点。如果你觉得本文有帮助,欢迎点赞收藏!如有问题欢迎评论交流。
更多推荐
所有评论(0)