react服務端渲染實踐1: 工程化配置

前言

本文面向有 webpack 經驗以及服務端經驗的同窗編寫,其中省略了部分細節的講解,初學者建議先學習 webpack 以及服務端相關知識再閱讀此文,文末有此配置的 git 倉庫,能夠直接使用。php

附上 webpack 入門講解 css

如下示例中的代碼有部分刪減,具體以 git 倉庫中的代碼爲準html

爲何要服務端渲染

爲何須要服務端渲染,這個問題社區中不少關於爲何使用服務端渲染的文章,這裏不細說。服務端渲染給我最直觀的感覺就是快!下面是兩種渲染模式的流程圖:前端

image

image

能夠很直觀的看見,服務端渲染(上圖)比客戶端渲染(下圖)少了不少步驟,一次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,先建立目錄以及文件,以下:

image

其中 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的配置,具體文件以下圖所示:

image

拆分的比較細,先看公共的配置:

// 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 啊,怎麼在開發階段預覽。

稍安勿躁,我們這是服務端渲染,玩法固然不同,接下來開始講解服務端相關的代碼。

目錄結構以下:

image

其中 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-middlewarewebpack-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…

相關文章
相關標籤/搜索