React 服务端渲染 (SSR) 详解

React 服务端渲染 (SSR) 详解

本文档解释了 React 服务端渲染 (SSR) 的原理、优势、挑战以及应用程序如何处理可能的 SSR 失败情况。

1. SSR 原理

SSR 在服务器端渲染 React 应用程序的初始 HTML,然后再发送给客户端的浏览器。这与客户端渲染 (CSR) 形成对比,后者只发送一个最小的 HTML 文件,然后由 JavaScript 在浏览器中渲染内容。

SSR 工作原理

  1. 服务器端渲染过程

    • 当用户请求页面时,服务器执行 React 应用程序代码。
    • 它使用 renderToString(来自 react-dom/server)等函数为请求的页面生成完整的 HTML 标记,包括在服务器上获取的数据。
    • 这个完整的 HTML 被发送到浏览器。
    javascript 复制代码
    // 服务器端代码示例(概念性)
    import { renderToString } from 'react-dom/server';
    import App from './App'; // 你的主 React 组件
    
    app.get('*', (req, res) => {
      // 如果需要,获取初始渲染所需的数据
      // const initialData = await fetchData(req.path);
    
      // 将 React 组件渲染为 HTML 字符串
      const appHtml = renderToString(<App /* initialData={initialData} */ />);
    
      // 将渲染的 HTML 注入到模板中
      const html = `
        <!DOCTYPE html>
        <html>
          <head><title>React SSR 应用</title></head>
          <body>
            <div id="root">${appHtml}</div>
            {/* 包含水合所需的数据 */}
            {/* <script>window.__INITIAL_DATA__ = ${JSON.stringify(initialData)}</script> */}
            <script src="/client.js"></script> {* 打包的客户端 JS *}
          </body>
        </html>
      `;
      res.send(html);
    });
  2. 客户端水合

    • 一旦 HTML 到达浏览器,用户几乎立即就能看到内容。
    • 打包的客户端 JavaScript(client.js)随后下载并执行。
    • React 不是从零开始重新渲染所有内容,而是使用一个称为水合的过程。它附加事件监听器并使服务器渲染的静态 HTML 具有交互性,有效地接管应用程序状态。
    javascript 复制代码
    // 客户端水合代码示例
    import { hydrateRoot } from 'react-dom/client';
    import App from './App';
    
    // 如果服务器注入了初始数据,则检索它
    // const initialData = window.__INITIAL_DATA__;
    
    hydrateRoot(
      document.getElementById('root'),
      <App /* initialData={initialData} */ />
    );

完整的 SSR 实现示例

以下是一个更完整的 SSR 实现示例,包括数据获取、状态管理和错误处理:

javascript 复制代码
// server.js - Express 服务器设置
import express from 'express';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import App from './src/App';
import rootReducer from './src/store/reducers';
import { fetchInitialData } from './src/api';

const app = express();
const PORT = process.env.PORT || 3000;

// 处理静态文件
app.use(express.static('public'));

// SSR 中间件
app.get('*', async (req, res) => {
  try {
    // 创建 Redux store
    const store = configureStore({
      reducer: rootReducer,
      preloadedState: {}
    });
    
    // 获取初始数据
    const initialData = await fetchInitialData(req.path);
    
    // 更新 store 状态
    store.dispatch({ type: 'SET_INITIAL_DATA', payload: initialData });
    
    // 渲染应用
    const context = {}; // 用于捕获重定向等
    const appHtml = renderToString(
      <Provider store={store}>
        <StaticRouter location={req.url} context={context}>
          <App />
        </StaticRouter>
      </Provider>
    );
    
    // 检查是否有重定向
    if (context.url) {
      return res.redirect(301, context.url);
    }
    
    // 获取 store 的最终状态
    const finalState = store.getState();
    
    // 发送 HTML
    const html = `
      <!DOCTYPE html>
      <html>
        <head>
          <title>React SSR 应用</title>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1">
          <link rel="stylesheet" href="/styles.css">
        </head>
        <body>
          <div id="root">${appHtml}</div>
          <script>
            window.__PRELOADED_STATE__ = ${JSON.stringify(finalState).replace(/</g, '\\u003c')}
          </script>
          <script src="/client.js"></script>
        </body>
      </html>
    `;
    
    res.status(200).send(html);
  } catch (error) {
    console.error('SSR 错误:', error);
    // 回退到客户端渲染
    res.status(500).send(`
      <!DOCTYPE html>
      <html>
        <head>
          <title>React 应用</title>
        </head>
        <body>
          <div id="root"></div>
          <script src="/client.js"></script>
        </body>
      </html>
    `);
  }
});

app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`);
});
javascript 复制代码
// client.js - 客户端入口
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import App from './src/App';
import rootReducer from './src/store/reducers';

// 从服务器注入的状态恢复 Redux store
const preloadedState = window.__PRELOADED_STATE__ || {};
delete window.__PRELOADED_STATE__;

const store = configureStore({
  reducer: rootReducer,
  preloadedState
});

// 水合应用
hydrateRoot(
  document.getElementById('root'),
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>
);

SSR 的优势

  1. 提升性能(感知):用户更快地看到内容(更低的首次字节时间 - TTFB,和首次内容绘制 - FCP),因为初始 HTML 已经在服务器端渲染。这减少了感知加载时间,避免了白屏。
  2. 更好的 SEO:搜索引擎爬虫可以轻松索引服务器发送的完整渲染 HTML 内容,提高网站的可见性和排名。
  3. 增强社交媒体分享:在社交媒体上分享链接时,平台可以获取服务器渲染的 HTML 来显示丰富的预览(标题、描述、图片),使用包含在初始 HTML 中的元数据(如 Open Graph 标签)。

实现与优化

  • 框架:Next.js、Remix 和 Gatsby 等框架大大简化了 SSR 的实现。
  • 数据获取:初始渲染所需的数据必须在服务器上渲染之前获取。
  • 状态管理:初始应用程序状态(例如来自 Redux 或 Zustand)需要在服务器上计算,发送到客户端(通常在 HTML 中序列化),并在客户端重新水合。
  • 代码分割:分割客户端 JavaScript 包以减少初始加载大小。
  • 缓存:实现服务器端缓存策略(组件缓存、页面缓存)以减少频繁请求的渲染开销。
数据获取示例
javascript 复制代码
// 服务器端数据获取
async function fetchInitialData(path) {
  // 根据路径获取不同数据
  if (path === '/products') {
    const products = await fetchProducts();
    return { products };
  } else if (path === '/users') {
    const users = await fetchUsers();
    return { users };
  }
  return {};
}

// 在组件中使用
function ProductList({ products }) {
  return (
    <div>
      <h1>产品列表</h1>
      <ul>
        {products.map(product => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </div>
  );
}

// 在服务器端渲染时获取数据
const initialData = await fetchInitialData(req.path);
store.dispatch({ type: 'SET_PRODUCTS', payload: initialData.products });
代码分割示例
javascript 复制代码
// 使用 React.lazy 和 Suspense 进行代码分割
import React, { Suspense, lazy } from 'react';

// 懒加载组件
const ProductList = lazy(() => import('./components/ProductList'));
const UserList = lazy(() => import('./components/UserList'));

function App() {
  return (
    <div>
      <h1>我的应用</h1>
      <Suspense fallback={<div>加载中...</div>}>
        <ProductList />
      </Suspense>
      <Suspense fallback={<div>加载中...</div>}>
        <UserList />
      </Suspense>
    </div>
  );
}

挑战

  • 复杂性:SSR 设置通常比纯 CSR 应用程序更复杂。
  • 服务器负载:与仅提供静态文件相比,在服务器上渲染会增加服务器负载。
  • 环境差异 :代码需要是同构的 (或通用的),这意味着它可以在服务器(Node.js 环境)和客户端(浏览器环境)上运行。这需要谨慎处理浏览器特定的 API(如 windowlocalStorage),这些在服务器上是不可用的。
  • 样式:CSS-in-JS 库通常需要特定的服务器端设置来提取样式并将其包含在初始 HTML 负载中。
处理环境差异的示例
javascript 复制代码
// 检查代码运行环境
const isServer = typeof window === 'undefined';

// 条件性使用浏览器 API
function getLocalStorage() {
  if (isServer) {
    return null; // 在服务器上返回 null
  }
  return localStorage.getItem('user'); // 在客户端使用 localStorage
}

// 使用 useEffect 处理仅客户端的逻辑
import { useEffect, useState } from 'react';

function ClientOnlyComponent() {
  const [windowWidth, setWindowWidth] = useState(0);
  
  useEffect(() => {
    // 这段代码只在客户端执行
    setWindowWidth(window.innerWidth);
    
    const handleResize = () => {
      setWindowWidth(window.innerWidth);
    };
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return <div>窗口宽度: {windowWidth}px</div>;
}

2. 处理 SSR 失败

如果服务器端渲染过程遇到错误怎么办?

理想情况下,应用程序应该具有弹性并提供回退机制。用户体验在很大程度上取决于如何处理失败。

失败场景与回退

  1. 回退到客户端渲染 (CSR)

    • 方式 :如果在 renderToString 期间发生错误,服务器捕获错误并发送一个最小的 HTML shell(类似于标准 CSR 应用程序),只包含 <div id="root\"></div><script> 标签。
    • 用户体验:用户最初看到空白页面(或如果包含在 shell 中,则看到基本加载指示器)。客户端 JavaScript 随后下载、执行并在浏览器中渲染整个应用程序。这是最常见的回退方式。
    • 优点:确保应用程序最终仍能加载。
    • 缺点:对于该特定请求失去了 SSR 的性能和 SEO 优势;用户会经历延迟。
    javascript 复制代码
    // 服务器端回退示例
    try {
      const appHtml = renderToString(<App />);
      res.send(renderFullPage(appHtml)); // 注入到模板的函数
    } catch (error) {
      console.error('SSR 错误:', error);
      res.send(renderClientFallbackShell()); // 发送基本 HTML shell
    }
  2. 部分渲染 / 错误边界

    • 方式:在服务器上使用 React 的错误边界。如果特定组件渲染失败,错误边界捕获错误并仅为该组件渲染回退 UI。
    • 用户体验:用户看到通过 SSR 正确渲染的大部分页面,只有失败的组件显示错误消息或加载状态。页面其余部分在水合后保持交互性。
    • 优点:优雅降级,保留大部分 SSR 优势。
    • 缺点:需要谨慎放置错误边界。
    javascript 复制代码
    // 错误边界组件
    class ErrorBoundary extends React.Component {
      constructor(props) {
        super(props);
        this.state = { hasError: false, error: null };
      }
      
      static getDerivedStateFromError(error) {
        return { hasError: true, error };
      }
      
      componentDidCatch(error, errorInfo) {
        // 记录错误到日志服务
        console.error('组件错误:', error, errorInfo);
      }
      
      render() {
        if (this.state.hasError) {
          // 渲染回退 UI
          return (
            <div className="error-container">
              <h2>出错了</h2>
              <p>我们正在努力修复这个问题。</p>
              <button onClick={() => this.setState({ hasError: false })}>
                重试
              </button>
            </div>
          );
        }
        
        return this.props.children;
      }
    }
    
    // 在应用中使用错误边界
    function App() {
      return (
        <div>
          <Header />
          <ErrorBoundary>
            <MainContent />
          </ErrorBoundary>
          <Footer />
        </div>
      );
    }
    
    // 服务器端错误边界处理
    function renderWithErrorBoundary(Component) {
      try {
        return renderToString(<Component />);
      } catch (error) {
        console.error('渲染错误:', error);
        // 渲染错误边界组件
        return renderToString(
          <ErrorBoundary>
            <Component />
          </ErrorBoundary>
        );
      }
    }
  3. 使用 Suspense 的流式 SSR

    • 方式 :较新的 React 功能允许流式 SSR。服务器立即发送初始 HTML shell,然后随着它们准备就绪发送渲染内容的块(通常与数据获取的 <Suspense> 结合使用)。
    • 用户体验:用户快速看到主布局。如果特定的数据获取或组件渲染通过 Suspense 在服务器上暂停失败,其回退 UI 最初被渲染,客户端可以在水合后尝试再次渲染它。
    • 优点:改善 TTFB 并允许渐进式加载。失败被隔离。
    • 缺点:实现更复杂;依赖于较新的 React 功能和潜在的 Node.js 流功能。
    javascript 复制代码
    // 流式 SSR 示例
    import { renderToPipeableStream } from 'react-dom/server';
    import { Suspense } from 'react';
    
    // 服务器端代码
    app.get('*', (req, res) => {
      // 设置响应头
      res.setHeader('Content-Type', 'text/html');
      res.setHeader('Transfer-Encoding', 'chunked');
      
      // 开始流式渲染
      const { pipe, abort } = renderToPipeableStream(
        <Suspense fallback={<div>加载中...</div>}>
          <App />
        </Suspense>,
        {
          bootstrapScripts: ['/client.js'],
          onShellReady() {
            // 发送初始 HTML shell
            res.write(`
              <!DOCTYPE html>
              <html>
                <head>
                  <title>React SSR 应用</title>
                </head>
                <body>
                  <div id="root">
            `);
            pipe(res);
          },
          onShellError(error) {
            console.error('Shell 错误:', error);
            // 回退到客户端渲染
            res.statusCode = 500;
            res.end(`
              <!DOCTYPE html>
              <html>
                <head>
                  <title>React 应用</title>
                </head>
                <body>
                  <div id="root"></div>
                  <script src="/client.js"></script>
                </body>
              </html>
            `);
          },
          onError(error) {
            console.error('渲染错误:', error);
            // 继续渲染,让错误边界处理错误
          }
        }
      );
      
      // 设置超时
      setTimeout(() => {
        abort();
      }, 5000);
    });
  4. 陈旧缓存 / 缓存回退

    • 方式:如果存在强大的缓存层,如果实时渲染失败,服务器可能会提供稍微陈旧(但成功)的页面缓存版本。
    • 用户体验:用户看到可能稍微过时的内容,但页面加载快速且看起来完整。
    • 优点:高可用性,快速回退。
    • 缺点:内容可能不新鲜;需要管理缓存失效。
    javascript 复制代码
    // 缓存策略示例
    import NodeCache from 'node-cache';
    
    // 创建缓存实例,TTL 为 5 分钟
    const pageCache = new NodeCache({ stdTTL: 300 });
    
    // 缓存中间件
    async function cacheMiddleware(req, res, next) {
      const cacheKey = req.originalUrl;
      const cachedPage = pageCache.get(cacheKey);
      
      if (cachedPage) {
        console.log('从缓存提供页面:', cacheKey);
        return res.send(cachedPage);
      }
      
      // 修改 res.send 以缓存响应
      const originalSend = res.send;
      res.send = function(body) {
        // 缓存成功响应
        if (res.statusCode === 200) {
          pageCache.set(cacheKey, body);
        }
        return originalSend.call(this, body);
      };
      
      next();
    }
    
    // 在 Express 应用中使用
    app.use(cacheMiddleware);
    
    // 在 SSR 失败时尝试使用缓存
    app.get('*', async (req, res) => {
      try {
        // 尝试实时渲染
        const appHtml = renderToString(<App />);
        const html = renderFullPage(appHtml);
        res.send(html);
      } catch (error) {
        console.error('SSR 错误:', error);
        
        // 尝试从缓存获取
        const cacheKey = req.originalUrl;
        const cachedPage = pageCache.get(cacheKey);
        
        if (cachedPage) {
          console.log('SSR 失败,使用缓存版本');
          return res.send(cachedPage);
        }
        
        // 如果没有缓存,回退到客户端渲染
        console.log('SSR 失败且无缓存,回退到客户端渲染');
        res.send(renderClientFallbackShell());
      }
    });

弹性的最佳实践

  • 健壮的错误处理 :在服务器上围绕渲染逻辑实现 try...catch 块。
  • 使用错误边界:用错误边界包装应用程序的逻辑部分以限制失败。
  • 监控和告警:在服务器上设置监控以跟踪 SSR 错误和性能。当失败率增加时提醒开发人员。
  • 优雅降级:确保即使必须回退到完整的 CSR,应用程序也能正确运行。
  • 测试失败模式:刻意测试 SSR 失败时应用程序的行为。
监控和告警示例
javascript 复制代码
// 简单的监控中间件
function monitoringMiddleware(req, res, next) {
  const start = Date.now();
  
  // 捕获响应完成事件
  res.on('finish', () => {
    const duration = Date.now() - start;
    const status = res.statusCode;
    
    // 记录请求指标
    console.log(`请求 ${req.method} ${req.url} 完成,状态码: ${status},耗时: ${duration}ms`);
    
    // 如果响应时间超过阈值,记录警告
    if (duration > 1000) {
      console.warn(`慢请求警告: ${req.method} ${req.url} 耗时 ${duration}ms`);
    }
    
    // 如果状态码是 5xx,记录错误
    if (status >= 500) {
      console.error(`服务器错误: ${req.method} ${req.url} 状态码 ${status}`);
    }
  });
  
  next();
}

// 在 Express 应用中使用
app.use(monitoringMiddleware);

结论

React SSR 为性能和 SEO 提供了显著优势,但引入了复杂性。当 SSR 失败时,设计良好的应用程序应该优雅降级,通常回退到客户端渲染或显示部分内容,确保用户仍然可以访问应用程序,尽管初始视图可能会延迟。

相关推荐
聪明的墨菲特i几秒前
React与Vue:哪个框架更适合入门?
开发语言·前端·javascript·vue.js·react.js
JiangJiang2 小时前
🚀 Vue 人看 useMemo:别再滥用它做性能优化
前端·react.js·面试
Dragon Wu3 小时前
前端 React 弹窗式 滑动验证码实现
前端·javascript·react.js·typescript·前端框架·reactjs
AI程序员罗尼5 小时前
React SSR 水合(Hydration)详解
react.js
AI程序员罗尼5 小时前
React useEffect 在服务端渲染中的执行行为
react.js
Rachel_wang6 小时前
如何安装并启动 electron-prokit(react+ts) 项目
react.js·electron
就是我6 小时前
如何用lazy+ Suspense实现组件延迟加载
javascript·react native·react.js
新时代农民工Top8 小时前
React + JavaScript 实现可拖拽进度条
前端·javascript·react.js
小钰能吃三碗饭9 小时前
第八篇:【React 性能调优】从优化实践到自动化性能监控
前端·javascript·react.js