跳到主要内容

设计 SSR 框架与应用架构

一、需求分析

SSR(Server-Side Rendering)框架是现代 Web 应用的核心基础设施。从零设计一套 SSR 框架需要覆盖多种渲染模式,同时兼顾开发体验和生产环境性能。这是面试中考察系统设计能力 + 前端深度的高频题目。

1.1 功能需求

功能模块核心能力说明
服务端渲染 SSR请求时在服务端生成完整 HTML首屏可见内容直出,SEO 友好
静态生成 SSG构建时预生成 HTMLCDN 分发,TTFB 极低
增量再生成 ISRSSG + 后台按需/定时更新兼顾静态性能与内容时效性
流式渲染 Streaming SSR分块传输 HTML + 选择性注水降低 TTFB,提升可交互速度
数据获取层统一的服务端数据预取机制支持并行请求、缓存、错误处理
路由系统文件系统路由 + 动态路由 + 中间件自动代码分割,嵌套布局
客户端注水服务端 HTML → 客户端可交互全量/选择性/渐进式 Hydration

1.2 非功能需求

关键设计约束

面试中明确非功能需求是拉开差距的核心。它体现了候选人的工程化思维和生产环境经验。

非功能需求设计目标实现手段
首屏性能TTFB < 200ms,LCP < 2.5s流式渲染、边缘计算、缓存分层
SEO 友好搜索引擎可抓取完整内容服务端直出完整 HTML、meta 管理
可扩展性支持插件、中间件、自定义渲染策略插件架构、Hook 系统
开发体验热更新、TypeScript 支持、统一 APIHMR、Fast Refresh、类型推断
容错降级SSR 失败自动降级为 CSRtry-catch 兜底、健康检查
部署灵活支持 Node.js、Serverless、Edge适配器模式、运行时抽象

二、整体架构

2.1 请求处理全景

2.2 分层架构


三、核心模块设计

3.1 渲染模式对比

渲染模式决策是 SSR 框架的灵魂

一个优秀的 SSR 框架不是只做 SSR,而是让开发者按页面/组件粒度灵活选择渲染策略。

特性CSRSSRSSGISRStreaming SSR
渲染时机浏览器每次请求构建时构建 + 增量每次请求(流式)
首屏速度最快快(渐进)
TTFB较高极低极低低(Shell 先达)
SEO
服务器压力
数据时效性实时实时构建时定期更新实时
适用场景后台管理动态内容博客/文档电商/新闻复杂动态页

3.2 数据获取层

数据获取层是连接路由和渲染的桥梁,不同框架有不同的抽象方式:

方案框架特点
getServerSidePropsNext.js (Pages Router)页面级、每次请求执行
getStaticPropsNext.js (Pages Router)页面级、构建时执行
loader / actionRemix / React Router路由级、支持嵌套并行
Server ComponentsNext.js (App Router)组件级、直接 async/await
useFetch / useAsyncDataNuxt 3组合式、SSR/CSR 统一
data-loader.ts
// 框架核心:数据获取层抽象
interface RouteDataLoader<T = unknown> {
/** 服务端数据获取函数 */
load: (context: LoaderContext) => Promise<T>;
/** 缓存策略 */
cache?: CachePolicy;
/** 错误边界 */
errorBoundary?: React.ComponentType<{ error: Error }>;
}

interface LoaderContext {
params: Record<string, string>; // 路由参数
searchParams: URLSearchParams; // 查询参数
request: Request; // 原始请求
headers: Headers; // 请求头
cookies: Record<string, string>; // Cookie
}

interface CachePolicy {
strategy: 'no-store' | 'no-cache' | 'force-cache';
revalidate?: number; // ISR 秒数
tags?: string[]; // 按需失效标签
}

// 数据获取调度器 —— 并行获取嵌套路由的数据,避免瀑布流
async function executeLoaders(
matchedRoutes: MatchedRoute[]
): Promise<Map<string, unknown>> {
const results = new Map<string, unknown>();

// 所有匹配路由的 loader 并行执行
const promises = matchedRoutes.map(async (route) => {
if (!route.loader) return;
const data = await route.loader.load(route.context);
results.set(route.id, data);
});

await Promise.all(promises);
return results;
}
避免数据获取瀑布流

嵌套路由场景下,父子路由的数据获取必须并行执行。如果串行执行(父完成 → 子开始),每增加一层嵌套就多一个 RTT(Round Trip Time),这是 SSR 性能的头号杀手。Remix 的 loader 设计和 React Router 的并行数据获取正是解决此问题的。

3.3 Hydration 机制

Hydration(注水)是 SSR 页面在客户端从"静态 HTML"变为"可交互应用"的关键过程。不同的 Hydration 策略对 TTI(Time to Interactive)影响巨大。

3.3.1 Hydration 策略对比

策略原理TTIJS 体积代表框架
全量注水客户端重新构建整棵虚拟 DOM 树并绑定事件传统 SSR
选择性注水结合 Streaming + Suspense,优先注水用户交互区域较快React 18
Partial Hydration只 Hydrate 标记为交互式的组件,其余保持静态 HTMLAstro
Islands Architecture页面是静态 HTML 海洋中的交互"岛屿"最小Astro, Fresh
React Server Components服务端组件零 JS,客户端组件按需 HydrateNext.js App Router
Resumability序列化框架状态,客户端直接恢复而非重新执行最快最小Qwik

3.3.2 React Server Components 模型

React Server Components(RSC)从根本上改变了 SSR + Hydration 模型:

app/page.tsx — Server Component(默认)
// Server Component:在服务端执行,不会打包到客户端 JS 中
// 无需 'use client' 声明,默认即是 Server Component
import { db } from '@/lib/database';
import { ProductList } from './product-list';
import { AddToCart } from './add-to-cart';

export default async function ProductPage() {
// 直接访问数据库,不需要 API 层
const products = await db.product.findMany({
where: { status: 'active' },
orderBy: { createdAt: 'desc' },
});

return (
<main>
<h1>商品列表</h1>
{/* Server Component:纯展示,零客户端 JS */}
<ProductList products={products} />
{/* Client Component:需要交互,会 Hydrate */}
<AddToCart products={products} />
</main>
);
}
app/add-to-cart.tsx — Client Component
'use client'; // 标记为客户端组件

import { useState, useTransition } from 'react';
import { addToCartAction } from './actions';

interface Product {
id: string;
name: string;
price: number;
}

export function AddToCart({ products }: { products: Product[] }) {
const [cart, setCart] = useState<Product[]>([]);
const [isPending, startTransition] = useTransition();

const handleAdd = (product: Product) => {
startTransition(async () => {
// Server Action:服务端执行的 mutation
await addToCartAction(product.id);
setCart((prev) => [...prev, product]);
});
};

return (
<div>
<p>购物车:{cart.length} 件商品</p>
{products.map((p) => (
<button
key={p.id}
onClick={() => handleAdd(p)}
disabled={isPending}
>
加入购物车 - {p.name}
</button>
))}
</div>
);
}
RSC 的核心价值
  • Server Component 的代码(包括依赖库如 momentlodash)完全不会出现在客户端 bundle 中
  • 只有 'use client' 组件的代码会被打包并发送到浏览器
  • 这从根本上解决了"为了 SSR 引入的库却全部发送到客户端"的问题

3.4 缓存策略

缓存是 SSR 性能的命脉。一个生产级 SSR 框架需要多层次缓存体系

缓存层级对比

缓存层级粒度命中率更新策略实现方式
CDN 边缘页面 URL最高Cache-Control / stale-while-revalidateVercel Edge, Cloudflare
反向代理页面 URLTTL + Purge APINginx proxy_cache
页面级完整 HTMLTTL / 按需失效Redis / 内存
组件级组件子树依赖追踪序列化 VNode
数据级API 响应TTL / SWRRedis / React cache()
cache-manager.ts
import { LRUCache } from 'lru-cache';

interface CacheEntry {
html: string;
headers: Record<string, string>;
createdAt: number;
tags: string[];
}

class SSRCacheManager {
private cache: LRUCache<string, CacheEntry>;

constructor(maxSize: number = 1000) {
this.cache = new LRUCache({
max: maxSize,
ttl: 1000 * 60 * 5, // 默认 5 分钟 TTL
});
}

/** 生成缓存 key:URL + 用户维度(如语言、登录态) */
private getCacheKey(url: string, vary: Record<string, string>): string {
const varyStr = Object.entries(vary)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
return `${url}?__vary=${varyStr}`;
}

get(url: string, vary: Record<string, string>): CacheEntry | undefined {
return this.cache.get(this.getCacheKey(url, vary));
}

set(
url: string,
vary: Record<string, string>,
entry: CacheEntry,
ttl?: number
): void {
this.cache.set(this.getCacheKey(url, vary), entry, { ttl });
}

/** 按标签批量失效 —— 用于 On-Demand ISR */
invalidateByTag(tag: string): number {
let count = 0;
for (const [key, entry] of this.cache.entries()) {
if (entry.tags.includes(tag)) {
this.cache.delete(key);
count++;
}
}
return count;
}

/** stale-while-revalidate 策略 */
async getOrRevalidate(
url: string,
vary: Record<string, string>,
regenerate: () => Promise<CacheEntry>,
maxStale: number = 60_000
): Promise<CacheEntry> {
const cached = this.get(url, vary);

if (cached) {
const age = Date.now() - cached.createdAt;
if (age < maxStale) {
return cached; // 新鲜,直接返回
}
// 过期但可用:返回旧数据,后台异步更新
regenerate().then((entry) => this.set(url, vary, entry));
return cached;
}

// 无缓存:同步生成
const entry = await regenerate();
this.set(url, vary, entry);
return entry;
}
}
CDN 缓存与个性化内容

SSR 页面如果包含用户个性化内容(如用户名、购物车数量),不能直接缓存完整 HTML。常见的解决方案:

  1. Vary 头:按 Cookie/Accept-Language 维度分别缓存(缓存命中率低)
  2. Edge Side Includes (ESI):静态壳 + 动态片段
  3. 客户端补全:SSR 渲染公共部分,个性化部分由客户端请求填充
  4. React Server Components:将个性化部分标记为 'use client',公共部分由 Server Component 渲染并缓存

3.5 路由与代码分割

router.ts
// 文件系统路由:自动扫描 pages/ 目录生成路由配置
interface RouteConfig {
path: string; // URL 路径
component: () => Promise<{ default: React.ComponentType }>; // 懒加载组件
loader?: RouteDataLoader; // 数据获取
layout?: () => Promise<{ default: React.ComponentType }>; // 布局组件
middleware?: MiddlewareFunction[]; // 中间件
children?: RouteConfig[]; // 嵌套路由
}

// 文件系统 → 路由映射规则
// pages/index.tsx → /
// pages/about.tsx → /about
// pages/blog/[slug].tsx → /blog/:slug
// pages/[...catchAll].tsx → /*(捕获所有)

// 自动代码分割:每个路由对应一个 chunk
function generateRoutes(fileMap: Map<string, string>): RouteConfig[] {
return Array.from(fileMap.entries()).map(([filePath, modulePath]) => {
const routePath = filePathToRoutePath(filePath);
return {
path: routePath,
// React.lazy + dynamic import → 自动 code splitting
component: () => import(/* webpackChunkName: "[request]" */ modulePath),
};
});
}

四、关键技术实现

4.1 简易 SSR 服务器

server.ts
import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import { createReadStream } from 'fs';
import { resolve } from 'path';
import type { Request, Response } from 'express';

import { App } from './App';
import { matchRoute } from './router';
import { createStore } from './store';

const app = express();

// 静态资源
app.use('/static', express.static(resolve(__dirname, '../dist/client')));

// SSR 请求处理
app.get('*', async (req: Request, res: Response) => {
try {
// 1. 路由匹配
const route = matchRoute(req.url);
if (!route) {
res.status(404).send('Not Found');
return;
}

// 2. 服务端数据预取
const store = createStore();
if (route.loader) {
const data = await route.loader.load({
params: route.params,
searchParams: new URLSearchParams(req.query as Record<string, string>),
request: req as unknown as globalThis.Request,
headers: new Headers(req.headers as Record<string, string>),
cookies: req.cookies ?? {},
});
store.setState({ [route.id]: data });
}

// 3. 序列化状态,注入到 HTML 中
const initialState = store.getState();
const serializedState = JSON.stringify(initialState)
.replace(/</g, '\\u003c') // 防止 XSS:转义 <script> 注入
.replace(/>/g, '\\u003e');

// 4. 服务端渲染
const html = renderToHTML(
<App url={req.url} store={store} />,
serializedState
);

res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (error) {
console.error('SSR Error:', error);
// SSR 失败降级为 CSR
res.sendFile(resolve(__dirname, '../dist/client/index.html'));
}
});

function renderToHTML(
element: React.ReactElement,
serializedState: string
): string {
const { renderToString } = require('react-dom/server');
const appHtml = renderToString(element);

return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div id="root">${appHtml}</div>
<script>
// 注入初始状态,客户端 Hydration 时使用
window.__INITIAL_STATE__ = ${serializedState};
</script>
<script src="/static/client.js" defer></script>
</body>
</html>`;
}

app.listen(3000, () => {
console.log('SSR server running at http://localhost:3000');
});
状态序列化安全

将服务端数据注入 HTML 时,必须转义 <> 等字符,否则攻击者可以构造 </script><script>alert('XSS')</script> 来注入恶意脚本。推荐使用 serialize-javascript 库或手动转义。

4.2 Streaming SSR(流式渲染)

流式渲染是 React 18 引入的核心能力,通过 renderToPipeableStream 实现分块传输:

streaming-server.ts
import { renderToPipeableStream } from 'react-dom/server';
import type { Request, Response } from 'express';
import { Suspense } from 'react';

interface StreamingSSROptions {
bootstrapScripts: string[];
onShellReady?: () => void;
onAllReady?: () => void;
onError?: (error: Error) => void;
}

async function handleStreamingSSR(
req: Request,
res: Response,
element: React.ReactElement,
options: StreamingSSROptions
): Promise<void> {
let didError = false;
let shellReady = false;

const { pipe, abort } = renderToPipeableStream(element, {
bootstrapScripts: options.bootstrapScripts,

onShellReady() {
// Shell(Suspense 边界外的内容)准备好时触发
// 此时立即开始发送 HTML,不等待所有数据
shellReady = true;
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
pipe(res);
options.onShellReady?.();
},

onShellError(error: unknown) {
// Shell 渲染失败:降级为 CSR
res.statusCode = 500;
res.send('<!DOCTYPE html><html><body><div id="root"></div>' +
'<script src="/static/client.js"></script></body></html>');
},

onAllReady() {
// 所有 Suspense 边界都已 resolve
// 用于 SSG/爬虫场景:等全部内容就绪再返回
options.onAllReady?.();
},

onError(error: unknown) {
didError = true;
console.error('Streaming SSR Error:', error);
options.onError?.(error as Error);
},
});

// 超时中止:防止某个数据源卡死导致连接无限挂起
setTimeout(() => {
if (!shellReady) {
abort();
}
}, 10_000);
}

流式渲染的页面组件

app/product-page.tsx
import { Suspense } from 'react';

// Shell 部分:立即发送
function ProductPageShell({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<head>
<title>商品详情</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<nav>导航栏(立即可见)</nav>
<main>{children}</main>
<footer>页脚(立即可见)</footer>
</body>
</html>
);
}

export default function ProductPage({ productId }: { productId: string }) {
return (
<ProductPageShell>
{/* 每个 Suspense 边界是一个独立的流式单元 */}
<Suspense fallback={<ProductSkeleton />}>
{/* 商品信息:优先级高,先到先渲染 */}
<ProductInfo productId={productId} />
</Suspense>

<Suspense fallback={<RecommendSkeleton />}>
{/* 推荐商品:优先级低,可以晚到 */}
<Recommendations productId={productId} />
</Suspense>

<Suspense fallback={<ReviewsSkeleton />}>
{/* 评论:优先级最低 */}
<Reviews productId={productId} />
</Suspense>
</ProductPageShell>
);
}

// 异步服务端组件 —— 数据就绪后自动流式推送到客户端
async function ProductInfo({ productId }: { productId: string }) {
const product = await fetch(`https://api.example.com/products/${productId}`)
.then((res) => res.json());
return (
<section>
<h1>{product.name}</h1>
<p className="price">¥{product.price}</p>
<p>{product.description}</p>
</section>
);
}
Streaming SSR 的工作原理
  1. Shell 阶段:服务端先渲染 Suspense 边界外的内容(导航、布局),立即发送给浏览器 → 用户看到页面骨架
  2. Streaming 阶段:每个 Suspense 边界的异步内容就绪后,以 <script> 标签的形式追加到 HTML 流中,浏览器替换对应的 fallback
  3. Hydration 阶段:JS 加载后,React 对已到达的 HTML 进行选择性注水,优先 Hydrate 用户正在交互的区域

4.3 数据预取与状态序列化

data-prefetch.ts
import { Request } from 'express';

// 统一的数据预取协议
interface PrefetchResult<T = unknown> {
data: T;
headers?: Record<string, string>; // 影响 HTTP 响应头
revalidate?: number; // ISR 秒数
tags?: string[]; // 缓存标签
}

// 服务端数据预取 + 并行执行
async function prefetchPageData(
url: string,
request: Request
): Promise<{ props: Record<string, unknown>; headers: Headers }> {
const matchedRoutes = matchRoutes(url);
const headers = new Headers();

// 所有路由层级的 loader 并行执行
const results = await Promise.allSettled(
matchedRoutes.map((route) =>
route.loader
? route.loader.load({
params: route.params,
searchParams: new URL(url, 'http://localhost').searchParams,
request: request as unknown as globalThis.Request,
headers: new Headers(request.headers as Record<string, string>),
cookies: request.cookies ?? {},
})
: Promise.resolve(null)
)
);

const props: Record<string, unknown> = {};
results.forEach((result, index) => {
const routeId = matchedRoutes[index].id;
if (result.status === 'fulfilled') {
props[routeId] = result.value;
} else {
// 单个 loader 失败不影响其他部分
console.error(`Loader failed for route ${routeId}:`, result.reason);
props[routeId] = { error: 'Failed to load data' };
}
});

return { props, headers };
}

// 客户端状态恢复
function hydrateState(): Record<string, unknown> {
const stateScript = document.getElementById('__SSR_STATE__');
if (!stateScript?.textContent) return {};

try {
return JSON.parse(stateScript.textContent);
} catch {
console.error('Failed to parse SSR state');
return {};
}
}
serialize-state.ts
/**
* 安全地将状态序列化为可嵌入 HTML 的 script 内容
* 防止 XSS 攻击和特殊字符导致的解析错误
*/
function serializeState(state: unknown): string {
const json = JSON.stringify(state);

// 关键安全转义
return json
.replace(/\u2028/g, '\\u2028') // 行分隔符
.replace(/\u2029/g, '\\u2029') // 段落分隔符
.replace(/</g, '\\u003c') // 防止 </script> 注入
.replace(/>/g, '\\u003e') // 防止 HTML 标签注入
.replace(/&/g, '\\u0026'); // 防止 HTML 实体注入
}

/** 生成状态注入的 script 标签 */
function renderStateScript(state: unknown): string {
return `<script id="__SSR_STATE__" type="application/json">${serializeState(state)}</script>`;
}

五、性能优化

5.1 TTFB 优化

TTFB(Time to First Byte)是 SSR 性能的核心指标,直接影响用户感知的首屏速度。

优化策略效果实现方式
Streaming SSRTTFB 降低 50%+renderToPipeableStream + Suspense
边缘渲染TTFB 降低 60-80%Vercel Edge / Cloudflare Workers
缓存分层命中时 TTFB < 10msCDN + Redis + 内存 LRU
并行数据获取消除瀑布流Promise.all 并行 loader
数据库就近部署减少数据库 RTTPlanetScale / Turso 边缘数据库
edge-ssr.ts
// Vercel Edge Runtime 示例
export const runtime = 'edge'; // 在边缘节点执行 SSR

export default async function handler(request: Request): Promise<Response> {
const url = new URL(request.url);

// 边缘缓存检查
const cacheKey = `ssr:${url.pathname}`;
const cached = await caches.default.match(request);
if (cached) return cached;

// 边缘渲染
const html = await renderPage(url.pathname);

const response = new Response(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 's-maxage=60, stale-while-revalidate=600',
},
});

// 写入边缘缓存
await caches.default.put(request, response.clone());
return response;
}

5.2 TTI 优化

TTI(Time to Interactive)决定用户何时可以与页面交互。

优化策略原理减少 JS 量
选择性注水优先 Hydrate 用户交互区域-
代码分割路由级 + 组件级 lazy loading首屏 JS 减少 40-60%
React Server Components服务端组件零客户端 JS减少 30-50%
Islands Architecture只 Hydrate 交互岛屿减少 70-90%
Script 优先级defer / fetchpriority 控制加载顺序-
模块预加载<link rel="modulepreload"> 预加载关键 JS-
selective-hydration.tsx
import { Suspense, lazy } from 'react';

// 非关键组件延迟加载 + 延迟 Hydration
const Comments = lazy(() => import('./Comments'));
const Sidebar = lazy(() => import('./Sidebar'));

export function ArticlePage({ article }: { article: Article }) {
return (
<article>
{/* 立即 Hydrate:用户核心交互区域 */}
<ArticleContent content={article.content} />
<LikeButton articleId={article.id} />

{/* 延迟 Hydrate:非关键区域 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments articleId={article.id} />
</Suspense>

<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</article>
);
}

5.3 缓存分层策略

层级介质延迟容量TTL失效方式
L1 CDN边缘节点~10ms60sCache-Control / Purge API
L2 内存进程内 LRU~1ms小 (1000 条)30sLRU 淘汰 + 按标签失效
L3 Redis分布式~5ms5minTTL + 按需 DEL

六、扩展设计

6.1 Edge SSR

Edge SSR 将渲染逻辑部署到 CDN 边缘节点,大幅降低 TTFB:

平台运行时冷启动限制
Vercel Edge FunctionsV8 Isolates~0ms无 Node.js API(无 fs/net)
Cloudflare WorkersV8 Isolates~0ms128MB 内存 / 50ms CPU
Deno DeployDeno~0msDeno API
AWS Lambda@EdgeNode.js~100ms5s 超时(Viewer Request)
Edge 运行时限制

Edge Runtime 运行在 V8 Isolates 中,不支持 Node.js 原生模块(如 fsnetcrypto)。这意味着:

  • 不能使用大部分 npm 包(如 sharpbcrypt
  • 数据库驱动必须使用 HTTP 协议(如 PlanetScale Serverless Driver、Turso HTTP API)
  • 适合轻量渲染逻辑,复杂计算仍需 Serverless Function

6.2 微前端 SSR

微前端场景下的 SSR 需要解决多个子应用的服务端渲染编排问题:

micro-frontend-ssr.ts
interface MicroApp {
name: string;
ssrEndpoint: string; // 子应用 SSR 服务地址
fallbackHtml: string; // 降级 HTML
timeout: number; // 超时时间
}

async function composeMicroFrontendSSR(
apps: MicroApp[],
shellHtml: string
): Promise<string> {
// 并行请求所有子应用的 SSR 内容
const results = await Promise.allSettled(
apps.map(async (app) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), app.timeout);

try {
const res = await fetch(app.ssrEndpoint, {
signal: controller.signal,
});
return { name: app.name, html: await res.text() };
} catch {
// 子应用 SSR 失败,使用降级 HTML
return { name: app.name, html: app.fallbackHtml };
} finally {
clearTimeout(timer);
}
})
);

// 将子应用内容注入主 Shell
let composedHtml = shellHtml;
for (const result of results) {
if (result.status === 'fulfilled') {
const { name, html } = result.value;
composedHtml = composedHtml.replace(
`<!--micro-app:${name}-->`,
html
);
}
}

return composedHtml;
}

6.3 A/B 测试与灰度

ab-test-ssr.ts
interface ABTestConfig {
experimentId: string;
variants: {
id: string;
weight: number; // 流量权重 0-100
component: () => Promise<{ default: React.ComponentType }>;
}[];
}

function resolveVariant(
config: ABTestConfig,
userId: string
): string {
// 基于用户 ID 的稳定哈希分桶,确保同一用户始终看到同一版本
const hash = stableHash(`${config.experimentId}:${userId}`);
const bucket = hash % 100;

let cumulative = 0;
for (const variant of config.variants) {
cumulative += variant.weight;
if (bucket < cumulative) {
return variant.id;
}
}
return config.variants[0].id;
}

// 中间件:在 SSR 前确定实验分组
function abTestMiddleware(configs: ABTestConfig[]) {
return (req: Request, res: Response, next: NextFunction) => {
const userId = req.cookies['uid'] || generateAnonymousId();
const experiments: Record<string, string> = {};

for (const config of configs) {
experiments[config.experimentId] = resolveVariant(config, userId);
}

// 注入到渲染上下文
(req as any).__experiments = experiments;

// 按实验分组设置 Vary,避免 CDN 缓存串组
res.setHeader('Vary', 'Cookie');
next();
};
}

6.4 错误容错降级

fallback-strategy.ts
type FallbackLevel = 'streaming' | 'full-ssr' | 'ssg-cache' | 'csr';

async function renderWithFallback(
req: Request,
res: Response,
levels: FallbackLevel[] = ['streaming', 'full-ssr', 'ssg-cache', 'csr']
): Promise<void> {
for (const level of levels) {
try {
switch (level) {
case 'streaming':
return await renderStreaming(req, res);
case 'full-ssr':
return await renderFullSSR(req, res);
case 'ssg-cache':
// 使用上次成功生成的静态 HTML
const cached = await getLastGoodSSG(req.url);
if (cached) {
res.send(cached);
return;
}
continue;
case 'csr':
// 最终兜底:返回空 Shell,客户端接管
res.sendFile(resolve(__dirname, '../dist/client/index.html'));
return;
}
} catch (error) {
console.error(`Fallback level "${level}" failed:`, error);
continue; // 尝试下一个降级策略
}
}
}
生产环境必须有降级方案

SSR 服务器可能因为各种原因挂掉(内存溢出、依赖服务不可用、CPU 打满)。生产环境中:

  1. 必须有 CSR 降级:SSR 失败时返回空 Shell,让客户端 JS 接管渲染
  2. 必须有健康检查:负载均衡器定期探测 SSR 服务健康状态
  3. 必须有限流:SSR 是 CPU 密集型,需要限制并发渲染数量,避免雪崩
  4. 必须有超时:数据获取和渲染必须设置合理超时,防止连接泄漏

七、常见面试问题

Q1: SSR 和 CSR 的核心区别是什么?各自的使用场景?

答案

对比维度CSR(客户端渲染)SSR(服务端渲染)
渲染位置浏览器服务器
首屏内容空白 HTML + JS 加载后渲染服务器直出完整 HTML
TTFB低(返回空 HTML 很快)较高(需等待渲染完成)
FCP/LCP慢(等 JS 下载+执行+请求数据)快(HTML 到达即可展示)
SEO差(爬虫看到空页面)好(爬虫直接抓取完整内容)
服务器成本低(静态文件托管)高(每个请求都要渲染)
交互性JS 加载完即可交互需要等 Hydration 完成才可交互

使用场景

  • CSR:后台管理系统、内部工具、不需要 SEO 的 SPA
  • SSR:电商商品页、新闻资讯、社交媒体(需要 SEO + 首屏性能 + 社交分享预览)

Q2: 什么是 Hydration?为什么说它是 SSR 的性能瓶颈?

答案

Hydration(注水)是将服务端渲染的静态 HTML 与 React/Vue 应用关联的过程,使页面从"可见但不可交互"变为"可见且可交互"。

过程

  1. 浏览器收到服务端直出的 HTML,立即渲染展示(用户可见)
  2. 浏览器下载客户端 JS bundle
  3. JS 执行后,框架在已有 DOM 上重新构建虚拟 DOM 树
  4. 对比虚拟 DOM 与真实 DOM,绑定事件处理器
  5. 页面变为可交互

性能瓶颈

hydration-cost.ts
// 全量 Hydration 的问题
// 即使页面有 90% 是静态内容,仍需:
// 1. 下载 100% 的组件 JS 代码
// 2. 执行 100% 的组件渲染逻辑
// 3. 为 100% 的组件构建虚拟 DOM
// 结果:FCP 很快(HTML 直出),但 TTI 可能很慢(等 Hydration 完成)

优化方案

方案原理效果
Streaming + 选择性注水边流式传输 HTML,边 Hydrate 已到达的部分TTI 降低
React Server Components服务端组件不发送 JSJS 减少 30-50%
Islands Architecture只 Hydrate 交互组件JS 减少 70-90%
Resumability (Qwik)序列化状态,无需重执行近乎零 Hydration 成本

Q3: Streaming SSR 是如何工作的?相比传统 SSR 有什么优势?

答案

传统 SSR 必须等所有数据获取完成,然后一次性返回完整 HTML。Streaming SSR 将这个过程拆分为多个阶段:

streaming-vs-traditional.ts
// 传统 SSR:串行,最慢的数据源决定 TTFB
// 时间线: |--- 获取数据 A (200ms) ---|--- 获取数据 B (800ms) ---|--- 渲染 (50ms) ---| → TTFB = 1050ms

// Streaming SSR:Shell 先走,数据就绪一个推送一个
// 时间线:
// |--- Shell 渲染 (10ms) → 发送 ---|
// |--- 获取数据 A (200ms) → 流式推送 ---|
// |--- 获取数据 B (800ms) → 流式推送 ---|
// → TTFB = 10ms(Shell),用户 10ms 后就看到页面骨架

核心优势

维度传统 SSRStreaming SSR
TTFB等最慢数据源Shell 就绪即返回
用户感知白屏 → 完整页面骨架 → 渐进填充
慢数据源影响阻塞整个页面只阻塞对应区块
Hydration全量一次性选择性、可打断

Q4: ISR 的工作原理?On-Demand ISR 解决了什么问题?

答案

ISR(增量静态再生)= SSG + 后台定时更新

isr-flow.ts
// 1. 构建时:预生成 HTML(与 SSG 相同)
// 2. 部署后:CDN 分发静态页面

// 3. 当 revalidate 时间到期后:
// - 第 N 次请求:返回旧页面(依然秒级响应)
// - 后台触发:重新执行 getStaticProps → 生成新 HTML
// - 第 N+1 次请求:返回新页面

// 关键特征:stale-while-revalidate
// 用户永远不会等待渲染,最差情况是看到稍旧的内容

On-Demand ISR 解决的问题

定时 ISR 的缺点是内容更新有延迟(最长等待 revalidate 秒数)。On-Demand ISR 允许通过 API 调用立即触发重新生成:

on-demand-isr.ts
// Next.js App Router: revalidateTag / revalidatePath
import { revalidateTag, revalidatePath } from 'next/cache';

// CMS 内容更新时,通过 Webhook 调用此 API
export async function POST(request: Request) {
const { type, slug } = await request.json();

// 按标签批量失效相关页面
revalidateTag('products'); // 失效所有带 'products' 标签的缓存
revalidatePath(`/blog/${slug}`); // 失效特定路径

return Response.json({ revalidated: true });
}
对比定时 ISROn-Demand ISR
触发方式自动(每 N 秒)手动(API 调用)
更新延迟最长 N 秒实时
适用场景新闻、行情CMS 内容发布
实现方式revalidate: 60revalidateTag() / Webhook

Q5: 如何设计 SSR 的容错降级策略?

答案

生产环境的 SSR 服务必须具备多级降级能力:

graceful-degradation.ts
// 降级层级:Streaming → Full SSR → SSG 缓存 → CSR
async function renderPage(req: Request, res: Response): Promise<void> {
// Level 1: 尝试 Streaming SSR(最佳体验)
try {
return await streamingSSR(req, res);
} catch (e) {
console.error('Streaming SSR failed:', e);
}

// Level 2: 降级为传统 SSR(完整等待)
try {
return await fullSSR(req, res);
} catch (e) {
console.error('Full SSR failed:', e);
}

// Level 3: 返回上次成功的 SSG 快照
const snapshot = await getSSGSnapshot(req.url);
if (snapshot) {
res.setHeader('X-Render-Mode', 'ssg-fallback');
return res.send(snapshot);
}

// Level 4: 最终兜底 CSR
res.setHeader('X-Render-Mode', 'csr-fallback');
res.sendFile('/dist/client/index.html');
}

配套措施

措施说明
健康检查/healthz 端点,负载均衡器检测到故障后摘除节点
并发限流限制同时渲染的请求数(如最大 50 并发),超出返回 CSR
超时控制数据获取超时 3s、渲染超时 5s,超时则降级
熔断器连续失败超过阈值,自动切换为 CSR 模式,定期探测恢复
监控告警监控 SSR 成功率、TTFB P95、降级比例

Q6: React Server Components 和传统 SSR 的区别?

答案

维度传统 SSRReact Server Components
渲染结果HTML 字符串RSC Payload(可序列化的 React 树)
客户端 JS所有组件的 JS 都发送到客户端只有 'use client' 组件的 JS
Hydration全量 Hydration只 Hydrate Client Components
数据访问需要 API 层(getServerSideProps直接访问数据库/文件系统
导航更新整页刷新或客户端路由局部更新 RSC Payload,保留客户端状态
组件分类无区分Server Component / Client Component
rsc-vs-ssr.tsx
// 传统 SSR:组件代码全部发送到客户端
// 假设 ProductPage 使用了 moment.js (67KB) 格式化日期
// → 客户端也需要下载 moment.js

// RSC:Server Component 的依赖不发送到客户端
// Server Component 中 import moment → 只在服务端执行
// → 客户端 JS bundle 不包含 moment.js
import moment from 'moment';

export default async function ProductPage() {
const product = await db.product.findUnique({ where: { id: '1' } });
return (
<div>
<h1>{product.name}</h1>
{/* moment 只在服务端执行,客户端不需要这个库 */}
<p>上架时间:{moment(product.createdAt).format('YYYY-MM-DD')}</p>
{/* Client Component:这部分 JS 会发送到客户端 */}
<AddToCartButton productId={product.id} />
</div>
);
}

Q7: 如何优化 SSR 应用的 Core Web Vitals?

答案

指标目标SSR 优化策略
LCP< 2.5sStreaming SSR 提前发送关键内容、关键 CSS 内联、图片 priority
INP< 200ms选择性 Hydration、减少主线程阻塞、useTransition 降低优先级
CLS< 0.1服务端直出完整布局、预留占位空间、font-display: optional
web-vitals-optimization.tsx
// LCP 优化:关键 CSS 内联 + 预加载关键资源
function Document({ criticalCSS }: { criticalCSS: string }) {
return (
<html>
<head>
{/* 关键 CSS 内联,避免额外请求阻塞渲染 */}
<style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
{/* 预加载 LCP 图片 */}
<link rel="preload" as="image" href="/hero.webp" fetchPriority="high" />
{/* 预加载关键 JS chunk */}
<link rel="modulepreload" href="/static/main.js" />
</head>
<body>
<div id="root" />
</body>
</html>
);
}

// CLS 优化:Skeleton 与实际内容尺寸一致
function ProductSkeleton() {
return (
// 骨架屏尺寸必须与实际内容匹配,避免布局偏移
<div style={{ width: '100%', height: '400px' }}>
<div style={{ width: '300px', height: '300px', background: '#eee' }} />
<div style={{ width: '200px', height: '24px', background: '#eee', marginTop: '16px' }} />
</div>
);
}

Q8: 如果让你从零设计一个 SSR 框架,你会如何设计?重点考虑哪些方面?

答案

这是一道综合性开放题,考察系统设计的全局观。回答框架如下:

第一步:确定核心抽象

framework-core.ts
// 三个核心抽象
// 1. 路由 → 页面映射
interface Route {
path: string;
component: () => Promise<{ default: React.ComponentType }>;
loader?: (ctx: LoaderContext) => Promise<unknown>;
}

// 2. 渲染策略
type RenderStrategy = 'ssr' | 'ssg' | 'isr' | 'csr';

// 3. 适配器(部署目标)
interface Adapter {
name: string;
buildOutput: () => Promise<void>;
startServer?: () => Promise<void>;
}

第二步:分层设计

层级职责关键设计点
路由层URL → 组件 + 数据文件系统路由、动态路由、中间件
数据层服务端数据获取并行 loader、缓存、错误隔离
渲染层组件 → HTMLStreaming、状态序列化、安全转义
注水层HTML → 可交互选择性 Hydration、状态恢复
缓存层多级缓存CDN + Redis + 内存 LRU + SWR
适配层部署灵活性Node.js / Serverless / Edge 适配器
容错层降级保障多级降级、熔断、超时、健康检查

第三步:关键技术决策

  1. 使用 Streaming 作为默认渲染模式:比传统 SSR 更快的 TTFB
  2. 支持 RSC:服务端组件减少客户端 JS
  3. 适配器模式:一套代码,多平台部署
  4. 缓存优先:减少不必要的渲染,用缓存换性能
  5. 容错降级:SSR 失败不能导致页面白屏,必须有 CSR 兜底

相关链接