为了学习一下 React 实现 SSR 的整体过程,并了解过程中会遇到哪些坑,所以着手写了这篇文件,记录一下。
note:项目中依赖还是比较多的,感兴趣的小伙伴可以去我的 github 仓库查看具体代码。
项目结构
├── build # 打包目录
├── src
│ ├── client # 客户端代码
│ ├── server # 服务端代码
│ └── share # 同构代码
├── babel.config.js
├── package.json
├── webpack.base.js # 公共配置
├── webpack.client.js # 客户端配置
└── webpack.server.js # 服务端配置
使用 Express 搭建简单的服务
首先我们用 Express 来搭建一个服务,后续用来渲染 React 组件。
// server/index.js
import app from './http'
app.get('/', (req, res) => {
res.send('hello world!')
})
// server/http.js
import express from 'express'
const app = express()
app.listen(3000, () => {
console.log('app is listening on 3000 port')
})
export default app
因为代码要运行在 Node 环境,所以会报这样的错 SyntaxError: Cannot use import statement outside a module
,所以我们需要使用 webpack
和 babel
对代码进行打包和编辑,其实这也是为了之后服务端渲染 React 组件做准备:
// babel.config.js
module.exports = {
presets: ['@babel/preset-env'],
}
// webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
// 这里还有一个小技巧,这样写可以在 js 文件中让编辑器提供代码提示
/** @type import('webpack').Configuration */
module.exports = {
mode: 'development',
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js',
},
resolve: {
extensions: ['.js', '.jsx'],
},
// 不去打包 node 原生模块,因为我们会在 node 环境中运行,本身就有这些模块
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
}
并在 package.json
中添加如下命令:
"scripts": {
"dev": "npm run dev:server-build",
"dev:server-build": "webpack -c webpack.server.js --watch",
"start": "nodemon build/bundle.js"
},
现在分别执行 npm dev
和 npm start
,服务便运行起来了,浏览器访问 http://localhost:3000/ 也是可以看到 hello world!
的。
服务端渲染
我们先去编写一个 React 组件,由于这个组件浏览器也要渲染它,所以要把它写到 share
中:
// share/pages/index.js
import React from 'react'
export default function Index() {
return <div>Home Page</div>
}
接下来修改服务端入口文件。服务端渲染的原理就是服务器将 React 组件转换为字符串,然后拼接到 HTML 中,React 中为我们提供了 renderToString()
去处理这件事。
// server/index.js
import app from './http'
import renderer from './renderer'
app.get('/', (req, res) => {
res.send(renderer())
})
// server/renderer.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import Home from '../share/pages'
export default function renderer() {
const content = renderToString(<Home />)
return `
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
}
这时刷新页面就可以看到 Home Page
了。
<div onClick={() => console.log('Home Page')}>Home Page</div>
但当为组件绑定点击事件后,发现事件没有被触发,这是怎么回事呢。因为 renderToString()
并不会帮我们绑定事件,我们需要在客户端进行二次渲染。
客户端渲染
往常我们都是用 render()
去渲染组件,但它并不会保留服务端返回的标签。React 中还提供了 hydrate()
,这个方法可以复用标签,所以我就用它来解决这个问题。
首先我们创建一个 client 的入口文件:
// client/index.jsx
import React from 'react'
import { hydrate } from 'react-dom'
import Home from '../share/pages'
// 渲染到服务端返回的 id 为 root 的标签中
hydrate(<Home />, document.getElementById('root'))
当然了,客户端也需要 webpack 帮我们去打包一下。
// webpack.client.js
const path = require('path')
/** @type import('webpack').Configuration */
module.exports = {
mode: 'development',
entry: './src/client/index.jsx',
output: {
path: path.join(__dirname, 'public'),
filename: 'bundle.js',
},
resolve: {
extensions: ['.js', '.jsx'],
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: 'babel-loader',
},
],
},
}
这里将 bundle.js
打包到了 public
中,服务端返回的 HTML 中应该引用这个文件,Express 中还要配置一下静态资源的目录。
// server/http.js
// ...其它代码
app.use(express.static('public'))
// server/index.js
// ...其它代码
function renderer(content) {
return `
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
}
现在刷新页面,点击 Home page
控制台可以正常输出了,证明事件绑定没问题。
路由
接下来添加一个 list.jsx
,实现服务端和客户端的路由功能。
// share/pages/list.jsx
import React from 'react'
export default function List() {
return <div>List Page</div>
}
服务端和客户端要公用一套规则,所以也把它存放到 share
文件夹中,并且采用数组对象的形式,方便分别渲染成两端不同的格式。
// share/routes.js
import Home from './pages'
import List from './pages/list'
export default [
{
path: '/',
component: Home,
exact: true,
},
{
path: '/list',
component: List,
exact: true,
},
]
服务端路由
服务端需要用到 StaticRouter
, 匹配到哪个路径就去渲染哪个组件,不会做跳转。使用 renderRoutes
渲染路由数组对象。并且需要把 Express 匹配的路由规则改为 *
,让他去监听所有的请求。
// server/index.js
// ...其它代码
app.get('*', (req, res) => {
res.send(renderer(req))
})
// server/renderer.js
// ...其它代码
import { StaticRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../share/routes'
export default function renderer(req) {
const content = renderToString(
<StaticRouter location={req.path}>{renderRoutes(routes)}</StaticRouter>
)
// ...其它代码
}
这时我们可以分别访问 http://localhost:3000/ 和 http://localhost:3000/list 查看结果。注意,需要禁用浏览器的 javascript ,不然之前做好的客户端渲染会覆盖服务端传过来的内容。
客户端路由
客户端所要做的工作和服务端差别不大,只不过要用 BrowserRouter
。
// client/index.jsx
// ...其它代码
import { BrowserRouter } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import routes from '../share/routes'
hydrate(
<BrowserRouter>{renderRoutes(routes)}</BrowserRouter>,
document.getElementById('root')
)
然后在 Home
组件中加入导航。
// share/pages/index.jsx
// ...其它代码
import { Link } from 'react-router-dom'
// ...其它代码
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/list">List</Link>
</li>
</ul>
这样就可以测试客户端的路由跳转了。之前禁用了 javascript 的小伙伴不要忘记改为允许。(我就忘了,找了半天,哈哈哈)
数据请求
每个页面都应该有自己独有的服务端请求方法,所以约定导出一个 getServerSideProps()
,并挂载到 routes
数组对象上。
// share/pages/index.jsx
export default function Index({ title }) {
return (
<div onClick={() => console.log('Home Page')}>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/list">List</Link>
</li>
</ul>
{title}
</div>
)
}
export async function getServerSideProps() {
return {
props: {
title: 'Home Page',
},
}
}
// share/pages/list.jsx
export default function List({ title }) {
return <div>{title}</div>
}
export async function getServerSideProps() {
return {
props: {
title: 'List Page',
},
}
}
// share/routes.js
import * as Home from './pages'
import * as List from './pages/list'
export default [
{
path: '/',
exact: true,
component: Home.default,
...Home,
},
{
path: '/list',
exact: true,
component: List.default,
...List,
},
]
然后服务端匹配当前路由对应的组件,使用 matchRoutes()
,执行组件上的 getServerSideProps()
获取 Props
,传递给组件,并挂载到全局的 INITIAL_PROPS
属性上,方便客户端获取。
// server/index.js
// ...其它代码
import { matchRoutes } from 'react-router-config'
app.get('*', async (req, res) => {
const promise = () => Promise.resolve({ props: {} })
// 这里考虑路由组件只会匹配到一个,所以直接获取了数组中的第一项
const getServerSideProps =
matchRoutes(routes, req.path)[0]?.route?.getServerSideProps || promise
const { props } = await getServerSideProps()
res.send(renderer(req, props))
})
// server/renderer.js
// ...其它代码
export default function renderer(req, props) {
const INITIAL_PROPS = JSON.stringify(props)
const content = renderToString(
<StaticRouter location={req.path}>
{renderRoutes(routes, props)}
</StaticRouter>
)
return `
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script>var INITIAL_PROPS = ${INITIAL_PROPS}</script>
<script src="/bundle.js"></script>
</body>
</html>
`
}
而客户端我目前的解决办法也是比较暴力,直接每个组件都都传递了 INITIAL_PROPS
,这样做的问题是客户端切换路由后,别的组件也可以拿到 INITIAL_PROPS
。
因为不想使用 Redux
,所以陷入了这样的困局,如果有好的想法的小伙伴,欢迎来讨论讨论。
总结
这样一个简单的 React ssr 就搭建好了,更加详细的代码可以去我的 github 仓库查看。