第 4 章:现代 Web 渲染模型全景

深入理解现代 Web 渲染策略:CSR、SSR、SSG、ISR、RSC 的原理、优缺点和适用场景,掌握如何在 Next.js 中选择合适的渲染模式,实现最佳的性能和用户体验。

本章是卷 II 的开篇章节。读完本章,你将建立完整的渲染模型心智地图,理解每种渲染策略的工作原理、优缺点和适用场景。


4.1 CSR 的局限性

什么是 CSR(Client-Side Rendering)

CSR(客户端渲染) 是传统 React SPA 的渲染方式:

  1. 浏览器下载空的 HTML 骨架
  2. 下载并执行 JavaScript
  3. React 在浏览器中渲染 UI
  4. 发起 API 请求获取数据
  5. 更新 UI 显示数据

典型流程

浏览器请求 → 服务器返回空 HTML → 下载 JS Bundle → 执行 JS → 渲染 UI → 请求数据 → 更新 UI

CSR 的 HTML 结构

<!DOCTYPE html>
<html>
<head>
  <title>My App</title>
</head>
<body>
  <div id="root"></div>
  <script src="/static/js/bundle.js"></script>
</body>
</html>

问题:HTML 中没有实际内容,只有一个空的 <div id="root"></div>

CSR 的局限性

1. 首屏白屏时间长

时间线

0ms    用户访问页面
50ms   下载空 HTML
500ms  下载 JS Bundle(可能很大)
1000ms 执行 JavaScript
1500ms React 渲染 UI
2000ms 发起 API 请求
2500ms 数据返回,更新 UI

问题:用户在 2-3 秒内看到的是白屏或加载动画。

实际影响

  • 用户流失率高(每增加 1 秒加载时间,转化率下降 7%)
  • 移动端网络慢,白屏时间更长
  • 用户体验差,感觉"卡顿"

2. SEO 不友好

搜索引擎爬虫的工作方式

Google Bot 访问页面
下载 HTML
尝试执行 JavaScript(成本高)
等待 React 渲染(可能超时)
抓取内容

问题

  • 爬虫看到的是空 <div id="root"></div>
  • 执行 JS 成本高,Google 可能不执行
  • 某些爬虫(Bing、百度)不支持 JS 渲染
  • 社交媒体预览(OG 标签)无法生成

SEO 影响

  • 搜索排名低
  • 社交媒体分享无预览图
  • 内容无法被索引

3. JavaScript Bundle 体积大

原因

  • 所有组件代码都打包到客户端
  • 所有依赖库(React、Router、状态管理)都打包
  • 即使用户只访问一个页面,也要下载整个应用的代码

示例

bundle.js 大小:
- React + ReactDOM: 130KB
- React Router: 40KB
- Redux: 30KB
- 组件代码: 200KB
- 总计: 400KB(gzip 后约 120KB)

问题

  • 首屏加载慢
  • 移动端流量消耗大
  • 低端设备执行 JS 慢

4. 性能指标差

Core Web Vitals

指标CSR 表现原因
LCP(Largest Contentful Paint)差(3-5s)需要等待 JS 执行和数据加载
FCP(First Contentful Paint)差(2-3s)需要等待 JS 下载和执行
TTI(Time to Interactive)差(4-6s)需要等待所有 JS 执行完成
CLS(Cumulative Layout Shift)内容加载时可能布局偏移

什么时候用 CSR?

尽管 CSR 有局限性,但在某些场景下仍然是合适的选择:

场景原因
内部管理系统不需要 SEO,用户固定,可以接受加载时间
高度交互应用如在线编辑器、游戏,需要实时响应
登录后页面用户已登录,不需要 SEO
实时数据应用如股票行情、聊天室,数据频繁更新

4.2 SSR(Server-Side Rendering)

什么是 SSR

SSR(服务端渲染):每次请求时,服务端执行 React 组件,生成完整的 HTML,返回给浏览器。

流程

浏览器请求 → 服务端执行 React → 生成 HTML → 返回完整 HTML → 浏览器显示 → Hydration(激活交互)

SSR 的工作原理

步骤 1:服务端渲染

// 服务端(Node.js)
import { renderToString } from 'react-dom/server';
import App from './App';

app.get('/', (req, res) => {
  const html = renderToString(<App />);
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head><title>My App</title></head>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `);
});

步骤 2:浏览器接收 HTML

<!DOCTYPE html>
<html>
<head>
  <title>My App</title>
</head>
<body>
  <div id="root">
    <!-- 服务端生成的完整 HTML -->
    <header>
      <h1>Welcome to My App</h1>
      <nav>
        <a href="/about">About</a>
        <a href="/contact">Contact</a>
      </nav>
    </header>
    <main>
      <p>This is the home page content.</p>
    </main>
  </div>
  <script src="/bundle.js"></script>
</body>
</html>

用户立即看到内容,无需等待 JS 执行。

步骤 3:Hydration(注水)

// 浏览器
import { hydrateRoot } from 'react-dom/client';
import App from './App';

hydrateRoot(document.getElementById('root'), <App />);

Hydration 的作用

  • React 接管服务端生成的 HTML
  • 添加事件监听器(onClickonChange
  • 激活交互功能
  • 建立状态管理

时间线

0ms    用户访问页面
100ms  服务端渲染 HTML
200ms  返回完整 HTML
300ms  浏览器显示内容(用户已看到页面)
500ms  下载 JS Bundle
800ms  Hydration 完成(页面可交互)

Next.js App Router 中的 SSR

在 App Router 中,使用动态渲染实现 SSR:

// app/page.tsx
export const dynamic = 'force-dynamic'; // 强制动态渲染(SSR)

async function getData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store', // 不缓存,每次请求都获取最新数据
  });
  return res.json();
}

export default async function Home() {
  const data = await getData();
  
  return (
    <div>
      <h1>Dynamic Data</h1>
      <p>Current time: {new Date().toISOString()}</p>
      <ul>
        {data.items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

关键点

  • export const dynamic = 'force-dynamic':强制每次请求都渲染
  • cache: 'no-store':不缓存 fetch 请求
  • 组件在服务端执行,生成 HTML

SSR 的优势

1. 首屏快

时间线对比

阶段CSRSSR
下载 HTML50ms(空 HTML)200ms(完整 HTML)
显示内容2500ms(等待 JS + 数据)300ms(立即显示)
可交互2500ms800ms(Hydration)

结果:SSR 首屏快 8 倍。

2. SEO 友好

搜索引擎爬虫看到的是完整 HTML

<header>
  <h1>Welcome to My App</h1>
  <nav>
    <a href="/about">About</a>
    <a href="/contact">Contact</a>
  </nav>
</header>
<main>
  <p>This is the home page content.</p>
</main>

SEO 优势

  • 爬虫直接抓取内容,无需执行 JS
  • 支持所有搜索引擎(Google、Bing、百度)
  • 社交媒体预览正常(OG 标签)

3. 更好的 Core Web Vitals

指标CSRSSR
LCP3-5s ❌0.5-1s ✅
FCP2-3s ❌0.3-0.5s ✅
TTI4-6s ❌1-2s ✅

SSR 的劣势

1. 服务器负载高

问题:每次请求都要在服务端执行 React,消耗 CPU 和内存。

示例

QPS(每秒请求数):1000
每次渲染耗时:50ms
CPU 使用率:50%(1000 * 50ms = 50s CPU 时间 / 秒)

解决方案

  • 使用 CDN 缓存
  • 使用 ISR(增量静态再生)
  • 使用 RSC(React Server Components)

2. TTFB 可能较高

TTFB(Time to First Byte):从请求到接收到第一个字节的时间。

影响因素

  • 服务端渲染时间
  • 数据库查询时间
  • API 调用时间

示例

如果数据查询需要 500ms,TTFB 至少 500ms。

3. Hydration 成本

问题:浏览器需要下载 JS 并执行 Hydration,才能激活交互。

时间线

300ms  显示内容(但不可交互)
800ms  Hydration 完成(可交互)

用户体验:页面看起来已加载,但点击按钮无反应(“Uncanny Valley”)。

什么时候用 SSR?

场景原因
个性化内容每个用户看到不同内容(如 Dashboard)
实时数据数据频繁更新(如股票行情、新闻)
需要 SEO内容页需要被搜索引擎索引
首屏性能要求高电商、内容站等

4.3 SSG(Static Generation)

什么是 SSG

SSG(静态生成):在构建时(next build)预渲染所有页面,生成静态 HTML 文件。

流程

构建时:执行 React → 生成 HTML → 保存为静态文件
请求时:CDN 直接返回静态 HTML → 浏览器显示 → Hydration

SSG 的工作原理

构建时

next build

Next.js 执行

  1. 渲染每个页面
  2. 生成 HTML 文件
  3. 保存到 .next/server/app/

生成的文件

.next/
└── server/
    └── app/
        ├── index.html          # /
        ├── about.html          # /about
        ├── blog/
        │   ├── index.html      # /blog
        │   └── hello.html      # /blog/hello
        └── ...

请求时

用户访问 /blog/hello
CDN 返回 /blog/hello.html(静态文件)
浏览器立即显示
下载 JS → Hydration

Next.js App Router 中的 SSG

在 App Router 中,默认就是 SSG(除非使用了动态 API):

// app/blog/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();
  
  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <a href={`/blog/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

行为

  • 构建时执行 getPosts()
  • 生成静态 HTML
  • 缓存到 CDN

动态路由的 SSG

对于动态路由,使用 generateStaticParams 预生成所有页面:

// app/blog/[slug]/page.tsx
async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`);
  return res.json();
}

// 告诉 Next.js 需要预生成哪些页面
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  
  return posts.map(post => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params,
}: {
  params: { slug: string };
}) {
  const post = await getPost(params.slug);
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

构建时

generateStaticParams 返回:
[
  { slug: 'hello-world' },
  { slug: 'nextjs-guide' },
  { slug: 'react-tips' }
]

Next.js 预生成:
- /blog/hello-world.html
- /blog/nextjs-guide.html
- /blog/react-tips.html

SSG 的优势

1. 性能极佳

时间线

0ms    用户访问页面
50ms   CDN 返回静态 HTML(全球边缘节点)
100ms  浏览器显示内容
300ms  Hydration 完成

原因

  • 静态文件存储在 CDN 边缘节点
  • 无需服务端渲染
  • 响应速度快(TTFB < 50ms)

2. 成本低

对比

渲染方式服务器成本
SSR高(每次请求都渲染)
SSG低(只返回静态文件)

示例

SSR:1000 QPS * 50ms = 50s CPU 时间 / 秒
SSG:1000 QPS * 1ms = 1s CPU 时间 / 秒(CDN 直接返回)

3. 可扩展性强

CDN 分发

用户在北京 → CDN 北京节点返回
用户在纽约 → CDN 纽约节点返回
用户在伦敦 → CDN 伦敦节点返回

优势

  • 全球低延迟
  • 自动负载均衡
  • 抗高并发

4. SEO 友好

和 SSR 一样,生成完整 HTML,搜索引擎可以直接抓取。

SSG 的劣势

1. 构建时间长

问题:如果有 10,000 个页面,构建时需要渲染所有页面。

示例

10,000 个页面 * 100ms / 页面 = 1,000 秒 = 16 分钟

解决方案

  • 使用 ISR(增量静态再生)
  • 按需生成(On-demand Revalidation)

2. 数据可能过时

问题:构建时生成的数据,可能已经过时。

示例

构建时间:2025-01-01 10:00
用户访问:2025-01-02 10:00
数据已过期 24 小时

解决方案

  • 使用 ISR(定时再生)
  • 使用 On-demand Revalidation(按需再生)

3. 不适合高度动态内容

问题:每次数据变化都要重新构建。

示例

电商网站:
- 库存每秒变化
- 价格每分钟变化
- 不可能每次变化都重新构建

什么时候用 SSG?

场景原因
博客文章内容不常变化,需要 SEO
文档站内容稳定,页面数量多
营销页需要极致性能和 SEO
产品介绍页内容相对固定

4.4 ISR(Incremental Static Regeneration)

什么是 ISR

ISR(增量静态再生):结合 SSG 和 SSR 的优势,页面在构建时生成,但可以定时或按需重新生成。

流程

构建时:生成静态页面
请求时:
  ├─ 页面未过期 → 返回缓存的静态页面
  └─ 页面已过期 → 返回缓存页面,后台重新生成

ISR 的工作原理

基于时间的 ISR

// app/blog/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 }, // 每 60 秒重新生成
  });
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();
  
  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

行为

  1. 首次请求:返回构建时生成的页面
  2. 60 秒内的请求:返回缓存页面(快速)
  3. 60 秒后的请求
    • 立即返回缓存页面(快速)
    • 后台重新生成页面
    • 下次请求使用新页面

时间线

T+0s     构建时生成页面
T+10s    请求 → 返回缓存页面
T+30s    请求 → 返回缓存页面
T+60s    请求 → 返回缓存页面,后台重新生成
T+61s    后台生成完成
T+70s    请求 → 返回新页面

按需再生(On-demand Revalidation)

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';

export async function POST(request: Request) {
  const { path, tag } = await request.json();
  
  if (path) {
    revalidatePath(path); // 重新生成指定路径
  }
  
  if (tag) {
    revalidateTag(tag); // 重新生成指定标签的页面
  }
  
  return Response.json({ revalidated: true });
}

使用场景

// CMS 更新文章时,调用 API 重新生成
await fetch('/api/revalidate', {
  method: 'POST',
  body: JSON.stringify({ path: '/blog/hello-world' }),
});

Next.js App Router 中的 ISR

方式 1:使用 next.revalidate

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 3600 }, // 每 1 小时重新生成
  });
  return res.json();
}

方式 2:使用 export const revalidate

// app/blog/page.tsx
export const revalidate = 3600; // 每 1 小时重新生成

async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();
  return <div>{/* ... */}</div>;
}

方式 3:使用 Tags

// app/blog/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['blog-posts'] }, // 添加标签
  });
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();
  return <div>{/* ... */}</div>;
}

按需再生

import { revalidateTag } from 'next/cache';

// 当博客文章更新时
revalidateTag('blog-posts'); // 重新生成所有带 'blog-posts' 标签的页面

ISR 的优势

1. 性能接近 SSG

时间线

缓存命中:50ms(和 SSG 一样快)
缓存未命中:返回缓存 + 后台生成(用户无感知)

2. 数据保持新鲜

对比

渲染方式数据新鲜度
SSG构建时数据,可能过时
ISR定时或按需更新,保持新鲜
SSR实时数据,但性能差

3. 成本低

和 SSG 一样,大部分请求由 CDN 直接返回静态文件。

4. 可扩展性强

不需要重新构建整个网站,只重新生成变化的页面。

ISR 的劣势

1. 可能显示过时数据

问题:在重新生成完成前,用户看到的是旧数据。

示例

T+0s     页面生成
T+59s    数据更新
T+60s    请求 → 返回旧数据(还未重新生成)
T+61s    后台重新生成
T+62s    请求 → 返回新数据

影响:用户可能看到 1-2 秒的过时数据。

2. 首次请求可能慢

问题:如果页面从未被访问,首次请求需要生成。

解决方案

  • 构建时预生成热门页面
  • 使用 fallback: 'blocking'(Pages Router)

什么时候用 ISR?

场景原因
电商商品页价格、库存定期更新,需要 SEO
新闻网站内容定期更新,需要快速响应
博客内容偶尔更新,需要 SEO 和性能
用户资料页资料偶尔更新,页面数量多

4.5 RSC(React Server Components)内部机制

什么是 RSC

RSC(React Server Components):组件在服务端执行,不发送 JavaScript 到客户端

与 SSR 的区别

特性SSRRSC
服务端执行
生成 HTML❌(生成 RSC Payload)
发送 JS 到客户端✅(Hydration)❌(零客户端 JS)
可交互✅(Hydration 后)❌(需要 Client Component)
适用场景需要交互的页面展示型内容

RSC 的工作原理

步骤 1:服务端执行组件

// app/blog/page.tsx(Server Component)
async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();
  
  return (
    <div>
      <h1>Blog</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

服务端执行

  1. 执行 getPosts()
  2. 渲染组件树
  3. 生成 RSC Payload(不是 HTML)

步骤 2:生成 RSC Payload

RSC Payload 示例(简化版):

{
  "type": "div",
  "props": {
    "children": [
      {
        "type": "h1",
        "props": { "children": "Blog" }
      },
      {
        "type": "ul",
        "props": {
          "children": [
            { "type": "li", "props": { "children": "Post 1" } },
            { "type": "li", "props": { "children": "Post 2" } }
          ]
        }
      }
    ]
  }
}

特点

  • 是组件树的序列化表示
  • 不包含 JavaScript 代码
  • 体积小(比 HTML 更小)

步骤 3:客户端渲染

浏览器接收 RSC Payload

  1. 解析 RSC Payload
  2. 渲染为 DOM
  3. 无需 Hydration(因为没有 JS 代码)

时间线

0ms    用户访问页面
100ms  服务端执行组件,生成 RSC Payload
150ms  返回 RSC Payload
200ms  浏览器渲染 DOM(完成)

对比 SSR

SSR:
0ms    用户访问页面
100ms  服务端执行组件,生成 HTML
150ms  返回 HTML
200ms  浏览器显示 HTML
400ms  下载 JS Bundle
600ms  Hydration 完成(可交互)

RSC:
0ms    用户访问页面
100ms  服务端执行组件,生成 RSC Payload
150ms  返回 RSC Payload
200ms  浏览器渲染 DOM(完成,无需 Hydration)

RSC 的优势

1. 零客户端 JavaScript

对比

传统 React(CSR):
- React + ReactDOM: 130KB
- 组件代码: 50KB
- 总计: 180KB

SSR:
- React + ReactDOM: 130KB(Hydration 需要)
- 组件代码: 50KB(Hydration 需要)
- 总计: 180KB

RSC:
- React Runtime: 10KB(轻量级)
- 组件代码: 0KB(服务端执行)
- 总计: 10KB

结果:RSC 减少 95% 的客户端 JS。

2. 更快的首屏

原因

  • 无需下载大量 JS
  • 无需 Hydration
  • RSC Payload 体积小

3. 直接访问服务端资源

示例:直接读取数据库

// app/users/page.tsx(Server Component)
import { db } from '@/lib/db';

export default async function UsersPage() {
  const users = await db.query('SELECT * FROM users');
  
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

优势

  • 无需 API 层
  • 无需序列化/反序列化
  • 更安全(数据库凭证不暴露到客户端)

4. 自动代码拆分

RSC 自动拆分

// app/page.tsx
import HeavyChart from './HeavyChart'; // 500KB

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <HeavyChart />
    </div>
  );
}

如果 HeavyChart 是 Server Component

  • 500KB 代码在服务端执行
  • 不发送到客户端
  • 客户端只接收 RSC Payload

RSC 的局限性

1. 不可交互

问题:RSC 无法使用事件监听器、状态管理。

错误示例

// ❌ Server Component 不能使用 useState
"use server"; // 默认就是 Server Component

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0); // ❌ 错误
  
  return (
    <button onClick={() => setCount(c => c + 1)}> // ❌ 错误
      Count: {count}
    </button>
  );
}

解决方案:使用 Client Component

// ✅ Client Component
"use client";

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

2. 不能使用浏览器 API

问题:RSC 在服务端执行,无法访问 windowdocumentlocalStorage

错误示例

// ❌ Server Component 不能使用浏览器 API
export default function Page() {
  const theme = localStorage.getItem('theme'); // ❌ 错误
  
  return <div>Theme: {theme}</div>;
}

3. 不能使用某些 Hooks

问题useStateuseEffectuseContext 等只能在 Client Component 中使用。

可用 Hooks(Server Component):

  • use()(读取 Promise)
  • useId()

RSC vs Client Component

特性Server ComponentClient Component
执行位置服务端客户端
发送 JS❌ 不发送✅ 发送
Hydration❌ 不需要✅ 需要
事件监听❌ 不支持✅ 支持
状态管理❌ 不支持✅ 支持
浏览器 API❌ 不支持✅ 支持
直接访问数据库✅ 支持❌ 不支持
适用场景展示型内容交互式 UI

4.6 混合渲染实战:同一页面中 RSC + SSR + CSR 的协作

为什么需要混合渲染?

现实场景:一个页面通常包含多种内容:

  • 静态内容:标题、描述(适合 SSG)
  • 动态内容:用户信息、实时数据(适合 SSR)
  • 交互内容:按钮、表单、图表(需要 Client Component)
  • 展示内容:文章列表、商品列表(适合 RSC)

解决方案:混合使用多种渲染策略。

实战:电商商品详情页

页面结构

商品详情页
├── Header(静态,SSG)
├── 商品信息(动态,RSC)
├── 价格/库存(实时,SSR)
├── 评论区(交互,Client Component)
└── 推荐商品(静态,RSC)

完整实现

// app/products/[id]/page.tsx

import ProductInfo from './ProductInfo';
import ProductPrice from './ProductPrice';
import ProductReviews from './ProductReviews';
import RecommendedProducts from './RecommendedProducts';

// 使用 ISR,每 10 分钟重新生成
export const revalidate = 600;

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  return (
    <div>
      {/* Header:静态内容,构建时生成 */}
      <header className="bg-white shadow">
        <h1>My Store</h1>
        <nav>
          <a href="/">Home</a>
          <a href="/products">Products</a>
          <a href="/cart">Cart</a>
        </nav>
      </header>

      {/* 商品信息:RSC,服务端执行,零客户端 JS */}
      <ProductInfo productId={params.id} />

      {/* 价格/库存:SSR,每次请求动态获取 */}
      <ProductPrice productId={params.id} />

      {/* 评论区:Client Component,需要交互 */}
      <ProductReviews productId={params.id} />

      {/* 推荐商品:RSC,服务端执行 */}
      <RecommendedProducts productId={params.id} />
    </div>
  );
}
// app/products/[id]/ProductInfo.tsx(Server Component)
import { db } from '@/lib/db';

export default async function ProductInfo({
  productId,
}: {
  productId: string;
}) {
  // 直接访问数据库
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  );

  return (
    <div className="p-8">
      <h1 className="text-3xl font-bold">{product.name}</h1>
      <p className="text-gray-600 mt-4">{product.description}</p>
      <img src={product.image} alt={product.name} />
    </div>
  );
}
// app/products/[id]/ProductPrice.tsx(Server Component,动态渲染)
import { db } from '@/lib/db';

export const dynamic = 'force-dynamic'; // 强制动态渲染

export default async function ProductPrice({
  productId,
}: {
  productId: string;
}) {
  // 实时获取价格和库存
  const price = await db.query(
    'SELECT price, stock FROM inventory WHERE product_id = ?',
    [productId]
  );

  return (
    <div className="p-8 bg-yellow-50">
      <p className="text-2xl font-bold text-red-600">
        ${price.price.toFixed(2)}
      </p>
      <p className="text-gray-600">
        {price.stock > 0 ? `In Stock (${price.stock})` : 'Out of Stock'}
      </p>
    </div>
  );
}
// app/products/[id]/ProductReviews.tsx(Client Component)
"use client";

import { useState } from 'react';

export default function ProductReviews({
  productId,
}: {
  productId: string;
}) {
  const [reviews, setReviews] = useState([]);
  const [newReview, setNewReview] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    const res = await fetch(`/api/reviews`, {
      method: 'POST',
      body: JSON.stringify({ productId, content: newReview }),
    });
    
    const review = await res.json();
    setReviews([...reviews, review]);
    setNewReview('');
  };

  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-4">Reviews</h2>
      
      <ul className="space-y-4">
        {reviews.map(review => (
          <li key={review.id} className="border p-4 rounded">
            <p>{review.content}</p>
            <p className="text-sm text-gray-500">
              {new Date(review.createdAt).toLocaleDateString()}
            </p>
          </li>
        ))}
      </ul>

      <form onSubmit={handleSubmit} className="mt-8">
        <textarea
          value={newReview}
          onChange={(e) => setNewReview(e.target.value)}
          className="w-full border p-2 rounded"
          placeholder="Write a review..."
        />
        <button
          type="submit"
          className="mt-2 bg-blue-600 text-white px-4 py-2 rounded"
        >
          Submit
        </button>
      </form>
    </div>
  );
}
// app/products/[id]/RecommendedProducts.tsx(Server Component)
import { db } from '@/lib/db';

export default async function RecommendedProducts({
  productId,
}: {
  productId: string;
}) {
  // 获取推荐商品
  const recommendations = await db.query(`
    SELECT * FROM products
    WHERE category = (
      SELECT category FROM products WHERE id = ?
    )
    AND id != ?
    LIMIT 4
  `, [productId, productId]);

  return (
    <div className="p-8 bg-gray-50">
      <h2 className="text-2xl font-bold mb-4">You May Also Like</h2>
      
      <div className="grid grid-cols-4 gap-4">
        {recommendations.map(product => (
          <a
            key={product.id}
            href={`/products/${product.id}`}
            className="border p-4 rounded hover:shadow-lg"
          >
            <img src={product.image} alt={product.name} />
            <h3 className="font-semibold mt-2">{product.name}</h3>
            <p className="text-red-600">${product.price}</p>
          </a>
        ))}
      </div>
    </div>
  );
}

渲染策略分析

组件渲染策略原因
HeaderSSG(构建时生成)静态内容,所有用户看到一样
ProductInfoRSC(服务端执行)展示型内容,无需交互,直接访问数据库
ProductPriceSSR(动态渲染)实时价格和库存,每次请求都不同
ProductReviewsClient Component需要交互(表单提交、状态管理)
RecommendedProductsRSC(服务端执行)展示型内容,无需交互

性能分析

时间线

0ms     用户访问页面
100ms   CDN 返回缓存的 Header(SSG)
150ms   服务端执行 ProductInfo(RSC)
200ms   服务端执行 ProductPrice(SSR,实时查询)
250ms   流式发送 HTML 到浏览器
300ms   浏览器显示 Header、ProductInfo、ProductPrice
350ms   下载 ProductReviews 的 JS
400ms   Hydration ProductReviews(可交互)
450ms   服务端执行 RecommendedProducts(RSC)
500ms   浏览器显示 RecommendedProducts

结果

  • 用户在 300ms 看到大部分内容
  • 交互组件在 400ms 可交互
  • 整体加载时间 < 500ms

对比纯 CSR

CSR:
0ms     用户访问页面
500ms   下载 JS Bundle
1000ms  执行 JS
1500ms  请求 API(商品信息)
2000ms  请求 API(价格)
2500ms  请求 API(评论)
3000ms  请求 API(推荐)
3500ms  渲染完成

混合渲染比 CSR 快 7 倍

混合渲染的最佳实践

1. 优先使用 Server Component

原则:如果组件不需要交互,使用 Server Component。

// ✅ 好的做法
export default function ProductList() {
  // Server Component,直接访问数据库
  const products = await db.query('SELECT * FROM products');
  return <ul>{/* ... */}</ul>;
}

2. 将 Client Component 下沉到叶子节点

原则:尽量减少 Client Component 的范围。

// ❌ 不好的做法:整个页面都是 Client Component
"use client";

export default function Page() {
  return (
    <div>
      <Header />
      <ProductList />
      <Button /> {/* 只有这个需要交互 */}
    </div>
  );
}

// ✅ 好的做法:只有 Button 是 Client Component
export default function Page() {
  return (
    <div>
      <Header />
      <ProductList />
      <Button /> {/* "use client" */}
    </div>
  );
}

3. 使用 Suspense 分割加载状态

原则:慢速组件使用 Suspense,不阻塞整个页面。

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />
      
      <Suspense fallback={<div>Loading products...</div>}>
        <ProductList />
      </Suspense>
      
      <Suspense fallback={<div>Loading recommendations...</div>}>
        <RecommendedProducts />
      </Suspense>
    </div>
  );
}

本章小结

渲染策略对比表

渲染策略生成时机性能SEO适用场景劣势
CSR浏览器运行时内部系统、高度交互应用白屏、SEO 差
SSR每次请求个性化内容、实时数据服务器负载高
SSG构建时极好博客、文档、营销页数据可能过时
ISR构建时 + 定时再生电商、新闻、内容站可能显示过时数据
RSC服务端执行极好展示型内容、数据密集不可交互

选择渲染策略的决策树

需要交互吗?
├─ 是 → 使用 Client Component
└─ 否 → 数据变化频率?
    ├─ 很少变化(几天/几周)→ SSG
    ├─ 定期变化(几分钟/几小时)→ ISR
    ├─ 每次请求都不同 → SSR 或 RSC(动态)
    └─ 需要直接访问数据库?
        ├─ 是 → RSC
        └─ 否 → SSR

Key Takeaways

  1. CSR 的局限性:白屏时间长、SEO 差、JS Bundle 大
  2. SSR 的优势:首屏快、SEO 好,但服务器负载高
  3. SSG 的优势:性能极佳、成本低,但数据可能过时
  4. ISR 的优势:结合 SSG 和 SSR,定时或按需更新
  5. RSC 的优势:零客户端 JS、直接访问数据库,但不可交互
  6. 混合渲染:同一页面中使用多种渲染策略,达到最佳性能

下一步

在下一章,我们将深入 React Server Components 的内部机制,理解 RSC vs 传统 SSR 的本质差异,以及 RSC 的多层缓存机制。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页