Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

React 生态 / React Ecosystem

1. React 生态知识体系 / React Ecosystem Knowledge System

2. React 组件渲染、协调与数据流 / React Components, Rendering, and Data Flow

3. Hooks 深度剖析:状态、副作用与并发模式 / Hooks Deep Dive: State, Effects, and Concurrency

4. 路由、表单与组件架构 / Routing, Forms, and Component Architecture

5. React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand

6. Next.js App Router:路由、渲染与工程边界 / Next.js App Router, Routing, Rendering, and Engineering Boundaries

7. Next.js 数据获取、缓存与变更 / Next.js Data Fetching, Caching, and Mutations

8. TanStack Query 与服务端状态管理 / TanStack Query and Server State Management

9. React 测试、性能与生产工程 / React Testing, Performance, and Production Engineering

本页目录

Next.js App Router:路由、渲染与工程边界 / Next.js App Router, Routing, Rendering, and Engineering Boundaries ​

📅 创建时间:2026-07-28 🏷️ 标签:#Nextjs #AppRouter #RSC #ServerComponents #SSR #Streaming #ServerActions #PPR 📚 前置知识:[[01-react-components-and-rendering]] [[02-hooks-state-and-effects]]


📋 本章目标 ​

  • 理解 RSC(React Server Components)的执行模型、RSC Payload 格式,以及它与传统 SSR 的本质区别
  • 掌握 'use client' 的真实含义:它声明的是"客户端模块图边界",不是"这个组件只在客户端运行"
  • 熟悉全部文件约定(layout/page/loading/error/not-found/route/template/default),理解嵌套布局的持久化与错误传播
  • 掌握 Server Components 与 Client Components 的四种组合模式(children pattern、数据获取与传递)
  • 理解 Server Actions 原理:'use server' 编译为 POST endpoint、渐进增强、revalidate 机制、React 19 新 Hook
  • 理解 Edge Runtime vs Node.js Runtime 的能力差异与 Middleware 限制
  • 理解 Streaming SSR 的工作机制和 Partial Prerendering (PPR) 的设计思路
  • 掌握导航机制:软导航 vs 硬导航、预取策略、编程式路由 API

第1部分:React Server Components (RSC) 原理深度 ​

1.1 三种组件执行模型 ​

在 App Router 中,React 组件不再只在浏览器执行。三种模型构成了整个框架的基础:

┌─────────────────────────────────────────────────────────────┐
│               React 组件的三种执行模型                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Server Components(默认)                                │
│     • 执行位置:服务器(构建时或请求时)                     │
│     • 输出产物:RSC Payload(序列化 UI 描述,非 HTML)       │
│     • 不发送 JS bundle 到浏览器                             │
│     • ✅ async/await、数据库、文件系统、密钥                 │
│     • ❌ useState、useEffect、事件处理、DOM API              │
│                                                             │
│  2. Client Components('use client')                        │
│     • 执行位置:服务器(SSR 首屏)+ 浏览器(水合后交互)    │
│     • 输出产物:HTML + JS bundle                            │
│     • ✅ useState、useEffect、事件处理、浏览器 API           │
│     • ❌ Node.js 原生模块、数据库直连、服务端密钥            │
│                                                             │
│  3. Server-only 代码(不在组件树中)                          │
│     • 仅服务器执行,永不发送到浏览器                        │
│     • 数据访问层、认证逻辑、Server Actions 函数体           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

关键认知:RSC 不是 SSR。SSR 是"在服务器把组件渲染成 HTML 发给浏览器水合"——组件代码仍会发送 JS bundle。RSC 是"组件在服务器执行完毕,只发 RSC Payload,组件代码永远留在服务器"。SSR 解决首屏白屏;RSC 解决 bundle 体积。

1.2 RSC Payload (RSC Wire Format) ​

RSC 的输出不是 HTML 字符串,而是一种紧凑的序列化格式:

┌─────────────────────────────────────────────────────────────┐
│                   RSC Payload 结构示意                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  M1:{"id":"./src/app/page.tsx","chunks":["@/div",...]}     │
│  J0:["$","div",null,{                                       │
│    "children":[                                             │
│      "$","h1",null,{"children":"Hello"},                   │
│      "$","@2",null,{}    ← Client Component 占位符引用      │
│    ]                                                        │
│  }]                                                         │
│                                                             │
│  M = Module Reference(模块引用)  J = JSX Element 序列化   │
│  @2 = Client Component 占位符,真实代码走 JS bundle          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

RSC 减小 Bundle 的原理:

┌─────────────────────────────────────────────────────────────┐
│              RSC 减小 Bundle 的原理                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  传统 SSR:组件树中每个组件 → HTML + JS Bundle               │
│  一个 200KB 的 markdown 库 → 全部进客户端 bundle             │
│                                                             │
│  RSC:Server Component → RSC Payload(无 JS)               │
│       Client Component → HTML + JS Bundle(才需要水合)     │
│       那个 200KB 库只在服务器执行 → 浏览器收入 0KB           │
│                                                             │
│  结论:将大依赖留在 Server Component = 用户无需下载。        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

1.3 序列化边界 ​

Server Component 传给 Client Component 的 props 必须可序列化:

typescript
// ❌ 不能传:函数、类实例(不可 JSON 序列化)
<ClientCard onClick={() => {}} />

// ❌ 不能传:JSX 作为普通 prop
<ClientCard header={<ServerHeader />} />  // 不行

// ✅ 可以传:原始类型、普通对象/数组
<ClientCard title="Hello" items={[{ id: 1 }]} />

// ✅ Children Pattern:JSX 作为 children 传入是可以的
<ClientCard>
  <ServerHeader />  {/* ✅ ServerHeader 在服务端渲染为 RSC Payload 后传入 */}
</ClientCard>
1
2
3
4
5
6
7
8
9
10
11
12
13

Children Pattern 原理:ServerHeader 在服务端已渲染成 RSC Payload 片段。ClientCard 的 children prop 收到的是已序列化好的 UI 描述数据。children 传递的是"已渲染结果",不是"组件引用"。


第2部分:'use client' 边界与组件树可视化 ​

2.1 'use client' 的真实含义 ​

'use client' 常被误解。它的准确语义是:

┌─────────────────────────────────────────────────────────────┐
│                'use client' 的边界语义                        │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  'use client' 声明的是"客户端模块图边界"                      │
│                                                             │
│  → 从该文件开始,所有 import(含再 import)都会被打包进      │
│    客户端 bundle。                                           │
│  → 该组件本身上仍会在服务端渲染一次(SSR 生成 HTML)。       │
│  → 它的完整 JS 代码会发送给浏览器用于水合和后续交互。       │
│                                                             │
│  Server Component (无 'use client'):                        │
│    import heavy-lib  ← 仅服务器,不进 client bundle          │
│                                                             │
│  Client Component (有 'use client'):                         │
│    import heavy-lib  ← 进 client bundle!                    │
│    import zustand    ← 进 client bundle                      │
│                                                             │
│  边界向下传播:一旦某个文件标记了 'use client',它 import    │
│  的所有文件都自动成为 Client Component。                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typescript
// client-wrapper.tsx
'use client'
import { ChildA } from './child-a' // ChildA → 自动 Client,即使没写 'use client'
import { ChildB } from './child-b' // ChildB 也是

// page.tsx (Server Component)
import { ClientWrapper } from './client-wrapper'
// ClientWrapper 是 Client,但 page.tsx 本身仍是 Server
// page.tsx 的 server-only import 不受影响
1
2
3
4
5
6
7
8
9

2.2 组件树边界可视化与最佳实践 ​

┌─────────────────────────────────────────────────────────────┐
│              服务端/客户端边界在组件树中                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Layout (Server)                                            │
│  ├── Header (Server)                                        │
│  ├── Sidebar (Server)                                       │
│  │   └── SearchInput ('use client') ← 边界                  │
│  │       ├── Icon (自动 Client)                             │
│  │       └── Dropdown (自动 Client)                         │
│  ├── <main>                                                 │
│  │   └── Page (Server)                                      │
│  │       └── InteractiveChart ('use client') ← 边界          │
│  └── Footer (Server)                                        │
│                                                             │
│  关键洞察:Client 边界向下传播,但父组件和兄弟组件不受影响。 │
│  最佳实践:把 Client 边界推到叶子节点,最小化 client bundle。│
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typescript
// ❌ 整个页面变 Client —— bundle 膨胀
// page.tsx
'use client'
export default function Page() {
  return <div><h1>Title</h1><p>Long content...</p><LikeButton /></div>
}

// ✅ 只把交互部分标记为 Client
// page.tsx (Server)
export default async function Page() {
  const data = await db.query('...')
  return <div><h1>{data.title}</h1><p>{data.content}</p><LikeButton initialLikes={data.likes} /></div>
}
// like-button.tsx
'use client'
export function LikeButton({ initialLikes }: { initialLikes: number }) {
  const [likes, setLikes] = useState(initialLikes)
  return <button onClick={() => setLikes(l => l + 1)}>Like ({likes})</button>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

第3部分:文件约定深度 ​

3.1 核心约定全景 ​

┌─────────────────────────────────────────────────────────────┐
│               App Router 文件约定                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  layout.tsx     共享布局(持久化,导航时不重新挂载)          │
│  template.tsx   共享布局(每次导航重新挂载)                  │
│  page.tsx       使路由公开可访问                              │
│  loading.tsx    自动 Suspense 边界(包裹 page.tsx)          │
│  error.tsx      错误边界(必须是 Client Component)           │
│  not-found.tsx  404 UI                                       │
│  default.tsx    Parallel Routes 默认回退                     │
│  route.ts       HTTP Handler(与 page.tsx 互斥)             │
│  global-error.tsx  捕获 layout 层错误(必须 Client)          │
│                                                             │
│  特殊目录:                                                 │
│  (group)/    Route Group(不影响 URL)                       │
│  @slot/      Parallel Route 插槽                            │
│  (.)path/    Intercepting Route(同级拦截)                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

3.2 layout.tsx vs template.tsx ​

typescript
// layout.tsx —— 持久化,导航时不重新挂载
export default function Layout({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0) // 导航到子路由 → count 保持
  return <div><nav>...</nav>{children}</div>
}

// template.tsx —— 每次导航都卸载并重新挂载
export default function Template({ children }: { children: React.ReactNode }) {
  const [count, setCount] = useState(0) // 每次导航 → count 重置
  return <div className="page-transition">{children}</div>
}
// 用于:页面切换动画、每次导航重置的 analytics
// 可同时存在。渲染顺序:layout > template > page
1
2
3
4
5
6
7
8
9
10
11
12
13

3.3 loading.tsx 与 Streaming ​

loading.tsx 是 Next.js 自动创建 Suspense 边界的语法糖。它等价于 <Suspense fallback={<Loading />}><Page /></Suspense>。

┌─────────────────────────────────────────────────────────────┐
│            加载状态层级递进(Streaming SSR)                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Layout 立即发送并渲染(导航栏、侧边栏可见)              │
│  2. loading.tsx 骨架屏流式到达                               │
│  3. page.tsx 内容完成 → 替换骨架屏,流式发送                 │
│                                                             │
│  步骤 1+2 的 HTML 先发送,步骤 3 作为后续 chunk 到达。       │
│  用户几乎立即看到外壳,TTFB 极短。                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12

3.4 error.tsx 的错误传播 ​

error.tsx 必须是 Client Component,接收 error 对象和 reset() 函数:

typescript
'use client'
export default function ErrorUI({ error, reset }: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return <div><h2>出错了</h2><p>{error.message}</p><button onClick={reset}>重试</button></div>
}
1
2
3
4
5
6
7
┌─────────────────────────────────────────────────────────────┐
│                error.tsx 错误传播层级                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  layout.tsx                                                  │
│  ├── error.tsx (app/)     ← 捕获下级所有错误                │
│  ├── dashboard/                                              │
│  │   ├── layout.tsx                                          │
│  │   ├── error.tsx        ← 捕获 dashboard 路由段内错误     │
│  │   └── page.tsx                                           │
│                                                             │
│  dashboard/page.tsx 报错 → 先找 dashboard/error.tsx →       │
│  不存在则冒泡到 app/error.tsx → 再没有则 global-error.tsx。 │
│                                                             │
│  注意:error.tsx 不能捕获同层 layout.tsx 的错误。            │
│  要捕获 layout 层错误,需用 app/global-error.tsx。           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

3.5 Parallel Routes 与 Intercepting Routes ​

Parallel Routes:在同一布局中同时渲染多个页面插槽,每个有独立导航和加载状态。

┌─────────────────────────────────────────────────────────────┐
│              Parallel Routes 架构                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  app/dashboard/                                             │
│  ├── layout.tsx         ← 接收多个插槽 props                │
│  ├── @analytics/        ← 插槽 1(独立页面)               │
│  │   ├── page.tsx                                           │
│  │   └── loading.tsx                                        │
│  ├── @team/             ← 插槽 2                            │
│  │   ├── page.tsx                                           │
│  │   └── default.tsx    ← 子路由导航时的回退               │
│  └── page.tsx           ← children 插槽                     │
│                                                             │
│  layout.tsx:                                                │
│  export default function Layout({ children, analytics, team })│
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Intercepting Routes:不改变 URL 的情况下"拦截"路由,以模态框等形式展示。拦截层级:(.) 同级、(..) 上一级、(...) 从根算起。

app/
├── photos/
│   ├── page.tsx             ← /photos(列表页)
│   └── [id]/page.tsx        ← /photos/[id](完整详情页)
└── (.)photos/
    └── [id]/page.tsx        ← 拦截 /photos/[id],模态框展示

从 /photos 点击图片 → 拦截触发,模态框覆盖在列表上
直接访问 /photos/123 → 拦截不触发,全页面详情视图
1
2
3
4
5
6
7
8
9

3.6 Route Groups 与 Route Handlers ​

typescript
// Route Groups: (folder) 不影响 URL
// app/(marketing)/about/page.tsx  →  URL: /about
// app/(dashboard)/settings/page.tsx → URL: /settings
// 不同 group 可用不同的 layout.tsx

// route.ts —— HTTP Handler(与 page.tsx 互斥)
// app/api/hello/route.ts
export async function GET(request: Request) {
  return Response.json({ message: 'Hello' })
}
export async function POST(request: Request) {
  const body = await request.json()
  return Response.json({ received: body })
}
// 同一文件可导出 GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

第4部分:Server Components 组合模式 ​

4.1 四种核心组合模式 ​

┌─────────────────────────────────────────────────────────────┐
│          Server 与 Client Component 的四种组合               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  模式1:Server 包裹 Client(最常见)                         │
│    Server 获取数据 → 序列化为 View Model → 传给 Client 渲染  │
│                                                             │
│  模式2:Client 包裹 Server(children pattern)               │
│    <ClientLayout><ServerContent /></ClientLayout>           │
│    ServerContent 在服务端渲染为 RSC Payload → 作为 children │
│                                                             │
│  模式3:Server 通过 props 传数据给 Client                    │
│    Server 做认证 → 传序列化后的用户数据给 Client 组件        │
│                                                             │
│  模式4:共享 Server Component 库                            │
│    MarkdownRenderer 等重依赖组件 → 任何 Server 直接 import  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

4.2 数据获取与传递完整示例 ​

typescript
// app/posts/page.tsx —— Server Component,负责获取数据
import { PostList } from './post-list'

export default async function PostsPage() {
  const posts = await db.post.findMany({
    where: { published: true },
    include: { author: true },
    orderBy: { createdAt: 'desc' },
  })

  if (posts.length === 0) return <EmptyState message="No posts yet" />

  // 只传递 View Model,不传原始数据库对象
  const postViewModels = posts.map(post => ({
    id: post.id,
    title: post.title,
    excerpt: post.content.slice(0, 200),
    authorName: post.author.name,
    createdAt: post.createdAt.toISOString(),
  }))

  return <PostList posts={postViewModels} />
}

// app/posts/post-list.tsx —— Client Component,负责交互
'use client'
import { useState } from 'react'

type PostVM = { id: string; title: string; excerpt: string; authorName: string; createdAt: string }

export function PostList({ posts }: { posts: PostVM[] }) {
  const [sort, setSort] = useState<'newest' | 'oldest'>('newest')
  const sorted = [...posts].sort((a, b) =>
    sort === 'newest' ? b.createdAt.localeCompare(a.createdAt) : a.createdAt.localeCompare(b.createdAt)
  )
  return (
    <div>
      <select value={sort} onChange={e => setSort(e.target.value as any)}>
        <option value="newest">Newest</option><option value="oldest">Oldest</option>
      </select>
      {sorted.map(post => (
        <article key={post.id}><h2>{post.title}</h2><p>{post.excerpt}</p></article>
      ))}
    </div>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

第5部分:Server Actions ​

5.1 'use server' 的本质 ​

Server Actions 让你在组件中直接调用服务端函数,无需手动创建 API Route:

┌─────────────────────────────────────────────────────────────┐
│              Server Actions 工作原理                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  'use server' 函数被编译为唯一的 POST endpoint               │
│  框架自动处理:网络调用 + 序列化 + CSRF 保护                 │
│                                                             │
│  定义方式1:文件级别(actions.ts 顶部 'use server')         │
│  定义方式2:函数级别(Server Component 内联 'use server')   │
│                                                             │
│  支持渐进增强:<form action={serverAction}>                  │
│  即使 JS 被禁用,表单仍然可以提交(标准 HTML form POST)。   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

5.2 完整 Server Action 实现 ​

typescript
// app/actions.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'

const CreatePostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(10),
})

export async function createPost(
  prevState: { error: string | null; success: boolean },
  formData: FormData,
) {
  // 1. 服务端参数校验 —— 不信任客户端数据
  const parsed = CreatePostSchema.safeParse({
    title: formData.get('title'), content: formData.get('content'),
  })
  if (!parsed.success) return { error: parsed.error.issues[0].message, success: false }

  // 2. 服务端认证
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')

  // 3. 数据写入
  try {
    await db.post.create({ data: { ...parsed.data, authorId: session.user.id } })
  } catch {
    return { error: 'Failed to create post', success: false }
  }

  // 4. 缓存失效
  revalidatePath('/posts')
  revalidateTag('posts-list')

  // 5. 重定向
  redirect('/posts')
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
typescript
// Client 侧表单 —— 使用 React 19 Hook
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'

function SubmitButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? 'Creating...' : 'Create Post'}</button>
}

export function CreateForm() {
  const [state, formAction] = useActionState(createPost, { error: null, success: false })
  return (
    <form action={formAction}>
      <input name="title" required minLength={3} />
      <textarea name="content" required />
      {state.error && <p className="error">{state.error}</p>}
      <SubmitButton />
    </form>
  )
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

5.3 revalidatePath vs revalidateTag ​

┌─────────────────────────────────────────────────────────────┐
│              revalidatePath 与 revalidateTag                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  revalidatePath('/posts')                                    │
│    → 使指定路径的完整页面缓存失效,下次访问重新渲染。        │
│    → 范围广,使用简单。                                      │
│                                                             │
│  revalidateTag('posts-list')                                 │
│    → 只使标记了 next: { tags: ['posts-list'] } 的 fetch 失效│
│    → 范围精确,需要 fetch 时预先打标签。                     │
│                                                             │
│  最佳实践:数据层用 revalidateTag 精准刷新;                 │
│  页面级操作用 revalidatePath 简单直接。                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

第6部分:运行时、流式渲染与导航 ​

6.1 Edge Runtime vs Node.js Runtime ​

┌─────────────────────────────────────────────────────────────┐
│              Edge Runtime vs Node.js Runtime                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Edge Runtime:                                             │
│    • 基于 Web API,轻量级,冷启动 ~0ms                      │
│    • ✅ fetch, Request/Response, URL, crypto, Web Streams   │
│    • ❌ fs, net, child_process, 原生 Node 模块              │
│    • ❌ 依赖 Node API 的 npm 包(如 Prisma、bcrypt)        │
│                                                             │
│  Node.js Runtime:                                          │
│    • 完整 Node.js API,冷启动较慢                           │
│    • ✅ fs, path, crypto, 数据库驱动, 所有 npm 包           │
│                                                             │
│  选择:export const runtime = 'edge' | 'nodejs'             │
│  Middleware 强制 Edge Runtime,只能使用 Web API。           │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

6.2 Middleware 示例 ​

typescript
// middleware.ts —— 强制 Edge Runtime
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const token = request.cookies.get('token')
  const url = request.nextUrl

  if (!token && url.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', url))
  }

  // ❌ 不能在 Middleware 中:import fs、使用 PrismaClient、复杂的数据库查询
  return NextResponse.next()
}

export const config = { matcher: ['/dashboard/:path*', '/api/:path*'] }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

6.3 Streaming SSR ​

┌─────────────────────────────────────────────────────────────┐
│              Streaming SSR 工作原理                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  传统 SSR:请求 → [等所有数据] → [渲染整页] → [发完整 HTML] │
│             TTFB 长,用户必须等待全部就绪。                   │
│                                                             │
│  Streaming:请求 → [立即发外壳 (layout+loading)]             │
│                    → [DB 查询完成] → [发该 Suspense chunk]   │
│                    → [另一个查询完成] → [发另一个 chunk]     │
│             TTFB 极短,用户几乎立刻看到页面结构。            │
│                                                             │
│  实现:<Suspense fallback={<Skeleton />}>                   │
│          <SlowDataComponent />                              │
│        </Suspense>                                          │
│                                                             │
│  layout + loading.tsx 立即流式发送,                          │
│  每个 Suspense 包裹的组件完成后作为独立 chunk 流式到达。     │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

6.4 Partial Prerendering (PPR) ​

PPR 在同一个页面中混合静态和动态内容——静态"外壳"构建时预渲染,动态"孔洞"请求时流式填入:

┌─────────────────────────────────────────────────────────────┐
│              Partial Prerendering (PPR)                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  // next.config.ts                                          │
│  experimental: { ppr: 'incremental' }                       │
│                                                             │
│  <Page>                                                     │
│    <StaticHeader />      ← 构建时预渲染,CDN 即时返回       │
│    <StaticSidebar />     ← 同上                              │
│    <Suspense fallback={<Sk />}>                             │
│      <DynamicRecs />     ← 请求时流式填入(动态孔洞)       │
│    </Suspense>                                              │
│  </Page>                                                    │
│                                                             │
│  SSG:全静态   ISR:全静态+定时刷新                         │
│  SSR:全动态   PPR:静态外壳+动态孔洞流式 = 混合最优        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

6.5 导航机制 ​

┌─────────────────────────────────────────────────────────────┐
│              软导航 vs 硬导航                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  软导航(Soft Navigation):<Link> 或 router.push()          │
│    • React 状态保持(layout 不重新挂载)                    │
│    • 仅获取 RSC Payload,非完整 HTML                         │
│    • 速度极快,类 SPA 体验                                   │
│                                                             │
│  硬导航(Hard Navigation):浏览器地址栏直接输入 URL         │
│    • 完整 HTML 请求,所有状态丢失                            │
│    • 完全重新水合                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

6.6 编程式导航与预取 ​

typescript
// 1. next/link —— 声明式(推荐)
import Link from 'next/link'
<Link href="/dashboard" prefetch={true}>Dashboard</Link>
// prefetch: true(生产默认,视口预取)/ false(关闭)/ null(hover 时预取,Next 15+)

// 2. useRouter —— 编程式(Client Component 中)
import { useRouter } from 'next/navigation'
const router = useRouter()
router.push('/dashboard')       // 软导航 + 添加历史
router.replace('/dashboard')    // 软导航 + 替换历史
router.back()                   // 返回
router.refresh()                // 重新获取当前路由 RSC Payload

// 3. usePathname / useSearchParams —— 读取当前路由信息
import { usePathname, useSearchParams } from 'next/navigation'
const pathname = usePathname()          // e.g. '/dashboard/settings'
const searchParams = useSearchParams()
const page = searchParams.get('page')   // URL query 参数

// 4. 服务端重定向(Server Component / Server Action)
import { redirect, permanentRedirect, notFound } from 'next/navigation'
redirect('/login')            // 307 临时
permanentRedirect('/new-url') // 308 永久
notFound()                    // 触发最近 not-found.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────────────┐
│              导航决策树                                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  需要导航?                                                 │
│  ├── 用户点击链接 → <Link href="..." prefetch={...}>       │
│  ├── 编程式跳转  → router.push() / router.replace()        │
│  ├── 服务端跳转  → redirect() / permanentRedirect()        │
│  ├── 显示 404    → notFound()                               │
│  ├── 刷新数据    → router.refresh()                         │
│  └── 全页面刷新  → window.location.href(硬导航,不推荐)  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13

核心总结 ​

总结1:RSC 的核心价值 ​

  • RSC 不是 SSR:RSC = 组件留服务器不发送 JS;SSR = 组件渲染成 HTML 仍发送 JS 用于水合
  • RSC Payload 是序列化 UI 描述数据,不是 HTML 字符串
  • 大依赖留在 Server Component 中,用户无需下载

总结2:'use client' 边界语义 ​

  • 'use client' = 客户端模块图边界声明,不是"这个组件只在客户端"
  • 该组件仍会 SSR 一次,但完整 JS 也发送到浏览器
  • 向下传播:通过 import 引入的子组件自动成为 Client Component
  • 向上不传播:children prop 传入的内容可仍是 Server Component

总结3:文件约定速查 ​

文件作用必须 Client?
layout.tsx持久化布局否
template.tsx每次导航重新挂载否
page.tsx路由页面否(默认 Server)
loading.tsx自动 Suspense 边界否
error.tsx错误边界 + reset是
not-found.tsx404 UI否
route.tsHTTP Handler否
default.tsxParallel Routes 回退否
global-error.tsx捕获 layout 层错误是

总结4:Server Actions 关键点 ​

  • 'use server' 函数编译为 POST endpoint,框架自动处理序列化和 CSRF
  • 渐进增强:<form action={serverAction}> 在 JS 禁用时仍可提交
  • 用 revalidatePath() / revalidateTag() 刷新缓存
  • React 19:useActionState 管理完整状态,useFormStatus 读取提交状态

总结5:PPR 定位 ​

  • PPR = 静态预渲染外壳(CDN 即时返回)+ 动态 Suspense 孔洞(请求时流式填充)
  • 不是替代 ISR/SSG/SSR,而是它们的混合体

章节测试 ​

测试1:RSC 与 SSR 的本质区别是什么? ​

测试2:分析以下模块的客户端/服务端执行情况 ​

page.tsx (Server) → import ClientWrapper ('use client') → import ChildA (无 'use client')
1

测试3:什么场景用 template.tsx 而不是 layout.tsx? ​

测试4:请写出一条 Server Action 的完整数据流(提交→校验→写入→缓存刷新→重定向),标注每步的执行位置。 ​

测试5:以下哪些可以在 Middleware 中执行? ​

A. 读取 cookie B. 连接 PostgreSQL C. URL 重写 D. fs 读取文件 E. 调用 request.geo

测试6:PPR 与 ISR 的核心区别是什么? ​

测试7:设计 Intercepting Routes 实现 /photos 点击图片以模态框展示,直接访问则完整详情页。 ​


参考答案 ​

测试1答案 ​

  • 传统 SSR:组件在服务器渲染为 HTML → 发送 HTML + 完整 JS bundle → 浏览器水合。bundle 大小不变,解决首屏白屏。
  • RSC:组件在服务器执行 → 只发送 RSC Payload → 不发送该组件的 JS bundle。解决 bundle 体积问题。
  • 核心区别:SSR 减 TTFB,RSC 减 bundle。

测试2答案 ​

服务端渲染:page.tsx (纯 Server) + ClientWrapper (SSR 一次) + ChildA (被边界传播)
客户端水合后:ClientWrapper + ChildA (都有完整 JS bundle)
page.tsx 不进客户端(无 JS bundle)
关键:ChildA 即使没写 'use client',因被 ClientWrapper import 也成了 Client Component。
1
2
3
4

测试3答案 ​

场景:页面切换动画(每次 useEffect 重跑)、每次导航重置的 analytics、多步表单向导。 template.tsx 每次导航卸载并重新挂载;layout.tsx 保持状态不重新挂载。


测试4答案 ​

Step 1: 表单提交 → 客户端发送 formData 到框架生成的 POST endpoint
Step 2: 参数校验 → 服务端(zod safeParse,不信任客户端数据)
Step 3: 数据写入 → 服务端(直接 db.post.create,无需 API 层)
Step 4: 缓存刷新 → 服务端(revalidatePath/revalidateTag 标记失效)
Step 5: 重定向 → 服务端抛出 NEXT_REDIRECT → 框架返回 307 响应给浏览器
1
2
3
4
5

测试5答案 ​

A ✅ request.cookies 是 Web API(Edge 支持) B ❌ PostgreSQL 直连需要 Node 网络模块(Edge 不支持) C ✅ NextResponse.rewrite/redirect 是 Middleware 核心功能 D ❌ fs 是 Node 核心模块(Edge 不支持) E ✅ request.geo 是 Vercel Edge 环境提供的属性 答案:A、C、E


测试6答案 ​

  • ISR:整页构建时生成 + 按 revalidate 间隔后台重生。页面要么全静态,要么全动态。
  • PPR:页面内动静混合。静态外壳预渲染缓存,动态 Suspense 孔洞每次请求流式填入。
  • 核心区别:ISR 是整页级别的定时刷新;PPR 是页面内部的动静分离。

测试7答案 ​

app/
├── photos/
│   ├── page.tsx              ← /photos 列表页
│   └── [id]/page.tsx         ← /photos/[id] 完整详情页
└── (.)photos/[id]/page.tsx   ← 拦截路由 → 模态框展示

从 /photos 点击 → (.)photos/[id]/page.tsx 拦截 → 模态框
直接访问 /photos/123(刷新/新标签) → photos/[id]/page.tsx → 完整页
1
2
3
4
5
6
7
8

相关笔记 ​

  • [[01-react-components-and-rendering]] - React 组件、渲染与数据流
  • [[02-hooks-state-and-effects]] - Hooks、State 与 Effects
  • [[03-state-management]] - React 状态管理方案
  • [[03-web-performance]] - Web 性能优化(与 RSC/SSR 的 Load 指标密切相关)

下一步学习 ​

  • [ ] 深入阅读 Next.js 官方文档 - Rendering
  • [ ] 深入阅读 Next.js 官方文档 - Data Fetching
  • [ ] 实践:将 Pages Router 项目迁移到 App Router,体验 RSC 的 bundle 优化
  • [ ] 阅读 06 - Next.js 数据获取、缓存与变更

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇5. React 状态管理:Redux Toolkit 与 Zustand / React State Management: Redux Toolkit and Zustand
下一篇7. Next.js 数据获取、缓存与变更 / Next.js Data Fetching, Caching, and Mutations

持续记录,持续成长

Copyright © Tidenflow