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. 同构渲染原理
- 服务端渲染:服务器通过
renderToString
方法将 React 组件渲染为 HTML 字符串,直接发送给浏览器 - 客户端水合(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 切换渲染模式的方法
我们提供了多种方式来切换渲染模式:
-
通过 URL 参数切换:
- 访问
http://localhost:3000/?renderMode=sync
使用同构渲染 - 访问
http://localhost:3000/?renderMode=stream
使用流式渲染
- 访问
-
通过环境变量切换:
- 使用
npm run dev:sync
以同构模式启动开发服务器 - 使用
npm run dev:stream
以流式模式启动开发服务器 - 生产环境分别使用
npm run start:sync
和npm run start:stream
- 使用
-
通过配置文件切换:
- 修改
src/shared/config.js
中的默认渲染模式
- 修改
-
通过界面按钮切换:
- 应用界面上提供了切换渲染模式的按钮
12. 同构渲染与流式渲染对比
流式渲染优势:
- 更快的首字节时间(TTFB)
- 渐进式加载和渲染内容
- 支持并行数据获取
- 可以提前释放服务器资源
- 与 React Suspense 等新特性集成更好
同构渲染优势:
- 技术方案成熟,生态完善
- 实现相对简单直观
- 调试和追踪更容易
- 对旧浏览器支持更好
- 缓存策略更简单
13. 流式渲染的问题
- 需要 React 18 及以上版本:流式 SSR 依赖 React 18 提供的新 API
- 客户端组件复杂度:需要正确处理 Suspense 和渐进式渲染
- 水合匹配挑战:流式内容的水合比传统 SSR 更复杂
- 调试困难:流式渲染的调试比传统渲染更复杂
- 不支持老旧浏览器:某些流式渲染特性在旧浏览器中不可用
14. 主流支持流式渲染的框架
- Next.js - App Router 提供了完整的流式渲染和 Server Components 支持
- Remix - 通过 defer 和嵌套路由提供流式数据加载
- Astro - 提供"岛屿架构"和部分水合策略
- Qwik - 专为流式和延迟加载设计的新兴框架
15. 项目扩展方向
- 添加路由系统(React Router)实现同构路由
- 集成状态管理(Redux/Context API)
- 添加样式解决方案(CSS Modules/Styled Components)
- 加入代码分割(Code Splitting)提升性能
- 实现数据预取(Data Fetching)和状态同步
- 添加 TypeScript 支持
- 增强错误边界处理流式渲染异常
- 实现更复杂的基于优先级的流式渲染策略
总结
通过以上步骤,我们成功搭建了一个同时支持传统同构渲染和现代流式渲染的 React SSR 项目。这个项目展示了两种渲染模式的实现方法,并提供了简便的切换机制。流式渲染虽然实现更复杂,但能提供更好的用户体验,特别是对于数据依赖复杂的应用。这个基础框架可以作为更复杂应用的起点,根据实际需求进行扩展。
16.项目选型
同构渲染的问题
同构渲染存在的主要问题:
- 代码复杂度高:需要编写同时适用于服务端和客户端的代码
- 性能开销大:服务器需要执行完整的 React 渲染流程
- 状态同步问题:服务端状态传递给客户端容易出现水合不匹配
- 等待数据阻塞:必须等待所有数据加载完成才能返回响应
- 双重构建配置:需要维护两套构建流程
同构渲染与流式渲染对比
流式渲染优势:
- 更快的首字节时间(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则提供了最激进的性能优化方案,但生态相对较新。