React-SSR
通过一个小项目把React SSR聊明白。
1、SSR
ssr(服务端渲染),在讲解这个概念之前,我们首先了解一下什么是csr(客户端渲染)。
- CSR
在这种渲染模式下,服务端只返回JSON
数据,data和html的渲染在客户端进行。
- SSR
在这种渲染模式下,服务端返回完整的Html
,html和data在服务端渲染完成,返回给客户端。
1.1、csr存在的问题
- 首屏等待时间长,用户体验差。
- 页面结构为空,不适合SEO。
1.2、ssr解决了什么问题
通过ssr,浏览器一开始拿到的就是准备好的html数据,首屏十分友好。
1.3、ssr同构
同构指代码复用,即实现客户端和服务端最大程度的代码复用。
2、项目结构初始化
-- react-ssr
-- src
-- client (客户端代码)
-- server(服务端代码)
-- share(同构代码)
- 安装依赖
{
"name": "react-ssr",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev:server-build": "webpack --config webpack.server.js --watch",
"dev:client-build": "webpack --config webpack.client.js --watch",
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.5.0",
"express": "^4.18.2",
"nodemon": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
"webpack-node-externals": "^3.0.0"
},
"devDependencies": {
"@babel/cli": "^7.22.15",
"@babel/core": "^7.22.15",
"@babel/preset-env": "^7.22.15",
"@babel/preset-react": "^7.22.15",
"babel-loader": "^9.1.3",
"webpack": "^5.88.2"
}
}
3、实现ReactSSR雏形
- 引入要渲染你的React组件。
- 通过renderToString方法将React组件转换成HTML字符串。
- 将结果HTML字符串响应到客户端。
3.1、server
- 新建
/src/server/http.js
,内容如下:
import express from 'express'
const app = express()
app.listen(3000, () => {
console.log('Server is listening on port 3000')
})
export default app
- 新建
/src/share/pages/Home.js
,内容如下:
import React from 'react'
function Home() {
return <div>Home Works</div>
}
export default Home
- 新建
/src/server/index.js
,内容如下:
import app from './http'
import Home from '../share/pages/Home'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
app.get('/', (req, res) => {
const html = ReactDOMServer.renderToString(<Home />)
res.send(`
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${html}</div>
</body>
</html>
`)
})
3.2、配置webpack打包规则
- 新建
webpack.server.js
,内容如下:
const path = require('path')
module.exports = {
mode: 'development',
target: 'node',
entry: './src/server/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'build'),
},
module: {
rules: [
{
test: /\.js?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
}
- 更改
package.json
,新增如下的script
:
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev:server-build": "webpack --config webpack.server.js --watch",
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
}
}
- 打包并运行
nr dev:server-build
nr dev:server-run
- 在浏览器打开:http://localhost:3000
看到组件的内容被成功渲染出来了。
3.3、为组件附加事件
- 更改Home组件的内容:
import React from 'react'
function Home() {
return (
<div
onClick={() => {
console.log('hello')
}}
>
Home1 Works
</div>
)
}
export default Home
重新打包并运行,发现组件的事件并没有生效。(服务端并不能提供js交互的能力)
因此我们需要,在客户端对组件进行二次渲染时候添加对应的事件
- 客户端二次渲染
hydrate
使用hydrate
方法对组件进行渲染,为组件元素附加事件。
hydrate
方法在实现渲染的时候,会复用原本已经存在的DOM节点
,减少重新生成节点以及删除原本DOM节点的开销
- 新建
webpack.client.js
(客户端运行在浏览器环境中)
const path = require('path')
module.exports = {
mode: 'development',
entry: './src/client/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [
{
test: /\.js?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
}
- 添加客户端打包命令:
"dev:client-build": "webpack --config webpack.client.js --watch",
- 配置静态资源文件夹
app.use(express.static('public'))
- 新建
/src/client/index.js
import React from 'react'
import ReactDom from 'react-dom/client'
import Home from '../share/pages/Home'
ReactDom.hydrateRoot(document.getElementById('root'), <Home />)
- 在服务端返回的html中引用客户端打包的代码:
app.get('/', (req, res) => {
const content = renderToString(<Home />)
res.send(`
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`)
})
整个流程如下:
这样就完成了为服务端的组件附加事件。
4、优化
4.1、合并配置文件
借助webpack-merge
这个工具来进行合并配置文件的操作。
将公共的配置选项抽出来放在webpack.base.js
这个文件中。
- 新建
webpack.base.js
module.exports = {
mode: 'development',
module: {
rules: [
{
test: /\.js?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
}
- 修改
webpack.server.js
const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const config = {
entry: './src/client/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'public'),
},
}
module.exports = merge(baseConfig, config)
服务端打包配置也差不多。
4.2、服务器端打包文件体积优化
服务端在打包的时候将node
中内置的很多模块进行了打包,但是由于我们代码本身就是运行在node环境中的,因此这些模块都无需打包。
通过webpack-node-externals
这个工具来优化打包文件的体积。
const nodeExternals = require('webpack-node-externals')
const config = {
externals: [nodeExternals()],
}
4.3、代码拆分
将组件渲染的代码拆分到一个单独的文件中。
- 新建
/src/server/renderer.js
,内容如下:
import Home from '../share/pages/Home'
import React from 'react'
import { renderToString } from 'react-dom/server'
export default () => {
const html = renderToString(<Home />)
return '<!DOCTYPE html>' + html
}
- 修改
index.js
代码如下:
import app from './http'
import renderer from './renderer'
app.get('/', (req, res) => {
res.send(renderer())
})
5、路由实现
我们需要实现两端的路由:
- 客户端路由是指用户通过点击链接来跳转页面。
- 服务端路由是指用户通过键入链接访问页面。
- 客户端和服务器端公用一套路由规则。
5.1、服务端路由
配置文档参见:https://reactrouter.com/en/main/guides/ssr#without-a-data-router
- 新建一个新的组件的
/src/share/pages/About.js
:
import React from 'react'
function About() {
return <div> About Works</div>
}
export default About
-
新建`/src/share/App.js`,用来配置我们的路由规则:
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'
export default function App() {
return (
<>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</>
)
}
- 摈弃掉上面的做法,我们通过更灵活的配置文件的方式进行,新建
/share/routes/index.js
:
import React from 'react'
import Home from '../pages/Home'
import About from '../pages/About'
const router = [
{
path: '/',
element: <Home />,
},
{
path: '/about',
element: <About />,
loadData: About.getInitProps,
},
]
export default router
- 配置静态路由:
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Route, Routes } from 'react-router-dom'
import { StaticRouter } from 'react-router-dom/server'
import { Helmet } from 'react-helmet'
import router from '../share/routes'
export default (req) => {
const helmet = Helmet.renderStatic()
let html = ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />
})}
</Routes>
</StaticRouter>,
)
return `
<html
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
}
这样一个简单的服务端路由就完成了。
react-router v5
版本需要借助react-router-config
来完成服务端路由的配置,但是在v6
版本中不需要了(会报错)。
5.2、客户端路由
和服务端路由一样,但是在这里我们通过BroswerRouter
来配置客户端路由:
- 修改
/src/client/index.js
:
import React from 'react'
import ReactDom from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
ReactDom.hydrateRoot(
document.getElementById('root'),
<BrowserRouter>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />
})}
</Routes>
</BrowserRouter>,
)
- 在
Home
组件中添加一个跳转链接:注意这里要使用客户端链接组件Link
import React from 'react'
import { Link } from 'react-router-dom'
function Home() {
return (
<div>
Home Works
<hr />
<Link to={'/about'}>to about</Link>
</div>
)
}
export default Home
5.2、header标签的修改
我们现在跳转路由的时候修改的都是body
的部分,不过这个还不能满足我们所有静态页面的需求,因为模板页面只是影响到 body 的部分,修改不同路由下对应的标题,多媒体适配或是 SEO 时加入的相关 meta 关键字,都需要加入相关的 header。
- 安装
react-helmet
:
ni react-helmet
- 我们可定制化任何页面的header,修改
/share/pages/Home
,内容如下:
import React, { Fragment } from 'react'
import { Helmet } from 'react-helmet'
function About() {
return (
<Fragment>
<Helmet>
<title>简易的服务器端渲染 - ABOUT</title>
<meta name="description" content="服务器端渲染"></meta>
</Helmet>
<div>
<h1>About</h1>
</div>
</Fragment>
)
}
export default About
由于服务端和客户端的不一致性导致客户端会hydrate
失败,因此我们需要对服务端的代码进行变更:
- 修改
/server/renderer.js
,内容如下:
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { Route, Routes } from 'react-router-dom'
import { StaticRouter } from 'react-router-dom/server'
import { Helmet } from 'react-helmet'
export default (req) => {
const helmet = Helmet.renderStatic()
let html = ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />
})}
</Routes>
</StaticRouter>,
)
return `
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
}
添加对应的head
标签并且添加helmet
相关的内容。
6、请求数据
使用常规的hook
试一下:
- 安装
axios
ni axios
- 在
About
页面中添加下面内容
import axios from 'axios'
function About() {
const [content, setContent] = useState('')
useEffect(() => {
axios.post('/api/getDemoData', { name: 'zhangsan' }).then((res) => {
setContent(res.data.data.name)
})
}, [])
return (
<Fragment>
...
<div>
...
<p>{content}</p>
</div>
</Fragment>
)
}
export default About
- 访问about对应的路由:
请求的内容被渲染出来了,但是可以在network中看到对应的请求,数据也没有在服务端请求的时候塞入html,这意味着这部分内容的渲染走的是客户端请求,而不是服务端请求,这个和我们的预期不一致。
我们来回忆之前静态页面的思路,是在服务器端拼凑好 HTML 并返回,所以请求的话,咱们应该也是获取到每个模板页面初始化的请求,并在服务器端请求好,进行 HTML 拼凑,在这之前我们需要建立一个全局的 store,使得服务端请求的数据可以提供到模板页面来进行操作。确认好思路,咱们就根据这个思路先来解决试试。
6.1、建立全局store
- 安装redux
ni -D @reduxjs/toolkit redux-thunk react-redux
其中 @reduxjs/toolkit 是 redux 最新提供的工具包,可以用于状态的统一管理,提供了更多 hook 的能力,相对代码更为简易,至于 redux-thunk 是一个 redux 的中间件,提供了 dispatch 和 getState 与异步方法交互的能力。
- redux基础架构如下:
- 创建
/share/store/userReducer.js
,内容如下:
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
const getDemoData = createAsyncThunk(
"demo/getData",
async (initData: string) => {
const res = await axios.post("http://127.0.0.1:3000/api/getDemoData", {
content: initData,
});
return res.data?.data?.content;
}
);
const demoReducer = createSlice({
name: "demo",
initialState: {
content: "默认数据",
},
// 同步reducer
reducers: {},
// 异步reducer
extraReducers(build) {
build
.addCase(getDemoData.pending, (state, action) => {
state.content = "pending";
})
.addCase(getDemoData.fulfilled, (state, action) => {
state.content = action.payload;
})
.addCase(getDemoData.rejected, (state, action) => {
state.content = "rejected";
});
},
});
export { demoReducer, getDemoData };
- reducers:可以存放同步的 reducers(不需要请求参数);
- initialState:可以理解成原来的 state;
- name: 是这个 reducer 的空间,后面取 store 的时候会根据这个进行区分;
- extraReducers:这个是我们这里需要的异步 reducer,其中包含三个状态,pending、fulfilled 和 rejected,分别对应到请求的三种状态。
- 创建
/share/store/index.js
对所有的reducer
进行统一的导入和导出。
import { demoReducer } from './demoReducer'
export { demoReducer }
接下来分别创建服务端和客户端的store
,将 reducer 导入一下,并且接入一下 thunk 的中间件,使得 dispatch 相关的函数支持异步函数的入参:
import { demoReducer } from './demoReducer'
import { configureStore } from '@reduxjs/toolkit'
import thunk from 'redux-thunk'
const clientStore = configureStore({
reducer: {
demo: demoReducer.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
})
const serverStore = configureStore({
reducer: {
demo: demoReducer.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(thunk),
})
export { clientStore, serverStore }
- 分别在服务端和客户端路由处进行注入:
/client/index.js
:
import React from 'react'
import ReactDom from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from '../share/App'
import { Provider } from 'react-redux'
import { clientStore } from '../share/store'
ReactDom.hydrateRoot(
document.getElementById('root'),
<Provider store={clientStore}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
)
/server/renderer.js
:
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import App from '../share/App'
import { Helmet } from 'react-helmet'
import { Provider } from 'react-redux'
import { serverStore } from '../share/store'
export default (req) => {
const helmet = Helmet.renderStatic()
let html = ReactDOMServer.renderToString(
<Provider store={serverStore}>
<StaticRouter location={req.url}>
<App />
</StaticRouter>
</Provider>,
)
return `
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`
}
到这里我们的store就注入好了,我们只需要将我们的组件和store进行连接。
connect暴露了两个参数,一个 state,一个 dispatch,它会根据你的需要拼接成指定的参数,以装饰器的形式包装你定义的函数
import React, { Fragment, useState, useEffect } from 'react'
import { Helmet } from 'react-helmet'
import axios from 'axios'
import { getDemoData } from '../store/demoReducer'
import { connect } from 'react-redux'
function About(data) {
// const [content, setContent] = useState('')
// useEffect(() => {
// axios.post('/api/getDemoData', { name: 'zhangsan' }).then((res) => {
// setContent(res.data.data.name)
// })
// }, [])
return (
<Fragment>
<Helmet>
<title>简易的服务器端渲染 - ABOUT</title>
<meta name="description" content="服务器端渲染"></meta>
</Helmet>
<div>
<h1>About</h1>
<button
onClick={() => {
data.getDemoData && data.getDemoData('刷新过后的数据')
}}
>
刷新
</button>
<p>{data.content}</p>
</div>
</Fragment>
)
}
const mapStateToProps = (state) => {
// 将对应reducer的内容透传回dom
return {
content: state?.demo?.content,
}
}
const mapDispatchToProps = (dispatch) => {
return {
getDemoData: (data) => {
dispatch(getDemoData(data))
},
}
}
const storeAbout = connect(mapStateToProps, mapDispatchToProps)(About)
export default storeAbout
可以看到新增了对应的请求,对应展示的内容也切换为了刷新过后的数据,那这就意味着咱们 store的部分已经走通了,接下来咱们只需要考虑,应该怎样在服务器端进行请求,使得在 html 拼接的时候就可以拿到初始化的数据呢?
6.2、建立服务器请求数据体系
首先我们肯定得先在服务器端拿到所有需要请求的函数,怎么透传过去呢?我们应该可以使用路由,因为客户端和服务端咱们都有配置路由,如果加一个参数通过路由把参数透传,然后在服务器端遍历,最后把结果对应分发是不是就可以了。
这里有个小细节大家要注意一下,服务器端不同于客户端,它是拿不到请求的域名的,所以服务器端下的axios请求应该是包含域名的绝对路径,而不是使用相对路径
- 给我们要进行数据初始化的页面挂载一个函数,用来出发请求
store
中的数据:About.js
...
const storeAbout = connect(mapStateToProps, mapDispatchToProps)(About)
storeAbout.getInitProps = (store, data) => {
return store.dispatch(getDemoData(data || '这是初始化的demo'))
}
export default storeAbout
- 将这个函数添加到对应的路由中去:
/share/routes/index.js
:
import React from 'react'
import Home from '../pages/Home'
import About from '../pages/About'
const router = [
{
path: '/',
element: <Home />,
},
{
path: '/about',
element: <About />,
//添加下面的方法 对应页面数据的初始化方法
loadData: About.getInitProps,
},
]
export default router
- 最后就是在渲染的时候拿到数据并且拼接成最终的
html
文件:
...
//匹配当前路由
const matchedRoutes = matchRoutes(router, req.path)
const promises = []
matchedRoutes?.forEach((item) => {
//如果当前路由下有loadData方法,就执行
if (routeMap.has(item.pathname)) {
promises.push(routeMap.get(item.pathname))
}
})
Promise.all(promises).then((data) => {
const helmet = Helmet.renderStatic()
let html = renderToString(
<Provider store={serverStore}>
<StaticRouter location={req.path}>
<Routes>
{router?.map((item, index) => {
return <Route {...item} key={index} />
})}
</Routes>
</StaticRouter>
</Provider>
)
res.send(`
<html
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
</head>
<body>
<div id="root">${html}</div>
<script>
window.context = {
state: ${JSON.stringify(serverStore.getState())}
}
</script>
<script src="/bundle.js"></script>
</body>
</html>
`)
})
...
7、总结
🤔至此,我们使用react
实现ssr
的整体流程大体上都走通了,我们在优化web用户的体验方面又多了一种强有力的手段。