# 前瞻
为了方便理解服务端渲染的原理,开始从零搭建一套简易的React SSR。
需要用到的技术栈:
- React全家桶,基于最新的React18.2.0版本
- Node Express框架
此次,并没有集成TypeScript,是因为TypeScript的作用与SSR原理并没有啥关系。
# 从零开始搭建
# Node Server搭建
需要安装的依赖项:
- npm i express(后端服务,这里以express为例子)
- npm i -D nodemon(启动Node程序时监听文件的变化,变化即刷新)
- npm i -D webpack webpack-cli webpack-node-externals(webpack-node-externals:排除掉node_modules中所有的模块,该库只针对于node环境,web环境是不需要的)
express服务:
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send(`Hello Node Server!`);
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
webpack配置(你可以将该文件命名为server.config.js,方便之后引入客户端配置好区分):
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = {
target: "node",
mode: "development",
entry: "./src/server/index.js",
output: {
filename: "server_bundle.js",
path: path.resolve(__dirname, "../build/server"),
},
externals: [nodeExternals()],
};
文件树:
# React搭建
需要安装的依赖项:
- npm i react react-dom
- npm i -D webpack-merge
- npm i -D babel-loader @babel/preset-react @babel/preset-env
App组件:
import React, { useState } from "react";
const App = () => {
const [counter, setCounter] = useState(0);
function add() {
setCounter(counter + 1);
}
return (
<div>
<h2>App</h2>
<div>{counter}</div>
<button onClick={add}>+1</button>
</div>
);
};
export default App;
express服务更新为:
ReactDOMServer对象允许你将组件渲染成静态标记,通常,它被使用在Node服务端上
// ES modules import * as ReactDOMServer from 'react-dom/server'; // CommonJS var ReactDOMServer = require('react-dom/server');
const express = require("express");
import React from "react";
import ReactDOMServer from "react-dom/server";
import App from "../app.jsx";
const app = express();
app.get("/", (req, res) => {
// 这里就是服务端渲染 => 生成的是静态页面
const AppHtmlString = ReactDOMServer.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root">
${AppHtmlString}
</div>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
webpack配置更新为:
const path = require("path");
const nodeExternals = require("webpack-node-externals");
module.exports = {
target: "node",
mode: "development",
entry: "./src/server/index.js",
output: {
filename: "server_bundle.js",
path: path.resolve(__dirname, "../build/server"),
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
options: {
presets: ["@babel/preset-react", "@babel/preset-env"],
},
},
],
},
resolve: {
extensions: [".js", ".json", ".wasm", ".jsx"],
},
externals: [nodeExternals()],
};
如果在以上步骤中出现以下报错:
- const AppHtmlString = ReactDOMServer.renderToString(
); ^ SyntaxError: Unexpected token '<' - Error: Cannot find module '@babel/core'
- babel-loader@9 requires Babel 7.12+ (the package '@babel/core'). If you'd like to use Babel 6.x ('babel-core'), you should install 'babel-loader@7'.
- 原因是因为缺少了@babel/core这个包,导致解析不了jsx语法
- 解法办法:npm install @babel/core --save
文件树:
效果:
至此,服务端渲染已完成,但此时页面并不具备交互性,因为渲染成的页面是个静态页面,还需要进行hydration水合!
# Hydration搭建
此步骤将使静态页面具备交互性,从而实现完整的SSR。
创建客户端,并进行Hydration
// client/index.js
import React from "react";
import ReactDOMClient from "react-dom/client";
import App from "../app";
ReactDOMClient.hydrateRoot(document.getElementById("root"), <App />);
express服务更新为:
const express = require("express");
import React from "react";
import ReactDOMServer from "react-dom/server";
import App from "../app.jsx";
const app = express();
// 部署打包好的静态资源
app.use(express.static("build"));
app.get("/", (req, res) => {
// 这里就是服务端渲染
const AppHtmlString = ReactDOMServer.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root">
${AppHtmlString}
</div>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
创建属于客户端的webpack配置:
// client.config.js
const path = require("path");
module.exports = {
target: "web",
mode: "development",
entry: "./src/client/index.js",
output: {
filename: "client_bundle.js",
path: path.resolve(__dirname, "../build/client"),
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
options: {
presets: ["@babel/preset-react", "@babel/preset-env"],
},
},
],
},
resolve: {
extensions: [".js", ".json", ".wasm", ".jsx"],
},
};
文件树:
效果:
访问部署好的静态资源
之后,再引入打包好的App实例,express服务更新为:
const express = require("express");
import React from "react";
import ReactDOMServer from "react-dom/server";
import App from "../app.jsx";
const app = express();
// 部署打包好的静态资源
app.use(express.static("build"));
app.get("/", (req, res) => {
// 这里就是服务端渲染
const AppHtmlString = ReactDOMServer.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
// 注意:这里不能有空格(即换行),否则会报错
<div id="root">${AppHtmlString}</div>
// 这里其实就是Hydration
<script src="/client/client_bundle.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
值得注意的是:上方body中的root部分不能有空格(即换行),否则会报错
效果:
至此,Hydration的工作就完成了,此时页面具有了交互性。
# Router搭建
需要安装的依赖项:
- npm i react-router-dom --save(默认会自动安装react-router)
注意:路由在客户端及服务端都要配置!
使用webpack-merge进行合并配置,config更新为:
// base.config.js
module.exports = {
mode: "development",
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
options: {
presets: ["@babel/preset-react", "@babel/preset-env"],
},
},
],
},
resolve: {
extensions: [".js", ".json", ".wasm", ".jsx"],
},
};
// client.config.js
const path = require("path");
const { merge } = require("webpack-merge");
const baseConfig = require("./base.config");
module.exports = merge(baseConfig, {
target: "web",
entry: "./src/client/index.js",
output: {
filename: "client_bundle.js",
path: path.resolve(__dirname, "../build/client"),
},
});
// webpack.config.js(实际上就是server.config.js,只不过我没这样子命名)
const path = require("path");
const nodeExternals = require("webpack-node-externals");
const { merge } = require("webpack-merge");
const baseConfig = require("./base.config");
module.exports = merge(baseConfig, {
target: "node",
entry: "./src/server/index.js",
output: {
filename: "server_bundle.js",
path: path.resolve(__dirname, "../build/server"),
},
externals: [nodeExternals()],
});
路由配置文件:
import Home from "../views/home";
import Mine from "../views/Mine";
import React from "react";
const routes = [
{
path: "/",
element: <Home />,
},
{
path: "/mine",
element: <Mine />,
},
];
export default routes;
客户端路由配置:
import React from "react";
import ReactDOMClient from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "../app";
ReactDOMClient.hydrateRoot(
document.getElementById("root"),
<BrowserRouter>
<App />
</BrowserRouter>
);
服务端路由配置:
const express = require("express");
import React from "react";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "../app.jsx";
const app = express();
// 部署打包好的静态资源
app.use(express.static("build"));
// 注意这里的路径要改,否则不能匹配到/mine,就会报错404!
app.get("/*", (req, res) => {
// 这里就是服务端渲染
const AppHtmlString = ReactDOMServer.renderToString(
// 指定服务器端渲染的是哪个页面
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root">${AppHtmlString}</div>
<script src="/client/client_bundle.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
App路由配置:
import React, { useState } from "react";
import { Link, useRoutes } from "react-router-dom";
import routes from "./router";
const App = () => {
const [counter, setCounter] = useState(0);
function add() {
setCounter(counter + 1);
}
return (
<div>
<h2>App</h2>
<div>{counter}</div>
<button onClick={add}>+1</button>
{useRoutes(routes)}
<div>
<Link to="/">
<button>Home</button>
</Link>
<Link to="/mine">
<button>Mine</button>
</Link>
</div>
</div>
);
};
export default App;
文件树:
效果:
至此,Router搭建完成。
# Redux搭建
需要的依赖项:
- npm i react-redux @reduxjs/toolkit
创建store以及home切片
// store/index.js
import { configureStore } from "@reduxjs/toolkit";
import homeReducer from "./modules/home";
const store = configureStore({
reducer: {
home: homeReducer,
},
});
export default store;
// store/modules/home.js
import { createSlice } from "@reduxjs/toolkit";
const homeSlice = createSlice({
name: "home",
initialState: {
counter: 100,
},
reducers: {
changeCounterAction(state, { payload }) {
state.counter += payload;
},
},
});
export const { changeCounterAction } = homeSlice.actions;
export default homeSlice.reducer;
客户端配置store:
import React from "react";
import ReactDOMClient from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "../app";
import { Provider } from "react-redux";
import store from "../store/index";
ReactDOMClient.hydrateRoot(
document.getElementById("root"),
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
服务端配置store:
const express = require("express");
import React from "react";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "../app.jsx";
import { Provider } from "react-redux";
import store from "../store/index";
const app = express();
// 部署打包好的静态资源
app.use(express.static("build"));
app.get("/*", (req, res) => {
// 这里就是服务端渲染
const AppHtmlString = ReactDOMServer.renderToString(
// 指定服务器端渲染的是哪个页面
<Provider store={store}>
<StaticRouter location={req.url}>
<App />
</StaticRouter>
</Provider>
);
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root">${AppHtmlString}</div>
<script src="/client/client_bundle.js"></script>
</body>
</html>
`);
});
app.listen(3000, () => {
console.log("server is running at http://localhost:3000");
});
页面展示数据:
// views/home.jsx
import React from "react";
import { useSelector, shallowEqual, useDispatch } from "react-redux";
import { changeCounterAction } from "../store/modules/home";
const Home = () => {
const dispatch = useDispatch();
const { counter } = useSelector(
(state) => ({
counter: state.home.counter,
}),
shallowEqual
);
function handleCounterClick() {
dispatch(changeCounterAction(10));
}
return (
<div>
<h2>Home</h2>
<h3>{counter}</h3>
<button onClick={handleCounterClick}>+10</button>
</div>
);
};
export default Home;
// views/mine.jsx
import React from "react";
import { useSelector, shallowEqual, useDispatch } from "react-redux";
import { changeCounterAction } from "../store/modules/home";
const Mine = () => {
const dispatch = useDispatch();
const { counter } = useSelector(
(state) => ({
counter: state.home.counter,
}),
shallowEqual
);
function handleCounterClick() {
dispatch(changeCounterAction(20));
}
return (
<div>
<h2>Mine</h2>
<h3>{counter}</h3>
<button onClick={handleCounterClick}>+20</button>
</div>
);
};
export default Mine;
文件树:
效果:
关于异步action,即createAsyncThunk API的使用和原来在React项目中是一样的,这里就没有演示了。
至此,Redux的搭建已完成,并且已经成功搭建了一个简易的React SSR了。
# 总结
在React中创建SSR应用时,需要调用ReactDOM.hydrateRoot函数(client中调用)
- hydrateRoot:创建水合Root,是在激活的模式下渲染App
- 服务器端可以用ReactDOM.renderToString来进行渲染静态页面
- 路由需要在客户端和服务器端都配置,并且API是不同的