服务端渲染(SSR)

服务端渲染即:html + css + js + data -> 渲染后的html。

对于服务端而言:

  • 所有模板等资源都存储在服务端;
  • 内网机器拉取数据更快;
  • 一个html返回所有数据(这里的所有数据是项目首屏的数据)。

客户端渲染 vs 服务端渲染

客户端渲染 服务端渲染
请求 多个请求(html,js,css,数据等) 1个请求(针对首屏而言)
加载过程 html,js,css,数据等串行加载 一个请求返回html文件和数据
渲染 前端渲染 服务端渲染
可交互 图片等静态资源加载完成,JS逻辑执行完成可交互

总结:服务端渲染(SSR)的核心思路就是减少http请求。

服务端渲染(SSR)优势

  • 减少白屏时间
  • 对SEO更加友好

服务端渲染(SSR)实现思路

这里以React来举例说明: 服务端:

  • 使用react-dom/server的renderToString方法将React组件渲染成字符串;
  • 服务端路由返回对应的模板。

客户端:

  • 打包出针对服务端的组件。
if (typeof window === 'undefined') {
    global.window = {};
}
const express = require('express');
const fs = require('fs');
const path = require('path');
const {renderToString} = require('react-dom/server');
const SSR = require('../dist/search-server');

const server = port => {
    const app = express();

    app.use(express.static('../dist'));
    app.get('/search', (req, res) => {
        const html = renderMarkup(renderToString(SSR));
        res.status(200).send(html);
    });
    app.listen(port, () => {
        console.log(`Server is running on port: ${port}`);
    });
}

server(process.env.PORT || 3000);

const renderMarkup = str => {
    return `<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Document</title>
    </head>
    <body>
        <div id="root">${str}</div>
    </body>
    </html>
    `;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

SSR打包存在的问题

解决样式不显示问题

  • 使用打包出来的浏览器端html作为模板;
  • 设置占位符,动态插入组件。
// server/index.js
if (typeof window === 'undefined') {
    global.window = {};
}
const express = require('express');
const fs = require('fs');
const path = require('path');
const {renderToString} = require('react-dom/server');
const SSR = require('../dist/search-server');

// 获取服务端渲染需要的模板文件和数据
const template = fs.readFileSync(path.join(__dirname, '../dist/search.html'), 'utf-8');
const data = require('./data.json');

const server = port => {
    const app = express();

    app.use(express.static('../dist'));
    app.get('/search', (req, res) => {
        const html = renderMarkup(renderToString(SSR));
        res.status(200).send(html);
    });
    app.listen(port, () => {
        console.log(`Server is running on port: ${port}`);
    });
}

server(process.env.PORT || 3000);

const renderMarkup = str => {
    const dataStr = JSON.stringify(data);
    // 进行占位符替换
    return template.replace('<!--HTML_PLACEHOLDER-->', str).replace('<!--INITIAL_DATA_PLACEHOLDER-->', `<script>window.__initial_data=${dataStr}</script>`);;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// webpack.ssr.config.js
'use strict';

const glob = require('glob');
const path = require('path');
const webpack = require('webpack');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackExternalsPlugin = require('html-webpack-externals-plugin');

const setMPA = () => {
    const entry = {};
    const htmlWebpackPlugins = [];
    const entryFiles = glob.sync(path.join(__dirname, './src/*/index-server.js'));

    // console.log(entryFiles);
    entryFiles.map(entryFile => {
        const match = entryFile.match(/src\/(.*)\/index-server\.js/);
        // console.log(match);
        const pathName = match && match[1];
        if (pathName) {
            entry[pathName] = entryFile;
            htmlWebpackPlugins.push(
                new HtmlWebpackPlugin({
                    inlineSource: '.css$',
                    template: path.join(__dirname, `src/${pathName}/index.html`),
                    filename: `${pathName}.html`,
                    chunks: [pathName],
                    inject: true,
                    minify: {
                        html5: true,
                        collapseWhitespace: true,
                        preserveLineBreaks: false,
                        minifyCSS: true,
                        minifyJS: true,
                        removeComments: false
                    }
                })
            );
        }
    });
    return {
        entry,
        htmlWebpackPlugins
    };
}

const {entry, htmlWebpackPlugins} = setMPA();
module.exports = {
    entry,
    output: {
        path: path.join(__dirname, 'dist'),
        filename: '[name]-server.js',
        libraryTarget: 'umd'
    },
    mode: 'production',
    // devtool: 'inline-source-map',
    module: {
        rules: [
            {
                test: /\.js$/,
                use: 'babel-loader'
            },
            {
                test: /\.css$/,
                use: [
                    MiniCssExtractPlugin.loader, 'css-loader'
                ]
            },
            {
                test: /\.less$/,
                use: [
                    // 'style-loader',
                    MiniCssExtractPlugin.loader,
                    'css-loader',
                    'less-loader',
                    'postcss-loader',
                    {
                        loader: 'px2rem-loader',
                        options: {
                            // 适用于750的设计稿
                            remUnit: 75, // 1rem等于75px
                            remPrecision: 8 // px转为rem时小数点的位数
                        }
                    }
                ]
            },
            {
                test: /\.(png|jpg|gif|svg|jpeg)$/,
                // use: 'file-loader'
                use: {
                    loader: 'url-loader',
                    options: {
                        limit: 1024,
                        name: 'img/[name]_[hash:8].[ext]' // 给图片设置文件指纹
                    }
                }
            },
            {
                test: /.(woff|woff2|eot|ttf|otf)$/,
                use: [
                    {
                        loader: 'file-loader',
                        options: {
                            name: '[name]_[hash:8].[ext]'
                        }
                    }
                ]
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin(),
        new MiniCssExtractPlugin({
            filename: '[name]_[contenthash:8].css' // 设置css文件指纹
        }),
        new OptimizeCSSAssetsPlugin({
            assetNameRegExp: /\.css$/g,
            cssProcessor: require('cssnano')
        }),
        // new webpack.optimize.ModuleConcatenationPlugin(),
        new HtmlWebpackExternalsPlugin({
            externals: [
              {
                module: 'react',
                // entry可以是本地文件也可以是cnd文件
                // 推荐使用cdn资源
                entry: 'https://cdn.bootcss.com/react/16.10.2/umd/react.production.min.js',
                global: 'React',
              },
              {
                module: 'react-dom',
                entry: 'https://cdn.bootcss.com/react-dom/16.10.2/umd/react-dom.production.min.js',
                global: 'ReactDOM',
              },
            ]
        })
    ].concat(htmlWebpackPlugins)
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// index-server.js
const React = require('react');
const logo = require('./images/logo.png');
require('./search.less');

class Search extends React.Component {
    constructor() {
        super();
        this.state = {
            Text: null
        };
        this.loadComponent = this.loadComponent.bind(this);
    }

    loadComponent() {
        import('./Text.js').then(Text => {
            this.setState({
                Text: Text.default
            });
        });
    }
    render() {
        const {Text} = this.state;
        console.log(Text);
        return <div className="search">
            hello react666
            <img
                src={logo}
                onClick={this.loadComponent}
            />
            {
                Text ? <Text /> : null
            }
        </div>;
    }
}

module.exports = <Search />;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <div id="root"><!--HTML_PLACEHOLDER--></div>
    <!--INITIAL_DATA_PLACEHOLDER-->
    <script>
        // 可以在这里使用服务端渲染加载的数据
        console.log(window.__initial_data);
    </script>
    <script src="https://cdn.bootcss.com/react/16.10.2/umd/react.production.min.js"></script>
    <script src="https://cdn.bootcss.com/react-dom/16.10.2/umd/react-dom.production.min.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!--HTML_PLACEHOLDER-->
1

使用html注释作为占位符标记的好处是:当我们不使用该占位符时并不会影响到页面的正常显示。