设计 SSR 框架与应用架构
一、需求分析
SSR(Server-Side Rendering)框架是现代 Web 应用的核心基础设施。从零设计一套 SSR 框架需要覆盖多种渲染模式,同时兼顾开发体验和生产环境性能。这是面试中考察系统设计能力 + 前端深度的高频题目。
1.1 功能需求
| 功能模块 | 核心能力 | 说明 |
|---|---|---|
| 服务端渲染 SSR | 请求时在服务端生成完整 HTML | 首屏可见内容直出,SEO 友好 |
| 静态生成 SSG | 构建时预生成 HTML | CDN 分发,TTFB 极低 |
| 增量再生成 ISR | SSG + 后台按需/定时更新 | 兼顾静态性能与内容时效性 |
| 流式渲染 Streaming SSR | 分块传输 HTML + 选择性注水 | 降低 TTFB,提升可交互速度 |
| 数据获取层 | 统一的服务端数据预取机制 | 支持并行请求、缓存、错误处理 |
| 路由系统 | 文件系统路由 + 动态路由 + 中间件 | 自动代码分割,嵌套布局 |
| 客户端注水 | 服务端 HTML → 客户端可交互 | 全量/选择性/渐进式 Hydration |
1.2 非功能需求
面试中明确非功能需求是拉开差距的核心。它体现了候选人的工程化思维和生产环境经验。
| 非功能需求 | 设计目标 | 实现手段 |
|---|---|---|
| 首屏性能 | TTFB < 200ms,LCP < 2.5s | 流式渲染、边缘计算、缓存分层 |
| SEO 友好 | 搜索引擎可抓取完整内容 | 服务端直出完整 HTML、meta 管理 |
| 可扩展性 | 支持插件、中间件、自定义渲染策略 | 插件架构、Hook 系统 |
| 开发体验 | 热更新、TypeScript 支持、统一 API | HMR、Fast Refresh、类型推断 |
| 容错降级 | SSR 失败自动降级为 CSR | try-catch 兜底、健康检查 |
| 部署灵活 | 支持 Node.js、Serverless、Edge | 适配器模式、运行时抽象 |
二、整体架构
2.1 请求处理全景
2.2 分层架构
三、核心模块设计
3.1 渲染模式对比
一个优秀的 SSR 框架不是只做 SSR,而是让开发者按页面/组件粒度灵活选择渲染策略。
| 特性 | CSR | SSR | SSG | ISR | Streaming SSR |
|---|---|---|---|---|---|
| 渲染时机 | 浏览器 | 每次请求 | 构建时 | 构建 + 增量 | 每次请求(流式) |
| 首屏速度 | 慢 | 快 | 最快 | 快 | 快(渐进) |
| TTFB | 低 | 较高 | 极低 | 极低 | 低(Shell 先达) |
| SEO | 差 | 好 | 好 | 好 | 好 |
| 服务器压力 | 无 | 高 | 无 | 低 | 中 |
| 数据时效性 | 实时 | 实时 | 构建时 | 定期更新 | 实时 |
| 适用场景 | 后台管理 | 动态内容 | 博客/文档 | 电商/新闻 | 复杂动态页 |
3.2 数据获取层
数据获取层是连接路由和渲染的桥梁,不同框架有不同的抽象方式:
| 方案 | 框架 | 特点 |
|---|---|---|
getServerSideProps | Next.js (Pages Router) | 页面级、每次请求执行 |
getStaticProps | Next.js (Pages Router) | 页面级、构建时执行 |
loader / action | Remix / React Router | 路由级、支持嵌套并行 |
| Server Components | Next.js (App Router) | 组件级、直接 async/await |
useFetch / useAsyncData | Nuxt 3 | 组合式、SSR/CSR 统一 |
// 框架核心:数据获取层抽象
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 策略对比
| 策略 | 原理 | TTI | JS 体积 | 代表框架 |
|---|---|---|---|---|
| 全量注水 | 客户端重新构建整棵虚拟 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 模型:
// 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>
);
}
'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>
);
}
- Server Component 的代码(包括依赖库如
moment、lodash)完全不会出现在客户端 bundle 中 - 只有
'use client'组件的代码会被打包并发送到浏览器 - 这从根本上解决了"为了 SSR 引入的库却全部发送到客户端"的问题
3.4 缓存策略
缓存是 SSR 性能的命脉。一个生产级 SSR 框架需要多层次缓存体系:
缓存层级对比
| 缓存层级 | 粒度 | 命中率 | 更新策略 | 实现方式 |
|---|---|---|---|---|
| CDN 边缘 | 页面 URL | 最高 | Cache-Control / stale-while-revalidate | Vercel Edge, Cloudflare |
| 反向代理 | 页面 URL | 高 | TTL + Purge API | Nginx proxy_cache |
| 页面级 | 完整 HTML | 中 | TTL / 按需失效 | Redis / 内存 |
| 组件级 | 组件子树 | 中 | 依赖追踪 | 序列化 VNode |
| 数据级 | API 响应 | 高 | TTL / SWR | Redis / React cache() |
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;
}
}
SSR 页面如果包含用户个性化内容(如用户名、购物车数量),不能直接缓存完整 HTML。常见的解决方案:
- Vary 头:按 Cookie/Accept-Language 维度分别缓存(缓存命中率低)
- Edge Side Includes (ESI):静态壳 + 动态片段
- 客户端补全:SSR 渲染公共部分,个性化部分由客户端请求填充
- React Server Components:将个性化部分标记为
'use client',公共部分由 Server Component 渲染并缓存
3.5 路由与代码分割
// 文件系统路由:自动扫描 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 服务器
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 实现分块传输:
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);
}
流式渲染的页面组件
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>
);
}
- Shell 阶段:服务端先渲染 Suspense 边界外的内容(导航、布局),立即发送给浏览器 → 用户看到页面骨架
- Streaming 阶段:每个 Suspense 边界的异步内容就绪后,以
<script>标签的形式追加到 HTML 流中,浏览器替换对应的 fallback - Hydration 阶段:JS 加载后,React 对已到达的 HTML 进行选择性注水,优先 Hydrate 用户正在交互的区域
4.3 数据预取与状态序列化
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 {};
}
}
/**
* 安全地将状态序列化为可嵌入 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 SSR | TTFB 降低 50%+ | renderToPipeableStream + Suspense |
| 边缘渲染 | TTFB 降低 60-80% | Vercel Edge / Cloudflare Workers |
| 缓存分层 | 命中时 TTFB < 10ms | CDN + Redis + 内存 LRU |
| 并行数据获取 | 消除瀑布流 | Promise.all 并行 loader |
| 数据库就近部署 | 减少数据库 RTT | PlanetScale / Turso 边缘数据库 |
// 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 | - |
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 | 大 | 60s | Cache-Control / Purge API |
| L2 内存 | 进程内 LRU | ~1ms | 小 (1000 条) | 30s | LRU 淘汰 + 按标签失效 |
| L3 Redis | 分布式 | ~5ms | 中 | 5min | TTL + 按需 DEL |
六、扩展设计
6.1 Edge SSR
Edge SSR 将渲染逻辑部署到 CDN 边缘节点,大幅降低 TTFB:
| 平台 | 运行时 | 冷启动 | 限制 |
|---|---|---|---|
| Vercel Edge Functions | V8 Isolates | ~0ms | 无 Node.js API(无 fs/net) |
| Cloudflare Workers | V8 Isolates | ~0ms | 128MB 内存 / 50ms CPU |
| Deno Deploy | Deno | ~0ms | Deno API |
| AWS Lambda@Edge | Node.js | ~100ms | 5s 超时(Viewer Request) |
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 需要解决多个子应用的服务端渲染编排问题:
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 测试与灰度
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 错误容错降级
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 打满)。生产环境中:
- 必须有 CSR 降级:SSR 失败时返回空 Shell,让客户端 JS 接管渲染
- 必须有健康检查:负载均衡器定期探测 SSR 服务健康状态
- 必须有限流:SSR 是 CPU 密集型,需要限制并发渲染数量,避免雪崩
- 必须有超时:数据获取和渲染必须设置合理超时,防止连接泄漏
七、常见面试问题
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 应用关联的过程,使页面从"可见但不可交互"变为"可见且可交互"。
过程:
- 浏览器收到服务端直出的 HTML,立即渲染展示(用户可见)
- 浏览器下载客户端 JS bundle
- JS 执行后,框架在已有 DOM 上重新构建虚拟 DOM 树
- 对比虚拟 DOM 与真实 DOM,绑定事件处理器
- 页面变为可交互
性能瓶颈:
// 全量 Hydration 的问题
// 即使页面有 90% 是静态内容,仍需:
// 1. 下载 100% 的组件 JS 代码
// 2. 执行 100% 的组件渲染逻辑
// 3. 为 100% 的组件构建虚拟 DOM
// 结果:FCP 很快(HTML 直出),但 TTI 可能很慢(等 Hydration 完成)
优化方案:
| 方案 | 原理 | 效果 |
|---|---|---|
| Streaming + 选择性注水 | 边流式传输 HTML,边 Hydrate 已到达的部分 | TTI 降低 |
| React Server Components | 服务端组件不发送 JS | JS 减少 30-50% |
| Islands Architecture | 只 Hydrate 交互组件 | JS 减少 70-90% |
| Resumability (Qwik) | 序列化状态,无需重执行 | 近乎零 Hydration 成本 |
Q3: Streaming SSR 是如何工作的?相比传统 SSR 有什么优势?
答案:
传统 SSR 必须等所有数据获取完成,然后一次性返回完整 HTML。Streaming SSR 将这个过程拆分为多个阶段:
// 传统 SSR:串行,最慢的数据源决定 TTFB
// 时间线: |--- 获取数据 A (200ms) ---|--- 获取数据 B (800ms) ---|--- 渲染 (50ms) ---| → TTFB = 1050ms
// Streaming SSR:Shell 先走,数据就绪一个推送一个
// 时间线:
// |--- Shell 渲染 (10ms) → 发送 ---|
// |--- 获取数据 A (200ms) → 流式推送 ---|
// |--- 获取数据 B (800ms) → 流式推送 ---|
// → TTFB = 10ms(Shell),用户 10ms 后就看到页面骨架
核心优势:
| 维度 | 传统 SSR | Streaming SSR |
|---|---|---|
| TTFB | 等最慢数据源 | Shell 就绪即返回 |
| 用户感知 | 白屏 → 完整页面 | 骨架 → 渐进填充 |
| 慢数据源影响 | 阻塞整个页面 | 只阻塞对应区块 |
| Hydration | 全量一次性 | 选择性、可打断 |
Q4: ISR 的工作原理?On-Demand ISR 解决了什么问题?
答案:
ISR(增量静态再生)= SSG + 后台定时更新:
// 1. 构建时:预生成 HTML(与 SSG 相同)
// 2. 部署后:CDN 分发静态页面
// 3. 当 revalidate 时间到期后:
// - 第 N 次请求:返回旧页面(依然秒级响应)
// - 后台触发:重新执行 getStaticProps → 生成新 HTML
// - 第 N+1 次请求:返回新页面
// 关键特征:stale-while-revalidate
// 用户永远不会等待渲染,最差情况是看到稍旧的内容
On-Demand ISR 解决的问题:
定时 ISR 的缺点是内容更新有延迟(最长等待 revalidate 秒数)。On-Demand ISR 允许通过 API 调用立即触发重新生成:
// 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 });
}
| 对比 | 定时 ISR | On-Demand ISR |
|---|---|---|
| 触发方式 | 自动(每 N 秒) | 手动(API 调用) |
| 更新延迟 | 最长 N 秒 | 实时 |
| 适用场景 | 新闻、行情 | CMS 内容发布 |
| 实现方式 | revalidate: 60 | revalidateTag() / Webhook |
Q5: 如何设计 SSR 的容错降级策略?
答案:
生产环境的 SSR 服务必须具备多级降级能力:
// 降级层级: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 的区别?
答案:
| 维度 | 传统 SSR | React Server Components |
|---|---|---|
| 渲染结果 | HTML 字符串 | RSC Payload(可序列化的 React 树) |
| 客户端 JS | 所有组件的 JS 都发送到客户端 | 只有 'use client' 组件的 JS |
| Hydration | 全量 Hydration | 只 Hydrate Client Components |
| 数据访问 | 需要 API 层(getServerSideProps) | 直接访问数据库/文件系统 |
| 导航更新 | 整页刷新或客户端路由 | 局部更新 RSC Payload,保留客户端状态 |
| 组件分类 | 无区分 | Server Component / Client Component |
// 传统 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.5s | Streaming SSR 提前发送关键内容、关键 CSS 内联、图片 priority |
| INP | < 200ms | 选择性 Hydration、减少主线程阻塞、useTransition 降低优先级 |
| CLS | < 0.1 | 服务端直出完整布局、预留占位空间、font-display: optional |
// 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 框架,你会如何设计?重点考虑哪些方面?
答案:
这是一道综合性开放题,考察系统设计的全局观。回答框架如下:
第一步:确定核心抽象
// 三个核心抽象
// 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、缓存、错误隔离 |
| 渲染层 | 组件 → HTML | Streaming、状态序列化、安全转义 |
| 注水层 | HTML → 可交互 | 选择性 Hydration、状态恢复 |
| 缓存层 | 多级缓存 | CDN + Redis + 内存 LRU + SWR |
| 适配层 | 部署灵活性 | Node.js / Serverless / Edge 适配器 |
| 容错层 | 降级保障 | 多级降级、熔断、超时、健康检查 |
第三步:关键技术决策
- 使用 Streaming 作为默认渲染模式:比传统 SSR 更快的 TTFB
- 支持 RSC:服务端组件减少客户端 JS
- 适配器模式:一套代码,多平台部署
- 缓存优先:减少不必要的渲染,用缓存换性能
- 容错降级:SSR 失败不能导致页面白屏,必须有 CSR 兜底