都5202年了还不了解React SSR?从零实现React的同构渲染和流式渲染吧。

12 阅读7分钟

React 同构渲染(SSR)项目从零搭建指南

本文档记录了从零开始构建 React 同构渲染(Server-Side Rendering)项目的完整过程。

1. 项目介绍

同构渲染是指一套 React 代码在服务器端执行一次,再到客户端再执行一次的技术方案。其主要优势包括:

  • 改善首屏加载速度和用户体验
  • 有利于 SEO 优化
  • 在弱网络环境下提供更好的用户体验
  • 复用相同的 React 组件代码

2. 项目初始化

首先创建并初始化项目:

# 初始化package.json
npm init -y

3. 安装依赖

安装核心依赖:

# 安装React相关核心依赖
npm install react react-dom express

安装开发依赖:

# 安装webpack相关
npm install --save-dev webpack webpack-cli webpack-node-externals webpack-merge

# 安装babel相关
npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react

# 安装开发工具
npm install --save-dev nodemon npm-run-all cross-env

4. 创建目录结构

创建项目所需的目录结构:

Isomorphic-react-ssr-demo/
├── build/                  # 服务端打包输出目录
├── public/                 # 客户端打包输出目录
├── src/
│   ├── client/            # 客户端代码
│   ├── server/            # 服务端代码
│   └── shared/            # 共享组件
├── webpack/               # webpack配置
│   ├── base.js
│   ├── client.js
│   └── server.js
└── package.json

5. Webpack 配置

5.1 基础配置 (webpack/base.js)

module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-react', '@babel/preset-env'],
          },
        },
      },
    ],
  },
}

5.2 客户端配置 (webpack/client.js)

const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./base')

const config = {
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, '../public'),
  },
}

module.exports = merge(baseConfig, config)

5.3 服务端配置 (webpack/server.js)

const path = require('path')
const nodeExternals = require('webpack-node-externals')
const { merge } = require('webpack-merge')
const baseConfig = require('./base')

const config = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  externals: [nodeExternals()],
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, '../build'),
  },
}

module.exports = merge(baseConfig, config)

6. 创建 React 组件

6.1 共享 App 组件 (src/shared/App.js)

import React from 'react'

const App = () => {
  return (
    <div>
      <h1>React 同构渲染 (SSR) 演示</h1>
      <p>这是一个服务端渲染的React应用</p>
      <button onClick={() => alert('你点击了按钮!')}>点击我</button>
    </div>
  )
}

export default App

7. 客户端与服务端代码

7.1 客户端入口 (src/client/index.js)

import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from '../shared/App'

// 客户端使用hydrateRoot进行水合,复用服务端渲染的DOM结构
hydrateRoot(document.getElementById('root'), <App />)

7.2 服务端入口 (src/server/index.js)

import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import path from 'path'
import App from '../shared/App'

const app = express()

// 设置静态文件目录
app.use(express.static('public'))

app.get('*', (req, res) => {
  // 将React组件渲染为HTML字符串
  const content = renderToString(<App />)

  // 返回完整的HTML
  res.send(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>React SSR 演示</title>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
      </head>
      <body>
        <div id="root">${content}</div>
        <script src="bundle.js"></script>
      </body>
    </html>
  `)
})

const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`)
})

8. 配置 package.json

更新 package.json,添加启动脚本:

{
  "name": "isomorphic-react-ssr-demo",
  "version": "1.0.0",
  "description": "React同构SSR演示项目",
  "main": "index.js",
  "scripts": {
    "dev:build:server": "webpack --config webpack/server.js --watch",
    "dev:build:client": "webpack --config webpack/client.js --watch",
    "dev:server": "nodemon --watch build build/server.js",
    "dev": "npm-run-all --parallel dev:*",
    "dev:sync": "cross-env RENDER_MODE=sync npm-run-all --parallel dev:*",
    "dev:stream": "cross-env RENDER_MODE=stream npm-run-all --parallel dev:*",
    "build:server": "webpack --config webpack/server.js",
    "build:client": "webpack --config webpack/client.js",
    "build": "npm-run-all --parallel build:*",
    "start": "node build/server.js",
    "start:sync": "cross-env RENDER_MODE=sync node build/server.js",
    "start:stream": "cross-env RENDER_MODE=stream node build/server.js"
  },
  "keywords": ["react", "ssr", "isomorphic"],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.22.9",
    "@babel/preset-env": "^7.22.9",
    "@babel/preset-react": "^7.22.5",
    "babel-loader": "^9.1.3",
    "nodemon": "^3.0.1",
    "npm-run-all": "^4.1.5",
    "webpack": "^5.88.2",
    "webpack-cli": "^5.1.4",
    "webpack-merge": "^5.9.0",
    "webpack-node-externals": "^3.0.0"
  }
}

9. 运行项目

在开发环境下启动项目:

# 启动开发模式
npm run dev

此命令会并行执行以下操作:

  • 监听并构建服务端代码
  • 监听并构建客户端代码
  • 启动服务器并自动重启

构建生产环境:

# 构建生产环境
npm run build

# 启动生产服务器
npm start

10. 同构渲染原理

  1. 服务端渲染:服务器通过renderToString方法将 React 组件渲染为 HTML 字符串,直接发送给浏览器
  2. 客户端水合(Hydration):浏览器加载 JavaScript 后,React 通过hydrateRoot方法为静态 HTML 添加事件监听和状态管理,接管页面交互

11. 项目实现流式渲染

除了上面的基础同构渲染,我们还实现了流式渲染功能,并提供了两种渲染模式的切换机制。

11.1 创建渲染模式配置文件

创建一个配置文件来控制渲染模式:

// src/shared/config.js
// 渲染模式配置
// renderMode: 'sync' - 使用传统同构渲染 (renderToString)
// renderMode: 'stream' - 使用流式渲染 (renderToPipeableStream)
module.exports = {
  renderMode: process.env.RENDER_MODE || 'sync',
}

11.2 改造服务端代码支持流式渲染

修改服务端代码,使其支持两种渲染模式:

// src/server/index.js
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { renderToPipeableStream } from 'react-dom/server'
import path from 'path'
import App from '../shared/App'
import { Suspense } from 'react'
const config = require('../shared/config')

const app = express()

// 设置静态文件目录
app.use(express.static('public'))

app.get('*', (req, res) => {
  // 获取查询参数,允许通过URL临时切换渲染模式
  const queryRenderMode = req.query.renderMode
  const renderMode = queryRenderMode || config.renderMode

  // HTML模板
  const generateHtml = (content) => `
    <!DOCTYPE html>
    <html>
      <head>
        <title>React SSR 演示</title>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
      </head>
      <body>
        <div id="root">${content}</div>
        <script>
          // 向客户端传递当前使用的渲染模式
          window.__RENDER_MODE__ = "${renderMode}";
        </script>
        <script src="bundle.js"></script>
      </body>
    </html>
  `

  // 使用传统的同构渲染
  if (renderMode === 'sync') {
    // 将React组件渲染为HTML字符串
    const content = renderToString(<App />)

    // 返回完整的HTML
    res.send(generateHtml(content))
  }
  // 使用流式渲染
  else if (renderMode === 'stream') {
    // 设置响应头
    res.setHeader('Content-Type', 'text/html')
    res.write('<!DOCTYPE html><html><head>')
    res.write('<title>React SSR 流式渲染演示</title>')
    res.write('<meta charset="utf-8" />')
    res.write(
      '<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />'
    )
    res.write('</head><body><div id="root">')

    // 使用renderToPipeableStream进行流式渲染
    const { pipe } = renderToPipeableStream(<App />, {
      bootstrapScripts: ['/bundle.js'],
      onShellReady() {
        // 当Shell准备好时,开始流式传输
        pipe(res)
      },
      onAllReady() {
        // 所有内容准备完成
        res.write(`<script>window.__RENDER_MODE__ = "${renderMode}";</script>`)
      },
    })
  } else {
    // 默认使用同构渲染
    const content = renderToString(<App />)
    res.send(generateHtml(content))
  }
})

const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
  console.log(`服务器运行在 http://localhost:${PORT}`)
  console.log(`当前渲染模式: ${config.renderMode}`)
  console.log(
    `提示: 可通过访问 http://localhost:${PORT}?renderMode=stream 或 http://localhost:${PORT}?renderMode=sync 切换渲染模式`
  )
})

11.3 改造 App 组件支持 Suspense 和流式渲染

改造 App 组件,使其支持基于 Suspense 的延迟加载和渲染模式切换:

// src/shared/App.js
import React, { Suspense, useState, useEffect } from 'react'

// 模拟异步数据加载的组件
const SlowComponent = () => {
  const [data, setData] = useState(null)

  useEffect(() => {
    // 模拟API请求
    const timer = setTimeout(() => {
      setData('这是异步加载的数据')
    }, 1500)

    return () => clearTimeout(timer)
  }, [])

  if (!data) return <p>加载中...</p>
  return <div className="slow-component">{data}</div>
}

// 这个组件仅在流式渲染模式下会被Suspense包裹
const DelayedComponent = () => {
  return (
    <div className="delayed-content">
      <h2>延迟加载内容</h2>
      <SlowComponent />
    </div>
  )
}

// 渲染模式切换组件
const RenderModeSwitch = () => {
  const [currentMode, setCurrentMode] = useState('sync')

  useEffect(() => {
    // 从window对象获取当前渲染模式
    setCurrentMode(window.__RENDER_MODE__ || 'sync')
  }, [])

  const switchMode = (mode) => {
    // 通过URL参数切换渲染模式
    window.location.href = `/?renderMode=${mode}`
  }

  return (
    <div className="render-mode-switch">
      <p>
        当前渲染模式:{' '}
        <strong>{currentMode === 'sync' ? '同构渲染' : '流式渲染'}</strong>
      </p>
      <div>
        <button
          onClick={() => switchMode('sync')}
          disabled={currentMode === 'sync'}
          style={{ marginRight: '10px' }}
        >
          切换到同构渲染
        </button>
        <button
          onClick={() => switchMode('stream')}
          disabled={currentMode === 'stream'}
        >
          切换到流式渲染
        </button>
      </div>
    </div>
  )
}

const App = () => {
  // 客户端才能访问window对象
  const isBrowser = typeof window !== 'undefined'
  const renderMode = isBrowser ? window.__RENDER_MODE__ : 'sync'

  return (
    <div>
      <h1>React 同构/流式渲染 (SSR) 演示</h1>

      {/* 这部分内容始终立即显示 */}
      <div className="instant-content">
        <p>这是立即渲染的内容</p>
        <p>当前时间: {new Date().toLocaleTimeString()}</p>
      </div>

      {/* 在客户端渲染模式切换组件 */}
      {isBrowser && <RenderModeSwitch />}

      {/* 基于渲染模式决定是否使用Suspense */}
      {renderMode === 'stream' ? (
        <Suspense fallback={<p>正在加载延迟内容...</p>}>
          <DelayedComponent />
        </Suspense>
      ) : (
        <DelayedComponent />
      )}

      <button onClick={() => alert('你点击了按钮!')}>点击我</button>
    </div>
  )
}

export default App

11.4 切换渲染模式的方法

我们提供了多种方式来切换渲染模式:

  1. 通过 URL 参数切换

    • 访问 http://localhost:3000/?renderMode=sync 使用同构渲染
    • 访问 http://localhost:3000/?renderMode=stream 使用流式渲染
  2. 通过环境变量切换

    • 使用 npm run dev:sync 以同构模式启动开发服务器
    • 使用 npm run dev:stream 以流式模式启动开发服务器
    • 生产环境分别使用 npm run start:syncnpm run start:stream
  3. 通过配置文件切换

    • 修改 src/shared/config.js 中的默认渲染模式
  4. 通过界面按钮切换

    • 应用界面上提供了切换渲染模式的按钮

12. 同构渲染与流式渲染对比

流式渲染优势:

  • 更快的首字节时间(TTFB)
  • 渐进式加载和渲染内容
  • 支持并行数据获取
  • 可以提前释放服务器资源
  • 与 React Suspense 等新特性集成更好

同构渲染优势:

  • 技术方案成熟,生态完善
  • 实现相对简单直观
  • 调试和追踪更容易
  • 对旧浏览器支持更好
  • 缓存策略更简单

13. 流式渲染的问题

  1. 需要 React 18 及以上版本:流式 SSR 依赖 React 18 提供的新 API
  2. 客户端组件复杂度:需要正确处理 Suspense 和渐进式渲染
  3. 水合匹配挑战:流式内容的水合比传统 SSR 更复杂
  4. 调试困难:流式渲染的调试比传统渲染更复杂
  5. 不支持老旧浏览器:某些流式渲染特性在旧浏览器中不可用

14. 主流支持流式渲染的框架

  1. Next.js - App Router 提供了完整的流式渲染和 Server Components 支持
  2. Remix - 通过 defer 和嵌套路由提供流式数据加载
  3. Astro - 提供"岛屿架构"和部分水合策略
  4. Qwik - 专为流式和延迟加载设计的新兴框架

15. 项目扩展方向

  • 添加路由系统(React Router)实现同构路由
  • 集成状态管理(Redux/Context API)
  • 添加样式解决方案(CSS Modules/Styled Components)
  • 加入代码分割(Code Splitting)提升性能
  • 实现数据预取(Data Fetching)和状态同步
  • 添加 TypeScript 支持
  • 增强错误边界处理流式渲染异常
  • 实现更复杂的基于优先级的流式渲染策略

总结

通过以上步骤,我们成功搭建了一个同时支持传统同构渲染和现代流式渲染的 React SSR 项目。这个项目展示了两种渲染模式的实现方法,并提供了简便的切换机制。流式渲染虽然实现更复杂,但能提供更好的用户体验,特别是对于数据依赖复杂的应用。这个基础框架可以作为更复杂应用的起点,根据实际需求进行扩展。

16.项目选型

同构渲染的问题

同构渲染存在的主要问题:

  1. 代码复杂度高:需要编写同时适用于服务端和客户端的代码
  2. 性能开销大:服务器需要执行完整的 React 渲染流程
  3. 状态同步问题:服务端状态传递给客户端容易出现水合不匹配
  4. 等待数据阻塞:必须等待所有数据加载完成才能返回响应
  5. 双重构建配置:需要维护两套构建流程

同构渲染与流式渲染对比

流式渲染优势:
  • 更快的首字节时间(TTFB)
  • 渐进式加载和渲染内容
  • 支持并行数据获取
  • 可以提前释放服务器资源
  • 与 React Suspense 等新特性集成更好
同构渲染优势:
  • 技术方案成熟,生态完善
  • 实现相对简单直观
  • 调试和追踪更容易
  • 对旧浏览器支持更好
  • 缓存策略更简单

支持流式渲染的成熟框架

1. Next.js (14+)

最流行的 React 框架,提供全面的流式渲染支持:

  • App Router:基于文件系统的路由支持流式渲染
  • React Server Components 完整支持
  • Suspense 边界集成
  • 默认启用流式渲染
  • 自动优化图片、字体和脚本加载
// Next.js 中使用流式渲染
// app/page.tsx
import { Suspense } from 'react';
import Loading from './loading';
import SlowComponent from './slow-component';

export default function Page() {
  return (
    <div>
      <h1>即时显示的内容</h1>
      <Suspense fallback={<Loading />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}
2. Remix

由 React Router 团队创建的全栈框架:

  • 嵌套路由设计支持流式渲染
  • 资源路由系统
  • Defer API实现数据加载控制
  • Progressive Enhancement理念
  • 完整类型安全支持
// Remix 中使用流式渲染
export function loader() {
  return defer({
    // 优先加载内容
    critical: getCriticalData(),
    // 延迟加载内容
    comments: getComments()
  });
}

export default function Page() {
  const { critical, comments } = useLoaderData();
  
  return (
    <div>
      <h1>{critical.title}</h1>
      <Suspense fallback={<p>加载评论中...</p>}>
        <Await resolve={comments}>
          {(resolvedComments) => (
            <ul>
              {resolvedComments.map(comment => (
                <li key={comment.id}>{comment.text}</li>
              ))}
            </ul>
          )}
        </Await>
      </Suspense>
    </div>
  );
}
3. Astro

支持多框架的静态站点生成器,提供"岛屿架构":

  • 部分水合技术
  • 按需加载组件
  • 多框架支持(React, Vue, Svelte等)
  • 默认零JS交付
  • 边缘渲染支持
---
// Astro文件
import SlowReactComponent from '../components/SlowReactComponent.jsx';
---

<html>
  <head><title>Astro流式渲染</title></head>
  <body>
    <h1>立即加载的内容</h1>
    
    <!-- 延迟加载React组件 -->
    <SlowReactComponent client:visible />
  </body>
</html>
4. Qwik

专为流式渲染设计的框架:

  • 可恢复性设计
  • 零水合架构
  • 延迟加载一切
  • 细粒度按需加载
  • 预测用户交互优化

选择合适的框架取决于项目需求、团队经验和性能目标。Next.js目前是流式渲染最成熟、生态最完善的选择,而Remix和Astro分别在数据处理和多框架支持方面具有优势。Qwik则提供了最激进的性能优化方案,但生态相对较新。