跳到主要内容

设计 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适配器模式、运行时抽象

面试速答版

怎么设计一个 SSR 框架(类 Next.js)? 一个成熟的 SSR 框架不是「只做 SSR」,而是让开发者按页面/组件粒度选择渲染策略:

  • 多种渲染模式:SSR(请求时渲染)、SSG(构建时预生成)、ISR(静态 + 后台增量重生成)、Streaming SSR(分块传 HTML),同一项目不同页面可以选不同模式。
  • 文件路由 + 中间件:pages/posts/[id].tsx 自动加载为路由,自动代码分割;中间件提供鉴权、重定向、A/B 入口。
  • 数据预取:提供统一的 getServerSideProps / loader / fetch in RSC 接口,服务端渲染前并发取数据并注入初始 HTML,客户端水合时从 HTML 读回。
  • Hydration:服务端输出静态 HTML + 初始数据,客户端 React/Vue 按同样组件树「贴上事件」变可交互;进阶优化用 Streaming + Selective Hydration 按优先级水合。
  • 运行时适配:抽象出 Adapter 层,同一代码能跑在 Node.js / Vercel Edge / Cloudflare Workers / Deno。

SSR / SSG / ISR 怎么选?

  • 内容不变 + 数量有限(博客、文档)走 SSG,部署到 CDN,最便宜、最快。
  • 内容会变 + 不要求实时(电商详情页、新闻)走 ISR,首访问走 SSG 缓存,N 分钟后在后台重生成。
  • 内容跟用户身份相关 / 需要实时(仗单页、个人中心)才走 SSR。
  • 完全客户端交互 且不要 SEO(后台仪表盘)走 CSR,不需要 SSR。

Streaming SSR 有什么优势? 传统 SSR 要「服务端取完所有数据才返 HTML」,最慢接口决定 TTFB。Streaming 可以:

  • 走 HTTP chunked,服务端调用 renderToPipeableStream,shell(导航/骨架屏)几十毫秒就发出去。
  • 慢块用 <Suspense> 包裹,服务端数据好了再向后追加 HTML 片段 + Hydration 脚本。
  • 结果是TTFB 调低 + LCP 提前,用户看见骨架的时机从 1s+ 降到 200ms 内。

二、整体架构​

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 标记为交互式的组件,其余保持静态 HTML快小Astro
Islands Architecture页面是静态 HTML 海洋中的交互"岛屿"快最小Astro, Fresh
React Server Components服务端组件零 JS,客户端组件按需 Hydrate快小Next.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 的代码(包括依赖库如 moment、lodash)完全不会出现在客户端 bundle 中
  • 只有 'use client' 组件的代码会被打包并发送到浏览器
  • 这从根本上解决了"为了 SSR 引入的库却全部发送到客户端"的问题

3.4 缓存策略​

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

缓存层级对比​

缓存层级粒度命中率更新策略实现方式
CDN 边缘页面 URL最高Cache-Control / stale-while-revalidateVercel Edge, Cloudflare
反向代理页面 URL高TTL + Purge APINginx proxy_cache
页面级完整 HTML中TTL / 按需失效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边缘节点~10ms大60sCache-Control / Purge API
L2 内存进程内 LRU~1ms小 (1000 条)30sLRU 淘汰 + 按标签失效
L3 Redis分布式~5ms中5minTTL + 按需 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 原生模块(如 fs、net、crypto)。这意味着:

  • 不能使用大部分 npm 包(如 sharp、bcrypt)
  • 数据库驱动必须使用 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 兜底

相关链接​