React 服务器端渲染概念回顾
什么是客户端渲染CSR(Client Side Rendering)
服务器端只返回json数据,Data和Html的拼接在客户端进行(渲染)。
什么是服务器端渲染SSR(Server Side Rendering)
服务器端返回数据拼接过后的HTML,Data和Html的拼接在服务器端进行(渲染)。
使用react实现的应用都是单页应用(Singe Page Application),都属于是客户端渲染。
为什么要实现服务器端渲染,SPA的服务器端渲染旨在解决哪些问题呢。
客户端首页渲染时间长,大部分时间处于页面外链的加载等待状态,用户体验差。
页面结构是空的,不利于网站的SEO
SSR同构,同构指的是代码复用,即实现客户端和服务器端最大程度的的代码复用。
原生实现
项目结构
react-ssr
- src 源代码文件夹
- client 客户端代码
- server 服务器端代码
- share 同构代码
三步快速实现React SSR
引入要渲染的React组件
通过renderToString方法将React 组件转换为HTML 字符串
renderToString方法用于将React组件转换成HTML字符串,可通过react-dom/server 导入
将结果HTML字符串响应到客户端
首先我们需要使用express快速搭建一个server服务器
// http.js
import express from 'express'
const app = express()
app.listen(3000, () => console.log('app is running on port 3000'))
export default app
将同构代码,主要为页面组件代码,放到share 文件夹下
webpack打包配置
这时候我们就像通过node来运行我们的express服务器是会报错的:
$ node src/server/index.js
(node:8130) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/Users/huiquandeng/projects/lg-fed-lp/my-module/react-ssr/src/server/index.js:1
import Home from '../share/pages/Home'
^^^^^^
SyntaxError: Cannot use import statement outside a module
问题:nodejs环境不支持ESModule模块系统,不支持JSX语法。
所以需要webpack工具使用babel插件对其进行转换。
const path = require('path')
module.exports = {
mode: 'development',
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react']
}
}
}
]
}
}
在 package.json中配置运行命令:
"scripts": {
"dev:server-build": "webpack --config webpack.server.js"
}
此时即可运行打包命令生成bundle.js 文件到build 目录下,
这时使用node命令允许该服务器就不会报错了。
$ node build/bundle.js
app is running on port 3000
尝试访问应用页面,发现报错React模块未定义。
$ node build/bundle.js
app is running on port 3000
ReferenceError: React is not defined
at eval (webpack://react-ssr/./src/server/index.js?:9:96)
回到server 的index.js 文件中,我们引入React即可。
此时服务器已经可以正常渲染React 组件中的内容。
改进: 这时候我们没改动一次代码要想页面内容生效,都需重新运行一下打包命令,这个操作可以交给我们的程序自主监测运行,减少我们的重复的手工操作,提升开发效率。
配置项目启动命令
配置服务器端webpack打包命令:
"dev:server-build": "webpack --config webapck.server.js --watch"
打包命令加入--watch 参数,可以让webpack监控文件的更改重新执行build打包命令生成build目录下多的文件。
配置服务端启动命令:
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
该命令使用了nodemon进行node程序的监控,通过--watch 监控指定文件夹中文件的变化,当有变化时重新运行服务器启动命令:node build/bundle.js。
此时开两个终端分别运行以上两个命令即可实现文件变化的监控。
为组件元素附加事件的方式
思路: 在客户端对组件进行二次渲染,为组件元素附加事件。
客户端二次渲染的方法:hydrate
使用hydrate 方法对组件进行渲染,为组件元素附加事件。
该方法在实现渲染的时候,会复用原本已经存在的DOM节点,减少重新生成节点以及删除原本DOM节点的开销。
可以通过react-dom模块引入。
import ReactDOM from 'react-dom'
ReactDOM.hydrate(<App />, document.querySelector('#root'))
在src/client 文件夹中新建index.js文件
import React from 'react'
import ReactDOM from 'react-dom'
import Home from '../share/pages/Home'
ReactDOM.hydrate(<Home />, document.querySelector('#root'))
客户端React打包配置
webpack打包配置 webpack.client.js
打包目的:转换JSX语法,转换浏览器不识别的高级JavaScript 语法
输出目标位置:public 目录
const path = require('path')
module.exports = {
mode: 'development',
// target: 'es5',
entry: './src/client/index.js',
output: {
path: path.join(__dirname, 'public'),
filename: 'main.js'
},
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"
添加客户端包文件请求链接
在响应客户端的HTML代码中添加script 标签,请求客户端JavaScript打包文件main.js
`<html>
<head>
<title>React SSR Base</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='main.js'></script>
</body>
</html>`
在server端的服务器中设置静态资源文件目录
在对网页进行访问之前还需要做一步工作,就是为服务器设置静态资源文件目录。
import express from 'express'
const app = express()
// 设置静态资源请求目录
app.use(express.static('public'))
app.listen(3000, () => console.log('app is running on port 3000'))
export default app
此时就可以为组件元素添加事件了:
import React from 'react'
export default function Home () {
return (
<div onClick={() => console.log('Hello onClick trigger')}>
Home works run with nodemon cli
</div>
)
}
此时就已经可以成功为组件附加上点击事件:
注意在V18版本的的react中hydrate 方法不再使用,改用hydrateRoot代替:
// Before
import { hydrate } from 'react-dom';
const container = document.getElementById('app');
hydrate(<App tab="home" />, container);
// After
import { hydrateRoot } from 'react-dom/client';
const container = document.getElementById('app');
const root = hydrateRoot(container, <App tab="home" />);
// Unlike with createRoot, you don't need a separate root.render() call here.
优化:合并webpack配置
服务器端和客户端的webpack配置文件存在重复,可将重复的部分配置抽象到webpack.base.js 配置文件中。
接下来在利用webpack-merge 对文件差异部分以及重复的base部分进行合并。
// 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.client.js
const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const config = {
entry: './src/client/index.js',
output: {
path: path.join(__dirname, 'public'),
filename: 'main.js'
}
}
module.exports = merge(baseConfig, config)
// webpack.server.js
const path = require('path')
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const config = {
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js'
}
}
module.exports = merge(baseConfig, config)
优化:合并项目启动命令
三个命令启动项目显得比较繁琐,所以我们需要一个工具帮我们合并多个命令,运行一个命令即可把完整项目运行起来。这个工具就是: npm-run-all
$ npm i npm-run-all
配置命令:
"dev": "npm-run-all --parallel dev:*"
--parallel 参数:表示同时执行多个命令。
这样一来,我们的项目开发启动命令只需要运行dev命令即可:
$ npm run dev
优化:服务器端打包文件体积优化
开始前我们先来看一下目前打包出来的bundle.js文件的大小:
asset bundle.js 1.54 MiB [compared for emit]
问题:显然我们这么简单的一个应用,这个文件大小显得有点过大了。
究其原因是实际上是在打包是把node代码环境中的一些系统模块打包到了我们的应用当中。
所以接下来我们就要对这部分的代码进行打包优化,通过webpack的配置把这些不必要的系统代码剔除,避免它们被打包盗目标文件中。
解决方法:需要用到 webpack-node-externals
const path = require('path')
const { merge } = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base')
const config = {
target: 'node',
entry: './src/server/index.js',
output: {
path: path.join(__dirname, 'build'),
filename: 'bundle.js'
},
externals: [nodeExternals()]
}
module.exports = merge(baseConfig, config)
重新运行打包:
asset bundle.js 7.96 KiB [emitted]
优化过后的体积只有8kb左右,非常的nice。
优化:代码拆分
在一个项目中,同构代码也需要实现前后端代码分离,所以我们需要将启动服务器代码和渲染代码进行模块化拆分。通过优化代码组织方式,渲染React组件代码是独立功能,所以把它从服务器端入口文件进行抽离。
把原本server/index.js中的HTML字符串渲染的内容抽离到server/render.js:
import React from 'react'
import Home from '../share/pages/Home'
import { renderToString } from 'react-dom/server'
export default (Component = Home) => {
const content = renderToString(<Component />)
return `<html>
<head>
<title>React SSR Base</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='main.js'></script>
</body>
</html>`
}
拆分抽离后的index.js:
import app from './http'
import render from './render'
app.get('/', (req, res) => {
// 未来会抽取出来作为入口html模版文件
res.send(render())
})
实现服务器端路由
实现路由支持分析
在React SSR项目中需要实现两端路由
客户端路由是用于支持用户通过点击链接的形式跳转页面。
服务器端路由是用于支持用户直接从浏览器地址栏中访问地址。
客户端和服务器端共用一套路由规则。
也就是说路由规则属于share公共代码
在src/share/pages目录下新建页面组件home 和 list:
import React from 'react'
import routes from '../routes'
export default function List () {
return (
<div>
<ul>
{routes.map(route => {
return (
<li key={route.path}>
<a href={route.path}>{route.component.name}</a>
</li>
)
})}
</ul>
</div>
)
}
接着在src/share目录下新建路由规则routes.js文件,其内部采用数组对象的形式。使得规则较为通用,可在node环境中直接使用。
import Home from './pages/Home'
import List from './pages/List'
export default [
{
path: '/',
component: Home,
exact: true
},
{
path: '/list',
component: List
}
]
这一步需要用到两个支持库: react-router-dom 和 react-router-config
$ npm i react-router-dom react-router-config
给render.js中的渲染函数添加路由支持代码:
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'
// renderRoutes方法旨在把数组形式的路由规则转换为组件形式的路由规则
import { renderRoutes } from 'react-router-config'
import routes from '../share/routes'
export default req => {
const content = renderToString(
// 根据请求req的path 匹配对应的路由获取页面组件渲染成字符串
<StaticRouter location={req.path}>{renderRoutes(routes)}</StaticRouter>
)
return `<html>
<head>
<title>React SSR Base</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='main.js'></script>
</body>
</html>`
}
同时需要修改服务器端的路由处理匹配规则为 * ,这样就可以接收到任何的客户端路由请求:
import app from './http'
import render from './render'
app.get('*', (req, res) => {
res.send(render(req)) // 把req传递给渲染函数render,以便可以进行路由支持
})
这里有个坑要留意:react-router的 v5 -> v6 有个升级指南说到react-router-config在v6中被合并并移除了,v6中使用useRoutes方法代替
这样以来,useRoutes是个钩子函数,我们得为页面路由组件列表创建一个入口组件App.js:
import React from 'react'
import { useRoutes } from 'react-router'
import routes from '../routes'
export default function App () {
const element = useRoutes(routes)
return element
}
则服务端渲染代码对应修改为:
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
// renderRoutes方法旨在把数组形式的路由规则转换为组件形式的路由规则
// import { useRoutes } from 'react-router'
// import routes from '../share/routes'
import App from '../share/pages/App'
export default req => {
// StaticRouter根据请求req的 url 匹配对应的路由获取页面组件渲染成字符串
const content = renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
)
return `<html>
<head>
<title>React SSR Base</title>
</head>
<body>
<div id='root'>${content}</div>
<script src='main.js'></script>
</body>
</html>`
}
实现客户端路由
客户端hydrate渲染的代码修改为:
import React from 'react'
// import ReactDOM from 'react-dom'
import { hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
// import { useRoutes } from 'react-router'
// import routes from '../share/routes'
import App from '../share/pages/App'
const container = document.querySelector('#root')
// ReactDOM.hydrate(<Home />, container)
const root = hydrateRoot(
container,
<BrowserRouter>
<App />
</BrowserRouter>
)
Home页面添加Link组件实现客户端页面路由跳转:
import React from 'react'
import { Link } from 'react-router-dom'
export default function Home () {
return (
<>
<div onClick={() => console.log('Hello onClick trigger')}>
Home works run with nodemon cli{' '}
</div>
<Link to='/list'>navigate to list page</Link>
</>
)
}
实现客户端Redux
思路分析:
SSR同样需要实现两端的Redux,
客户端Redux就是通过JavaScript管理store 中的数据。
服务器端Redux 就是在服务器端搭建一套Redux 代码,用于管理组件中的数据。
客户端和服务器端共用一套Reducer 代码。
创建Store的代码由于参数传递不同,所以不可以共用。
首先,创建客户端的store
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './features/todos/todosSlice'
import usersReducer from './features/users/usersSlice'
export const store = configureStore({
reducer: {
todos: todosReducer,
users: usersReducer
}
})
创建状态数据切片并导出相对应的actions 和 reducers
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import axios from 'axios'
// await axios.get('https://jsonplaceholder.typicode.com/users');
export const loadUsers = createAsyncThunk(
'users/loads',
// (payload, thunkAPI) => {
// axios
// .get(payload)
// .then(response => thunkAPI.dispatch(setUsers(response.data)))
// }
payload => {
return axios.get(payload).then(response => response.data)
}
)
const usersSlice = createSlice({
name: 'users',
initialState: [],
reducers: {
userAdded (state, action) {
state.push({
id: action.payload.id,
text: action.payload.text,
completed: false
})
},
addUser: {
prepare: user => {
return { payload: { ...user, id: Math.ceil(Math.random() * 100) } }
},
reducer: (state, action) => {
state.push(action.payload)
}
},
userToggled (state, action) {
const user = state.find(user => user.id === action.payload)
user.completed = !user.completed
},
setUsers (state, action) {
action.payload.forEach(user => state.push(user))
}
},
extraReducers: {
[loadUsers.fulfilled]: (state, action) => {
action.payload.forEach(user => state.push(user))
}
}
})
export const { userAdded, userToggled, setUsers } = usersSlice.actions
export default usersSlice.reducer
在应用的最外层组件使用Provider封装 并提供上面创建的store 作为store 参数
import React from 'react'
// import ReactDOM from 'react-dom'
import { hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
// import { useRoutes } from 'react-router'
// import routes from '../share/routes'
import App from '../share/pages/App'
import { store } from '../store/Store.client'
import { Provider } from 'react-redux'
const container = document.querySelector('#root')
// ReactDOM.hydrate(<Home />, container)
const root = hydrateRoot(
container,
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
)
在使用store中数据的 List 页面 通过connect 连接并使用store
import React, { useEffect } from 'react'
import routes from '../routes'
import { connect } from 'react-redux'
import { loadUsers } from '../../store/features/users/usersSlice'
function List ({ users, dispatch }) {
useEffect(() => {
try {
dispatch(loadUsers('https://jsonplaceholder.typicode.com/users'))
// dispatch(setTodos(data))
} catch (error) {
console.log(error)
}
}, [])
return (
<div>
ListPage<a href='/8888'>To a not found page</a>
<ul>
{users.map(user => {
return <li key={user.id}>{user.name}</li>
})}
</ul>
</div>
)
}
const mapStateProps = state => {
return { users: state.users }
}
export default connect(mapStateProps)(List)
实现服务器端Redux
创建store
因为服务器端的 store 是在接收到请求之后才创建的,所以我们需要把store创建的代码封装在一个函数当中
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './features/todos/todosSlice'
import usersReducer from './features/users/usersSlice'
export const createStore = () => {
return configureStore({
reducer: {
todos: todosReducer,
users: usersReducer
}
})
}
export default createStore
配置store
import { createStore } from '../store/store.server'
import app from './http'
import render from './render'
app.get('*', async (req, res) => {
// 接收到请求后创建服务器端store
const store = createStore()
// 未来会抽取出来作为入口html模版文件
res.send(render(req, store)) // 把req传递给渲染函数render,以便可以进行路由支持
})
服务器端的数据填充
当客户端JavaScript被禁用的时候,前端的获取数据的就会失败,页面没有我们原本store中的数据。
问题:当前服务器端创建的Store是空的,组件并不能从Store中获取到任何数据。
解决:服务器端在渲染组件之前就获取到组件所需的数据。
在组件中添加loadData 方法,此方法用于获取组件所需的数据,方法被服务器端调用。
将loadData 方法保存在当前组件的路由信息对象中
服务器端接收到请求之后,根据请求地址匹配出要渲染的组件的路由信息
从路由信息中获取组件中的loadData 方法并调用方法获取组件所需的数据
当数据获取完成后再渲染组件并将结果响应给客户端
List页面组件初始化时需要对已经在服务器端渲染过的内容做判断,避免重复加载数据产生二次渲染报错。
import React, { useEffect } from 'react'
import routes from '../routes'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { loadUsers } from '../../store/features/users/usersSlice'
function List ({ users, dispatch }) {
useEffect(() => {
try {
// 这里做数据长度判断是为了避免服务端已经渲染过一遍的情况下页面组件初始化时再次渲染
users.length === 0 &&
dispatch(loadUsers('https://jsonplaceholder.typicode.com/users'))
// dispatch(setTodos(data))
} catch (error) {
console.log(error)
}
return () => {
console.log('Component unmounted')
// 卸载回调
window.INITIAL_STATE = undefined
}
}, [])
return (
<div>
ListPage<Link to='/8888'>To a not found page</Link>
<ul>
{users.map(user => {
return <li key={user.id}>{user.name}</li>
})}
</ul>
</div>
)
}
const mapStateProps = state => {
return { users: state.users }
}
// 添加loadData 方法, 被服务器端调用
const loadData = async store => {
return store.dispatch(loadUsers('https://jsonplaceholder.typicode.com/users'))
}
// export default connect(mapStateProps)(List)
// 把页面组件封装成路由对象所需的内容
export default {
element: connect(mapStateProps)(List),
loadData
}
路由信息对象数组,List对象结构中包含loadData 函数。
import React from 'react'
import Home from '../share/pages/Home'
import List from '../share/pages/List'
import NotFound from './pages/NotFound'
export default [
{
path: '/',
element: <Home />,
exact: true
},
{
path: '/list',
// element: <List />
...List,
element: <List.element />
},
{
path: '*',
element: <NotFound />
}
]
服务器端接收到请求后,获取当前请求路径对应的页面组件路由对象信息,并调用其中的获取数据方法loadData,并把后端的store对象传递进去以便获取数据后调用dispatch触发action更新state。
import routes from '../share/routes'
import { createStore } from '../store/store.server'
import app from './http'
import render from './render'
import { matchRoutes } from 'react-router'
app.get('*', async (req, res) => {
// 接收到请求后创建服务器端store
const store = createStore()
// 获取请求地址
// 获取路由配置信息
// 根据地址匹配出要渲染的组件的路由对象信息
const promises = matchRoutes(routes, req.path).map(({ route }) => {
// 如何才能知道数据什么时候获取完成
if (route.loadData) return route.loadData(store)
})
// 数据获取完成后将渲染结果响应给客户端
Promise.all(promises).then(() => {
res.send(render(req, store)) // 把req传递给渲染函数render,以便可以进行路由支持
})
})
最后再响应渲染结果之前的后端渲染环节,给页面window挂载INITIAL_STATE记录当前服务器store数据状态,作为客户端的store的初始化状态。
window.INITIAL_STATE = ${serialize(store.getState())}
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Provider } from 'react-redux'
import serialize from 'serialize-javascript'
// renderRoutes方法旨在把数组形式的路由规则转换为组件形式的路由规则
// import { useRoutes } from 'react-router'
// import routes from '../share/routes'
import App from '../share/pages/App'
// import { store } from '../store/store.server'
export default (req, store) => {
// StaticRouter根据请求req的 url 匹配对应的路由获取页面组件渲染成字符串
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.url}>
<App />
</StaticRouter>
</Provider>
)
return `<html>
<head>
<title>React SSR Base</title>
</head>
<body>
<div id='root'>${content}</div>
<script>
// 这里是为了避免前后端页面二次渲染出现错误提示
// Warning: Did not expect server HTML to contain a <li> in <ul>.
window.INITIAL_STATE = ${serialize(store.getState())}
</script>
<script src='main.js'></script>
</body>
</html>`
}
window.INITIAL_STATE = ${serialize(store.getState())}
这句代码存在的意义是为了给客户端的hydrate提供store的初始化的状态值。
以消除React的警告:
Warning: Did not expect server HTML to contain a <li> in <ul>.
分析:
该警告出现的原因是客户端Store在初始化的状态是没有数据的,在渲染组件的时候生成的是空ul,但是服务器端是先获取数据在进行的组件渲染,其生成的页面元素是有li 子元素的ul,hydrateRoot方法在对比时发现两者不一致,所以报了个警告。
解决思路:
将服务器端获取到的数据回填给客户端,让客户端拥有初始数据。
import { configureStore } from '@reduxjs/toolkit'
import todosReducer from './features/todos/todosSlice'
import usersReducer from './features/users/usersSlice'
export const store = configureStore({
reducer: {
todos: todosReducer,
users: usersReducer
},
preloadedState: window.INITIAL_STATE
})
防止XSS攻击
就是说当服务器端返回的数据中包含恶意的JavaScript代码多的时候,我们要阻止这些恶意代码多的执行。即:转义状态的恶意代码。
也就是说我们使用服务器端渲染给客户端返回的页面内容中包含的JavaScript代码片段,是可能被恶意利用的,不能简单的使用JSON.stringify()方法进行字符串序列化,而是需要进行转义,以防止XSS攻击。
例如:
let response = {
name: '</script></script>setInterval(function(){alert(1)}, 1000)</script>',
age: 18
}
这时候我们在服务器端返回数据前就需要通过一个serialize方法,它是从serialize-javascript这个包提供的。
import serialize from 'serialize-javascript'
const initialState = serialize(store.getState())
后续我们会继续深入学习当前流行的react服务端渲染支持框架Next.js和Gatsby。