React SSR
源码地址:https://github.com/qifutian/learngit/tree/main/react-ssr/react-ssr
React服务端渲染
什么是服务端渲染
SSR: Server Side Rendering
服务器返回html,DATA和html在服务端进行渲染
什么是客户端渲染
CSR:Client Side Rendering
服务器返回html,DATA和html在客户端进行渲染
客户端渲染的问题
- 首屏等待时间藏,体验差
- 页面结构为空,不利于SEO
SPA应用在服务端渲染解决的问题
React SSR同构
同构指的是代码复用,即实现客户端和服务端最大程度的代码复用
服务端渲染
搭建项目
- 新建文件夹react-ssr
- 新建src,及下的client,server,share
- 创建package.json
- 安装依赖 npm install
package.json
{
"name": "ssr-guide",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "npm-run-all --parallel dev:*",
"dev:server-run": "nodemon --watch build --exec \"node build/bundle.js\"",
"dev:server-build": "webpack --config webpack.server.js --watch",
"dev:client-build": "webpack --config webpack.client.js --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@babel/cli": "^7.10.3",
"@babel/core": "^7.10.3",
"@babel/polyfill": "^7.10.4",
"@babel/preset-env": "^7.10.3",
"@babel/preset-react": "^7.10.1",
"axios": "^0.19.2",
"babel-loader": "^8.1.0",
"express": "^4.17.1",
"nodemon": "^2.0.4",
"npm-run-all": "^4.1.5",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "^6.1.0",
"react-redux": "^7.2.0",
"react-router-config": "^5.1.1",
"react-router-dom": "^5.2.0",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"serialize-javascript": "^4.0.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12",
"webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2",
"webpack-node-externals": "^1.7.2"
}
}
创建node服务器
server下创建http.js 和index.js
http.js
import express from 'express';
const app = express();
app.use(express.static('public'));
app.listen(3000, () => console.log('app is running on 3000 port'));
export default app;
index.js 用来处理客户端的请求
import app from './http';
app.get('/',(req,res)=>{
})
实现React SSR
- 引入要渲染react组件
- 通过renderToString方法将react组件转为html字符串
- 将结果html字符串想到客户端
renderToString方法用来将React组件换HTML字符串,通过render-dom/sever导入
在share下创建pages/Home.js,获取首页组件换为html返回客户端
import React from 'react'
function Home (){
return <div>Home works</div>
}
export default Home;
在server的index.js引用
import app from './http';
import React from 'react'
import Home from '../share/pages/Home'
import { renderToString } from 'react-dom/server'
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>
`)
})
webpack打包配置
问题:在Node环境下不支持ESModule模块系统,不支持JSX语法
根目录新建webpack.server.js
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base');
const nodeExternals = require('webpack-node-externals');
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);
公共的webpack配置webpack.base.js
module.exports = {
mode: "development",
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{
useBuiltIns: "usage"
}
],
"@babel/preset-react"
]
}
}
}
]
}
};
项目启动命令配置
- 配置服务器端打包命令: “dev:server-build”: “webpack --config webpack.server.js --watch” 在修改文件之后自动打包
- 配置服务端启动命令: “dev:server-run”: “nodemon --watch build --exec “node build/bundle.js”” 自动从新运行
运行时: npm run dev:server-build
客户端 React 附加事件
实现思路分析:在客户端对组件进行二次"渲染", 为组件元素附加事件.客户端二次 “渲染” hydrate
使用 hydrate 方法对组件进行渲染, 为组件元素附加事件.
hydrate 方法在实现渲染的时候, 会复用原本已经存在的 DOM 节点, 减少重新生成节点以及删除原本 DOM 节点的开销.
通过 react-dom 导入 hydrate.
在client下index.js,是专门处理客户端的方式
import React from 'react';
import ReactDOM from 'react-dom';
import Home from '../share/pages/Home'
ReactDOM.HYDRATE(<Home />,document.getElementById("root"))
在Home上增加点击事件
import React from 'react'
function Home (){
return <div onClick={()=>console.log('hello')}>Home works</div>
}
export default Home;
在node下不支持,需要引入react支持
客户端React附加事件
客户端react打包配置
- webapck配置
打包目的:转换jsx雨大,转换浏览器不是吧的高阶JS语法
打包目标位置:public文件夹- 打包启动命令配置
“dev:client-build”:“webpack --config webpack.client.js --watch”
客户端打包配置
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: "bundle.js"
}
};
module.exports = merge(baseConfig, config);
引入打包后的hundle.js,在server下的index.js
在服务端实现访问静态资源功能,客户端的js打包文件会作为静态资源
app.use(express.static("public"))
合并公共的配置
进行webpack的合并,分为webpack.client.js,webpack.server.js,weebpack.base.js存放公共资源
合并项目启动命令
目的:使用一个明亮启动项目,解决多个命令启动繁琐问题,通过npm-run-all实现
"dev":"npm-run-all --parallel dev:*"
直接npm run dev就可以
打包体积的优化
拆分一些node的隐藏模块,去除一些node的无用模块
问题:在服务器端打包文件中, 包含了 Node 系统模块. 导致打包文件本身体积庞大.
解决:通过 webpack 配置剔除打包文件中的 Node 模块.
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base');
const nodeExternals = require('webpack-node-externals');
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);
代码优化
将启动服务器代码和渲染代码进行模块拆分
优化代码的组织方式,渲染React组件代码是独立功能,所以把他从服务器入口文件抽离
server下新建renderer.js,放置模板渲染的代码
import React from "react";
import Home from '../share/pages/Home'
import { renderToString } from "react-dom/server";
export default (req, store) => {
const content = renderToString(
<Home />
);
const initalState = serialize(store.getState());
return `
<html>
<head>
<title>React SSR</title>
</head>
<body>
<div id="root">${content}</div>
<script>window.INITIAL_STATE = ${initalState} </script>
<script src="bundle.js"></script>
</body>
</html>
`;
};
server/index.js
import app from './http'
import renderer from './renderer'
app.get("/",(req,res)=>{
res.send(renderer())
})
实现服务端路由
实现思路分析
在 React SSR 项目中需要实现两端路由.
客户端路由是用于支持用户通过点击链接的形式跳转页面.
服务器端路由是用于支持用户直接从浏览器地址栏中访问页面.
客户端和服务器端公用一套路由规则.
编写路由规则
- Express 路由接收任何请求
Express 路由接收所有 GET 请求, 服务器端 React 路由通过请求路径匹配要进行渲染的组件.
- 服务器端路由配置
renderRoutes将数组形式的路由转为组件形式
调用StaticRouter组件
在share下的pages新建List.js,share新建routes.js
import Home from '../share/pages/Home';
import List from '../share/pages/List';
export default [{
path: '/',
component: Home,
exact: true
}, {
path: '/list',
...List
}]
在renderer.js中增加路由引用
在server/index.js支持所有的路由,/改为*
客户端路由
添加客户端路由配置
在client/index.js
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import { renderRoutes } from "react-router-config";
import routes from "../share/routes";
ReactDOM.hydrate(
<BrowserRouter>{renderRoutes(routes)}</BrowserRouter>,
document.getElementById("root")
);
在Home中添加a链接
import React from "react";
import { Link } from 'react-router-dom';
function Home() {
return <div onClick={() => console.log("Hello")}>
Home works
<Link to="/list">jump to list</Link>
</div>;
}
export default Home;
Redux支持
思路分析
在实现了 React SSR 的项目中需要实现两端 Redux.
客户端 Redux 就是通过客户端 JavaScript 管理 Store 中的数据.
服务器端 Redux 就是在服务器端搭建一套 Redux 代码, 用于管理组件中的数据.
客户端和服务器端共用一套 Reducer 代码.
创建 Store 的代码由于参数传递不同所以不可以共用.
实现客户端 Redux
- 创建 Store
- 配置 Store
- 创建 Action 和 Reducer
- 配置 polyfill
由于浏览器不能识别异步函数代码, 所以需要 polyfill 进行填充.
在client下新建createStore.js
import { applyMiddleware, createStore } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../share/store/reducers';
const store = createStore(reducer, window.INITIAL_STATE, applyMiddleware(thunk));
export default store;
在client/index.js下使用Provider包裹根组件,在renderer方法创建store
在share下新建store放置redux代码,新建actions,reducers
user.reducer.js
import { SAVE_USER } from "../actions/user.action";
export default (state = [], action) => {
switch(action.type) {
case SAVE_USER:
return action.payload.data;
default:
return state;
}
}
user.action.js
import axios from 'axios';
export const SAVE_USER = 'save_user';
// 发送请求 获取用户列表数据
export const fetchUser = () => async dispatch => {
// let response = await axios.get('https://jsonplaceholder.typicode.com/users');
let response = {
data: [{id: 1, name: "</script><script>alert(1)</script>"}]
}
dispatch({
type: SAVE_USER,
payload: response
})
}
实现服务端redux
-
创建store
-
配置 Store
服务端store数据填充
问题:服务器端创建的 store 是空的, 组件并不能从Store中获取到任何数据.
解决:服务器端在渲染组件之前获取到组件所需要的数据.
- 在组件中添加 loadData 方法, 此方法用于获取组件所需数据,方法被服务器端调用
- 将 loadData 方法保存在当前组件的路由信息对象中.
- 服务器端在接收到请求后,根据请求地址匹配出要渲染的组件的路由信息
- 从路由信息中获取组件中的 loadData 方法并调用方法获取组件所需数据
- 当数据获取完成以后再渲染组件并将结果响应到客户端
服务器端 store 数据填充
- 组件 loadData 方法
服务器端通过调用组件的 loadData 方法获取组件所需数据并将数据存储在服务器端的 Store 中
- 服务器端获取组件所需数据
服务器端在接收到请求以后,先根据请求路径分析出要渲染的路由信息,再从路由信息中得到 loadData方法.
React 警告消除
警告原因: 客户端 Store 在初始状态下是没有数据的, 在渲染组件的时候生成的是空 ul, 但是服务器端是先获取数据再进行的组件渲染, 所以生成的是有子元素的ul, hydrate 方法在对比的时候发现两者不一致, 所以报了个警告.
解决思路: 将服务器端获取到的数据回填给客户端, 让客户端拥有初始数据.
- 服务器响应 Store 初始状态
- 客户端设置 Store 初始状态
预防XSS攻击
防范 XSS 攻击
转义状态中的恶意代码.