本文面向有 webpack 经验以及服务端经验的同窗编写,其中省略了部分细节的讲解,初学者建议先学习 webpack 以及服务端相关知识再阅读此文,文末有此配置的 git 仓库,能够直接使用。php
附上 webpack 入门讲解 css
如下示例中的代码有部分删减,具体以 git 仓库中的代码为准html
为何须要服务端渲染,这个问题社区中不少关于为何使用服务端渲染的文章,这里不细说。服务端渲染给我最直观的感觉就是快!下面是两种渲染模式的流程图:前端
能够很直观的看见,服务端渲染(上图)比客户端渲染(下图)少了不少步骤,一次http请求,就能获取到页面的数据,而不用像客户端那样再三的请求。vue
若是非要说服务端渲染有什么弊端,那么就是工程化配置较为麻烦,可是目前 react 有 nextjs, vue 有 nuxtjs 对应的两个服务端渲染框架,对于不是很懂工程配置的同窗能够开箱即用。这里我没有选择 nextjs 的缘由在于,一是这些框架在框架的基础上继续封装,没有精力再去学哪些并无太大帮助的api, 二是想挑战一下本身的知识盲区,增强对于 react 以及 webpack 的实践(以前没有react大型项目的经历)。node
接下来讲说实现目标:react
单独提出这个问题的缘由在于,在搭建这套服务端渲染的脚手架以前,看过很多网上相关的文章,其中大部分文章,对于细节讲述的很浅,服务端直接使用renderToString渲染jsx就完事了,实际上这样根本不可能跑在生产环境中,真正这样尝试过就会发现不少坑。对于项目中的静态资源,如图片资源,样式资源的引用,nodejs会报错,即便项目中用不到这些资源,那么每次请求的渲染jsx都会耗费大量cpu资源。webpack
以前看过的相关文章中,也不多提到这一点,因此这一点也单独提出来,不过有坑待解决。git
毕竟要作的是一个项目,而不是个demo。github
接下来开始工程化配置,这里假设你有必定的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.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…