如何在服务器端渲染 React、在客户端进行混合以及组合客户端和服务器路由
如何在服务器端渲染 React、在客户端进行混合以及组合客户端和服务器路由
React SSR 快速入门
⚛️
📦
如何在服务器端渲染 React、在客户端进行混合以及组合客户端和服务器路由
在本文中,我想分享一种简单的方法,用于在服务器端渲染
React 应用程序,并在
客户端对 JavaScript 包进行 hydrate 处理。如果您不知道“hydrate”是什么,我会尝试解释一下:假设
您使用 ReactDOMServer API 将 React 组件渲染为字符串,
您将向客户端发送静态 HTML。为了处理
您在组件中设置的动态事件,您必须将此 HTML
标记附加到其原始 React 组件。React 通过向生成的标记发送一个标识来实现这一点,
以便它能够稍后解析哪个事件应该
附加到 DOM 中的哪个元素。(某种程度上)。您可以在
官方文档中阅读更多内容。
这是最终的代码和演示
在我之前尝试在服务器上正确渲染我的应用并
在客户端进行 hydrate 操作时,我迷失在了 Webpack 的配置中:它
在每个主要版本中都会发生很大变化,因此文档和教程经常会过时。这也是为了节省大家的时间。
我试图使其尽可能详细以简化学习过程,因此我将其分为七个部分:
- 初始 Webpack 配置
- 首次服务器端渲染
- 切换到 Streams
- 将 Express 路由器与 React Router 结合起来
- 使用 Express 查询字符串
- 创建测试环境
- (尝试)代码分割
初始 Webpack 配置
首先我们应该安装依赖项:
npm i -E express react react-dom
以及我们的开发依赖项:
npm i -DE webpack webpack-cli webpack-node-externals @babel/core babel-loader @babel/preset-env @babel/preset-react
其他有助于我们开发的工具:
npm i -DE concurrently nodemon
让我们配置 Webpack。我们需要两个 Webpack 配置,一个用于
Node.js 服务器代码,另一个用于客户端代码。如果您想查看我们应用程序的结构,请
参阅代码库。另外,请注意:
- 我使用的是ES2015 预设,而不是新的env 预设,如果您愿意,可以自行更改。
- 我还添加了transform-class-properties Babel 插件,这样我就不需要到处
.bind
都写我的类方法了。你可以自行决定是否使用,但它默认在CRA上。
由于我对服务器和客户端使用相同的模块规则,因此我将
它们提取到一个变量中js
:
// webpack.config.js
const js = {
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"],
},
},
};
请注意,在两种配置中我使用不同的目标。
在服务器配置上,我以前尝试进行服务器端渲染时错过了两个细节,因此我甚至无法构建我的应用程序: Webpack 插件webpack-node-externalsnode.__dirname
的属性和使用。
在第一种情况下,我将其设置__dirname
为 false,因此当 Webpack 编译我们的服务器代码时,它不会提供 polyfill 并会保留 的原始值__dirname
,当我们使用 Express 提供静态资产时
,此配置很有用,如果我们不将其设置为false
Express,将无法找到 的
引用__dirname
。
使用webpack-node-externals
是为了让 Webpack 忽略 的内容node_modules
,
否则,它会将整个目录包含在最终的 bundle 中。(我不确定
为什么这不是默认行为,我们需要一个外部库来实现这一点。
我的理解是,如果你将配置目标设置为
node,它应该将 排除node_modules
在 bundle 之外。)
注意:在这两种情况下,我发现文档确实令人困惑,所以请不要相信我的话,如果有其他问题,请自行检查文档。
// webpack.config.js
const serverConfig = {
mode: "development",
target: "node",
node: {
__dirname: false,
},
externals: [nodeExternals()],
entry: {
"index.js": path.resolve(__dirname, "src/index.js"),
},
module: {
rules: [js],
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name]",
},
};
以及我们的客户端配置:
// webpack.config.js
const clientConfig = {
mode: "development",
target: "web",
entry: {
"home.js": path.resolve(__dirname, "src/public/home.js"),
},
module: {
rules: [js],
},
output: {
path: path.resolve(__dirname, "dist/public"),
filename: "[name]",
},
};
最后,我们将导出这两种配置:
// webpack.config.js
module.exports = [serverConfig, clientConfig];
您可以在此处找到最终文件
首次服务器端渲染
现在我们将创建一个组件并将其挂载到 DOM 中:
// src/public/components/Hello.js
import React from "react";
const Hello = (props) => (
<React.Fragment>
<h1>Hello, {props.name}!</h1>
</React.Fragment>
);
export default Hello;
这是将在 DOM 中安装我们的组件的文件,请注意,我们
使用的hydrate
方法react-dom
而不是render
通常的方法。
// src/public/home.js
import React from "react";
import ReactDOM from "react-dom";
import Hello from "./components/Hello";
ReactDOM.hydrate(
<Hello name={window.__INITIAL__DATA__.name} />,
document.getElementById("root")
);
然后我们就可以编写我们的服务器代码了:
// src/index.js
import express from "express";
import path from "path";
import React from "react";
import ReactDOMServer from "react-dom/server";
import Hello from "./public/components/Hello";
const app = express();
app.use("/static", express.static(path.resolve(__dirname, "public")));
app.get("/", (req, res) => {
const name = "Marvelous Wololo";
const component = ReactDOMServer.renderToString(<Hello name={name} />);
const html = `
<!doctype html>
<html>
<head>
<script>window.__INITIAL__DATA__ = ${JSON.stringify({ name })}</script>
</head>
<body>
<div id="root">${component}</div>
<script src="/static/home.js"></script>
</body>
</html>`;
res.send(html);
});
app.listen(3000);
请注意,我们正在对内容进行字符串化,以便我们可以在 客户端name
上重用它的值来补充我们的组件。
然后我们将创建一个 NPM 脚本来运行我们的项目:
// package.json
"scripts": {
"dev": "webpack && concurrently \"webpack --watch\" \"nodemon dist\""
}
这里我们构建了 bundle 文件,然后concurrently
观察
它的变化,并运行服务器/dist
。如果我们在第一次构建之前就启动应用
,命令就会崩溃,因为还没有文件/dist
。
如果您npm run dev
在终端中,您的应用程序应该可以在 上使用localhost:3000
。
切换到 Streams
现在我们将切换到流 API 以提高我们的性能,如果您
不知道流是什么,您可以
在这里阅读有关它们的更多信息,并在这里
阅读 有关 React 的更多具体信息。
这是我们的新/
路线:
app.get("/", (req, res) => {
const name = "Marvelous Wololo";
const componentStream = ReactDOMServer.renderToNodeStream(
<Hello name={name} />
);
const htmlStart = `
<!doctype html>
<html>
<head>
<script>window.__INITIAL__DATA__ = ${JSON.stringify({ name })}</script>
</head>
<body>
<div id="root">`;
res.write(htmlStart);
componentStream.pipe(res, { end: false });
const htmlEnd = `</div>
<script src="/static/home.js"></script>
</body>
</html>`;
componentStream.on("end", () => {
res.write(htmlEnd);
res.end();
});
});
将 Express 路由器与 React Router 结合起来
我们可以将 Express 路由器与 React Router 库一起使用。
安装 React Router:
npm i -E react-router-dom
首先我们需要在中添加一个新的 Webpack 条目clientConfig
:
// webpack.config.js
entry: {
'home.js': path.resolve(__dirname, 'src/public/home.js'),
'multipleRoutes.js': path.resolve(__dirname, 'src/public/multipleRoutes.js')
}
然后让我们像之前一样创建两个组件。第一个组件与React Router 文档Home
中的基本示例几乎相同
,我们将其命名为:MultipleRoutes
// src/public/components/MultipleRoutes.js
import React from "react";
import { Link, Route } from "react-router-dom";
const Home = () => (
<div>
<h2>Home</h2>
</div>
);
const About = () => (
<div>
<h2>About</h2>
</div>
);
const Topics = ({ match }) => (
<div>
<h2>Topics</h2>
<ul>
<li>
<Link to={`${match.url}/rendering`}>Rendering with React</Link>
</li>
<li>
<Link to={`${match.url}/components`}>Components</Link>
</li>
<li>
<Link to={`${match.url}/props-v-state`}>Props v. State</Link>
</li>
</ul>
<Route path={`${match.url}/:topicId`} component={Topic} />
<Route
exact
path={match.url}
render={() => <h3>Please select a topic.</h3>}
/>
</div>
);
const Topic = ({ match }) => (
<div>
<h3>{match.params.topicId}</h3>
</div>
);
const MultipleRoutes = () => (
<div>
<ul>
<li>
<Link to="/with-react-router">Home</Link>
</li>
<li>
<Link to="/with-react-router/about">About</Link>
</li>
<li>
<Link to="/with-react-router/topics">Topics</Link>
</li>
<li>
<a href="/">return to server</a>
</li>
</ul>
<hr />
<Route exact path="/with-react-router" component={Home} />
<Route path="/with-react-router/about" component={About} />
<Route path="/with-react-router/topics" component={Topics} />
</div>
);
export default MultipleRoutes;
和
// src/public/multipleRoutes.js
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import MultipleRoutes from "./components/MultipleRoutes";
const BasicExample = () => (
<Router>
<MultipleRoutes />
</Router>
);
ReactDOM.hydrate(<BasicExample />, document.getElementById("root"));
在我们的服务器中,我们将导入新组件以及 React Router
库。我们还将创建一个通配符路由/with-react-router*
,以便所有
请求都/with-react-router
将在这里处理。例如:/with-react-router/one
、/with-react-router/two
、/with-react-router/three
。
// src/index.js
// ...
import { StaticRouter as Router } from "react-router-dom";
import MultipleRoutes from "./public/components/MultipleRoutes";
// ...
app.get("/with-react-router*", (req, res) => {
const context = {};
const component = ReactDOMServer.renderToString(
<Router location={req.url} context={context}>
<MultipleRoutes />
</Router>
);
const html = `
<!doctype html>
<html>
<head>
<title>document</title>
</head>
<body>
<div id="root">${component}</div>
<script src="/static/multipleRoutes.js"></script>
</body>
</html>
`;
if (context.url) {
res.writeHead(301, { Location: context.url });
res.end();
} else {
res.send(html);
}
});
请注意,我们在 客户端和服务器react-router-dom
中使用了不同的路由器。
现在,你的应用应该同时具备客户端和服务器渲染的路由。为了
改进导航,我们将/with-react-router
在组件中添加一个链接Hello
:
// src/public/components/Hello.js
// ...
const Hello = (props) => (
<React.Fragment>
<h1>Hello, {props.name}!</h1>
<a href="/with-react-router">with React Router</a>
</React.Fragment>
);
使用 Express 查询字符串
由于我们已经使用 Express 设置了一个完整的 Node.js 应用程序,因此我们可以访问
Node 提供的所有功能。为了演示这一点,我们将通过路由中的查询字符串接收组件 的name
prop :Hello
/
// src/index.js
app.get('/', (req, res) => {
const { name = 'Marvelous Wololo' } = req.query
// ...
这里我们为变量定义了一个默认值,name
如果req.query
没有
提供默认值。因此,Hello
组件会渲染你传入的任何值
。name
localhost:3000?name=anything-I-want-here
创建测试环境
为了测试我们的 React 组件,我们首先需要安装一些依赖项。我选择了 Mocha 和 Chai 来运行和断言我们的测试,但您也可以使用任何
其他测试运行器/断言库。测试此环境的缺点是
我们还必须编译测试文件(我不确定是否有其他
方法可以解决这个问题,我认为没有)。
npm i -DE mocha chai react-addons-test-utils enzyme enzyme-adapter-react-16
因此,我将为测试创建一个新的 Webpack 配置,您会注意到该配置
与我们已有的服务器文件几乎完全相同:
// webpack.tests.js
const webpack = require("webpack");
const nodeExternals = require("webpack-node-externals");
const path = require("path");
const js = {
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"],
},
},
};
module.exports = {
mode: "development",
target: "node",
node: {
__dirname: false,
},
externals: [nodeExternals()],
entry: {
"app.spec.js": path.resolve(__dirname, "specs/app.spec.js"),
},
module: {
rules: [js],
},
output: {
path: path.resolve(__dirname, "test"),
filename: "[name]",
},
};
我将在项目的根目录中创建一个测试文件app.spec.js
和一个目录 。specs
// specs/app.spec.js
import { expect } from "chai";
import Enzyme, { shallow } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import React from "react";
import Hello from "../public/components/Hello";
Enzyme.configure({ adapter: new Adapter() });
describe("<Hello />", () => {
it("renders <Hello />", () => {
const wrapper = shallow(<Hello name="tests" />);
const actual = wrapper.find("h1").text();
const expected = "Hello, tests!";
expect(actual).to.be.equal(expected);
});
});
我们还将创建一个新的(又长又丑的)NPM 脚本来运行我们的测试:
"scripts": {
"dev": "webpack && concurrently \"webpack --watch\" \"nodemon dist\"",
"test": "webpack --config webpack.test.js && concurrently \"webpack --config webpack.test.js --watch\" \"mocha --watch\""
}
此时,运行npm test
应该通过一个测试用例。
(尝试)代码分割
说实话,我觉得用 Webpack 进行代码拆分的新方法有点难
理解
,但我还是会尝试一下。请记住,这不是
最终的解决方案,你可能需要对 Webpack 进行一些调整,以提取
其中的最佳功能,但我现在不想为此费力地查看文档。我目前得到的结果对我来说已经足够好了。抱歉。如有 疑问,
请参考文档。
因此,如果我们添加:
// webpack.config.js
// ...
optimization: {
splitChunks: {
chunks: "all";
}
}
// ...
对于我们的clientConfig
,Webpack 会将我们的代码拆分为四个文件:
- home.js
- multipleRoutes.js
- 供应商~home.js~multipleRoutes.js
- 供应商~multipleRoutes.js
运行时它甚至会给出一份漂亮的报告npm run dev
。我认为这些文件的含义
不言自明,但我们仍然有一些特定页面独有的文件
,以及一些包含通用供应商代码的文件,这些文件旨在在
页面之间共享。因此,我们在路由底部的脚本标签/
应该是:
<script src="/static/vendors~home.js~multipleRoutes.js"></script>
<script src="/static/home.js"></script>
对于/with-react-router
路线:
<script src="/static/vendors~home.js~multipleRoutes.js"></script>
<script src="/static/vendors~multipleRoutes.js"></script>
<script src="/static/multipleRoutes.js"></script>
如果您好奇,那么在将配置模式设置为 的情况下,捆绑包大小的差异如下production
:
Asset Size
home.js 1.82 KiB
multipleRoutes.js 3.27 KiB
vendors~multipleRoutes.js 24.9 KiB
vendors~home.js~multipleRoutes.js 127 KiB
和development
:
Asset Size
home.js 8.79 KiB
multipleRoutes.js 13.6 KiB
vendors~multipleRoutes.js 147 KiB
vendors~home.js~multipleRoutes.js 971 KiB
好了,我想就是这样了。希望你喜欢这个小教程,也希望它能对你的项目有所帮助。
文章来源:https://dev.to/marvelouswololo/how-to-server-side-render-react-enchant-it-on-the-client-and-combine-client-and-server-routes-1a3p