主题
服务端渲染原理
一、服务端渲染简介
客户端渲染:单页面应用中,index.html 文件只有一个<div id="XXX"></div>DOM结构,其余页面内容都是js代码控制
服务端渲染:服务端直接返回完整的html文档让浏览器直接渲染
1. 一个简易SSR
新建react-ssr文件夹,运行npm init -y 初始化 package.json文件
安装
bash
npm i express根目录下新建index.js
js
var express = require("express");
var app = express();
app.get("/", (req, res) => {
res.send(
`
<html>
<head>
<title>hello</title>
</head>
<body>
<h1>hello</h1>
<p>world</p>
</body>
</html>
`,
);
});
app.listen(4000, () => {
console.log("listen:4000");
});修改package.json
json
"scripts": {
"dev": "node index.js"
},运行npm run dev,打开localhost:4000

二、为什么使用服务端渲染
如图:客户端渲染(左)和服务端渲染(右)的流程

服务端渲染的优点:
首屏加载快,无需等待JS下载且执行才显示内容,更快地看到完整渲染页面
更友好的SEO,爬虫可以直接抓取渲染之后的页面。客户端渲染首次返回的HTML文档中,是空节点,爬虫无法分析所以就无法给好的排名。服务端是返回渲染之后的HTML,内容完整所以能更好的被爬虫分析与索引
题外:客户端渲染可以通过 title 和 description 做简单的SEO优化
服务端渲染的缺点:
对服务器性能消耗较高,多了SSR机器的维护
项目复杂度变高,多个node层
三、在服务端编写React代码
1. 安装babel和webpack
前提:Node.js不能识别ES6的modules语法,不能识别JSX语法
方法:先用webpack将服务端代码打包进行转义,再通过node运行打包后的服务端代码
安装babel
bash
npm i @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-react babel-loader -D安装webpack
bash
npm i webpack webpack-cli webpack-node-externals webpack-merge -D服务端渲染第三方模块的代码不需要打包到最终的源码中的,因为node_modules中已经包含了这些包。可以使用 webpack-node-externals 解决这个问题
2. 进行webpack配置
新建webpack.base.js
js
module.exports = {
module: {
rules: [
{
test: /\.js?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["@babel/preset-env", "@babel/preset-react"],
plugins: ["@babel/plugin-transform-runtime"],
},
},
],
},
};新建webpack.server.js
js
const path = require("path"); //node的path模块
const nodeExternals = require("webpack-node-externals");
const { merge } = require("webpack-merge");
const config = require("./webpack.base.js");
module.exports = merge(config, {
//node核心模块的代码不需要打包到最终源码中,指明target:'node'
target: "node",
mode: "development",
entry: "./server/index.js",
output: {
filename: "server.js",
path: path.resolve(__dirname, "build"),
},
externals: [nodeExternals()],
});3. webpack自动打包和服务端自动重启
webpack自动打包只需增加 --watch
服务端重启,安装nodemon
bash
npm install nodemon -g同时启动webpack服务和node服务,安装npm-run-all
bash
npm install npm-run-all -D配置package.json
json
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:start": "nodemon --watch build --exec node \"./build/server.js\"",
},更换服务端引入方式(从 CommonJS 换成ES6 modules)
运行npm run dev,访问http://localhost:4000/,可以看到hello world页面
4. 编写React代码
安装
bash
npm i react react-dom -S新建pages/login.js
jsx
import React from "react";
const Login = () => {
return <div>login页面</div>;
};
export default Login;修改server/index.js
jsx
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import Login from "../pages/login";
var app = express();
const content = renderToString(<Login />);
app.get("/", (req, res) => {
res.send(
`
<html>
<head>
<title>hello</title>
</head>
<body>
${content}
</body>
</html>
`,
);
});
app.listen(4000, () => {
console.log("listen:4000");
});启动服务:npm run dev;访问:http://localhost:4000/ ,可以看到login页面
六、同构
上面如果在DOM元素上绑定React的监听事件,是不生效的。因为renderToString并没有做事件相关的处理。
同构:React代码在服务器上运行一遍,页面直接呈现内容;然后浏览器渲染时判断已有的DOM结构和即将渲染出的结构是否相同,若相同,则不重新渲染DOM渲染,只需进行事件绑定
1. 客户端
新建 client/index.js
jsx
import React from "react";
import ReactDom from "react-dom";
import Login from "../pages/login";
ReactDom.hydrate(<Login />, document.getElementById("root"));水合:在已有服务端渲染标记的节点上调用ReactDom.hydrate方法,React将会保留该节点且只进行事件处理绑定
修改 pages/login.js
jsx
// 新增
<button
onClick={() => {
alert("666");
}}
>
click
</button>2. 服务端
修改server/index.js
js
app.use(express.static("public"));
//修改body区域
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>;下面进行webpack配置,将客户端资源进行打包到public/index.js
3. webpack配置
新建webpack.client.js
js
const path = require("path");
const { merge } = require("webpack-merge");
const config = require("./webpack.base.js");
module.exports = merge(config, {
mode: "development",
entry: path.resolve(__dirname, "./client/index.js"),
output: {
filename: "index.js",
path: path.resolve(__dirname, "public"),
},
});修改package.josn
json
//在dev:build:server下新增
"dev:build:client": "webpack --config webpack.client.js --watch",重启项目,运行npm run dev,访问http://localhost:4000/,发现按钮已经可以点击,同构成功
4. 流程
在服务端中,通过renderToString方法,将react代码转为HTML字符串,拼接在HTML中。
服务端发送生成好的HTML给浏览器
浏览器接到内容并显示
浏览器加载JS文件
JS代码通过hydrate方法,将React代码进行水合,绑定监听事件等,使页面具有交互性
七、在SSR中使用路由
1. 创建组件
安装
bash
npm install react-router-dom@5.3.0新建pages/components/header.js
jsx
import React from "react";
import { Link } from "react-router-dom";
const Header = () => {
return (
<div>
<Link to="/">Home </Link>
<Link to="/login">Login</Link>
</div>
);
};
export default Header;修改pages/login.js
jsx
import React from "react";
import Header from "./components/header";
const Login = () => {
return (
<div>
<Header />
login页面
<button
onClick={() => {
alert("666");
}}
>
click
</button>
</div>
);
};
export default Login;新建pages/home.js
jsx
import React from "react";
import Header from "./components/header";
const Home = () => {
return (
<div>
<Header />
<div>This is ssr</div>
</div>
);
};
export default Home;2. 在客户端使用路由
新建routes/index.js
jsx
import React from "react";
import { Route, Switch } from "react-router-dom";
import Home from "../pages/home";
import Login from "../pages/login";
export default (
<Switch>
<Route path="/" exact component={Home}></Route>
<Route path="/login" exact component={Login}></Route>
</Switch>
);修改client/index.js
jsx
import React from "react";
import ReactDom from "react-dom";
import { BrowserRouter } from "react-router-dom";
import Router from "../routes";
const App = () => {
return <BrowserRouter>{Router}</BrowserRouter>;
};
ReactDom.hydrate(<App />, document.getElementById("root"));由于使用Route组件之后,客户端和服务端的层级不一致。所以需要在服务端也配置路由
3. 在服务端使用路由
安装
bash
npm i @babel/plugin-proposal-class-properties -D修改webpack.base.js
js
//在plugins中新增"@babel/plugin-proposal-class-properties"
plugins: [
"@babel/plugin-transform-runtime",
"@babel/plugin-proposal-class-properties",
];新建server/render.js
jsx
import { renderToString } from "react-dom/server"; //引入renderToString方法
import { StaticRouter } from "react-router-dom";
import React from "react"; //引入React以支持JSX的语法
import Router from "../routes";
export default function (req, res) {
const content = renderToString(
<StaticRouter context={{}} location={req.path}>
{Router}
</StaticRouter>,
);
res.send(` <html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>`);
}StaticRouter是React-router针对服务端渲染专门提供的一个路由组件
location:根据当前路径分析出所需要的组件
context:用于服务端渲染时的参数传递
修改server/index.js
jsx
import express from "express";
import render from "./render";
const app = express();
app.use(express.static("public"));
app.get("*", (req, res) => {
render(req, res);
});
app.listen(3001, () => console.log("Exampleapp listening on port 3001!"));重新启动项目,访问http://localhost:4000/,可以看到有路由页面
4. 段落小结
服务端路由是为了第一次进入页面时能够找到对应的网页文件
客户端路由是为了能让React路由接管页面实现无刷新跳转
服务端不写路由的话,页面内容(DOM结构)不统一而出现错误
第一次请求时,服务端根据请求地址返回相应HTML文件。再运行客户端的js文件进行水合,使页面具有交互作用。之后页面被客户端的React代码接管,进行路由跳转时,不再发送请求,可以当做spa 页面
八、在SSR中使用redux
1. 创建store仓库
安装:
bash
npm install redux react-redux -S新建store/action-types.js
js
/* 增加 */
export const INCREMENT = "INCREMENT";
/* 删除 */
export const DECREMENT = "DECREMENT";新建store/action.js
js
import * as types from "./action-types";
export const INCREMENT = (value) => {
return {
type: types.INCREMENT,
value,
};
};
export const DECREMENT = (value) => {
return {
type: types.DECREMENT,
value,
};
};新建store/reducer/count.js
js
import * as types from "../action-types";
const initState = {
count: 0,
};
const reducer = (state = initState, action) => {
switch (action.type) {
case types.INCREMENT:
return {
count: state.count + action.value,
};
case types.DECREMENT:
return {
count: state.count - action.value,
};
default:
return state;
}
};
export default reducer;新建store/reducer/index.js
js
import { combineReducers } from "redux";
import counter from "./count";
const rootReducer = combineReducers({
counter,
});
export default rootReducer;新建store/index.js
js
import { createStore } from "redux";
import rootReducer from "./reducer";
const getStore = () => {
return createStore(rootReducer);
};
export default getStore;createStore创建的store是单例的store,在服务器端这样的写法将导致所有用户共享一个store,所以我们将创建store这一步封装成getStore方法,每次调用都返回一个新的store
2. 引入store
通过Provider组件,将store注入全局
修改client/index.js
jsx
//新增
import { Provider } from "react-redux";
import getStore from "../store";
//修改
const App = () => {
return (
<Provider store={getStore()}>
<BrowserRouter>{Router}</BrowserRouter>
</Provider>
);
};修改server/render.js
jsx
//新增
import { Provider } from "react-redux";
import getStore from "../store";
//修改
export default function (req, res) {
const store = getStore();
console.log("store", store.getState());
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
{Router}
</StaticRouter>
</Provider>,
);
res.send(` <html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>`);
}这里打印的store,会在浏览器每次刷新重新调用。如果通过createStore直接创建仓库,刷新页面则不会重新调用
3. 在组件中使用store
修改pages/home.js
jsx
import React from "react";
import Header from "./components/header";
import { useSelector, useDispatch } from "react-redux";
import { INCREMENT, DECREMENT } from "../store/action";
const Home = () => {
const { count } = useSelector((store) => store.counter);
const dispatch = useDispatch();
return (
<div>
<Header />
<div>{count}</div>
<button onClick={() => dispatch(INCREMENT(1))}>+1</button>
<button onClick={() => dispatch(DECREMENT(1))}>-1</button>
</div>
);
};
export default Home;重启服务,刷新页面查看效果,可以发现计时器功能已实现
4. 在redux中调用接口
(1)新建一个模拟后台提供接口的node服务
安装cors,处理前端跨域问题
bash
npm i cors新建 api/index.js
js
let express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
let users = [
{ id: 1, name: "user1" },
{ id: 2, name: "user2" },
];
app.get("/api/users", function (req, res) {
res.json(users);
});
app.listen(4001);修改package.json
json
"scripts": {
"dev": "npm-run-all --parallel dev:**",
"dev:build:server": "webpack --config webpack.server.js --watch",
"dev:build:client": "webpack --config webpack.client.js --watch",
"dev:start": "nodemon --watch build --exec node \"./build/server.js\"",
"dev:api": "node api/index.js",
},(2)修改store/action-types.js
js
//新增
export const GETUSER = "GETUSER";(3)安装axios
在服务端,没有fetch和axios,所以安装axios
安装
bash
npm i axios -S修改store/action.js
js
//新增
import axios from "axios";
export const GETUSER = () => {
return (dispatch) => {
return axios.get("http://localhost:4001/api/users").then((res) => {
dispatch({
type: types.GETUSER,
value: res.data,
});
});
};
};(4)新建store/reducer/user.js
js
import * as types from "../action-types";
const initState = {
users: [],
};
const reducer = (state = initState, action) => {
switch (action.type) {
case types.GETUSER:
return Object.assign({}, state, { users: action.value });
default:
return state;
}
};
export default reducer;(5)修改reducer/index.js
js
//新增
import user from "./user";
//修改
const rootReducer = combineReducers({
counter,
user,
});
export default rootReducer;(6)安装thunk
安装
bash
npm i redux-thunk修改store/index.js
js
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import rootReducer from "./reducer";
const getStore = () => {
return createStore(rootReducer, {}, applyMiddleware(thunk));
};
export default getStore;(7)在pages/homes.js中调用
jsx
//修改
import { INCREMENT, DECREMENT, GETUSER } from "../store/action";
const Home = () => {
const {
counter: { count },
user: { users },
} = useSelector((store) => store);
const dispatch = useDispatch();
return (
<div>
<Header />
<ul>
{users && users.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
<div>{count}</div>
<button onClick={() => dispatch(INCREMENT(1))}>+1</button>
<button onClick={() => dispatch(DECREMENT(1))}>-1</button>
<button onClick={() => dispatch(GETUSER())}>获取接收数据</button>
</div>
);
};
export default Home;重启服务,查看效果
九、异步数据服务端渲染
上面请求异步数据,是点击按钮获取的,是在客户端生成的。但很多情况是需要在一开始就调用接口,并将接口内容呈现在页面
在home.js组件新增
jsx
useEffect(() => {
dispatch(GETUSER());
}, []);在生命周期中,调用接口数据,可以看到页面是被渲染了,但是服务端返回的HTML文档并没有这块内容。这是因为在客户端的生命周期,在服务端不生效

如果希望在呈现之前加载数据,这样接口数据也能被SEO
思路:根据请求路径,加载路由所需数据,并调用接口,获取完数据之后再去渲染页面
1. 修改路由
jsx
import Home from "../pages/home";
import Login from "../pages/login";
export default [
{
path: "/",
exact: true,
component: Home,
loadData: Home.loadData, //该路由需调用数据
key: "home",
},
{
path: "/login",
exact: true,
component: Login,
key: "login",
},
];2. 修改客户端引入路由方式
修改client/index.js
jsx
//修改
import { BrowserRouter, Switch, Route } from "react-router-dom";
import routes from "../routes";
const App = () => {
return (
<Provider store={getStore()}>
<BrowserRouter>
<Switch>
{routes.map((route) => (
<Route {...route} />
))}
</Switch>
</BrowserRouter>
</Provider>
);
};3. 修改服务端引入路由方式
先通过matchPath方法,收集当前访问路由所对应的组件; 再判断该路由上是否有loadData属性,如果有说明需要异步加载数据; 通过Promise.all()来控制,先请求数据,后加载页面
jsx
//修改
import { StaticRouter, Switch, Route, matchPath } from "react-router-dom";
import routes from "../routes";
export default function (req, res) {
const store = getStore();
/* 收集匹配路由 */
const matchRoutes = [];
routes.some((route) => {
matchPath(req.path, route) && matchRoutes.push(route);
});
/* 收集需要调用数据的方法 */
const promises = [];
matchRoutes.forEach((item) => {
if (item.loadData) {
const promise = new Promise((resolve, reject) => {
//接口不管成功还是失败都返回resolve,防止堵塞页面
item.loadData(store).then(resolve).catch(resolve);
});
promises.push(promise);
}
});
/* 调用完数据再加载页面 */
Promise.all(promises).then(() => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<Switch>
{routes.map((route) => (
<Route {...route} />
))}
</Switch>
</StaticRouter>
</Provider>,
);
res.send(` <html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script src="/index.js"></script>
</body>
</html>`);
});
}4. 修改home.js
在Home组件赋值loadData属性,并将请求的方法赋值上去,代表在服务端渲染时,需要调用该方法去请求数据;
在useEffect中再调用接口,这样才能保持客户端和服务端渲染出来的页面保持一致
jsx
//修改
import React, { useEffect } from "react";
//在Home组件中新增
useEffect(() => {
dispatch(GETUSER());
}, []);
//新增
Home.loadData = function (store) {
return store.dispatch(GETUSER());
};重启服务器,可以看到接口数据已经被渲染出来了,但是在浏览器控制台中却有个报错

这个错误说明服务端和客户端的store不同步。因为当服务端拿到store并获取数据后,客户端的js代码又执行了一遍,在客户端代码执行的时候又创建了一个空的store。此时一个有数据的store与一个空的store比较,导致渲染的DOM结构不同,所以报错。页面能渲染出来,是因为客户端空的store又通过接口数据填充和服务端的store才保持了一致。
通过数据的注水和脱水解决这问题
十、数据的注水和脱水
1. 数据的注水
在服务端获取数据后,将数据也加入到客户端的window对象上
修改server/render.js
html
<html>
<head>
<title>ssr</title>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>注意这里引入的script顺序不能错,先注入数据,后加载页面
2. 数据的脱水
先修改store/index.js
客户端在创建store的时候,先去window对象上读取初始值
js
/* 服务端的store创建函数 */
export const getStore = () => {
return createStore(rootReducer, {}, applyMiddleware(thunk));
};
/* 客户端的store创建函数 */
export const getClientStore = () => {
const defaultState = window.context ? window.context.state : {};
return createStore(rootReducer, defaultState, applyMiddleware(thunk));
};修改server/render.js
js
import { getStore } from "../store";修改client/index.js
jsx
import { getClientStore } from "../store";jsx
<Provider store={getClientStore()}></Provider>优化:由于服务端已获取数据并注入到客户端,客户端有了数据不用再请求接口数据 修改containers/Home.js
jsx
//如果有数据的话,不调用接口
useEffect(() => {
if (!users.length) {
dispatch(GETUSER());
}
}, []);十一、CSS服务端渲染
安装
bash
npm install style-loader css-loader --D修改webpack.client.js
js
//新增
module: {
rules: [{
test: /\.css?$/,
use: ['style-loader', {
loader: 'css-loader',
options: {
modules: true
}
}]
}]
},安装
bash
npm install -D isomorphic-style-loader修改webpack.server.js
js
//新增
module: {
rules: [
{
test: /\.css?$/,
use: [
"isomorphic-style-loader",
{
loader: "css-loader",
options: {
modules: true,
},
},
],
},
],
},新建 pages/assets/style.css
css
body {
background: gray;
}修改login.js
jsx
//修改
import React, { useEffect } from "react";
//新增
import styles from "./assets/style.css";
//在Login中新增
//判断是否为服务端渲染环境
useEffect(() => {
props.staticContext.css.push(styles._getCss());
}, []);修改server/render.js
jsx
let context = {css:[]}
Promise.all(promises).then(() => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<Switch>
{routes.map((route) => (
<Route {...route} />
))}
</Switch>
</StaticRouter>
</Provider>
);
//拼接代码
const cssStr = context.css.length ? context.css.join('\n') : '';
res.send(` <html>
<head>
<title>ssr</title>
<style>${cssStr}</style>
</head>
<body>
<div id="root">${content}</div>
<script>
window.context = {
state: ${JSON.stringify(store.getState())}
}
</script>
<script src="/index.js"></script>
</body>
</html>`);