create-react-app项目支持服务端渲染
说到服务渲染,其实已经有很多成熟的框架,vercel 的 Next.js 字节的morder.js 阿里的umi.js 几乎每个React开发框架都有开箱即用的SSR能力。
对于已经使用了这些开发框架的项目来说,开启SSR并不难,但是不少项目由于前期没有使用开发框架只能自己手动搭建服务端渲染,接下来我们以 create-react-app 创建的项目为例,循序渐进搭建一个属于自己的服务端渲染。
在开始之前我们用一张图看一下服务渲染的过程
如图所示SSR服务器需要根据当前访问的路径返回对应的HTML
如何生成HTML?
ReactDom提供了API可以在Nodejs服务器中运行React 应用并生成HTML,听上去并不难,但是要想在Nodejs 服务器中运行React应用,我们还需要做一些处理,如果将客户端渲染的构建产物直接在nodejs中运行会报错。
所以接下来我们要做的第一件事就是,构建出可以在Nodejs 中运行的bundle
如上图所示
源码经过webpack打包产生两份bundle,
Client Bundle 是客户端渲染构建的产物
Server Bundle 是服务端渲染构建的产物 (cra项目默认不支持,我们需要自己创建)
对于create-react-app 创建的项目而言 ,一种方案是eject 然后修改,一种方案是基于react-scripts进行修改,为了不破坏项目结构,我们采用后者,当然我们后续可以兼容这两种形式。
step1: 创建项目
首先我们需要一个CRA项目为了方便你可以直接clone 我已经创建好的项目
git clone https://github.com/pengzai-dev/UniversalSSR
我们先来熟悉一下项目的目录结构
├── lerna.json
├── package.json
└── packages
├── front
├── react-ssr-scripts
│ ├── bin
│ │ └── react-ssr-scripts.js
│ ├── config
│ │ └── webpack.ssr.config.js
│ ├── package.json
│ ├── scripts
│ │ └── start.js
│ ├── utils
│ │ ├── project.js
│ │ └── runWebpack.js
└── ssr-server
├── index.js
├── package.json
可能与其他文章有所不同,这里采用了monorepo 来管理项目的各个功能,至于是否采用这种形式,你可以根据自己的需要来选择,当然我推荐使用这种形式,这样我们后续可以直接在其他项目中复用SSR能力。
项目中我们拆分成了三个package
- front 是 采用create-react-app 创建的项目
- react-ssr-script 是一个命令行工具,可以在front中运行 react-ssr-script 构建 Server Bundle
- ssr-server SSR服务器,负责接受用户的请求,生成并返回HTML
STEP2: 构建server builder
接下来我们先完成react-ssr-script 部分
这里比较重要的是服务端渲染的webpack配置文件,我们可以直接复用react-scripts中的webpack config,否则我们可能需要重新添加每一个 loader,plugin 想想都是一件头疼的事
关于如果开发一个nodejs 的命令行二方库,这里就不做过多的介绍了,
我们直接来看 服务选渲染的 webpack config
我们先来分析一下我们的 Server Bundle 与 Client Bundle 具体由哪些区别
- 入口文件可能不同
- 导出目录不同
- webpack的target属性不同
- Server Bundle 需要以CJS的格式导出(便于被nodejs 服务器引用)
- 部分插件可能在服务端渲染来说是多余的(比如将热更新、HtmlWebpackPlugin)等
结合上述差异的分析,我们利用webpack-merge 在react-script 的基础配置进行修改,代码如下:
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const { merge } = require('webpack-merge');
// 部分插件SSR时可以不用添加
const ssrDisablePlugins = [
'HtmlWebpackPlugin',
'InlineChunkHtmlPlugin',
'HotModuleReplacementPlugin',
'ManifestPlugin',
'ReactRefreshWebpackPlugin',
'DefinePlugin'
];
module.exports = function ( webpackEnv = 'development') {
const projectRoot = process.cwd();
const baseConfigFactory = require(path.resolve(projectRoot,'node_modules/react-scripts/config/webpack.config.js'));
// TIP: 这里采用了production的配置作为基础,后续会解释
const baseConfig = baseConfigFactory('production');
// 去掉多余的插件
baseConfig.plugins = baseConfig.plugins.filter((plugin) => {
return !ssrDisablePlugins.includes(plugin.constructor.name);
});
// 读取env文件,或者自定义
return merge(baseConfig, {
entry: { main: '/src/App.js' },
output: {
filename: '[name].js',
path: path.resolve(
projectRoot,
'build/ssr'
),
libraryTarget: 'commonjs',
},
optimization: {
minimize: false,
splitChunks: false,
runtimeChunk: false,
},
target: 'node',
externals: [nodeExternals()],
});
};
这里需要注意的是:
- 我们以react-scripts 的默认配置作为基础进行扩展
- 我们构建的target 是node,并且导出的是一个二方库(可以在后续引入)
- 我们排除了一下多余的插件,比如html的生成,对于服务端渲染是多余的
- 利用nodeExternals排除node_modules 中的包,对于nodejs 来说这些可以直接读取,无需打包
- 关闭了部分优化功能,比如拆包等,这些对于客户端来说有用的优化,对于服务端渲染来说帮助并不明显。
为什么上述并没有采用 react-scripts development 模式下的webpack 配置是因为,开发模式下引入了很多开发工具,比如热更新,style插入header,这在服务端渲染时都会带来一些不确定因素,甚至可能会报错。
上述代码只是展示了核心逻辑,并不是全部代码,我们还可以为这个工具加上一些其他特性,比如兼容 eject 后的项目、入口文件和产出目录可配置等。
通过上述思路我们可以将源码打包成支持在Nodejs 服务器中运行的React应用
STEP3:SSR 服务器
我们先来分析一下SSR 服务器的职责
- 根据路由渲染并返回HTML
- 正常返回静态资源的内容
针对于SSR服务器职责,我们的技术设计如下:
需要注意的是对于静态资源我们并没有直接读取相应文件的内容,而是将请求代理到静态资源服务器上
我们的代码具体实现如下:
// 启动express 服务器
const express = require('express');
const http = require('http');
const app = express();
const { createProxyMiddleware } = require('http-proxy-middleware');
const server = http.createServer(app);
const { renderToString } = require('../front/node_modules/react-dom/server');
const React = require('../front/node_modules/react');
const csrUrl = 'http://localhost:3000';
const renderEntry = '../front/build/ssr/main.js';
const App = require(renderEntry).default;
app.get('/*', async (req, res, next) => {
// 有后缀名或者是静态资源不处理
if (
req.originalUrl.indexOf('.') > -1 ||
req.originalUrl.startsWith('/static')
) {
// 交给代理中间件去处理
return next();
}
const appString = renderToString(
React.createElement(App, {
location: req.originalUrl,
})
);
// 通过csrUrl 获取csr的html,这里不应该每次获取,可以做缓存优化
const csrHtml = await new Promise((resolve, reject) => {
require('http').get(csrUrl, (res) => {
let html = '';
res.on('data', (chunk) => {
html += chunk;
});
res.on('end', () => {
resolve(html);
});
});
});
const data = csrHtml.replace(
'<div id="root">',
`<div id="root">${appString}`
);
return res.send(data);
});
// 上述get不处理的请求都会走代理
app.use('/*', createProxyMiddleware({ target: csrUrl, changeOrigin: true }));
server.listen(3001, () => {
console.log('server start at 3001', 'http://localhost:3001');
});
上述代码的逻辑如下:
- 起了一个和客户端渲染独立的服务器
- 引入Server Builder
- 与Client Builder 中的index.html 拼接(index.html 的获取是通过请求)
- 返回内容
- 代理静态资源到CSR服务器
上述代码看上去很长但是基本逻辑就是这些
到这里我们已经完成了绝大部分,但是他依然不完美,比如热更新失效了,原因在于,ssr-server 并没有处理热更新的websocket请求。
完整功能可直接查看 github github.com/pengzai-dev…
总结
综上所述,我们将一个creat-react-app 创建的项目改造成一个支持服务端渲染的项目,到目前为止我们实现的仍然只是一个最基础的服务端渲染项目
我们还没有考虑
- 服务端渲染异步数据如何处理
- 服务端渲染结果缓存
- 浏览器和Nodejs 环境的差异(整个开发过程中,我们总是需要考虑到SSR场景)
思考
正如我们所看到的,在开始这个小研究时,让一个CRA应用完全适应SSR场景并不简单。但现在,如果您阅读了本文并查看了示例GitHub项目,您会发现这个过程变得比较简单。然而,虽然它可以正常工作,但仍然容易出错,而且在整个开发过程中,我们总是需要考虑到SSR场景。我们还需要等待
目前,我们可以使用一些第三方解决方案,如Next.js和Gatsby,它们为React提供了内置的SSR支持。这些框架可以帮助开发人员更轻松地处理SSR场景,并减少出错的可能性。
最后我希望这能够对理解服务端渲染或者服务端渲染的技术方案评估有所帮助。