1. 渲染方案介绍
1.1 什么是客户端渲染
CSR:Client Side Rendering
服务器端仅返回 JSON 数据, DATA 和 HTML 在客户端进行渲染.
1.2 什么是服务器端渲染
SSR:Server Side Rendering
服务器端返回HTML, DATA 和 HTML 在服务器端进行渲染
1.3 传统的服务端渲染(直接返回html,客户端无感知)
- 获取页面模板
- 获取数据
- 渲染 数据+ 模板 = 结果
- 把渲染结果发送给客户端
1.3.1 缺点
- 前后端代码耦合在一起,不利于开发维护
- 前端没有足够的发挥空间
- 服务端压力大
- 用户体验一般(如果需要查看其它页面,需要完全刷新页面)
1.4 客户端渲染
1.4.1 优点
- 减少服务端压力
- 实现前后端职责的划分(后端处理数据接口,前端实现页面渲染)
- 体验提升 spa 无刷新体验
1.4.2 不足
-
不利于seo:
1. seo:网站的搜索引擎的排名,搜索引擎需要知道你的网站有什么 2. 搜索引擎通过程序获取你的网页内容(获取的是html字符串,不会去像spa应用一样进行渲染)
-
首屏渲染慢:
1. 传统的服务端渲染只需要请求一次,就会将结果返回给我们 2. 客户端渲染需要经过多次请求才能获得所有的数据进行渲染(多次http请求)
1.5 SPA 应用中服务器端渲染解决的问题:ReacrSSR同构
ReacrSSR同构
同构指的是代码复用. 即实现客户端和服务器端最大程度的代码复用.
在实现过程中我们将复用代码放置在在shared文件夹(页面文件、路由文件、redux相关),
server、client分别放置服务端、客户端相关文件
2. 服务器端渲染快速开始
2.1 创建 Node 服务器
src/server/http.js
import express from "express"
const app = express()
app.use(express.static('public'))
app.listen(3001, () => {
console.log('server is runing on http://localhost:3001')
})
export default app
2.2 实现 React SSR
src/server/index.js
- 引入要渲染的 React 组件
- 通过 renderToString 方法将 React 组件转换为 HTML 字符串
- 将结果HTML字符串渲染到客户端
renderToString 方法用于将 React 组件转换为 HTML 字符串, 通过 react-dom/server 导入.
import app from './http'
import React from "react"
import Home from "../shared/pages/Home"
import { renderToString } from 'react-dom/server'
import routes from "../shared/routes"
import { renderRoutes, matchRoutes } from 'react-router-config'
import { StaticRouter } from 'react-router-dom'
import { Provider } from "react-redux"
import createStore from "./createStore"
app.get('*', (req, res) => {
const store = createStore()
const promises = matchRoutes(routes, req.path).map(({route}) => {
if (route.loadData) {
return route.loadData(store)
}
})
Promise.all(promises).then(() => {
const initState = store.getState()
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
)
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script>window.INITIAL_STATE=${JSON.stringify(initState)}</script>
<script src="bundle.js"></script>
</body>
</html>
`)
})
})
2.3 webpack 打包配置
webpack.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
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', {
useBuiltIns:"usage"
}], '@babel/preset-react']
}
}
}]
},
externals:[nodeExternals()]
}
问题: Node 环境不支持 ESModule 模块系统, 不支持 JSX 语法. 通过配置babel-loader options支持
2.4 项目启动命令配置
1. 配置服务器端打包命令: "dev:server-build": "webpack --config webpack.server.js --watch"
2. 配置服务端启动命令: "dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\""
2.5 客户端 React 附加事件
-
实现思路分析
在客户端对组件进行二次"渲染", 为组件元素附加事件.
-
客户端二次 “渲染” hydrate
使用 hydrate 方法对组件进行渲染, 为组件元素附加事件.
hydrate 方法在实现渲染的时候, 会复用原本已经存在的 DOM 节点, 减少重新生成节点以及删除原本 DOM 节点的开销.
通过 react-dom 导入 hydrate.
ReactDOM.hydrate( <组件/>, document.getElementById('root'))
2.6 客户端 React 打包配置
-
webpack 配置
打包目的: 转换JSX语法, 转换浏览器不识别的高级 JavaScript 语法
打包目标位置: public 文件夹
const path = require('path')
module.exports = {
mode:'development',
entry:'./src/client/index.js',
output:{
path:path.join(__dirname, 'public'),
filename:'bundle.js'
},
module:{
rules:[{
test:/\.js$/,
exclude:/node_modules/,
use:{
loader:'babel-loader',
options:{
presets:[['@babel/preset-env',{
/**
* 当我做polyfill填充的时候,去加一些低版本特性的时候,我不是把所有特性都加进来
* 是根据你的业务代码来决定要加什么
*/
useBuiltIns: 'usage',
}], '@babel/preset-react']
}
}
}]
},
}
- 打包启动命令配置
"dev:client-build": "webpack --config webpack.client.js --watch"
打包生成bundle.js文件
2.7 添加客户端包文件请求链接
在响应给客户端的 HTML 代码中添加 script 标签, 请求客户端 JavaScript 打包文件.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
2.8 服务器端实现静态资源访问
服务器端程序实现静态资源访问功能, 客户端 JavaScript 打包文件会被作为静态资源使用.
app.use(express.static('public'))
3. 优化
3.1 合并项目启动命令
目的: 使用一个命令启动项目, 解决多个命令启动的繁琐问题. 通过 npm-run-all 工具实现.
"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\"",
"dev": "npm-run-all --serial dev:*"
3.2 服务器端打包文件体积优化
问题:在服务器端打包文件中, 包含了 Node 系统模块. 导致打包文件本身体积庞大.
解决:通过 webpack 配置剔除打包文件中的 Node 模块.
webpack,server.js
var nodeExternals = require('webpack-node-externals');
const serverConfig = merge(config, {
...
externals: [nodeExternals()]
})
module.exports = serverConfig
3.3 将启动服务器代码和渲染代码进行模块化拆分
优化代码组织方式, 渲染 React 组件代码是独立功能, 所以把它从服务器端入口文件中进行抽离.
4. 路由支持
4.1 实现思路分析
- 在 React SSR 项目中需要实现两端路由.
- 客户端路由是用于支持用户通过点击链接的形式跳转页面.
- 服务器端路由是用于支持用户直接从浏览器地址栏中访问页面.
- 客户端和服务器端公用一套路由规则.
4.2 编写路由规则
shared/router.js
import Home from './pages/Home'
import List, {loadData} from './pages/List'
export default [
{
path:'/',
component:Home,
exact:true
},
{
path:'/list',
component:List,
loadData
}
]
4.3 实现服务器端路由
-
Express 路由接收任何请求
Express 路由接收所有 GET 请求, 服务器端 React 路由通过请求路径匹配要进行渲染的组件.
-
服务器端路由配置
import app from './http'
import React from 'react'
import { renderToString } from 'react-dom/server';
import Home from '../shared/pages/Home'
import routes from "../shared/routes"
import { renderRoutes, matchRoutes } from "react-router-config"
import { StaticRouter } from "react-router-dom"
import createStore from "./createStore"
import { Provider } from 'react-redux'
app.get('*', (req, res) => {
// redux 创建仓库
const store = createStore()
// 匹配请求路径,执行需要在服务端执行的加载数据的函数
const promises = matchRoutes(routes, req.path).map(({route}) => {
if(route.loadData){route.loadData(store)}
})
// 数据请求结束 执行渲染
Promise.all(promises).then(() => {
// 匹配路由
const content = renderToString(
<StaticRouter location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
)
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`)
})
})
4.4 实现客户端路由
- 添加客户端路由配置
import React from "react"
import ReactDOM from "react-dom"
import routes from "../shared/routes"
import { renderRoutes } from "react-router-config"
import { BrowserRouter } from "react-router-dom"
import { Provider } from 'react-redux'
import store from './createStore'
ReactDOM.hydrate(
<BrowserRouter>
{renderRoutes(routes)}
</BrowserRouter>
, document.getElementById('root'))
5. Redux 实现
5.1 实现思路分析
- 在实现了 React SSR 的项目中需要实现两端 Redux.
- 客户端 Redux 就是通过客户端 JavaScript 管理 Store 中的数据.
- 服务器端 Redux 就是在服务器端搭建一套 Redux 代码, 用于管理组件中的数据.
- 客户端和服务器端共用一套 Reducer 代码.
- 创建 Store 的代码由于参数传递不同所以不可以共用.
module:{
rules:[{
test:/\.js$/,
exclude:/node_modules/,
use:{
loader:'babel-loader',
options:{
presets:[['@babel/preset-env', {
// 支持dispatch的异步
useBuiltIns:"usage"
}], '@babel/preset-react']
}
}
}]
},
5.2 实现客户端 Redux
- 创建 Store
src/client/createStore.js
import { createStore, applyMiddleware } from "redux"
import thunk from 'redux-thunk'
import reducer from "../shared/reducers"
const store = createStore(reducer, {}, applyMiddleware(thunk))
export default store
- 配置 Store
src/client/index.js
import ReactDOM from "react-dom"
import React from 'react'
import routes from "../shared/routes"
import { renderRoutes } from 'react-router-config'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import store from "./createStore"
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
{renderRoutes(routes)}
</BrowserRouter>
</Provider>
, document.getElementById('root'))
- 创建 Action 和 Reducer
- 配置 polyfill
由于浏览器不能识别异步函数代码, 所以需要 polyfill 进行填充.
5.3 实现服务端 Redux
- 创建 Store
src/server/createStore.js
import { createStore, applyMiddleware } from "redux"
import thunk from 'redux-thunk'
import reducer from "../shared/reducers"
export default () => createStore(reducer, {}, applyMiddleware(thunk))
- 配置 Store
src/server/index.js
...
app.get('*', (req, res) => {
//每次创建新的store
const store = createStore()
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
)
})
...
5.4 服务器端 store 数据填充
问题:服务器端创建的 store 是空的, 组件并不能从Store中获取到任何数据.
解决:服务器端在渲染组件之前获取到组件所需要的数据.
-
在组件中添加 loadData 方法, 此方法用于获取组件所需数据,方法被服务器端调用
服务器端通过调用组件的 loadData 方法获取组件所需数据并将数据存储在服务器端的 Store 中
function loadData (store) {
// dispatch的返回值是要触发的action对象
// 现在通过thunk触发action返回的是异步函数
// 异步函数返回的是promise,所以这里返回的是promise
return store.dispatch(getList())
}
- 将 loadData 方法保存在当前组件的路由信息对象中.
import Home from './pages/Home'
import List, {loadData} from './pages/List'
export default [
{
path:'/',
component:Home,
exact:true
},
{
path:'/list',
component:List,
loadData
}
]
- 服务器端在接收到请求后,根据请求地址匹配出要渲染的组件的路由信息
- 从路由信息中获取组件中的 loadData 方法并调用方法获取组件所需数据
- 当数据获取完成以后再渲染组件并将结果响应到客户端
import app from './http'
import React from "react"
import Home from "../shared/pages/Home"
import { renderToString } from 'react-dom/server'
import routes from "../shared/routes"
import { renderRoutes, matchRoutes } from 'react-router-config'
import { StaticRouter } from 'react-router-dom'
import { Provider } from "react-redux"
import createStore from "./createStore"
app.get('*', (req, res) => {
const store = createStore()
//服务器端在接收到请求后,根据请求地址匹配出要渲染的组件的路由信息
const promises = matchRoutes(routes, req.path).map(({route}) => {
//从路由信息中获取组件中的 loadData 方法并调用方法获取组件所需数据
if (route.loadData) {
return route.loadData(store)
}
})
Promise.all(promises).then(() => {
const initState = store.getState()
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
)
//当数据获取完成以后再渲染组件并将结果响应到客户端
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script>window.INITIAL_STATE=${JSON.stringify(initState)}</script>
<script src="bundle.js"></script>
</body>
</html>
`)
})
})
5.5 React 警告消除
警告原因: 客户端 Store 在初始状态下是没有数据的, 在渲染组件的时候生成的是空 ul, 但是服务器端是先获取数据再进行的组件渲染, 所以生成的是有子元素的ul, hydrate 方法在对比的时候发现两者不一致, 所以报了个警告.
解决思路: 将服务器端获取到的数据回填给客户端, 让客户端拥有初始数据.
- 服务器响应 Store 初始状态
import app from './http'
import React from "react"
import Home from "../shared/pages/Home"
import { renderToString } from 'react-dom/server'
import routes from "../shared/routes"
import { renderRoutes, matchRoutes } from 'react-router-config'
import { StaticRouter } from 'react-router-dom'
import { Provider } from "react-redux"
import createStore from "./createStore"
app.get('*', (req, res) => {
const store = createStore()
const promises = matchRoutes(routes, req.path).map(({route}) => {
if (route.loadData) {
return route.loadData(store)
}
})
Promise.all(promises).then(() => {
// 务器端获取到的初始数据
const initState = store.getState()
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path}>
{renderRoutes(routes)}
</StaticRouter>
</Provider>
)
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
// 数据回填
<script>window.INITIAL_STATE=${JSON.stringify(initState)}</script>
<script src="bundle.js"></script>
</body>
</html>
`)
})
})
-
客户端设置 Store 初始状态
给客户端store 设置初始数据
export default createStore(reducer, window.INITIAL_STATE, applyMiddleware(thunk))
6. 完整代码
代码地址:https://gitee.com/liannian9/fed-e-task-04-04/tree/master/code/2.0react-ssr