← 返回目录

第三章:渲染模式

Server Components、SSR/SSG 与 Streaming

1. Server Components 与 Client Components

App Routerapp/)下,Next.js 15 默认将所有组件视为 Server Components:它们先在服务器上完成渲染,再把结果以 RSC Payload 的形式参与页面组装。

下表概括二者在能力上的差异(有 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" 指令

示例:带状态的计数器

"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 的优势

示例:在 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,就会落入动态渲染。

动态段(如 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 中路由 paramssearchParams 在页面 props 中为 Promise,需在 async 组件内 await 后再使用。

5. ISR(增量静态再生)

ISR 让静态页面在一段时间后自动在后台刷新,兼顾 CDN 缓存与内容时效。可通过 fetchnext.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 边界,提供即时加载占位。

// 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 与失效

revalidatefetch(..., { next: { revalidate } }) 控制增量再生;用 revalidatePath / revalidateTag 做按需刷新。

Streaming

loading.tsxSuspense 实现流式输出与渐进加载,改善首屏与交互等待体验。