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…

相关文章
相关标签/搜索