前言
本文面向有 webpack 经验以及服务端经验的同学编写,其中省略了部分细节的讲解,初学者建议先学习 webpack 以及服务端相关知识再阅读此文,文末有此配置的 git 仓库,可以直接使用。
附上 webpack 入门讲解
以下示例中的代码有部分删减,具体以 git 仓库中的代码为准
为什么要服务端渲染
为什么需要服务端渲染,这个问题社区中很多关于为什么使用服务端渲染的文章,这里不细说。服务端渲染给我最直观的感受就是快!下面是两种渲染模式的流程图:


可以很直观的看见,服务端渲染(上图)比客户端渲染(下图)少了很多步骤,一次http请求,就能获取到页面的数据,而不用像客户端那样再三的请求。
服务端渲染弊端
如果非要说服务端渲染有什么弊端,那么就是工程化配置较为麻烦,但是目前 react 有 nextjs, vue 有 nuxtjs 对应的两个服务端渲染框架,对于不是很懂工程配置的同学可以开箱即用。这里我没有选择 nextjs 的原因在于,一是这些框架在框架的基础上继续封装,没有精力再去学哪些并没有太大帮助的api, 二是想挑战一下自己的知识盲区,加强对于 react 以及 webpack 的实践(之前没有react大型项目的经历)。
实现目标
接下来说说实现目标:
开发阶段也使用服务端渲染,模拟线上真实情况
单独提出这个问题的原因在于,在搭建这套服务端渲染的脚手架之前,看过不少网上相关的文章,其中大部分文章,对于细节讲述的很浅,服务端直接使用renderToString渲染jsx就完事了,实际上这样根本不可能跑在生产环境中,真正这样尝试过就会发现很多坑。对于项目中的静态资源,如图片资源,样式资源的引用,nodejs会报错,即使项目中用不到这些资源,那么每次请求的渲染jsx都会耗费大量cpu资源。
开发时热更新进行调试
之前看过的相关文章中,也很少提到这一点,所以这一点也单独提出来,不过有坑待解决。
区分开发和生产环境,并能方便的切换及部署
毕竟要做的是一个项目,而不是个demo。
工程化配置
接下来开始工程化配置,这里假设你有一定的webpack配置基础,所以不细讲webpack。
开发环境
两个入口
分别是客户端入口,以及服务端入口,在不懂服务端渲染之前,我很难理解为什么要有两个入口。理解后发现很其实简单,服务端入口,处理路由,将对应的页面至渲染成html字符串,那么客户端的作用呢?
要知道,前端页面事件的绑定是需要js代码来完成的,所以客户端的代码作用其一就是绑定相关事件,以及提供一个客户端渲染的能力。(第一次请求得到的是服务端渲染的数据,之后每次进行跳转都是客户端进行渲染)
ok,先创建目录以及文件,如下:

其中 client.entry.tsx 内容如下
import * as React from 'react'
import { hydrate } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './app'
hydrate(<BrowserRouter>
<App/>
</BrowserRouter>, document.getElementById('root'))
这里使用 hydrate 函数,而不是 render 函数,其次路由使用的是 BrowserRouter
server.entry.tsx代码如下
import * as React from 'react'
const { renderToString } = require("react-dom/server")
import { StaticRouter } from "react-router"
import App from './app'
export default async (req, context = {}) => {
return {
html: renderToString(
<StaticRouter location={req.url} context={context}>
<App/>
</StaticRouter>)
}
}
于client的区别在于,这里 export 了一个函数,其次路由使用的是用于服务端渲染的 StaticRouter
再看看 app.tsx
import * as React from 'react'
import { Switch } from 'react-router-dom'
import { renderRoutes } from 'react-router-config'
import Login from './src/view/login'
import Main from './src/view/main/router'
import NotFoundPage from './src/view/404/404'
interface componentProps {
}
interface componentStates {
}
class App extends React.Component<componentProps, componentStates> {
constructor(props) {
super(props)
}
render() {
const routes = [
Main,
{
path: '/app/login',
component: Login,
exact: true
}, {
component: NotFoundPage
}]
return (
<Switch>
{renderRoutes(routes)}
</Switch>
)
}
}
export default App
与客户端渲染不同的是,服务端渲染需要用到静态路由 react-router-config 提供的 renderRoutes 函数进行渲染 ,而不是客户端的动态路由,因为这涉及到服务端数据预渲染,在下一章节会详细讲解这一点。
webpack配置
创建完文件后,接下来进行webpack的配置,具体文件如下图所示:

拆分的比较细,先看公共的配置:
// webpack.common.js
const config = require('./config/index')
const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin')
module.exports = {
resolve: {
// Add `.ts` and `.tsx` as a resolvable extension.
extensions: [".ts", ".tsx", ".js"],
alias: {
"@src":path.resolve(config.root, "./app/client/src")
}
},
module: {
rules: [
{
test: /\.(tsx|ts|js)$/,
use: ['babel-loader'],
exclude: /node_modules/,
}
]
}
};
主要处理了typescript文件,以及文件别名啥的。
再看看客户端相关配置
// client.base.js
const merge = require('webpack-merge')
const commonConfig = require('./webpack.common')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = merge(commonConfig, {
optimization: {
splitChunks: {
chunks: 'all',
name: 'commons',
filename: '[name].[chunkhash].js'
}
},
module: {
rules: [
{
test: /\.s?css$/,
use: [{
loader: MiniCssExtractPlugin.loader,
options: {
hmr: process.env.NODE_ENV === 'development',
},
}, 'css-loader', 'sass-loader']
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].[hash].css',
chunkFilename: '[id].css',
})
]
})
主要处理样式文件,以及公共模块提取,接下来是服务端配置:
// server.base.js
const merge = require('webpack-merge')
const commonConfig = require('./webpack.common')
const nodeExternals = require('webpack-node-externals')
module.exports = merge(commonConfig, {
externals: [nodeExternals()],
module: {
rules: [
{test: /\.s?css$/, use: ['ignore-loader']}
]
}
})
可以看见使用了 ignore-loader 忽略了样式文件,以及有一个关键的 webpack-node-externals 处理,这个模块可以在打包时将服务端nodejs依赖的文件排除,因为服务端直接 require 就好了,不需要将代码打包 bundle 中,毕竟一个 bundle 都快 1MB 了,去掉这些的话,打包后的 bundle 只有 几十 kb。
最后再看看开发阶段的配置:
// const baseConfig = require('./webpack.common')
const clientBaseConfig = require('./webpack.client.base')
const serverBaseConfig = require('./webpack.server.base')
const webpack = require('webpack')
const merge = require('webpack-merge')
const path = require('path')
module.exports = [merge(clientBaseConfig, {
entry: {
client: [path.resolve(__dirname, '../app/client/client.entry.tsx'), 'webpack-hot-middleware/client?name=client']
},
devtool: "inline-source-map",
output: {
publicPath: '/',
filename: '[name].index.[hash].js',
path: path.resolve(__dirname, '../app/server/static/dist')
},
mode: "development",
plugins: [
new webpack.HotModuleReplacementPlugin()
]
}), merge(serverBaseConfig, {
target: 'node',
entry: {
server: [path.resolve(__dirname, '../app/client/server.entry.tsx')]
},
devtool: "inline-source-map",
output: {
publicPath: './',
filename: '[name].index.js',
path: path.resolve(__dirname, '../app/dist'),
libraryTarget: 'commonjs2'
},
mode: "development"
})]
这里 export 的是一个列表,告诉 webpack 使用多配置打包,同时打包服务端和客户端代码。
客户端入口文件添加了 webpack-hot-middleware/client?name=client,这个文件是用于模块热更新,不过有坑待解决。
服务端入口打包的目标是 node,因为是需要跑在nodejs平台的,libraryTarget 为 commonjs2 commonjs 模块的规范。
可能有同学发现了,这没配置 devServer 啊,怎么在开发阶段预览。
稍安勿躁,咱们这是服务端渲染,玩法当然不一样,接下来开始讲解服务端相关的代码。
目录结构如下:

其中 dist static 目录用于存放打包后的文件。
先看看 index.js 文件
const express = require('express')
const path = require('path')
const app = express()
const dev = require('./dev')
const pro = require('./pro')
console.log('server running for ' + process.env.NODE_ENV)
if (process.env.NODE_ENV === 'development') {
dev(app)
} else if (process.env.NODE_ENV === 'production') {
pro(app)
}
app.listen(8080, () => console.log('Example app listening on port 8080!'));
这里启动了一个 express 服务,使用 cross-env 区分了开发模式以及生产模式,通过不同命令来启动。
// package.json
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"nodemon:dev": "cross-env NODE_ENV=development node app/server/index.js",
"dev": "nodemon",
"start": "cross-env NODE_ENV=production pm2-runtime start app/server/index.js --watch",
"build:server": "webpack --config ./build/webpack.server.js",
"build:client": "webpack --config ./build/webpack.client.js",
"build": "npm run build:server & npm run build:client"
}
}
// nodemon.json
{
"ignore": ["./app/client/**", "**/*.test.ts", "**/*.spec.ts", ".git", "node_modules"],
"watch": ["./app/server/**"],
"exec": "npm run nodemon:dev",
"ext": "js"
}
dev.js 内容如下:
const path = require('path')
const webpack = require('webpack')
const requireFromString = require('require-from-string');
const webpackMiddleWare = require('webpack-dev-middleware')
const webpackDevConfig = require('../../build/webpack.dev.js')
const compiler = webpack(webpackDevConfig)
function normalizeAssets(assets) {
if (Object.prototype.toString.call(assets) === "[object Object]") {
return Object.values(assets)
}
return Array.isArray(assets) ? assets : [assets];
}
module.exports = function(app) {
app.use(webpackMiddleWare(compiler, { serverSideRender: true, publicPath: webpackDevConfig[0].output.publicPath }));
app.use(require("webpack-hot-middleware")(compiler));
app.get(/\/app\/.+/, async (req, res, next) => {
const clientCompilerResult = res.locals.webpackStats.toJson().children[0]
const serverCompilerResult = res.locals.webpackStats.toJson().children[1]
const clientAssetsByChunkName = clientCompilerResult.assetsByChunkName
const serverAssetsByChunkName = serverCompilerResult.assetsByChunkName
const fs = res.locals.fs
const clientOutputPath = clientCompilerResult.outputPath
const serverOutputPath = serverCompilerResult.outputPath
const renderResult = await requireFromString(
fs.readFileSync(
path.resolve(serverOutputPath, serverAssetsByChunkName.server), 'utf8'
),
serverAssetsByChunkName.server
).default(req)
res.send(`
<html>
<head>
<title>My App</title>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no">
<style>${normalizeAssets(clientAssetsByChunkName.client)
.filter((path) => path.endsWith('.css'))
.map((path) => fs.readFileSync(clientOutputPath + '/' + path))
.join('\n')}</style>
</head>
<body>
<div id="root">${renderResult.html}</div>
${normalizeAssets(clientAssetsByChunkName.commons)
.filter((path) => path.endsWith('.js'))
.map((path) => `<script src="/${path}"></script>`)
.join('\n')}
${normalizeAssets(clientAssetsByChunkName.client)
.filter((path) => path.endsWith('.js'))
.map((path) => `<script src="/${path}"></script>`)
.join('\n')}
</body>
</html>
`);
});
app.use((req, res) => {
res.status(404).send('Not found');
})
}
先看看开发环境干了啥,先提取关键的几行代码:
const webpack = require('webpack')
const webpackMiddleWare = require('webpack-dev-middleware')
const webpackDevConfig = require('../../build/webpack.dev.js')
const compiler = webpack(webpackDevConfig)
// app.use(express.static(path.resolve(__dirname, './static')));
app.use(webpackMiddleWare(compiler, { serverSideRender: true, publicPath: webpackDevConfig[0].output.publicPath }));
app.use(require("webpack-hot-middleware")(compiler));
首先引入了 webpack 以及 webpack 开发环境配置文件,并且实例化了一个 compiler 用于构建。
其次使用了 webpack-dev-middleware、 webpack-hot-middleware 两个中间件对该 compiler 进行了加工。
webpack-dev-middleware 中间件用于webpack开发模式下文件改动监听,以及触发相应构建。
webpack-hot-middleware 中间件用于模块热更新,不过这里貌似有点问题待解决。
当用户访问以 /app 开头的url时,可通过 res.locals.webpackStats 获取到 webpack 构建的结果,通过模版语法拼接,将最终的html字符串返回给客户端,就完成了一次服务端渲染。
其中需要单独讲解的代码是下面这一段:
const renderResult = await requireFromString(
fs.readFileSync(
path.resolve(serverOutputPath, serverAssetsByChunkName.server), 'utf8'
),
serverAssetsByChunkName.server
).default(req)
还记得服务端渲染入口文件export了一个函数吗?就是在这里进行了调用。因为webpack打包后的文件是存储在内存中的,所以需要使用 memory-fs 去获取相应文件,memory-fs 可以通过如下代码得到引用:
const fs = res.locals.fs
通过 memory-fs 获取到服务器打包后的文件后,因为读取的是一个文本,所以需要使用 require-from-string 模块,将文本转换为可执行的 js 代码,最终通过.default(req)执行服务端入口导出的函数得到服务端渲染后的html字符文本。
到这一步开发环境下的服务端渲染配置基本结束,接下来是生产环境的配置
生产环境
首先看看 webpack 配置:
// webpack.server.js
const baseConfig = require('./webpack.server.base')
const merge = require('webpack-merge')
const path = require('path')
const express = require('express')
module.exports = merge(baseConfig, {
target: "node",
mode: 'production',
entry: path.resolve(__dirname, '../app/client/server.entry.tsx'),
output: {
publicPath: './',
filename: 'server.entry.js',
path: path.resolve(__dirname, '../app/server/dist'),
libraryTarget: "commonjs2"
}
})
// webpack.client.js
const baseConfig = require('./webpack.client.base')
const merge = require('webpack-merge')
const path = require('path')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const HtmlWebpackPlugin = require('html-webpack-plugin')
const config = require('./config')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
module.exports = merge(baseConfig, {
mode: 'production',
entry: path.resolve(__dirname, '../app/client/client.entry.tsx'),
output: {
publicPath: '/',
filename: 'bundle.[hash].js',
path: path.resolve(__dirname, '../app/server/static/dist'),
},
plugins: [
new HtmlWebpackPlugin({
filename: path.resolve(config.root, './app/server/dist/index.ejs'),
template: path.resolve(config.root, './public/index.ejs'),
templateParameters: false
}),
new CleanWebpackPlugin()
]
})
与开发环境大同小异,具体的区别在于打包后的文件目录,以及客户端使用了 html-webpack-plugin 将打包得到的文件名写入ejs模版中保存,而不是开发模式下的字符串拼接。原因在于,打包后的文件名因为存在 hash 值,导致不知道具体的文件名,所以这里将之写入 ejs 模版中保存起来,用于生产模式下的模版渲染。
new HtmlWebpackPlugin({
filename: path.resolve(config.root, './app/server/dist/index.ejs'),
template: path.resolve(config.root, './public/index.ejs'),
templateParameters: false
}),
// index.ejs
<!DOCTYPE html>
<html lang="zh">
<head>
<title>My App</title>
<meta charset="UTF-8">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no">
</head>
<body>
<div id="root"><?- html ?></div>
</body>
</html>
再看看生产环境下的后台代码:
const path = require('path')
const serverEntryBuild = require('./dist/server.entry.js').default
const ejs = require('ejs')
module.exports = function(app) {
app.use(express.static(path.resolve(__dirname, './static')));
app.use(async (req, res, next) => {
const reactRenderResult = await serverEntryBuild(req)
ejs.renderFile(path.resolve(process.cwd(), './app/server/dist/index.ejs'), {
html: reactRenderResult.html
}, {
delimiter: '?',
strict: false
}, function(err, str){
if (err) {
console.log(err)
res.status(500)
res.send('渲染错误')
return
}
res.end(str, 'utf-8')
})
});
}
在接收到请求时执行 react 服务端入口打包后的文件,进行 react ssr 得到渲染后的文本。然后使用 ejs 模版引擎渲染打包后得到的 ejs 模版,将 react ssr 得到的文本填充进 html,返回给客户端,完成服务端渲染流程。
const serverEntryBuild = require('./dist/server.entry.js').default
const reactRenderResult = await serverEntryBuild(req)
const htmlRenderResult = ejs.renderFile(path.resolve(process.cwd(), './app/server/dist/index.ejs'), {
html: reactRenderResult.html
}, {
delimiter: '?',
strict: false
}, function(err, str){
if (err) {
console.log(err)
res.status(500)
res.send('渲染错误')
return
}
res.end(str, 'utf-8')
})
到这里 react 服务端渲染工程配置基本结束。下一章节讲解,如何进行数据预渲染。
仓库地址: github.com/Richard-Cho…