文章

SSR性能优化深度解析

SSR 三大性能瓶颈(TTFB 长、CPU 高、水合慢)的解法:多级缓存、流式渲染、部分水合。

SSR性能优化深度解析

一句话概括

SSR 解决了首屏白屏和 SEO,却引入了新问题:服务端每次请求都要跑一遍 JS 渲染(TTFB 变长、CPU 飙高),客户端还要整页重新绑定事件(Hydration 慢、可交互时间推迟)。所以 SSR 性能优化就三条主线——缓存(内存→Redis→CDN 多级命中)、流式渲染(React 18 的 renderToPipeableStream 边渲染边发送)、部分水合(只激活用户会交互的区域)。这是 SSR 项目从”能跑”到”能扛住生产流量”的分水岭。

核心知识点

1. 多级缓存:把”每次现算”变成”命中即回”

缓存是 SSR 优化收益最大的一招。核心是分级 + 按请求特征定缓存键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 内存缓存(进程内,最快,适合热点)
const cache = new Map();
function memGet(key) {
  const e = cache.get(key);
  if (e && Date.now() - e.t < 60_000) return e.v; // TTL 60s
  return null;
}

// 缓存键:登录用户不缓存 / 匿名用户按 URL 缓存
function buildKey(req) {
  const authed = !!req.cookies?.session;
  return authed ? `user:${req.userId}:${req.url}` : `anon:${req.url}`;
}

// 响应头控制 CDN:浏览器不缓存,CDN 缓存 60s,过期后 stale-while-revalidate 后台更新
function setCacheHeaders(res) {
  res.setHeader('Cache-Control',
    'public, max-age=0, s-maxage=60, stale-while-revalidate=600');
}

关键点:登录/个性化页面绝不能无脑缓存,否则 A 用户看到 B 用户的页面。Redis 负责跨进程/跨机器共享(分布式多实例时内存缓存各自为政),CDN 负责边缘就近响应。三级加起来能把 P90 从 500ms 打到 10ms。

2. 流式渲染:不等数据全齐,渲染多少发多少

传统 SSR 要等所有 API 返回才 renderToString 一次性输出,慢接口卡住整页。流式渲染用 renderToPipeableStream,骨架先出,慢数据后到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(<Page />, {
    onShellReady() {
      res.setHeader('Content-Type', 'text/html');
      pipe(res); // 页面骨架(shell)就绪立即开始流式发送
    },
    onError(err) { res.status(500).end('error'); }
  });
});

// Page 内部:慢组件用 Suspense 包裹,先渲染 fallback 骨架屏
function Page() {
  return (
    <>
      <Header />                          {/* 立即渲染 */}
      <Suspense fallback={<Skeleton />}>
        <ProductDetail />                 {/* 数据就绪后流式补上 */}
      </Suspense>
    </>
  );
}

收益直接体现在 TTFB 大幅下降——用户不用等最慢的那个接口。数据是”边渲染边落 HTML 边发”的 chunked 响应,而不是一个憋大招的大字符串。

3. 部分水合:只激活会交互的区域

整页 Hydration 的浪费在于:页脚、版权文案、静态图文这些永远没人点的节点也白绑一遍事件。部分水合按需激活:

1
2
3
4
5
6
7
8
9
10
11
12
// 只对打了 data-hydrate 标记、且进入视口的区块激活
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const name = entry.target.dataset.hydrate;
      hydrateComponent(entry.target, name); // 只水合这一小块
      observer.unobserve(entry.target);
    }
  });
}, { rootMargin: '200px' });

document.querySelectorAll('[data-hydrate]').forEach((el) => observer.observe(el));

进阶是渐进式水合(Progressive):视口内交互组件最高优先级立即水合,视口外组件用 requestIdleCallback 空闲时再水合。Astro 的岛屿架构、Qwik 的”零 Hydration”都是这个方向的极端化——能静态就不上 JS。

4. 降低服务端 CPU 开销

SSR 把原本分摊到用户浏览器的渲染压力全集中到了服务器,所以还要在计算侧省:

1
2
3
4
5
6
7
8
9
10
11
12
// 组件级缓存:计算密集但内容稳定的组件,缓存其渲染结果
const memoized = React.memo(function Table({ data }) {
  const rows = useMemo(() => data.map(expensiveTransform), [data]);
  return <table>{rows.map(...)}</table>;
});

// 服务端避免在渲染路径做重活:
// ❌ 渲染函数里 await 一个 800ms 的数据库全表扫描
// ✅ 数据在进入渲染前就并行取好:Promise.all 而不是 await 串行
const [user, posts, comments] = await Promise.all([
  fetchUser(id), fetchPosts(id), fetchComments(id)
]);

Promise.all 并行取数 vs 串行 await,在三个 300ms 接口下能省下 600ms——这是最容易被忽视却最直接的服务端提速点。

5. 关键指标与目标值

指标含义目标
TTFB收到第一个字节的时间< 200ms
LCP最大内容渲染完成< 2.5s
TTI可交互时间< 3.5s
TBT主线程总阻塞时间< 100ms

SSR 的陷阱是:FCP 很好看,但 TTI(可交互)可能比 CSR 还晚——因为浏览器要先下载大 bundle、再整页 Hydration。所以优化的终点不是”首屏快”,而是”首屏快 + 可交互也快”。

其实你每天都在用

  • 商品详情页秒开:标题价格主图是 CDN 缓存命中直出的 HTML,你在全国任何地方点开都近,因为边缘节点离你最近
  • 刷微博/资讯流:内容流是骨架屏先出来,正文和图片流式加载,你感觉”先看到框架再填内容”就是流式渲染
  • 列表页”加载中”骨架:慢接口被 Suspense 兜底,先渲染灰色占位块,数据到了自动替换,页面不白屏
  • 页面底部评论区:你不往下滚,评论区 JS 根本不会激活,这就是懒水合在帮你省流量和电量
  • 网页点分享”加载不出来”:往往是服务端渲染超时,说明缓存没命中、接口串行阻塞了渲染主流程

常见误解(FAQ)

❌ 误区一:”SSR 一定比 CSR 快”

SSR 快的是首屏内容展示(FCP),但可交互时间(TTI)往往更慢——浏览器要等 JS bundle 下载完再做整页 Hydration,这段时间页面看着能用实际点不动。所以 SSR 不是无条件更优,内容型/SEO 敏感场景才划算,重交互后台系统可能 CSR 更好。

❌ 误区二:”缓存设个 max-age 就完事了”

缓存最难的不是”存”,是失效。文章更新后用户还看到旧内容、登录用户看到别人页面,都是缓存键和失效策略没设计好。正确做法是按请求特征(登录态、个性化)区分缓存键,页面变更时主动 invalidate 相关 key,而不是靠”过期时间到了自然更新”。

❌ 误区三:”流式渲染就是服务端不阻塞了,数据随便串行取”

流式渲染只解决”发送”阶段的等待,解决不了”取数”阶段的等待。如果数据源还是 await A; await B; await C 串行,服务端 CPU 依然被憋着,TTFB 照样难看。流式 + 并行取数(Promise.all)要配合使用。

❌ 误区四:”部分水合后不用管那些没水合的区域”

没水合的区块是纯静态 HTML,用户点了没反应,如果不做降级体验会很怪(比如按钮点了毫无反馈)。部分水合的前提是:要么该区块真的纯展示、要么留了明确的”加载中/点击后激活”兜底,不能把交互功能静默阉割掉。

一句话总结

SSR 优化的核心就一句话:让服务器少算、少传、传得早,让客户端少激活、晚激活、只激活该激活的——缓存降计算、流式抢时间、部分水合省交互,三者叠加才是生产级的 SSR。

本文由作者按照 CC BY 4.0 进行授权