1. Server Components 与 Client Components
在 App Router(app/)下,Next.js 15 默认将所有组件视为 Server Components:它们先在服务器上完成渲染,再把结果以 RSC Payload 的形式参与页面组装。
- Server Components:在服务器上渲染;可直接访问数据库、文件系统、内部 API;其代码不会打包进发往浏览器的客户端 JS bundle,有利于减小体积与保护敏感逻辑。
- Client Components:文件顶部使用
"use client"声明;可使用useState、useEffect、事件处理器,以及依赖浏览器环境的 API。
下表概括二者在能力上的差异(有 React 经验时,可把 Server Component 理解为「默认在服务端跑的 React 树」):
| 能力 / 限制 | Server Component | Client Component |
|---|---|---|
| useState / useReducer | ❌ 不支持 | ✅ 支持 |
| useEffect / 浏览器事件 | ❌ 不支持 | ✅ 支持 |
| 直接访问数据库 / 读文件 | ✅ 支持 | ❌ 不应在浏览器侧暴露 |
| async 组件(顶层 await) | ✅ 支持 | ❌ 不支持(需用子 Server 组件或数据层拆分) |
| 进入客户端 bundle | ❌ 默认不打包到浏览器 | ✅ 会打包 |
与纯 React(CRA / Vite SPA)对比
传统客户端 React 应用里,几乎所有组件最终都会在浏览器执行。Next.js App Router 则把默认渲染位置改到服务端,只有显式标记 "use client" 的模块才按「客户端组件」打包与 hydration,这是性能与数据边界上的根本不同。
💡 要点
优先使用 Server Components;仅在需要交互性(本地状态、事件、浏览器 API、部分第三方仅客户端库)时,再把对应模块改为 Client Component 并加上 "use client"。
2. "use client" 指令
- 必须放在模块最顶部(在任何 import 之前),作为该文件的「客户端边界」声明。
- 一旦某文件带有
"use client",该文件及其静态导入的依赖链都会按客户端组件处理(会进入客户端 bundle)。
示例:带状态的计数器
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<button type="button" onClick={() => setCount((c) => c + 1)}>
点击次数:{count}
</button>
);
}
示例:使用 useEffect 订阅浏览器环境
"use client";
import { useEffect, useState } from "react";
export function WindowWidth() {
const [w, setW] = useState<number | null>(null);
useEffect(() => {
const onResize = () => setW(window.innerWidth);
onResize();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return <p className="text-sm text-gray-600">视口宽度:{w ?? "…"}</p>;
}
💡 边界规则
"use client" 定义了 Server / Client 的模块边界。Client Component 不能把 Server Component 当作普通子模块直接 import(会迫使服务端模块进入客户端 bundle)。常见做法是:在 Server 页面里组合——把服务端渲染好的节点作为 children 或 props 传给 Client 组件,由客户端只负责交互外壳。
3. Server Components 的优势
- 更小的客户端 JS:大量展示型组件留在服务端,减少下载与解析成本。
- 零距离访问后端资源:在组件内直接查询数据库、读配置文件、使用仅服务器可见的环境变量(勿把密钥下发到浏览器)。
- 自动按路由分割:App Router 天然按路由段组织,Server 组件树随路由懒加载,利于代码分割。
示例:在 Server Component 中直接 async 拉取数据(Next.js 15 对 fetch 有内置缓存语义,下文 ISR 会展开):
// app/posts/page.tsx — 默认为 Server Component,无需 "use client"
type Post = { id: string; title: string };
export default async function PostsPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5", {
cache: "force-cache",
});
const posts: Post[] = await res.json();
return (
<ul className="space-y-2">
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
💡 提示
需要把上述列表中的某项变成可点击、可收藏时,再把「那一小块」拆成 Client Component,而不是整页加 "use client"。
4. 静态渲染 (SSG) 与动态渲染 (SSR)
Next.js 15 会在构建或请求时自动推断路由是「静态」还是「动态」。不访问请求专属 API 的路由更可能被静态化;一旦在渲染过程中使用了依赖当前请求的 API,就会落入动态渲染。
- 静态渲染(SSG):在构建阶段生成 HTML(并可配合 ISR 后续更新)。适合内容相对固定、不依赖单次请求上下文的页面。
- 动态渲染(SSR):在每次请求时(或按需)在服务器上渲染。若在 Server Component / Route 中调用
cookies()、headers(),或页面使用依赖请求的searchParams等,通常会触发动态行为。
对动态段(如 app/blog/[slug]/page.tsx),可用 generateStaticParams 在构建时预生成一组静态路径,其余路径仍可按需动态渲染:
// app/blog/[slug]/page.tsx
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
return [{ slug: "hello" }, { slug: "next-15" }];
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
return (
<article>
<h1 className="text-2xl font-bold">文章:{slug}</h1>
<p className="text-gray-600 mt-2">构建时预生成的 slug 会走静态路径;其他 slug 仍可运行时解析。</p>
</article>
);
}
说明:Next.js 15 中路由 params 与 searchParams 在页面 props 中为 Promise,需在 async 组件内 await 后再使用。
5. ISR(增量静态再生)
ISR 让静态页面在一段时间后自动在后台刷新,兼顾 CDN 缓存与内容时效。可通过 fetch 的 next.revalidate,或在 page.tsx / layout.tsx 导出 revalidate 配置段级默认行为。
页面级:export const revalidate = 60
// app/dashboard/page.tsx
export const revalidate = 60; // 秒:该路由段默认 ISR 周期
export default async function DashboardPage() {
return <p>此段每 60 秒可在后台再生成一次(具体以部署环境缓存策略为准)。</p>;
}
fetch 级 revalidate
const res = await fetch("https://api.example.com/items", {
next: { revalidate: 120 },
});
按需失效:revalidatePath / revalidateTag
在 Server Action 或 Route Handler 中,可在数据变更后精确刷新缓存:
import { revalidatePath, revalidateTag } from "next/cache";
export async function POST() {
revalidatePath("/posts");
revalidateTag("posts-list");
return Response.json({ ok: true });
}
配合 fetch(..., { next: { tags: ['posts-list'] } }) 使用 revalidateTag 可只失效相关数据,而非整站。
6. Streaming 与 Suspense
React 18+ 的 Streaming 允许服务器分块发送 HTML,配合 Suspense 让「慢」部分不阻塞「快」部分。Next.js App Router 中,同一路由下的 loading.tsx 会自动包裹该段的 Suspense 边界,提供即时加载占位。
- loading.tsx:在对应路由段展示 fallback,实现导航时的即时反馈与流式替换。
- 手动 Suspense:对局部慢组件单独分包,更细粒度控制流式输出。
// app/streaming-demo/page.tsx
import { Suspense } from "react";
async function SlowBlock() {
await new Promise((r) => setTimeout(r, 2000));
return <p className="text-green-700">慢内容已就绪</p>;
}
export default function StreamingDemoPage() {
return (
<div className="space-y-4">
<p>首屏立即可见。</p>
<Suspense fallback={<p className="animate-pulse text-gray-500">加载中…</p>}>
<SlowBlock />
</Suspense>
</div>
);
}
💡 体验优势
首屏更快:关键 UI 先抵达浏览器;重数据或慢接口可在 Suspense 边界内渐进展示,避免整页白屏等待。
7. 组合模式最佳实践
推荐结构:页面与数据获取尽量留在 Server Components;把需要交互的 UI(表单、对话框、富交互小组件)拆成 Client Components;通过 children 或 props 把服务端渲染好的片段传入客户端「外壳」。
示意:Layout(Server) → InteractivePanel(Client) → 其中插槽内为 DataDisplay(Server,作为 children):
// app/demo/page.tsx — Server Component
import { InteractivePanel } from "@/components/InteractivePanel";
import { DataDisplay } from "@/components/DataDisplay";
export default async function DemoPage() {
const data = await Promise.resolve({ title: "服务端数据" });
return (
<InteractivePanel title="可交互面板">
<DataDisplay data={data} />
</InteractivePanel>
);
}
// components/InteractivePanel.tsx — Client
"use client";
import { useState, type ReactNode } from "react";
export function InteractivePanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<section className="rounded border p-4">
<button type="button" onClick={() => setOpen((o) => !o)}>
{open ? "收起" : "展开"} {title}
</button>
{open ? <div className="mt-3">{children}</div> : null}
</section>
);
}
// components/DataDisplay.tsx — Server Component(无 "use client")
type Props = { data: { title: string } };
export function DataDisplay({ data }: Props) {
return <p className="text-gray-800">{data.title}</p>;
}
此处 DataDisplay 由服务端先渲染成 RSC 输出,再作为 children 传入客户端的 InteractivePanel,符合官方推荐的组合方式。
8. 本章要点
默认服务端
App Router 下组件默认为 Server Components;只有需要状态、事件、浏览器 API 时使用 "use client"。
边界与组合
"use client" 在文件顶;客户端模块不宜直接 import 服务端模块,宜用 children 传入服务端渲染结果。
静态 vs 动态
Next 自动判断;cookies / headers / 请求相关 searchParams 常导致动态渲染;generateStaticParams 可预生成动态路由的静态页。
ISR 与失效
用 revalidate、fetch(..., { next: { revalidate } }) 控制增量再生;用 revalidatePath / revalidateTag 做按需刷新。
Streaming
loading.tsx 与 Suspense 实现流式输出与渐进加载,改善首屏与交互等待体验。