以下是个人对 React SSR 的理解,纯手工打造,可扩展性高(业务依赖低),以及在手写 React SSR 中遇到的问题,感谢阅读
随着前端的发展,前端从页面仔再到现在大前端时代的到来,从浏览器到跨端,从客户端到服务器,前端能做的事越来越多,也越来越强稳,包括跨平台的桌面软件、跨平台的手机 APP、跨浏览器和手机端的页面及小程序、Html5 游戏、还有一些偏门的技术,比如小机器人(Ruff)等等。
主要使用技术栈:
我们知道,SSR 之所以能实现,都是基于 Virtual DOM,有人说 Virtual DOM 的出现是因为它更快,其实并不是更快,而是减少了真实 DOM 重排重绘的的次数,我们都知道,操作 DOM 很快,而在通过 Js 内部去操作,有时候反而会更慢,我们知道 DOM 很昂贵,任何一个小节点,内存都不小,Virtual DOM 主要是为了解决频繁操作真实 DOM,以最小的代价来渲染真实 DOM,况且操作真实 DOM 还会阻塞主线程,Virtual DOM 不会立马渲染真实 DOM,也不会大面积的重排与重绘,当然,Virtual DOM 抽象了真实 DOM,带来了跨平台的能力,还有现在比较火的多端统一的趋势。
既然有了 Virtual DOM,React 和 Node 都可以解析以及执行,最后渲染成真实 HTML,刷新进行 server 端渲染,站内切换进行 JS 控制视图,而且都支持 JavaScript,但是,紧接着面临如何挂载的问题,我们知道 React.render()可以进行 DOM 的挂载,那么 Node 呢,React 提供了 renderToString(),返回 HTML 字符串,在浏览器调用 ReactDOM.hydrate()来复用 Html,挂载事件。
接下来,我们会想到,那么前端路由和服务端路由一样,怎么区分是服务端渲染还是客户端渲染,我们知道,如果是刷新,首先会进入 serwer 端的真路由,然后再到浏览器,才会再到浏览器路由。
服务端路由要和客户端路由一样的写出一份吗,答案是的,只是我们不需要准备两份,维护起来也麻烦,只需要一份路由,双端都可以执行,业内出了一个词,叫路由同构。
服务端渲染时,如何拿到数据,并返回有数据且渲染好的 html,涉及到了数据同构
服务端返回的 html 样式怎么办,还要等待静态资源吗,答案是不需要,我们可以直接返回 style,更快的展示友好的视图,也就是样式同构。
下面,拿我的博客来举例,手写 React SSR




首先,我们要知道我们的目标技术栈的打包技术,结合我们的预想及脑补,打包项目,当然,也要了解上面的原理以及那些 api 可以实现你想要的
// webpack.config.js
/*
* @Author: your name
* @Date: 2020-11-13 12:25:36
* @LastEditTime: 2020-11-27 18:07:33
* @LastEditors: your name
* @Description: In User Settings Edit
* @FilePath: /duanxl.com/webpack.config.js
*/
// yargs-parser可以取到shell参数
const argv = require("yargs-parser")(process.argv.slice(2));
const _mode = argv.mode;
// merge其他webpack配置
const { merge } = require("webpack-merge");
const { resolve } = require("path");
const _mergeConfig = require(`./config/webpack.${_mode}.js`);
const HtmlWebpackPlugin = require("html-webpack-plugin");
// script嵌入属性
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin')
// 通过命令区分上线开发环境,使用不同模板
const _htmlSrc =
_mode === "production"
? "./src/client/server.html"
: "./src/client/index.html";
// webpack基础配置
const basicConfig = {
entry: resolve("/src/client/index.tsx"),
output: {
path: _mode === "production" ? resolve(__dirname, "src/server/assets") : resolve(__dirname, "dist"),
filename: _mode === "production" ? "[name].[hash].js" : "[name].js",
publicPath: '/'
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
alias: {
'@': resolve(__dirname, 'src')
},
},
plugins: [
new HtmlWebpackPlugin({
filename: "index.html",
template: _htmlSrc,
}),
new ScriptExtHtmlWebpackPlugin({
defaultAttribute: _mode === "production" ? 'defer' : ''
})
],
module: {
rules: [
{
test: /\.tsx?$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: "babel-loader",
},
},
{
test: /\.(less|css)$/,
use: [
{
loader: 'style-loader', // creates style nodes from JS strings
},
{
loader: 'css-loader', // translates CSS into CommonJS
},
{
loader: 'less-loader', // compiles Less to CSS
},
],
}
],
},
};
module.exports = merge(basicConfig, _mergeConfig)
这里的 babel 配置也很简单,使用了@babel/preset-react、@babel/preset-typescript、@babel/plugin-syntax-dynamic-import,针对 react、typescript、动态导入的 babel 配置,开发环境和上线环境的 webpack 配置这里就不说了,比如我们上线环境要打包 less 等,静态资源的打包、optimization 等
// .babelrc
{
"presets": [
"@babel/preset-react",
"@babel/preset-typescript",
],
"plugins": [
"@babel/plugin-syntax-dynamic-import"
]
}
// webpack.server.js
const { resolve, join } = require("path");
// 独立css
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
entry: join(__dirname, "./src/client/pages/App/server.tsx"),
target: 'node',
mode: 'development',
output: {
path: join(__dirname, "src/server/entry"),
filename: "index.js",
libraryTarget: "commonjs2" //这里注意,如果我们想打包出module.exports规范的包,使用commonjs2规范
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx"],
alias: {
'@': resolve(__dirname, 'src')
},
},
plugins: [
new MiniCssExtractPlugin({
filename: "index.css",
chunkFilename: "index.css",
ignoreOrder: false,
}),
],
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
include: [resolve('src')],
exclude: /node-modules/,
use: {
loader: "babel-loader",
},
},
{
test: /\.(less|css)$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
options: {
publicPath: './',
},
},
{
loader: 'css-loader', // translates CSS into CommonJS
options: {
importLoaders: 1,
}
},
{
loader: 'less-loader', // compiles Less to CSS
},
],
},
{
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
name: './images/[contenthash].[ext]'
}
},
],
},
],
},
externals: Object.keys(require('./package.json').dependencies), //剔除开发环境引用
};
使用官方文档里的 lazy API,实现动态路由打包分割以及懒加载
import React, { lazy, Suspense } from "react";
import { Route, RouteProps, Switch } from "react-router-dom";
import Loading from "../client/components/Loading";
interface RouterType extends RouteProps {
loadData?: Function;
}
const Home = lazy(() => import("../client/pages/Home"));
const Blogs = lazy(() => import("../client/pages/Blogs"));
const Detail = lazy(() => import("../client/pages/Details"));
const About = lazy(() => import("../client/pages/About"));
const ClientRouters: RouterType[] = [
{
path: "/",
component: Home,
exact: true,
},
{
path: "/blogs",
component: Blogs,
exact: true,
},
{
path: "/detail/:id",
component: Detail,
exact: true,
},
{
path: "/about",
component: About,
exact: true,
},
];
const RouterPages = () => {
return (
<>
<Suspense fallback={<Loading />}>
<Switch>
{ClientRouters.map((t: RouterType, index: number) => {
const LazyComponent: any = t.component;
return (
<Route
path={t.path}
exact={t.exact}
key={`page${index}`}
forceRefresh
component={LazyComponent}
></Route>
);
})}
</Switch>
</Suspense>
</>
);
};
export default RouterPages;
export { ClientRouters };
Node 我们使用了 Nest,Nest 相比于 koa 等技术更先进,无论是 IOC、MVC、typescript 等,当然,也有缺点,在打包成 Es5 时,因为 typescript 版本配置难以逾越。这里我们不依托于 Nest CLI,直接去 github 看 Nest 主要构成以及主要依赖
安装完主要模块,开始搬砖(看 API),以下是我简单的主文件入口
// app.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./modules/app.module";
import { join } from "path";
// import { createProxyMiddleware } from 'http-proxy-middleware'
import { NestExpressApplication } from "@nestjs/platform-express";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(join(__dirname, "/", "assets"));
app.setBaseViewsDir(join(__dirname, "/", "views"));
app.setViewEngine("hbs");
// app.use('/', createProxyMiddleware({ target: 'http://127.0.0.1:3000/', changeOrigin: true }))
app.enableCors(); //开发客户端暂时打开cors
await app.listen(3000);
}
bootstrap();
紧接着,我们可以将上面打包好的两套代码(生产环境)开始进行 render,我们使用官网的 renderToString()
/*
* @Author: your name
* @Date: 2020-11-07 17:42:34
* @LastEditTime: 2020-11-29 19:07:51
* @LastEditors: Please set LastEditors
* @Description: In User Settings Edit
* @FilePath: /duanxl.com/src/server/controllers/app.controller.ts
*/
import { Controller, Get, Next, Req, Res } from "@nestjs/common";
import { Response, Request } from "express";
import { AppService } from "../services/app.service";
import { renderToString } from "react-dom/server";
import { createServerStore } from "../../redux/index";
import App from "../entry/index.js";
import React from "react";
import { StaticRouter } from "react-router";
import { createReadStream } from "fs";
import { join } from "path";
import { matchRoutes } from "react-router-config";
import { Provider } from "react-redux";
import routes from "../../redux/matches";
import util from "util";
import { getTitle } from "../middlewares/getTitle";
@Controller("*")
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
async helloData(
@Req() req: Request,
@Res() res: Response,
@Next() next: Function
) {
if (req.url.includes("api") && !req.url.includes("api-about")) {
next();
} else {
const readCss = function () {
return new Promise((resolve: any) => {
const stream = createReadStream(
join(__dirname, "../entry/index.css")
);
stream.on("data", function (data) {
resolve(`<style>${String(data)}</style>`);
});
});
};
let urlParam = "";
if (req.url.includes("/detail/")) {
urlParam = req.url.replace("/detail", "");
}
const matchedRoutes = matchRoutes(routes, req.url);
const promises: any[] = [];
const store = createServerStore();
matchedRoutes.forEach((item) => {
if (item.route.loadData) {
promises.push(item.route.loadData(store, urlParam));
}
});
const cssString = await readCss();
const title = getTitle(req.url.replace("/detail/", ""));
const metaString = `
<title>${title} | 段鑫磊</title>
<meta name="description" content={段鑫磊 | ${title}} />
<meta name="keywords" content={段鑫磊 | ${title}} />
`;
Promise.all(promises).then(() => {
const renderString: any = renderToString(
<Provider store={store}>
<div className="config">
<StaticRouter location={req.url}>
<App data={store.getState()} url={req.url} />
</StaticRouter>
</div>
</Provider>
);
res.render("index", {
content: renderString,
cssString,
metaString,
SERVER_DATA: `<script>window.DUANXL_CN = ${util.inspect(
store.getState() || { data: "" }
)}</script>`,
});
});
}
}
}
这里我的思路是首先代理所有路由,然后分级处理,readCss 方法是流读取打包好的 css,可以理解为我的样式同构
从上面可以看到,在 node 中,我们解析了组件中的 loadData 方法,其实就是获取数据,然后注入 state 的过程,以及将数据注水道 window,下面看一下我的 loadDatas,没什么可说的,就是请求接口
// /src/redux/loadDatas.ts
import { Store } from "redux";
import { request } from "../client/utils/request";
const BlogsLoadData = async (store: Store, ..._args: any[]) => {
const data = await request("https://duanxl.com/api/lists");
store.dispatch({
type: "DUANXL_CN",
payload: {
type: "list",
data,
},
});
};
const DetailLoadData = async (store: Store, url: string) => {
const data = await request(`https://duanxl.com/api/detail/${url}`);
store.dispatch({
type: "DUANXL_CN",
payload: {
type: "detail",
data,
},
});
};
const AboutLoadData = async (store: Store, url: string) => {
const data = await request(`https://duanxl.com/api/about`);
store.dispatch({
type: "DUANXL_CN",
payload: {
type: "about",
data,
},
});
};
export { BlogsLoadData, DetailLoadData, AboutLoadData };
上面的 type 是我区分路由用的,因为自己手写 ssr,避免将业务组件执行,分离了数据请求。。。当然,还有很多没做,比如 404、ts-docs、jest 等等,后面会一步步优化,现有的博客业务很简单
最后,我们看一下,Server Render 后,客户端怎么脱水,还有客户端判断是否需要请求等
// /redux/index.ts
import { createStore } from "redux";
export interface StateType {
data: unknown;
title: string;
}
export interface ActionType {
type: string;
payload: unknown;
}
const state: StateType = {
data: null,
title: "段鑫磊 | 段鑫磊的博客 | 前端技术",
};
const reducer = (initState = state, action: any) => {
switch (action.type) {
case "DUANXL_CN":
return {
...initState,
data: action.payload.data,
type: action.payload.type,
title: action.payload.title ?? initState.title,
};
default:
return { ...initState };
}
};
const createClientStore = () => {
return createStore(reducer, (window as any).DUANXL_CN);
};
const createServerStore = () => {
return createStore(reducer);
};
export { createClientStore, createServerStore };
我们使用经典的 Redux 进行状态管理,可以看到,服务端状态管理 Commonjs,数据请求则被注水到 window 上,浏览器接手,就会 createStore(),简称脱水,在组件里,我们使用 connect 将 dispatch 以及 state 注入到 components 中,这样就可以得到 state
拿简单的一个 page 来举例
import React, { useEffect } from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { request } from "@/client/utils/request";
import Skeleton from "antd/es/skeleton";
import marked from "marked";
import hljs from "highlight.js";
import "../Details/index.less";
import "highlight.js/styles/agate.css";
import javascript from "highlight.js/lib/languages/javascript";
import { AboutLoadData } from "@/redux/loadDatas";
import Header from "@/client/components/Header";
import { Helmet } from "react-helmet";
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
pedantic: false,
sanitize: false,
breaks: true,
smartLists: true,
smartypants: true,
highlight: function (code) {
hljs.registerLanguage("javascript", javascript);
return hljs.highlightAuto(code, ["javascript", "typescript", "css"]).value;
},
});
const About = (props: any) => {
useEffect(() => {
if (props.data && props.type === "about") {
} else {
request(`https://duanxl.com/api/about`).then((data) => {
props.getData(data);
});
}
}, [props]);
return (
<>
<Header active="about" />
<Helmet>
<title>段鑫磊 | 前端博客 | 关于博客</title>
<meta name="description" content={`段鑫磊 | 段鑫磊的博客 | 前端`} />
<meta name="keywords" content={`段鑫磊 | 段鑫磊的博客 | 前端`} />
</Helmet>
{props.data && props.type === "about" ? (
<>
<div
className="detail"
dangerouslySetInnerHTML={{ __html: marked(props.data) }}
></div>
</>
) : (
<Skeleton active />
)}
</>
);
};
function mapStateToProps(state: any) {
return state;
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
getData(data: any) {
return dispatch({
type: "DUANXL_CN",
payload: {
type: "about",
data,
},
});
},
};
}
About.loadData = AboutLoadData;
export default connect(mapStateToProps, mapDispatchToProps)(About);
上面也说了。这个 type 是我单独传递的,目的就是判断站内切是否请求数据,这里有瑕疵,可以改造成一个 isServerRender 的变量,然后判断直刷还是站内切
结语:博客很简单,是我手写 React SSR 简单的认识以及实践,Node 端使用 Ts-node+pm2 部署,因为静态编译,并不会有运行性能的问题,文章做得是流直出 Md,CI&CD 使用 jenkins+github+sonar。