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 函数体 │
│ │
└─────────────────────────────────────────────────────────────┘关键认知: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 │
│ │
└─────────────────────────────────────────────────────────────┘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.3 序列化边界
Server Component 传给 Client Component 的 props 必须可序列化:
// ❌ 不能传:函数、类实例(不可 JSON 序列化)
<ClientCard onClick={() => {}} />
// ❌ 不能传:JSX 作为普通 prop
<ClientCard header={<ServerHeader />} /> // 不行
// ✅ 可以传:原始类型、普通对象/数组
<ClientCard title="Hello" items={[{ id: 1 }]} />
// ✅ Children Pattern:JSX 作为 children 传入是可以的
<ClientCard>
<ServerHeader /> {/* ✅ ServerHeader 在服务端渲染为 RSC Payload 后传入 */}
</ClientCard>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。 │
│ │
└─────────────────────────────────────────────────────────────┘// 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 不受影响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。│
│ │
└─────────────────────────────────────────────────────────────┘// ❌ 整个页面变 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>
}第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(同级拦截) │
│ │
└─────────────────────────────────────────────────────────────┘3.2 layout.tsx vs template.tsx
// 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 > page3.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 极短。 │
│ │
└─────────────────────────────────────────────────────────────┘3.4 error.tsx 的错误传播
error.tsx 必须是 Client Component,接收 error 对象和 reset() 函数:
'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>
}┌─────────────────────────────────────────────────────────────┐
│ 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。 │
│ │
└─────────────────────────────────────────────────────────────┘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 })│
│ │
└─────────────────────────────────────────────────────────────┘Intercepting Routes:不改变 URL 的情况下"拦截"路由,以模态框等形式展示。拦截层级:(.) 同级、(..) 上一级、(...) 从根算起。
app/
├── photos/
│ ├── page.tsx ← /photos(列表页)
│ └── [id]/page.tsx ← /photos/[id](完整详情页)
└── (.)photos/
└── [id]/page.tsx ← 拦截 /photos/[id],模态框展示
从 /photos 点击图片 → 拦截触发,模态框覆盖在列表上
直接访问 /photos/123 → 拦截不触发,全页面详情视图3.6 Route Groups 与 Route Handlers
// 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第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 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 数据获取与传递完整示例
// 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>
)
}第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)。 │
│ │
└─────────────────────────────────────────────────────────────┘5.2 完整 Server Action 实现
// 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')
}// 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>
)
}5.3 revalidatePath vs revalidateTag
┌─────────────────────────────────────────────────────────────┐
│ revalidatePath 与 revalidateTag │
├─────────────────────────────────────────────────────────────┤
│ │
│ revalidatePath('/posts') │
│ → 使指定路径的完整页面缓存失效,下次访问重新渲染。 │
│ → 范围广,使用简单。 │
│ │
│ revalidateTag('posts-list') │
│ → 只使标记了 next: { tags: ['posts-list'] } 的 fetch 失效│
│ → 范围精确,需要 fetch 时预先打标签。 │
│ │
│ 最佳实践:数据层用 revalidateTag 精准刷新; │
│ 页面级操作用 revalidatePath 简单直接。 │
│ │
└─────────────────────────────────────────────────────────────┘第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。 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 Middleware 示例
// 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*'] }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 流式到达。 │
│ │
└─────────────────────────────────────────────────────────────┘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:静态外壳+动态孔洞流式 = 混合最优 │
│ │
└─────────────────────────────────────────────────────────────┘6.5 导航机制
┌─────────────────────────────────────────────────────────────┐
│ 软导航 vs 硬导航 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 软导航(Soft Navigation):<Link> 或 router.push() │
│ • React 状态保持(layout 不重新挂载) │
│ • 仅获取 RSC Payload,非完整 HTML │
│ • 速度极快,类 SPA 体验 │
│ │
│ 硬导航(Hard Navigation):浏览器地址栏直接输入 URL │
│ • 完整 HTML 请求,所有状态丢失 │
│ • 完全重新水合 │
│ │
└─────────────────────────────────────────────────────────────┘6.6 编程式导航与预取
// 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┌─────────────────────────────────────────────────────────────┐
│ 导航决策树 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 需要导航? │
│ ├── 用户点击链接 → <Link href="..." prefetch={...}> │
│ ├── 编程式跳转 → router.push() / router.replace() │
│ ├── 服务端跳转 → redirect() / permanentRedirect() │
│ ├── 显示 404 → notFound() │
│ ├── 刷新数据 → router.refresh() │
│ └── 全页面刷新 → window.location.href(硬导航,不推荐) │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
总结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
- 向上不传播:
childrenprop 传入的内容可仍是 Server Component
总结3:文件约定速查
| 文件 | 作用 | 必须 Client? |
|---|---|---|
layout.tsx | 持久化布局 | 否 |
template.tsx | 每次导航重新挂载 | 否 |
page.tsx | 路由页面 | 否(默认 Server) |
loading.tsx | 自动 Suspense 边界 | 否 |
error.tsx | 错误边界 + reset | 是 |
not-found.tsx | 404 UI | 否 |
route.ts | HTTP Handler | 否 |
default.tsx | Parallel 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')测试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。测试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 响应给浏览器测试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 → 完整页相关笔记
- [[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 数据获取、缓存与变更
学习状态:🟡 开始学习