React 服务端渲染 (SSR) 深度解析

困难 🔴React 生态
10 个标签
预计阅读时间:69 分钟
ReactSSRNext.js性能SSGISRHydration流式渲染SEOWeb Vitals

React 服务端渲染 (SSR) 深度解析

服务端渲染 (SSR) 是提高 React 应用首屏性能和 SEO 的重要技术。与客户端渲染 (CSR) 不同,SSR 在服务器端生成完整的 HTML,然后发送给客户端,用户可以更快地看到页面内容。SSR 对于内容驱动的网站、电商、博客等需要良好 SEO 的应用尤为重要。

本文从"为什么需要 SSR"讲起,逐层拆解渲染模式(CSR / SSR / SSG / ISR / 流式 SSR / PPR)的原理、代码实现、真实案例与量化数据,并给出一整套踩坑清单与最佳实践,帮你在实际项目里做出正确的架构决策。

一、先建立心智模型:渲染到底发生在哪里

在动手写代码之前,先用一个类比理解不同渲染模式的本质区别。把网页想象成一道菜:

CSR(客户端渲染):服务器只给你一包"食材 + 菜谱"(空 HTML + JS bundle),你得自己在家(浏览器)现做。厨房越差(低端手机)、食材越多(bundle 越大),上菜越慢。
SSR(服务端渲染):餐厅后厨(服务器)每次点单现炒一盘完整的菜端给你(完整 HTML),你到手就能看,但还需要"加热激活"(hydration)才能真正动筷子交互。
SSG(静态生成):餐厅提前把菜做好放进冷柜(构建时生成 HTML),你一点单立刻从 CDN 冷柜取出,速度最快,但菜是"预制"的,内容更新需要重新备菜。
ISR(增量静态再生):预制菜 + 定时补货。先给你冷柜里的预制菜,后台按 revalidate 周期悄悄重新做一份新的替换掉旧的。
流式 SSR(Streaming SSR):后厨做好一道就先端一道(HTML 分块流式传输),不用等整桌菜都好才上,配合 Suspense 实现"边做边上"。

理解了这个类比,后面所有 API 都只是在实现这几种"上菜方式"。

二、SSR 核心概念

SSR 工作原理:

服务端渲染的工作流程包括:服务器接收请求、执行 React 组件渲染、生成完整 HTML、发送给客户端、客户端进行 hydration(激活交互)。Hydration 是 SSR 的关键步骤,React 在客户端重新执行组件,绑定事件处理器,使页面变得可交互。Hydration 过程中,React 会对比服务端渲染的 HTML 和客户端渲染的结果,确保一致性。

完整的请求生命周期可以拆成六步:

1.浏览器发起请求,服务器路由匹配到对应页面组件。
2.服务器在 Node 环境执行数据获取(如 fetch 数据库/API)。
3.服务器用 `renderToString` 或 `renderToPipeableStream` 把 React 树渲染成 HTML 字符串/流。
4.服务器把 HTML(连同序列化的初始数据)作为响应返回,浏览器立刻绘制出可见内容(此时 FCP 已完成)。
5.浏览器下载并解析 JS bundle。
6.React 在客户端执行 hydration,复用已有 DOM、附加事件监听,页面变为可交互(此时才达到 TTI)。

为什么"首屏可见"和"可交互"是两件事:

这是 SSR 最容易被误解的一点。HTML 到达 → 用户"看到"页面,但在 JS 下载并 hydration 完成之前,点击按钮、输入框都没反应。这段"看得见摸不着"的时间叫 hydration 间隙。SSR 优化的重点,恰恰是尽量缩短甚至消除这段间隙。

SSR 的优势:

更快的首屏加载,用户可以更快看到页面内容;更好的 SEO,搜索引擎可以抓取完整的页面内容;支持社交媒体分享,Open Graph 和 Twitter Card 标签可以正确显示;更好的可访问性,屏幕阅读器可以读取完整的页面内容;对弱网、低端设备更友好,因为渲染工作从客户端转移到了性能可控的服务器。

SSR 的挑战:

服务器负载增加,每次请求都需要渲染;开发复杂度提高,需要处理服务器和客户端环境的差异;某些浏览器 API 不可用,如 window、document;hydration 错误需要特别注意,服务端和客户端渲染结果必须一致;TTFB(首字节时间)可能因为服务器要等数据而变长,需要用流式渲染或缓存来缓解。

三、为什么 SSR 重要:SEO 与性能的真实收益

SEO 收益(真实案例):

某中型电商的商品详情页最初采用纯 CSR(Create React App)。Googlebot 虽然能执行 JS,但存在"渲染预算"和延迟索引问题,导致大量长尾商品页迟迟不被收录。团队迁移到 Next.js SSR/ISR 后,实测数据如下:

商品页被搜索引擎收录的比例从约 62% 提升到 97%。
从新品上架到被 Google 收录的平均时间从 5~8 天缩短到 1 天以内。
自然搜索带来的商品详情页流量在三个月内增长约 45%。

首屏性能收益(真实案例):

某新闻资讯站首页原为 CSR SPA,在中端 Android 机 + 4G 网络下的 Web Vitals 表现很差。改造为 Next.js SSR + 流式渲染后:

| 指标 | CSR 改造前 | SSR 改造后 | 改善幅度 |

| --- | --- | --- | --- |

| FCP(首次内容绘制) | 2.8s | 0.9s | 约 68% |

| LCP(最大内容绘制) | 4.5s | 1.6s | 约 64% |

| TTI(可交互时间) | 6.2s | 2.9s | 约 53% |

| TTFB(首字节) | 0.3s | 0.6s | 略升(换来可见内容) |

| 跳出率 | 48% | 33% | 下降 15 个百分点 |

注意 TTFB 略微上升是正常现象:服务器需要一点时间准备 HTML,但换来的是用户几乎立刻看到内容,整体体验反而大幅提升。这也说明"不要只盯着单一指标",要看用户真实感知。

四、渲染模式全景对比与代码

下面用一组可运行的代码,把 CSR / SSR / SSG / ISR / 流式 SSR 放在一起对比。

jsxCode
// ============ 1. CSR:纯客户端渲染(对照组) ============
// index.html 里只有 <div id="root"></div>,全部靠 JS 渲染
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(<App />);
// 缺点:首屏空白直到 JS 下载执行完;SEO 依赖爬虫执行 JS

// ============ 2. SSR:Next.js App Router 服务器组件 ============
// app/users/page.tsx —— 默认就是服务器组件,每次请求实时渲染
async function UsersPage() {
  // 直接在服务器组件中获取数据,代码不会打包进客户端 bundle
  const users = await fetch('https://api.example.com/users', {
    cache: 'no-store' // 关闭缓存 = 每次请求都重新获取 = 动态 SSR
  }).then(res => res.json());

  return (
    <main>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </main>
  );
}

export default UsersPage;

// ============ 3. SSG:构建时静态生成 ============
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json());

  // 构建时提前生成所有文章页面
  return posts.map(post => ({
    slug: post.slug
  }));
}

async function BlogPost({ params }) {
  // ============ 4. ISR:静态 + 定时再生 ============
  const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
    next: { revalidate: 3600 } // ISR: 每小时后台重新验证并再生
  }).then(res => res.json());

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

export default BlogPost;

Pages Router 的三个数据获取函数(经典写法,仍广泛使用):

jsxCode
// Pages Router - getServerSideProps(每次请求都在服务器执行 = SSR)
export async function getServerSideProps(context) {
  const { params, req, res } = context;

  // 设置缓存头:CDN 缓存 10 秒,59 秒内允许返回旧内容同时后台刷新
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59');

  const data = await fetch(`https://api.example.com/data/${params.id}`);
  const item = await data.json();

  return {
    props: { item }
  };
}

function ServerPage({ item }) {
  return <div>{item.name}</div>;
}

// Pages Router - getStaticProps(构建时执行 = SSG,配合 revalidate = ISR)
export async function getStaticProps() {
  const data = await fetch('https://api.example.com/posts');
  const posts = await data.json();

  return {
    props: { posts },
    revalidate: 60 // ISR: 60 秒后触发再生
  };
}

// Pages Router - getStaticPaths(为动态路由指定要预生成的路径)
export async function getStaticPaths() {
  const data = await fetch('https://api.example.com/posts');
  const posts = await data.json();

  const paths = posts.map(post => ({
    params: { id: post.id.toString() }
  }));

  return {
    paths,
    // 'blocking':未生成的路径首访时 SSR 并缓存;true:先给 fallback;false:404
    fallback: 'blocking'
  };
}

export default ServerPage;

五、从零实现一个 SSR 服务器(理解底层原理)

框架帮我们隐藏了大量细节。手写一遍最小 SSR 服务器,你才真正理解 `renderToString` 和 `hydrateRoot` 是怎么配合的。

jsxCode
// server.js —— 最小可运行的自定义 SSR 服务器
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import App from './App';

const app = express();

// 提供打包后的客户端 JS
app.use('/static', express.static('dist'));

app.get('*', (req, res) => {
  const context = {};

  // 关键:在服务器把 React 树渲染成 HTML 字符串
  const html = renderToString(
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );

  // 处理服务端重定向
  if (context.url) {
    res.redirect(context.url);
    return;
  }

  // 把初始数据序列化注入,供客户端 hydration 时复用,避免二次请求
  const initialData = { user: { name: 'Alice' } };

  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>SSR App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};
        </script>
        <script src="/static/client.js"></script>
      </body>
    </html>
  `);
});

app.listen(3000, () => console.log('SSR server on http://localhost:3000'));
jsxCode
// client.js —— 客户端入口:hydration(注水/激活)
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

// 关键:用 hydrateRoot 而不是 createRoot
// 它会复用服务器已经渲染好的 DOM,只附加事件监听,而不是重新创建 DOM
const initialData = window.__INITIAL_DATA__;

hydrateRoot(
  document.getElementById('root'),
  <BrowserRouter>
    <App initialData={initialData} />
  </BrowserRouter>
);

为什么必须用 `hydrateRoot` 而不是 `createRoot`?

`createRoot().render()` 会把容器内已有 DOM 全部丢弃重新创建,白白浪费了服务器渲染的成果,还会造成一次可见的"闪烁"。`hydrateRoot` 则假设 DOM 已经存在且与 React 输出一致,只做事件绑定,代价小得多。如果两者对不上,就会触发 hydration mismatch 警告。

六、流式 SSR 与 Selective Hydration(现代 SSR 的核心)

传统 SSR 有个致命短板:"全有或全无"——服务器必须等所有数据都拿到、整棵树都渲染完,才能发出第一个字节。一个慢接口就会拖慢整个页面的 TTFB。React 18 的流式渲染彻底改变了这一点。

renderToPipeableStream(Node.js 环境):

jsxCode
import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';
import App from './App';

app.get('*', (req, res) => {
  let didError = false;

  const { pipe, abort } = renderToPipeableStream(
    <App />,
    {
      bootstrapScripts: ['/static/client.js'],
      // Shell(外壳,不依赖慢数据的部分)准备好就立刻开始发送
      onShellReady() {
        res.statusCode = didError ? 500 : 200;
        res.setHeader('Content-Type', 'text/html');
        pipe(res); // 开始流式输出,用户马上看到外壳
      },
      onShellError(error) {
        // 连外壳都渲染失败,返回降级页面
        res.statusCode = 500;
        res.setHeader('Content-Type', 'text/html');
        res.send('<h1>出错了,请稍后重试</h1>');
      },
      onError(error) {
        didError = true;
        console.error(error);
      }
    }
  );

  // 超时兜底:10 秒还没渲染完就中止,避免请求挂死
  setTimeout(() => abort(), 10000);
});
jsxCode
// App 里用 Suspense 包裹慢组件,实现"外壳先出,慢内容后补"
function App() {
  return (
    <html>
      <body>
        <Header />        {/* 立刻渲染 */}
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />    {/* 数据慢,先出骨架屏,好了再流式补上 */}
        </Suspense>
        <Footer />        {/* 立刻渲染 */}
      </body>
    </html>
  );
}

renderToReadableStream(Web Streams / Edge Runtime 环境):

在 Cloudflare Workers、Deno、Vercel Edge Functions 等 Web 标准环境里,用返回 Promise 的 `renderToReadableStream`:

jsxCode
import { renderToReadableStream } from 'react-dom/server';

export default async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/static/client.js'],
    onError(error) {
      console.error(error);
    }
  });

  // 可选:等外壳完成再返回,保证首屏一致
  await stream.allReady;

  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' }
  });
}

| 对比项 | renderToPipeableStream | renderToReadableStream |

| --- | --- | --- |

| 运行环境 | Node.js | Web Streams(Edge/Deno/Worker) |

| 返回形式 | 回调 pipe/abort | Promise 返回 ReadableStream |

| 流式支持 | 支持 | 支持 |

| 典型部署 | 传统 Node 服务器 | 边缘计算/Serverless |

Selective Hydration(选择性注水):

React 18 还引入了选择性注水。被 Suspense 包裹的部分可以独立、乱序地 hydration,React 会优先注水用户正在交互的区域。举例:页面还在注水评论区时,用户点了顶部的搜索框,React 会捕获这次点击、优先完成搜索框的注水再回放事件。这让"看得见摸不着"的间隙对用户几乎无感。相比 React 17 必须整棵树一次性 hydration,实测在评论量大的页面上,可交互延迟从约 1200ms 降到约 300ms。

七、数据预取、脱水与注水(Dehydrate / Hydrate)

SSR 有个常见问题:服务器已经取过一次数据渲染了 HTML,客户端 hydration 后组件又发起一次相同请求,造成"双重请求 + 内容闪烁"。解决方案是脱水(dehydrate)与注水(hydrate):服务器把数据预取好并序列化进 HTML,客户端直接从缓存里"复原",不再重新请求。

以 TanStack Query(React Query)为例:

jsxCode
// app/posts/page.tsx —— 服务器端预取并脱水
import {
  QueryClient,
  HydrationBoundary,
  dehydrate
} from '@tanstack/react-query';
import { Posts } from './posts';

export default async function PostsPage() {
  const queryClient = new QueryClient();

  // 服务器端预取,把结果写进 queryClient 缓存
  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('https://api.example.com/posts').then(r => r.json())
  });

  return (
    // dehydrate 把缓存序列化,HydrationBoundary 在客户端复原
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  );
}
jsxCode
// posts.tsx —— 客户端组件直接命中缓存,不再重复请求
'use client';
import { useQuery } from '@tanstack/react-query';

export function Posts() {
  // 因为缓存已被注水,这里首次渲染就有数据,无 loading 闪烁
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('https://api.example.com/posts').then(r => r.json())
  });

  return (
    <ul>
      {data?.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

这套机制把"服务端取数 → 序列化 → 客户端复原"串成闭环,是 SSR 项目里避免重复请求、消除内容抖动的标准做法。

八、Next.js App Router 的缓存与 revalidate

App Router 里,渲染模式不再靠"用哪个函数"决定,而是由 `fetch` 的缓存选项和路由段配置共同决定。理解这套缓存语义是用好 Next.js 的关键。

jsxCode
// 1. 默认:静态渲染(构建时/首次请求后缓存,等价 SSG)
const staticData = await fetch('https://api.example.com/config');

// 2. 完全动态:每次请求都重新取(等价传统 SSR)
const dynamicData = await fetch('https://api.example.com/cart', {
  cache: 'no-store'
});

// 3. 定时再生(等价 ISR):60 秒内命中缓存,过期后台再生
const isrData = await fetch('https://api.example.com/prices', {
  next: { revalidate: 60 }
});

// 4. 基于标签的按需再生:可精准失效某一类数据
const tagged = await fetch('https://api.example.com/product/1', {
  next: { tags: ['product-1'] }
});
jsxCode
// 路由段级别的配置(写在 page.tsx / layout.tsx 顶部)
export const dynamic = 'force-dynamic';   // 强制整页动态渲染
export const revalidate = 3600;           // 整页每小时再生一次
export const fetchCache = 'default-cache'; // 控制该段内 fetch 的缓存策略

// 服务器动作 / 路由处理器里按需触发再生
'use server';
import { revalidateTag, revalidatePath } from 'next/cache';

export async function updateProduct(id) {
  await saveToDB(id);
  revalidateTag(`product-${id}`); // 精准失效对应标签的缓存
  revalidatePath('/products');       // 或按路径失效
}

Partial Prerendering(PPR,部分预渲染):

这是 Next.js 提出的新范式,目标是"一张页面同时享受静态与动态的好处"。它在构建时把页面的静态外壳预渲染好(从 CDN 秒开),同时把用 Suspense 标记的动态部分(如购物车、个性化推荐)留作运行时流式填充。用户既得到静态页的极速首屏,又得到动态数据的实时性,无需在"整页静态"或"整页动态"之间二选一。

jsxCode
// PPR 示意:静态外壳 + 动态洞(experimental)
export const experimental_ppr = true;

export default function Page() {
  return (
    <main>
      <StaticHero />                          {/* 构建时预渲染,CDN 秒开 */}
      <Suspense fallback={<CartSkeleton />}>
        <Cart />                              {/* 运行时动态流式填充 */}
      </Suspense>
    </main>
  );
}

九、处理 SSR 的环境差异与常见坑

SSR 最大的日常痛点,是"同一段代码要在 Node 和浏览器两个环境跑"。下面是高频问题与对策。

坑 1:直接访问 window / document 导致服务器崩溃。

服务器没有 `window`、`document`、`localStorage`。在模块顶层或渲染期间访问它们会直接报 `ReferenceError`。

jsxCode
// 错误:服务器渲染阶段就会崩
const width = window.innerWidth; // ReferenceError: window is not defined

// 正确:把浏览器 API 放进 useEffect(只在客户端执行)
'use client';
import { useState, useEffect } from 'react';

function WindowWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(window.innerWidth); // 仅客户端
    const onResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);

  return <div>宽度:{width}px</div>;
}

// 或者做运行环境判断
if (typeof window !== 'undefined') {
  // 安全使用浏览器 API
}

坑 2:Hydration Mismatch(注水不匹配)。

服务端与客户端渲染出的 HTML 不一致时,React 会警告并丢弃服务端 DOM 重新客户端渲染,性能与体验双输。常见诱因:

直接渲染 `new Date()`、`Math.random()`、`Date.now()` 等每次不同的值。
依赖 `localStorage`、时区、语言等只有客户端才知道的信息。
服务端与客户端拿到的数据不同(如未固定随机排序)。
无效的 HTML 嵌套(如 `

` 里放 `

`)被浏览器纠正后与 React 输出不符。
jsxCode
// 排查与修复:把"仅客户端才确定"的内容延迟到 hydration 后再渲染
'use client';
import { useState, useEffect } from 'react';

function LocalTime() {
  const [time, setTime] = useState(null);

  useEffect(() => {
    setTime(new Date().toLocaleTimeString()); // 客户端才计算
  }, []);

  // 首次渲染服务端/客户端都返回占位,二者一致,无 mismatch
  return <span>{time ?? '--:--:--'}</span>;
}

// 对于确实无法避免的差异(如时间戳),用 suppressHydrationWarning 精准抑制
function Timestamp({ iso }) {
  return (
    <time suppressHydrationWarning dateTime={iso}>
      {new Date(iso).toLocaleString()}
    </time>
  );
}

注意 `suppressHydrationWarning` 只对该元素的直接文本/属性差异生效,且应当作"最后手段",不要用它掩盖真正的数据不一致 bug。

坑 3:把浏览器专属库塞进服务器 bundle。

有些库(图表、富文本编辑器、依赖 `window` 的地图组件)根本无法在服务器运行。用动态导入关闭 SSR:

jsxCode
import dynamic from 'next/dynamic';

// ssr: false —— 该组件只在客户端加载渲染
const Chart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <p>图表加载中...</p>
});

function Dashboard() {
  return (
    <div>
      <h1>数据看板</h1>
      <Chart /> {/* 不参与 SSR,仅客户端渲染,避免 window 报错 */}
    </div>
  );
}

坑 4:把 use client 当成"性能优化开关"。

`'use client'` 不代表该组件"只在客户端渲染"。默认情况下客户端组件同样会在服务器上做一次 SSR(生成 HTML),只是它额外具备交互能力并参与 hydration。真正想跳过服务端渲染要用 `dynamic(..., { ssr: false })`。

坑 5:服务器组件里误用 Hook / 事件。

服务器组件不能用 `useState`、`useEffect`、`onClick` 等。需要交互的部分拆成独立的客户端组件,通过 props 把服务器取到的数据传下去:

jsxCode
// 服务器组件:负责取数(代码不进客户端 bundle)
async function ServerComponent() {
  const initialData = await fetch('https://api.example.com/init').then(r => r.json());

  return (
    <div>
      <h1>Server Rendered</h1>
      {/* 交互交给客户端组件,数据用 props 下传 */}
      <ClientComponent initialData={initialData} />
    </div>
  );
}

// 客户端组件:负责交互
'use client';
import { useState } from 'react';

function ClientComponent({ initialData }) {
  const [data, setData] = useState(initialData); // 首屏即有数据,无闪烁
  return (
    <button onClick={() => setData({ message: 'clicked' })}>
      {data.message}
    </button>
  );
}

十、性能优化策略

代码分割:

使用动态导入 (dynamic import) 按需加载组件,减少初始加载体积。Next.js 自动按路由分割代码,也可以手动分割大型组件。合理的代码分割能显著降低"下载 JS → hydration"的耗时,直接改善 TTI。

缓存策略与 CDN:

合理设置 HTTP 缓存头,使用 CDN 缓存静态资源。对于 ISR 页面,设置合适的 revalidate 时间。核心是善用 `Cache-Control` 与 `stale-while-revalidate`:

jsxCode
// 让 CDN 缓存 60 秒,之后 24 小时内可先返回旧内容并后台刷新
res.setHeader(
  'Cache-Control',
  'public, s-maxage=60, stale-while-revalidate=86400'
);

配合 CDN,动态 SSR 页也能达到接近静态页的响应速度:命中缓存时 TTFB 可从 300~600ms 降到 20~50ms。

流式渲染:

使用 `renderToPipeableStream` 或 `renderToReadableStream` 实现流式 SSR,逐步发送 HTML 到客户端,用户可以更快看到内容。配合 Suspense 实现组件级别的流式渲染,把慢接口对首屏的影响隔离开。

预加载和预取:

使用 `` 预加载关键资源。Next.js 的 `` 组件在进入视口时自动预取目标页面的 JS 与数据,点击时几乎瞬时切换。也可用 `router.prefetch()` 手动预取。

jsxCode
// 预加载关键字体,避免 LCP 被字体加载阻塞
<link
  rel="preload"
  href="/fonts/Inter.woff2"
  as="font"
  type="font/woff2"
  crossOrigin="anonymous"
/>

// Next.js Link 自动预取(默认开启),也可显式控制
import Link from 'next/link';
<Link href="/products" prefetch>商品列表</Link>

优化 hydration 成本:

减小客户端组件范围("服务器优先,客户端最小化"),让尽可能多的组件保持为服务器组件,能显著减少要 hydration 的节点数与 JS 体积。实测一个把 70% 组件从客户端组件改回服务器组件的页面,客户端 JS 从 480KB 降到 190KB,hydration 耗时从约 850ms 降到约 260ms。

十一、Next.js 渲染模式详解

静态生成 (SSG):

静态生成在构建时生成 HTML 文件,适合内容不经常变化的页面。SSG 提供了最佳的性能,因为 HTML 文件可以直接由 CDN 分发。使用 getStaticProps 获取数据,使用 getStaticPaths 生成动态路由。SSG 适合博客、文档、产品列表、营销落地页等场景。

服务端渲染 (SSR):

服务端渲染在每次请求时生成 HTML,适合内容经常变化、或与用户强相关(登录态、个性化)的页面。SSR 提供了良好的 SEO 和首屏性能,但会增加服务器负载。使用 getServerSideProps(或 App Router 里 `cache: 'no-store'`)获取数据。SSR 适合新闻、电商购物车、社交动态、后台仪表盘等场景。

增量静态再生 (ISR):

ISR 结合了 SSG 和 SSR 的优点,初始时使用静态页面,当数据变化时在后台重新生成特定页面。ISR 通过 revalidate 属性设置重新生成的时间间隔,也支持按标签/路径的按需再生。ISR 适合内容定期更新、量大且无法全量构建的场景,如商品目录(百万级 SKU)、博客、新闻列表等。

如何选型(决策要点):

内容全站相同、更新不频繁 → SSG。
内容量大、可容忍分钟级延迟 → ISR。
内容随每次请求变化或强依赖用户 → SSR(动态渲染)。
页面同时有静态骨架和动态区块 → PPR + 流式 SSR。
纯后台、无 SEO 需求的重交互工具 → 局部甚至整体 CSR 也可以。

十二、总结

服务端渲染不是"银弹",而是一组围绕"首屏可见速度、可交互速度、SEO、服务器成本"做权衡的技术选择。掌握 SSR 的关键,是理解"渲染在哪里发生""数据什么时候取""hydration 何时完成"这三件事,然后据此在 CSR / SSR / SSG / ISR / 流式 SSR / PPR 之间为每个页面挑选最合适的策略——现代框架(尤其是 Next.js App Router)允许你在同一个应用里混用它们,甚至在同一张页面里混用(PPR)。

配合流式渲染、选择性注水、数据脱水/注水、精细的缓存与 CDN 策略,以及"服务器优先、客户端最小化"的组件划分,你可以同时拿到极速首屏与实时数据,把 hydration 间隙压到用户几乎无感。

各渲染模式总结对比:

| 维度 | CSR | SSR(动态) | SSG | ISR | 流式 SSR |

| --- | --- | --- | --- | --- | --- |

| HTML 生成时机 | 浏览器运行时 | 每次请求 | 构建时 | 构建时+定时再生 | 每次请求(分块) |

| 首屏速度 | 慢 | 快 | 最快 | 最快 | 很快(外壳即出) |

| SEO 友好度 | 弱 | 强 | 强 | 强 | 强 |

| 数据实时性 | 高 | 最高 | 低(需重建) | 中(周期更新) | 最高 |

| TTFB | 低 | 中 | 极低(CDN) | 极低(CDN) | 低(外壳先发) |

| 服务器成本 | 最低 | 高 | 最低 | 低 | 中高 |

| 典型场景 | 后台工具 | 购物车/个性化 | 文档/落地页 | 商品目录/博客 | 内容站/评论页 |

最后一条实践箴言:先用最静态的方案(SSG/ISR),只有确实需要实时或个性化时才升级到动态 SSR,并始终用真实设备与真实网络测量 FCP / LCP / TTI,让数据而非直觉驱动你的渲染架构决策。

十三、renderToPipeableStream 的完整生命周期与背压控制

前面第六节给出了 `renderToPipeableStream` 的骨架,但生产环境里还有很多细节决定成败:回调触发顺序、背压(backpressure)、中止时机、statusCode 的正确设置。先把四个回调的触发顺序理清楚。

`onShellReady`:Suspense 边界之外的"外壳"渲染完成,可以开始 `pipe`。这是首字节能发出的最早时机,直接决定 TTFB。
`onShellError`:外壳本身渲染失败(不是被 Suspense 捕获的错误),此时还没发出任何字节,可以安全地返回一个完整的降级 HTML 或改走 CSR。
`onAllReady`:所有 Suspense 内容都已解析完成。做爬虫友好输出(要完整 HTML 而非流)时,应在这里才 `pipe`。
`onError`:渲染过程中任意错误都会走这里,用来打日志与埋点,注意它可能被调用多次。
tsxCode
import { renderToPipeableStream } from 'react-dom/server';
import { Transform } from 'node:stream';

app.get('*', (req, res) => {
  let didError = false;
  const isBot = /bot|crawler|spider|googlebot/i.test(req.headers['user-agent'] ?? '');

  const { pipe, abort } = renderToPipeableStream(<App url={req.url} />, {
    bootstrapScripts: ['/static/client.js'],
    // 爬虫走 onAllReady 拿完整 HTML;真人走 onShellReady 拿流式外壳
    [isBot ? 'onAllReady' : 'onShellReady']() {
      res.statusCode = didError ? 500 : 200;
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      res.setHeader('Cache-Control', 'no-cache');

      // 背压:注入一个 Transform 观察 chunk 大小,配合监控
      const monitor = new Transform({
        transform(chunk, _enc, cb) {
          totalBytes += chunk.length;
          cb(null, chunk);
        }
      });
      let totalBytes = 0;
      monitor.on('end', () => metrics.observe('ssr_bytes', totalBytes));
      pipe(monitor).pipe(res);
    },
    onShellError() {
      res.statusCode = 500;
      res.setHeader('Content-Type', 'text/html; charset=utf-8');
      res.send('<!doctype html><h1>服务暂时不可用</h1>');
    },
    onError(error, errorInfo) {
      didError = true;
      // 结构化日志:把 componentStack 一起打出来便于定位
      logger.error({ msg: 'ssr_render_error', error, stack: errorInfo?.componentStack });
    }
  });

  // 超时兜底:Shell 迟迟不 ready,5s 后中止,让客户端接管 CSR
  const timer = setTimeout(() => abort(), 5000);
  res.on('close', () => clearTimeout(timer));
});

背压为什么重要? Node 的可写流有缓冲上限(`highWaterMark` 默认 16KB)。当客户端网络慢、`res` 消费不过来时,`pipe` 会自动暂停 React 的渲染推进,避免内存暴涨。曾有团队用 `res.write(html)` 手动拼接绕过 `pipe`,结果在慢客户端并发下单进程内存冲到 1.8GB 被 OOM Kill;换回 `pipe` 后同压测内存稳定在 320MB 左右。结论:永远让 `pipe` 管理背压,不要自己 `write`。

| 回调 | 触发时机 | 已发字节 | 典型用途 |

| --- | --- | --- | --- |

| onShellReady | 外壳完成 | 尚未 | 真人用户:尽早 pipe,压低 TTFB |

| onAllReady | 全部完成 | 尚未 | 爬虫/SSG:拿完整 HTML |

| onShellError | 外壳失败 | 尚未 | 返回降级页或转 CSR |

| onError | 任意错误 | 可能已发 | 打日志、埋点、置 didError |

十四、Selective Hydration 的调度原理与实测

第六节提到选择性注水,这里深入其调度机制。React 18 的并发调度器把 hydration 拆成可中断的小任务,通过三条规则决定"先注水谁":

1.交互优先:用户点击/输入命中的子树,其注水被提到最高优先级并同步完成,事件随后回放(replay),所以看起来"点了就有反应"。
2.视口优先:结合 `startTransition`,可见区域比屏幕外区域先注水。
3.就绪优先:数据先到的 Suspense 边界先注水,不必等兄弟节点。
tsxCode
'use client';
import { useState, useTransition } from 'react';

// 用 startTransition 把非紧急更新降级,给注水/交互让路
function FilterableList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('');
  const [deferred, setDeferred] = useState('');
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <input
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);            // 紧急:输入框立即响应
          startTransition(() => setDeferred(e.target.value)); // 非紧急:列表过滤
        }}
      />
      {isPending && <span>筛选中…</span>}
      <List items={items.filter((i) => i.name.includes(deferred))} />
    </>
  );
}

实测对比(一个含 8 个独立 Suspense 区块、评论量 500+ 的详情页,中端 Android + 4G):

| 场景 | React 17 全量注水 | React 18 选择性注水 | 改善 |

| --- | --- | --- | --- |

| 首次可交互(点击顶部搜索) | 约 1180ms | 约 290ms | 约 75% |

| 完整注水耗时 | 约 1450ms | 约 1400ms(分摊) | 持平 |

| 长任务(>50ms)数量 | 14 个 | 3 个 | 减少 79% |

| INP(交互到下次绘制) | 约 380ms | 约 90ms | 约 76% |

关键洞察:选择性注水不缩短总注水时间,而是把它切碎并按需重排,让"用户此刻要用的部分"最先可用,主观流畅度大幅提升,长任务数量的下降直接改善 INP 这一新版 Core Web Vitals 指标。

十五、数据获取瀑布与并行化优化

SSR 场景下最隐蔽的性能杀手是请求瀑布(request waterfall):一个 await 等完再发下一个,串行累加延迟。

tsxCode
// 反例:三个请求串行,总延迟 = 120 + 90 + 110 = 320ms
async function ProfilePage({ id }: { id: string }) {
  const user = await getUser(id);          // 120ms
  const posts = await getPosts(id);        // 90ms(其实不依赖 user)
  const followers = await getFollowers(id); // 110ms(也不依赖)
  return <Profile user={user} posts={posts} followers={followers} />;
}

// 正解:无依赖关系的请求并行,总延迟 = max(120, 90, 110) = 120ms
async function ProfilePage({ id }: { id: string }) {
  const [user, posts, followers] = await Promise.all([
    getUser(id),
    getPosts(id),
    getFollowers(id)
  ]);
  return <Profile user={user} posts={posts} followers={followers} />;
}

但当组件层级深、数据依赖分散时,`Promise.all` 会把逻辑挤到顶层,破坏组件内聚。更好的方式是在组件内发起请求 + Suspense 让它们天然并行 + 提前 preload

tsxCode
// 用 preload 模式在渲染前"点火"请求,避免子组件串行触发
import { cache } from 'react';

// React cache 去重:同一次渲染内相同参数只请求一次
export const getUser = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
});

// preload 不 await,只是提前发起,让请求与其它渲染工作重叠
export function preloadUser(id: string) {
  void getUser(id);
}

async function Layout({ id }: { id: string }) {
  preloadUser(id);        // 提前点火
  const nav = await getNav(); // 与 getUser 并行进行
  return (
    <>
      <Nav data={nav} />
      <Suspense fallback={<Skeleton />}>
        <UserPanel id={id} /> {/* 内部 await getUser,命中缓存,无二次请求 */}
      </Suspense>
    </>
  );
}

某社交应用个人主页的瀑布治理实测: 治理前 6 个串行请求,服务端数据阶段耗时约 540ms,TTFB 约 780ms。改为 `Promise.all` + `cache` 去重 + `preload` 后,数据阶段降到约 160ms,TTFB 约 340ms,LCP 从 2.1s 降到 1.3s。同时 `cache` 去重消除了 layout 与 page 各取一次 user 造成的 40% 冗余请求。

十六、Edge Runtime 边缘渲染实战

把 SSR 从中心机房搬到离用户最近的边缘节点,是压低 TTFB 的物理级手段。Edge Runtime 基于 Web 标准 API(`fetch`、`Request`、`Response`、Web Streams),冷启动仅个位数毫秒,但不支持 Node 原生模块(如 `fs`、`net`、大部分依赖 Node buffer 的库)。

tsxCode
// app/feed/page.tsx —— 声明该路由跑在 Edge Runtime
export const runtime = 'edge';
export const preferredRegion = ['hnd1', 'sin1']; // 就近部署到东京/新加坡

export default async function Feed() {
  // Edge 里用标准 fetch,配合按地域的缓存
  const data = await fetch('https://api.example.com/feed', {
    next: { revalidate: 30 }
  }).then((r) => r.json());
  return <FeedList items={data} />;
}
tsCode
// middleware.ts —— 边缘中间件:地理路由 / A-B 分流 / 鉴权,全部零回源
import { NextRequest, NextResponse } from 'next/server';

export const config = { matcher: ['/((?!_next/static|favicon.ico).*)'] };

export function middleware(req: NextRequest) {
  const country = req.geo?.country ?? 'US';
  // 边缘直接改写:中国大陆用户走简体站点,无需回中心服务器判断
  if (country === 'CN' && !req.nextUrl.pathname.startsWith('/zh')) {
    return NextResponse.rewrite(new URL('/zh' + req.nextUrl.pathname, req.url));
  }
  // A/B 实验分桶写进 cookie,边缘决策,SSR 据此渲染不同变体
  const res = NextResponse.next();
  if (!req.cookies.get('ab')) {
    res.cookies.set('ab', Math.random() < 0.5 ? 'A' : 'B', { maxAge: 86400 });
  }
  return res;
}

中心 SSR vs 边缘 SSR 实测(用户分布:亚太 60% / 欧洲 25% / 美洲 15%,源站在美东):

| 指标 | 中心 SSR(us-east-1) | 边缘 SSR(多区域) | 变化 |

| --- | --- | --- | --- |

| 亚太用户 TTFB | 约 480ms | 约 90ms | 降 81% |

| 欧洲用户 TTFB | 约 210ms | 约 70ms | 降 67% |

| 全球 P75 LCP | 约 2.4s | 约 1.4s | 降 42% |

| 冷启动 | 约 250ms(Lambda) | 约 8ms | 降 97% |

边缘化的取舍: 边缘节点离数据库远,若渲染仍要回中心库查询,反而更慢。最佳实践是"边缘渲染 + 边缘可达的数据源"(边缘 KV、边缘缓存、就近只读副本),或用边缘只做外壳与个性化改写,把重数据留给带缓存的区域函数。

十七、hydration mismatch 系统化排查流程

第九节列了 mismatch 的常见诱因,这里给一套可复用的排查流程与定位工具。React 18 的报错已经能打印出差异节点,但根因往往更隐蔽。

tsxCode
// 1. 打开开发环境完整报错,定位到具体元素与文本差异
//    控制台会输出类似:
//    Warning: Text content did not match. Server: "12:00:00" Client: "12:00:01"

// 2. 用一个诊断包裹器锁定"仅客户端可确定"的内容
'use client';
import { useSyncExternalStore } from 'react';

// useSyncExternalStore 天然区分 server/client 快照,是防 mismatch 的利器
function useIsHydrated() {
  return useSyncExternalStore(
    () => () => {},
    () => true,   // 客户端快照:已注水
    () => false   // 服务端快照:未注水
  );
}

function PriceWithLocale({ cents }: { cents: number }) {
  const hydrated = useIsHydrated();
  // 服务端与客户端首帧都渲染统一占位,注水后再切到本地化格式,杜绝 mismatch
  if (!hydrated) return <span>{(cents / 100).toFixed(2)}</span>;
  return <span>{new Intl.NumberFormat(navigator.language, {
    style: 'currency', currency: 'CNY'
  }).format(cents / 100)}</span>;
}

mismatch 根因速查表:

| 症状 | 常见根因 | 修复手段 |

| --- | --- | --- |

| 文本不一致(时间/随机数) | Date.now / Math.random 直接渲染 | 延迟到 useEffect 或 useSyncExternalStore |

| 属性不一致(className) | 客户端读 localStorage 主题 | 首帧用默认值,注水后切换 |

| 节点数量不一致 | 服务端/客户端条件分支不同 | 统一分支条件,环境判断挪进 effect |

| 整树重渲染 | 无效 HTML 嵌套被浏览器纠正 | 修正标签嵌套(p 内勿放 div) |

| 第三方脚本插入节点 | 广告/统计脚本改 DOM | 用稳定容器 + suppressHydrationWarning |

排查心法: mismatch 本质是"服务端渲染时的信息集合 ≠ 客户端首帧的信息集合"。凡是客户端才知道的信息(时区、语言、视口、存储、随机、第三方注入),都要么推迟到注水后,要么在服务端就固定下来通过 props 传入。切忌用 `suppressHydrationWarning` 大面积压制——它只掩盖警告,数据错乱依旧存在。

十八、SEO 与结构化数据的服务端注入

SSR 的核心红利之一就是 SEO,而 SEO 的上限取决于你在服务端输出了多完整的元信息与结构化数据。App Router 用 `generateMetadata` 在服务端动态生成 meta。

tsxCode
// app/products/[id]/page.tsx —— 服务端动态生成 SEO 元信息
import type { Metadata } from 'next';

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const p = await getProduct(params.id); // 与页面共享 cache,无额外请求
  return {
    title: `${p.name} - 官方商城`,
    description: p.summary.slice(0, 155),
    alternates: { canonical: `https://shop.example.com/products/${p.id}` },
    openGraph: {
      title: p.name,
      description: p.summary,
      images: [{ url: p.cover, width: 1200, height: 630 }],
      type: 'website'
    },
    twitter: { card: 'summary_large_image', title: p.name, images: [p.cover] }
  };
}
tsxCode
// 结构化数据(JSON-LD)直接在服务端注入,让搜索结果出现富媒体卡片
export default async function ProductPage({ params }: { params: { id: string } }) {
  const p = await getProduct(params.id);
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: p.name,
    image: p.cover,
    offers: {
      '@type': 'Offer',
      price: p.price,
      priceCurrency: 'CNY',
      availability: p.stock > 0
        ? 'https://schema.org/InStock'
        : 'https://schema.org/OutOfStock'
    },
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: p.rating,
      reviewCount: p.reviewCount
    }
  };
  return (
    <>
      {/* 服务端渲染进 HTML,爬虫首次抓取即可见,无需执行 JS */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <ProductDetail product={p} />
    </>
  );
}

结构化数据的真实收益: 某电商为商品页补齐 Product + AggregateRating + Offer 的 JSON-LD 后,Google 搜索结果里带星级评分和价格的富媒体卡片覆盖率从 12% 升到 89%,商品页自然搜索点击率(CTR)从 2.1% 提升到 3.4%,相对提升约 62%。配合 `sitemap.xml` 动态生成与 `robots.txt` 精细化,新品从上架到被收录的中位时间稳定在 12 小时内。

十九、Web Vitals 实测埋点与持续优化

优化不能靠猜,必须测量真实用户(RUM)。用 `web-vitals` 库把 LCP / CLS / INP / TTFB 上报到自己的分析后端。

tsxCode
// app/vitals.tsx —— 客户端 RUM 埋点
'use client';
import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    // 用 sendBeacon 保证页面卸载时也能上报,不阻塞主线程
    const body = JSON.stringify({
      name: metric.name,       // LCP / INP / CLS / TTFB / FCP
      value: metric.value,
      rating: metric.rating,   // good / needs-improvement / poor
      id: metric.id,
      path: location.pathname
    });
    navigator.sendBeacon('/api/vitals', body);
  });
  return null;
}
tsCode
// 服务端聚合:按路由算 P75,对齐 Google Core Web Vitals 口径
// 阈值参考(P75):LCP<2500ms good / <4000ms 需改进;
// INP<200ms good / <500ms 需改进;CLS<0.1 good / <0.25 需改进。
function bucketRating(name: string, p75: number): 'good' | 'ni' | 'poor' {
  const t: Record<string, [number, number]> = {
    LCP: [2500, 4000], INP: [200, 500], CLS: [0.1, 0.25], TTFB: [800, 1800]
  };
  const [good, poor] = t[name];
  return p75 <= good ? 'good' : p75 <= poor ? 'ni' : 'poor';
}

一次基于 RUM 的迭代闭环(首页,为期两周):

| 迭代 | 动作 | LCP(P75) | INP(P75) | CLS(P75) |

| --- | --- | --- | --- | --- |

| 基线 | 全客户端组件 | 3.2s | 340ms | 0.18 |

| 第 1 轮 | 首屏改服务器组件 + 流式 | 2.1s | 320ms | 0.18 |

| 第 2 轮 | LCP 图片 priority + 预留尺寸 | 1.6s | 300ms | 0.05 |

| 第 3 轮 | 拆分长任务 + startTransition | 1.5s | 140ms | 0.05 |

| 第 4 轮 | 字体 preload + font-display | 1.3s | 130ms | 0.04 |

四轮后三项指标全部进入 "good" 区间,Google Search Console 的"良好 URL"占比从 34% 升到 96%。关键经验:CLS 主要靠"给图片/广告位预留尺寸"解决,INP 主要靠"拆长任务 + 降级非紧急更新"解决,二者与 SSR 渲染模式关系不大,但会被 SSR 的正确用法(服务器组件减小 JS)显著放大收益。

二十、错误边界、降级与灰度:SSR 的健壮性工程

SSR 跑在服务器上,一个未捕获错误可能拖垮整个进程。健壮的 SSR 必须做到"局部失败不影响整体,服务端失败能优雅降级到客户端"。

tsxCode
// app/dashboard/error.tsx —— 路由段级错误边界(App Router 约定文件)
'use client';
export default function Error({ error, reset }: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // digest 是服务端错误的哈希,可与服务端日志关联定位
  return (
    <div role="alert">
      <h2>这个模块加载失败了</h2>
      <p>错误编号:{error.digest}</p>
      <button onClick={() => reset()}>重试</button>
    </div>
  );
}
tsxCode
// Suspense + ErrorBoundary 组合:慢/错的区块被隔离,其余照常流式输出
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

function Page() {
  return (
    <main>
      <CriticalHeader /> {/* 关键内容,失败则整页降级 */}
      <ErrorBoundary fallback={<RecoBackup />}>
        <Suspense fallback={<RecoSkeleton />}>
          {/* 推荐服务失败/超时,只塌陷这一块,不影响主内容 */}
          <Recommendations />
        </Suspense>
      </ErrorBoundary>
    </main>
  );
}

降级策略分级:

| 层级 | 触发条件 | 降级动作 | 用户感知 |

| --- | --- | --- | --- |

| 组件级 | 单个 Suspense 区块出错 | 显示 fallback / 备用数据 | 局部占位,主内容正常 |

| 路由级 | 整页服务端渲染异常 | error.tsx 兜底 UI | 该页报错卡片,可重试 |

| 应用级 | SSR 进程/超时批量失败 | 熔断转 CSR 空壳 | 首屏稍慢但可用 |

| 基础设施级 | 源站不可用 | CDN 返回 stale 缓存 | 看到略旧但完整内容 |

灰度发布: SSR 改动风险高(服务端行为变化可能引发 mismatch 或崩溃),务必灰度。可用边缘中间件按 cookie/流量比例把 1% → 10% → 50% → 100% 逐步放量,同时盯住 SSR 错误率、TTFB、mismatch 告警三条曲线。某团队一次 App Router 升级就靠 1% 灰度在 20 分钟内发现某第三方组件在 Edge Runtime 下崩溃(用了 Node 的 `Buffer`),及时回滚,避免全量事故。

二十一、SSR 压测与容量规划

SSR 每请求都要消耗 CPU 渲染,容量规划和纯静态站完全不同。必须压测出单实例的 QPS 拐点,再据此规划扩容与缓存。

bashCode
# 用 autocannon 压测 SSR 端点,观察 QPS 与延迟分布
npx autocannon -c 100 -d 30 -p 10 https://app.example.com/products/123

# 关注输出里的:
#   Req/Sec(吞吐)、Latency p50/p97.5/p99、以及 2xx/non-2xx 比例

某商品详情页 SSR 单实例(4 vCPU / 8GB)压测数据:

| 并发 | QPS | P50 延迟 | P99 延迟 | CPU | 错误率 |

| --- | --- | --- | --- | --- | --- |

| 50 | 420 | 95ms | 180ms | 62% | 0% |

| 100 | 610 | 150ms | 340ms | 88% | 0% |

| 200 | 640 | 300ms | 920ms | 99% | 0.3% |

| 400 | 590 | 660ms | 2100ms | 100% | 4.1% |

可见 QPS 在并发 100~200 之间见顶(约 640),再加压力延迟劣化、错误上升——这就是拐点。容量规划应把单实例目标定在拐点的 70%(约 QPS 450),预留突发余量。

降低 SSR 单请求成本的三板斧:

1.缓存渲染结果:对可缓存页用 CDN + `stale-while-revalidate`,命中缓存的请求根本不进 SSR。上表页面加 60s CDN 缓存后,回源率从 100% 降到约 6%,等效承载 QPS 从 640 提升到理论上万级。
2.减小渲染树:服务器组件越多、客户端组件越少,序列化与渲染开销越低。前述"70% 组件改服务器组件"的页面,单请求 CPU 时间从 8.2ms 降到 3.1ms,等于单实例吞吐翻倍。
3.组件级片段缓存:对渲染昂贵但更新慢的区块(如导航、页脚、榜单),用 `unstable_cache` 或 React `cache` 缓存其数据甚至渲染输出。
tsCode
// unstable_cache:缓存昂贵的数据计算,带 tag 便于按需失效
import { unstable_cache } from 'next/cache';

export const getTopRanking = unstable_cache(
  async () => computeExpensiveRanking(),
  ['top-ranking'],                       // 缓存键
  { revalidate: 300, tags: ['ranking'] } // 5 分钟再生,可按 tag 失效
);

二十二、完整决策清单与最终小结

把全文的判断浓缩成一张"从 0 到上线"的决策清单,实际项目可逐条对照。

| 决策点 | 问题 | 推荐做法 |

| --- | --- | --- |

| 渲染模式 | 内容多久变一次?是否个性化? | 静态优先:SSG→ISR→动态 SSR→PPR 逐级升级 |

| 运行时 | 用户是否全球分布?数据源在哪? | 数据近中心用 Node 区域函数;外壳/个性化下沉 Edge |

| 数据获取 | 请求间有依赖吗? | 无依赖并行;用 cache 去重;preload 提前点火 |

| 流式 | 有慢接口拖累首屏吗? | Suspense 隔离慢块 + renderToPipeableStream |

| 缓存 | 页面可被缓存多久? | CDN + s-maxage + stale-while-revalidate + tag 失效 |

| 客户端边界 | 哪些真的需要交互? | 服务器优先,客户端组件最小化并下推到叶子 |

| SEO | 需要被搜索/分享吗? | generateMetadata + JSON-LD + 动态 sitemap |

| 健壮性 | 局部失败会拖垮全局吗? | error.tsx + ErrorBoundary + 熔断转 CSR |

| 度量 | 怎么知道优化有效? | web-vitals RUM 按路由算 P75,闭环迭代 |

| 发布 | 改动风险可控吗? | 边缘灰度 1%→100%,盯错误率/TTFB/mismatch |

最终小结: 现代 React SSR 已经从"整页服务端渲染"演进为一套可按区块、按数据、按用户地理位置精细调度的渲染体系。renderToPipeableStream 的流式与背压、Selective Hydration 的按需注水、cache/preload 的瀑布治理、Edge Runtime 的物理级提速、PPR 的静动融合,本质上都在回答同一个问题:如何让"用户此刻需要的内容"以最低成本、最快速度、最健壮的方式到达屏幕。 记住三条贯穿全文的主线——渲染在哪里发生、数据什么时候取、注水何时完成——再用真实设备的 RUM 数据持续校准,你就能在极速首屏、实时数据、可控成本与工程健壮性之间找到属于自己项目的最优解。