CSR & SSR & 同构 指南

322 阅读8分钟

🎯 第一章:核心概念解析

1.1 CSR(客户端渲染)

// CSR = Client Side Rendering
// 页面在浏览器中通过 JavaScript 动态渲染

const csrFlow = {
  1: '浏览器请求 HTML',
  2: '服务器返回空壳 HTML + JS 链接',
  3: '浏览器下载 JS',
  4: 'JS 执行,动态生成内容',
  5: '页面显示'
};

// 典型 CSR 应用:Create React App、Vue CLI
<!-- CSR 返回的 HTML 示例 -->
<!DOCTYPE html>
<html>
<head>
  <title>CSR App</title>
</head>
<body>
  <div id="root"></div>  <!-- 空的容器 -->
  <script src="bundle.js"></script>  <!-- JS 负责渲染 -->
</body>
</html>

1.2 SSR(服务端渲染)

// SSR = Server Side Rendering
// 页面在服务器端渲染完成,直接返回 HTML

const ssrFlow = {
  1: '浏览器请求页面',
  2: '服务器执行渲染逻辑',
  3: '服务器返回完整的 HTML',
  4: '浏览器直接显示',
  5: '然后加载 JS 进行 hydration(激活)'
};

// 典型 SSR 应用:Next.js、Nuxt.js
<!-- SSR 返回的 HTML 示例 -->
<!DOCTYPE html>
<html>
<head>
  <title>SSR App</title>
</head>
<body>
  <div id="root">
    <!-- 已经渲染好的内容 -->
    <h1>用户列表</h1>
    <ul>
      <li>张三</li>
      <li>李四</li>
    </ul>
  </div>
  <script src="bundle.js"></script>  <!-- JS 负责交互 -->
</body>
</html>

1.3 同构渲染(Universal Rendering)

// 同构渲染 = SSR + CSR 的结合
// 同一套代码,既能在服务端运行,也能在客户端运行

const isomorphicFlow = {
  1: '服务端执行代码,渲染出 HTML',
  2: '返回完整 HTML,用户立刻看到内容',
  3: '浏览器下载 JS',
  4: 'JS 在客户端再次执行(hydrate)',
  5: '页面变成可交互的 SPA'
};

// 关键概念:hydration(水合/激活)
// 客户端 JS 接管服务端渲染的静态页面,添加事件监听

🔗 第二章:联系

2.1 关系图谱

const relationship = {
  // CSR 和 SSR 是渲染方式的两种选择
  rendering: {
    csr: '客户端渲染',
    ssr: '服务端渲染',
    isomorphic: '同构(两者结合)'
  },

2.2 数据流向对比

// CSR
const csrWithSOA = {
  1: '浏览器请求 HTML',
  2: '返回空壳 HTML',
  3: '浏览器加载 JS',
  4: 'JS 调用多个后端服务',
  5: '服务 A 返回用户数据',
  6: '服务 B 返回商品数据',
  7: 'JS 组合数据,渲染页面'
};

// SSR
const ssrWithSOA = {
  1: '浏览器请求页面',
  2: '服务器调用多个后端服务',
  3: '服务 A 返回用户数据',
  4: '服务 B 返回商品数据',
  5: '服务器组合数据,渲染 HTML',
  6: '返回完整 HTML',
  7: '浏览器显示,JS 激活'
};

2.3 同构的关键:hydration

// hydration 过程
const hydration = {
  // 服务端渲染的 HTML
  serverHTML: `
    <button class="counter">0</button>
  `,
  
  // 客户端 JS 进行 hydration
  clientJS: `
    // 找到已有的 DOM 元素
    const button = document.querySelector('.counter');
    
    // 添加事件监听(不重新创建 DOM)
    button.addEventListener('click', () => {
      const count = parseInt(button.textContent) + 1;
      button.textContent = count;
    });
  `,
  
  // 注意:不是重新渲染,而是"激活"已有的 DOM
  key: '复用服务端渲染的 DOM,只添加事件'
};

📊 第三章:详细对比

3.1 对比表

维度CSRSSR同构渲染
首次内容显示慢(需等 JS 加载执行)快(直接返回 HTML)快(服务端渲染)
SEO 友好❌ 差(爬虫看不到内容)✅ 好✅ 好
服务端压力小(只返回静态文件)大(需要渲染)大(需要渲染)
用户体验交互后流畅(SPA)页面跳转有白屏两者兼顾
开发复杂度
首屏加载时间2-5秒0.5-1秒0.5-1秒
TTI(可交互时间)
数据获取时机客户端服务端服务端+客户端

3.2 性能指标对比

const performanceMetrics = {
  // FCP (First Contentful Paint)
  FCP: {
    CSR: '2.5s+ (需等待 JS)',
    SSR: '0.5s (直接返回 HTML)',
  },
  
  // TTI (Time to Interactive)
  TTI: {
    CSR: '3s+ (JS 执行完)',
    SSR: '2s (HTML 显示后还需 JS 激活)',
  },
  
  // TTFB (Time to First Byte)
  TTFB: {
    CSR: '快(静态文件)',
    SSR: '慢(需要服务端渲染)',
  }
};

🎯 第四章:使用场景分析

4.1 CSR 适合的场景

const csrScenarios = {
  // ✅ 适合
  suitable: [
    '后台管理系统',
    '数据看板/仪表盘',
    '在线编辑器',
    '企业内部应用',
    '需要复杂交互的SPA'
  ],
  
  // ❌ 不适合
  notSuitable: [
    '内容型网站(博客、新闻)',
    '电商首页',
    '对SEO有强需求的网站',
    '首屏加载时间敏感的应用'
  ],
  
  // 典型例子
  examples: {
    dashboard: '蚂蚁金服 Ant Design Pro',
    tool: 'Google Docs',
    admin: '各种中后台系统'
  }
};

4.2 SSR 适合的场景

const ssrScenarios = {
  // ✅ 适合
  suitable: [
    '内容型网站',
    '电商网站',
    '新闻门户',
    '博客',
    '对SEO有要求的网站'
  ],
  
  // ❌ 不适合
  notSuitable: [
    '复杂交互应用',
    '实时性要求高的应用',
    '服务器资源有限的应用'
  ],
  
  // 典型例子
  examples: {
    ecommerce: '淘宝商品详情页',
    content: '知乎问题页',
    news: '今日头条'
  }
};

4.3 同构渲染适合的场景

const isomorphicScenarios = {
  // ✅ 适合
  suitable: [
    '需要SEO的复杂应用',
    '内容+交互混合的网站',
    '电商网站(列表+详情)',
    '社区/论坛',
    '企业官网'
  ],
  
  // 需要权衡
  tradeoffs: [
    '服务器压力较大',
    '开发复杂度高',
    '需要处理水合不匹配'
  ],
  
  // 典型例子
  examples: {
    nextjs: 'Next.js 应用',
    nuxt: 'Nuxt.js 应用',
    remix: 'Remix 应用'
  }
};

🏗️ 第五章:架构选型指南

5.1 选型决策树

const decisionTree = {
  question1: '是否需要 SEO?',
  branches: {
    yes: {
      question2: '交互复杂度高吗?',
      options: {
        high: '同构渲染(Next.js/Nuxt)',
        low: 'SSR 或 SSG'
      }
    },
    no: {
      question2: '首屏加载速度要求高吗?',
      options: {
        high: 'SSR 或 同构',
        low: 'CSR'
      }
    }
  },
  
  question2: '服务器资源有限吗?',
  impact: {
    yes: '倾向 CSR(服务器压力小)',
    no: '可考虑 SSR/同构'
  },
  
  question3: '团队技术栈?',
  impact: {
    react: 'Next.js(同构)',
    vue: 'Nuxt.js(同构)',
    angular: 'Angular Universal'
  }
};

5.2 不同规模项目的选择

const projectScales = {
  // 小型项目(个人博客、官网)
  small: {
    recommendation: 'CSR 或 SSG',
    reason: '开发简单,维护成本低',
    tech: 'Vite + Vue/React'
  },
  
  // 中型项目(电商、社区)
  medium: {
    recommendation: '同构渲染',
    reason: '需要 SEO,也有交互',
    tech: 'Next.js/Nuxt.js'
  },
  
  // 大型项目(复杂平台)
  large: {
    recommendation: '同构 + 微前端 + SOA',
    reason: '复杂业务,多团队协作',
    tech: 'Next.js + Module Federation + BFF'
  },
  
  // 内容型网站(博客、文档)
  content: {
    recommendation: 'SSG (Static Site Generation)',
    reason: '内容不变,构建时生成',
    tech: 'Next.js SSG, Gatsby'
  }
};

🚀 第六章:实战代码示例

6.1 CSR 实现

// React CSR 示例
// App.js
function App() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // 客户端获取数据
    fetch('/api/data')
      .then(res => res.json())
      .then(setData);
  }, []);
  
  if (!data) return <div>Loading...</div>;
  
  return <div>{data.content}</div>;
}

// 构建产物
// index.html (简化版)
<!DOCTYPE html>
<div id="root"></div>
<script src="bundle.js"></script>

6.2 SSR 实现(Node.js + React)

// 服务端渲染代码
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';

const app = express();

app.get('/', async (req, res) => {
  // 1. 获取数据
  const data = await fetchData();
  
  // 2. 渲染组件为 HTML
  const html = renderToString(<App data={data} />);
  
  // 3. 返回完整 HTML
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>SSR App</title>
      </head>
      <body>
        <div id="root">${html}</div>
        <script>
          window.__INITIAL_DATA__ = ${JSON.stringify(data)};
        </script>
        <script src="bundle.js"></script>
      </body>
    </html>
  `);
});

// 客户端 hydration
// client.js
import { hydrateRoot } from 'react-dom/client';

const data = window.__INITIAL_DATA__;
hydrateRoot(document.getElementById('root'), <App data={data} />);

6.3 同构的关键:数据同步

// 确保客户端和服务端数据一致
class IsomorphicApp {
  // 服务端:获取数据并渲染
  static async serverRender(req) {
    const stores = {
      userStore: await userStore.fetch(req.params.id),
      postStore: await postStore.fetch()
    };
    
    const html = renderToString(
      <Provider stores={stores}>
        <App />
      </Provider>
    );
    
    return {
      html,
      initialState: {
        user: stores.userStore.getState(),
        posts: stores.postStore.getState()
      }
    };
  }
  
  // 客户端:使用服务端数据初始化
  static clientRender() {
    // 使用服务端注入的数据
    const initialState = window.__INITIAL_STATE__;
    
    const stores = {
      userStore: new UserStore(initialState.user),
      postStore: new PostStore(initialState.posts)
    };
    
    hydrateRoot(
      document.getElementById('root'),
      <Provider stores={stores}>
        <App />
      </Provider>
    );
  }
}

🎓 第七章:难点

Q1:CSR 和 SSR 的主要区别是什么?

const answer = {
  // 渲染位置
  location: 'CSR在客户端渲染,SSR在服务端渲染',
  
  // 首屏时间
  FCP: 'SSR比CSR快,因为直接返回HTML',
  
  // SEO
  SEO: 'SSR对SEO友好,CSR不友好',
  
  // 交互体验
  interaction: 'CSR后续交互更流畅,SSR页面跳转有白屏',
  
  // 服务器压力
  serverLoad: 'SSR增加服务器压力,CSR服务器压力小',
  
  // 开发复杂度
  complexity: 'SSR比CSR复杂,需要处理同构问题'
};

Q2:什么是 hydration?为什么需要它?

const hydrationAnswer = {
  // 定义
  definition: 'hydration是客户端接管服务端渲染的静态页面,添加事件监听的过程',
  
  // 为什么需要
  why: '服务端只生成了静态HTML,没有事件交互,需要JS来"激活"',
  
  // 过程
  process: `
    1. 服务端返回完整的HTML,用户立刻看到内容
    2. 浏览器下载JS bundle
    3. JS执行,遍历已有的DOM树
    4. 为DOM元素添加事件监听
    5. 页面变得可交互
  `,
  
  // 常见问题
  issues: '如果服务端和客户端渲染结果不一致,会导致hydration失败'
};

Q3:同构渲染的常见问题集合?

同构渲染的理想与现实:

const ideal = {
  // 理想情况
  server: '服务端渲染出完整HTML,用户立刻看到内容',
  client: '客户端无缝接管,保持SPA体验',
  code: '同一套代码,两端运行'
};

const reality = {
  // 实际情况
  server: 'Node.js环境没有window/document',
  client: '水合过程可能出错或闪烁',
  code: '同一份代码,行为却可能不同'
};

1️⃣环境差异的坑:window/document 不存在

// ❌ 坑:直接在服务端使用浏览器API
function Component() {
  // 服务端执行时会报错!
  const width = window.innerWidth;
  const element = document.getElementById('app');
  
  return <div>宽度: {width}</div>;
}

// ✅ 解决方案1:动态导入(Next.js方式)
function Component() {
  const [width, setWidth] = useState(null);
  
  useEffect(() => {
    // 只在客户端执行
    setWidth(window.innerWidth);
    
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return <div>宽度: {width ?? '计算中...'}</div>;
}

// ✅ 解决方案2:条件判断
function SafeComponent() {
  const isBrowser = typeof window !== 'undefined';
  
  if (!isBrowser) {
    // 服务端返回占位
    return <div>加载中...</div>;
  }
  
  // 客户端才真正使用
  return <div>宽度: {window.innerWidth}</div>;
}

// ✅ 解决方案3:Next.js 动态导入(不渲染服务端)
import dynamic from 'next/dynamic';

const ClientOnlyComponent = dynamic(
  () => import('../components/WidthComponent'),
  { ssr: false } // 只在客户端渲染
);

2️⃣环境差异的坑:localStorage/sessionStorage 不存在

// ❌ 坑:服务端直接使用 localStorage
function UserInfo() {
  // 服务端报错!localStorage is not defined
  const token = localStorage.getItem('token');
  
  return <div>Token: {token}</div>;
}

// ✅ 解决方案:封装存储服务
class StorageService {
  static get(key) {
    if (typeof window === 'undefined') {
      return null; // 服务端返回null
    }
    return localStorage.getItem(key);
  }
  
  static set(key, value) {
    if (typeof window === 'undefined') return;
    localStorage.setItem(key, value);
  }
}

// ✅ 或者使用状态管理 + useEffect
function TokenDisplay() {
  const [token, setToken] = useState(null);
  
  useEffect(() => {
    setToken(localStorage.getItem('token'));
    
    // 可选:监听变化
    const handleStorage = () => {
      setToken(localStorage.getItem('token'));
    };
    window.addEventListener('storage', handleStorage);
    
    return () => window.removeEventListener('storage', handleStorage);
  }, []);
  
  return <div>{token ? `Token: ${token}` : '未登录'}</div>;
}

3️⃣环境差异的坑:服务端没有事件系统

// ❌ 坑:服务端绑定事件
function Button() {
  // 服务端没有 addEventListener!
  window.addEventListener('click', handleClick);
  
  return <button>点击</button>;
}

// ✅ 正确做法
function CorrectButton() {
  useEffect(() => {
    // 只在客户端绑定
    window.addEventListener('click', handleClick);
    return () => window.removeEventListener('click', handleClick);
  }, []);
  
  return <button onClick={handleClick}>点击</button>;
  // 注意:onClick 是 React 合成事件,不是原生事件
}

4️⃣数据不一致的坑:水合失败(Hydration Mismatch)

// ❌ 坑:服务端和客户端渲染结果不一致
function RandomComponent() {
  // 服务端生成一个随机数
  // 客户端生成另一个随机数
  return <div>随机数: {Math.random()}</div>;
  // 水合时会报错!Text content does not match server-rendered HTML
}

// ✅ 解决方案1:保持一致
function FixedRandom() {
  const [random, setRandom] = useState(null);
  
  useEffect(() => {
    // 只在客户端生成
    setRandom(Math.random());
  }, []);
  
  return <div>随机数: {random ?? '...'}</div>;
}

// ✅ 解决方案2:使用 stable 值
function StableComponent({ data }) {
  // 使用服务端传递的数据
  return <div>数据: {data.id}</div>;
}

5️⃣数据不一致的坑:时间戳不一致

// ❌ 坑:时间格式化结果不同
function DateComponent({ timestamp }) {
  // 服务端和客户端时区可能不同
  // 格式化结果可能不同
  const date = new Date(timestamp).toLocaleString();
  return <div>{date}</div>;
}

// ✅ 解决方案:统一格式化
function FixedDate({ timestamp }) {
  const [formatted, setFormatted] = useState(null);
  
  useEffect(() => {
    // 客户端统一格式化
    setFormatTimestamp(timestamp);
  }, [timestamp]);
  
  // 服务端返回 ISO 字符串
  return <div>{formatted ?? new Date(timestamp).toISOString()}</div>;
}

// 或者使用专门的库处理时区
import { formatInTimeZone } from 'date-fns-tz';

function SafeDate({ timestamp, timezone = 'UTC' }) {
  // 明确指定时区
  return <div>{formatInTimeZone(timestamp, timezone, 'yyyy-MM-dd HH:mm:ss')}</div>;
}

6️⃣数据不一致的坑:数据预取不一致

// ❌ 坑:服务端和客户端数据不同
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    // 客户端重新获取数据
    fetchUser(userId).then(setUser);
  }, [userId]);
  
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
  // 问题:服务端没数据,客户端有数据,水合失败
}

// ✅ 解决方案:服务端预取数据
// Next.js 方式
export async function getServerSideProps({ params }) {
  const user = await fetchUser(params.id);
  
  return {
    props: {
      user // 传递给组件
    }
  };
}

function UserProfile({ user }) {
  // 服务端已经有数据,客户端直接用
  return <div>{user.name}</div>;
}

7️⃣性能相关的坑:服务端渲染压力过大

// ❌ 坑:所有请求都走服务端渲染
// 每个用户请求都触发完整的渲染流程
// 高并发时服务器扛不住

// ✅ 解决方案1:缓存策略
// Next.js 的 ISR (Incremental Static Regeneration)
export async function getStaticProps() {
  const data = await fetchData();
  
  return {
    props: { data },
    revalidate: 60 // 60秒内走缓存
  };
}

// ✅ 解决方案2:页面级缓存
const pageCache = new Map();

async function renderWithCache(req, res) {
  const key = req.url;
  
  if (pageCache.has(key)) {
    const { html, expire } = pageCache.get(key);
    if (Date.now() < expire) {
      return res.send(html); // 返回缓存
    }
  }
  
  // 重新渲染
  const html = await renderToString(<App />);
  pageCache.set(key, {
    html,
    expire: Date.now() + 60000 // 缓存1分钟
  });
  
  res.send(html);
}

8️⃣性能相关的坑:水合开销过大

// ❌ 坑:整个应用水合,导致 TTI 变慢
// 即使是大页面,也要等所有组件都hydrate完才能交互

// ✅ 解决方案1:渐进式水合
function HeavyComponent() {
  const [shouldRender, setShouldRender] = useState(false);
  
  useEffect(() => {
    // 延迟水合
    const timer = setTimeout(() => setShouldRender(true), 1000);
    return () => clearTimeout(timer);
  }, []);
  
  if (!shouldRender) {
    // 返回服务端渲染的静态HTML
    return <div dangerouslySetInnerHTML={{ __html: serverHtml }} />;
  }
  
  return <ActualComponent />;
}

// ✅ 解决方案2:部分水合
// 使用 React 18 的 Selective Hydration
import { hydrateRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';

const Comments = lazy(() => import('./Comments'));

function Page() {
  return (
    <div>
      <Header /> {/* 立即水合 */}
      <Content /> {/* 立即水合 */}
      <Suspense fallback={<div>加载评论...</div>}>
        <Comments /> {/* 延迟水合 */}
      </Suspense>
    </div>
  );
}

9️⃣性能相关的坑:内存泄漏

// ❌ 坑:服务端每次渲染都创建新对象,但未清理
const cache = new Map(); // 全局缓存,无限增长

function renderPage() {
  // 每次渲染都添加,但从不删除
  cache.set(Date.now(), largeData);
  
  return renderToString(<App />);
}

// ✅ 解决方案:合理管理内存
class LRUCache {
  constructor(maxSize = 100) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }
  
  set(key, value) {
    if (this.cache.size >= this.maxSize) {
      // 删除最旧的
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
  
  get(key) {
    const value = this.cache.get(key);
    if (value) {
      // 更新为最近使用
      this.cache.delete(key);
      this.cache.set(key, value);
    }
    return value;
  }
}

🔟第三方库的坑:依赖 window 的库

// ❌ 坑:在服务端引入需要 window 的库
import * as d3 from 'd3'; // d3 内部使用了 window

// 服务端直接报错

// ✅ 解决方案1:动态导入
function Chart() {
  useEffect(() => {
    import('d3').then(d3 => {
      // 在客户端使用
      d3.select('#chart')....
    });
  }, []);
  
  return <div id="chart" />;
}

// ✅ 解决方案2:NoSSR 包装
const NoSSR = ({ children }) => {
  const [isClient, setIsClient] = useState(false);
  
  useEffect(() => {
    setIsClient(true);
  }, []);
  
  return isClient ? children : null;
};

function SafeChart() {
  return (
    <NoSSR>
      <Chart />
    </NoSSR>
  );
}

🔟第三方库的坑:CSS-in-JS 服务端渲染问题

// ❌ 坑:CSS-in-JS 样式闪烁(FOUC)
// 服务端渲染的HTML没有样式,客户端注入后才出现

// ✅ 解决方案:使用支持SSR的CSS-in-JS
// styled-components 示例
import { ServerStyleSheet } from 'styled-components';

const sheet = new ServerStyleSheet();
try {
  const html = renderToString(sheet.collectStyles(<App />));
  const styles = sheet.getStyleTags(); // 获取样式标签
  
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>${styles}</head>
      <body>
        <div id="root">${html}</div>
      </body>
    </html>
  `);
} finally {
  sheet.seal();
}

// ✅ 或者使用 CSS Modules(更简单)
import styles from './Button.module.css';

function Button() {
  return <button className={styles.btn}>点击</button>;
}
// CSS 已构建好,服务端客户端一致

🔟路由相关的坑:服务端路由与客户端路由

// ❌ 坑:服务端不知道客户端路由变化
// 用户在前端切换路由,刷新页面后404

// ✅ 解决方案:配置 catch-all 路由
// Next.js 方式 - pages/[...slug].js
export default function CatchAll({ params }) {
  return <div>路由: {params.slug.join('/')}</div>;
}

export async function getServerSideProps({ params }) {
  // 服务端也能处理所有路由
  return { props: { params } };
}

// 或者配置 nginx 返回 index.html
nginx`
location / {
  try_files $uri /index.html;
}
`;

🔟路由相关的坑:重定向问题

// ❌ 坑:客户端重定向导致 SEO 问题
function PrivateRoute({ children }) {
  const { user } = useAuth();
  
  useEffect(() => {
    if (!user) {
      router.push('/login'); // 客户端重定向
    }
  }, [user]);
  
  return children;
}
// 搜索引擎爬虫看不到内容

// ✅ 解决方案:服务端重定向
// Next.js 方式
export async function getServerSideProps(context) {
  const { user } = await getAuth(context);
  
  if (!user) {
    return {
      redirect: {
        destination: '/login',
        permanent: false
      }
    };
  }
  
  return { props: { user } };
}