本文面向有 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…