VUE-SSR框架搭建步骤及与SPA应用结合,实现一套代码两种渲染方式

1,723 阅读4分钟

VUE-SSR简介

Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的HTML字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。

服务器渲染的 Vue.js 应用程序也可以被认为是"同构"或"通用",因为应用程序的大部分代码都可以在服务器和客户端上运行。

摘抄自VUE-SSR指南:ssr.vuejs.org/zh/guide/

为什么需要服务器端渲染 (SSR)?

更好的SEO - 搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。

更快的内容到达时间 - 对于缓慢的网络情况或运行缓慢的设备,无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面,通常可以产生更好的用户体验。

VUE-SSR搭建步骤

1.初始化项目

  • 新建一个文件夹vue-ssr

  • 进入文件夹,执行命令 npm init -y

  • 安装webpack和vue的依赖库

    npm install webpack webpack-cli webpack-merge vue-template-compiler webpack-node-externals -D 
    npm install vue -S
    
  • 项目根路径下新建build和src文件夹

  • src目录下新建app.js 和 app.vue两个文件

    // app.js
    import Vue from 'vue';
    import App from './app.vue';
      
    export function createApp() {
      const app = new Vue({
          // 根实例简单的渲染应用程序组件。
          render: h => h(App)
      });
      return { app };
    }
    

    app.js 是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任转移到纯客户端 entry 文件。app.js 简单地使用 export 导出一个 createApp 函数。

    // app.vue
    <template>
      <div>app</div>
    </template>
    
    <script>
      export default {
          name: "app"
      }
    </script>
    
    <style scoped>
    
    </style>
    
  • src目录下创建 entry-client.js 和 entry-server.js

    // entry-client.js
    import { createApp } from './app.js';
    const { app } = createApp();
    app.$mount('#app');
    
    // entry-server.js
    import { createApp } from './app';
    export default context => {
      const { app } = createApp();
      return app;
    }
    
  • 根目录新建index.html

    // index.html
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>vue-ssr</title>
    </head>
    <body>
      <div id="app">
        <!--vue-ssr-outlet-->
      </div>
    </body>
    </html>
    注意:<!--vue-ssr-outlet-->必不可少, 它是应用程序 HTML 标记注入的地方
    

现在项目目录如下图所示:

2.配置webpack

  • 添加 vue、js、css的相关loader

    npm install vue-loader url-loader css-loader less-loader -D
    
  • 添加babel相关依赖库

    npm install @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader -D
    
  • build文件下新建 webpack.base.conf.js

     // webpack.base.conf.js
     const path = require('path');
     const {VueLoaderPlugin} = require('vue-loader');
     
     module.exports = {
         output: {
             path: path.resolve(__dirname, '../dist'),
             publicPath: '/dist/',
             filename: 'static/[name].js',
             chunkFilename: 'static/[id].js'
         },
         resolve: {
             alias: {
                 'public': path.resolve(__dirname, '../public')
             }
         },
         module: {
             noParse: /es6-promise\.js$/, // avoid webpack shimming process
             rules: [
                 {
                     test: /\.vue$/,
                     loader: 'vue-loader',
                     options: {
                         compilerOptions: {
                             preserveWhitespace: false
                         },
                         extractCSS: true
                     }
                 },
                 {
                     test: /\.js$/,
                     loader: 'babel-loader',
                     exclude: /node_modules/
                 },
                 {
                     test: /\.(png|jpg|gif|svg)$/,
                     loader: 'url-loader',
                     options: {
                         limit: 10000,
                         name: '[name].[ext]?[hash]'
                     }
                 },
                 {
                     test: /\.(css|less)$/,
                     use: [
                         {loader: "css-loader"},
                         {loader: "less-loader"}
                     ]
                 },
             ]
         },
         performance: {
             hints: false
         },
         plugins: [
             new VueLoaderPlugin()
         ]
     };
     以上配置有不明白的可以查询webpack相关文档。    
    

3.打包客户端

  • 安装 vue-server-renderer 依赖库

    npm install vue-server-renderer -S
    
  • build目录下新建 webpack.client.conf.js

    // webpack.client.conf.js
    const webpack = require('webpack');
    const merge = require('webpack-merge');
    const baseConfig = require('./webpack.base.conf.js');
    const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
    
    module.exports = merge(baseConfig, {
        mode: 'development',
        entry: './src/entry-client.js',
        devtool: false,
        optimization: {
            splitChunks: {
                cacheGroups: {
                    commons: {
                        name: "commons",
                        chunks: "initial",
                        minChunks: 2
                    }
                }
            },
            minimizer: []
        },
        plugins: [
            new VueSSRClientPlugin()
        ]
    });
    配置比较简单,不明白的可以查看webpack文档
    
  • package.json 文件中添加客户端打包命令

    "scripts": {
        "build:ssr:client": "webpack --config build/webpack.client.conf.js --progress --hide-modules"
     }
    
  • 执行打包命令

    npm run build:ssr:client
    

    命令执行完之后如果项目中出现dist目录并且目录中包含vue-ssr-client-manifest.json文件的话则大功告成。

4.打包服务端

  • build文件夹中新建 webpack.server.conf.js

    // webpack.server.conf.js
    const merge = require('webpack-merge');
    const baseConfig = require('./webpack.base.conf.js');
    const nodeExternals = require('webpack-node-externals');
    const VueSSRServicePlugin = require('vue-server-renderer/server-plugin');
    
    module.exports = merge(baseConfig, {
        mode: 'development',
        entry: './src/entry-server.js',
        // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
        // 并且还会在编译 Vue 组件时,
        // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
        target: 'node',
        // 对 bundle renderer 提供 source map 支持
        devtool: '#source-map',
    
        // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
        output: {
            libraryTarget: 'commonjs2'
        },
        // https://webpack.js.org/configuration/externals/#function
        // https://github.com/liady/webpack-node-externals
        // 外置化应用程序依赖模块。可以使服务器构建速度更快,
        // 并生成较小的 bundle 文件。
        externals: nodeExternals({
            // 不要外置化 webpack 需要处理的依赖模块。
            // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
            // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
            whitelist: /\.css$/
        }),
    
        // 这是将服务器的整个输出
        // 构建为单个 JSON 文件的插件。
        // 默认文件名为 `vue-ssr-server-bundle.json`
        plugins: [
            new VueSSRServicePlugin()
        ]
    });
    
    注释很详细,不再过多解释
    
  • package.json 文件中添加服务端打包命令

    "scripts": {
       "build:ssr:client": "webpack --config build/webpack.client.conf.js --progress --hide-modules",
       "build:ssr:server": "webpack --config build/webpack.server.conf.js --progress --hide-modules"
     },
    
  • 执行打包命令

    npm run build:ssr:server
    

    命令执行完之后如果项目dist目录出现vue-ssr-server-bundle.json文件的话则大功告成。

5.与服务器集成

  • 添加 express 依赖包

    npm install express -S
    
  • 根目录下添加 server.js

    const express = require('express');
    const fs = require('fs');
    const { createBundleRenderer } = require('vue-server-renderer');
    
    const server = express();
    const template = fs.readFileSync('index.html', 'utf-8');
    const serverBundle = require('./dist/vue-ssr-server-bundle.json');
    const clientManifest = require('./dist/vue-ssr-client-manifest.json');
    
    const createRenderer = (bundle, options) => {
        // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
        return createBundleRenderer(bundle, Object.assign({}, options));
    };
    
    const renderer = createRenderer(serverBundle, {
        template,  // (可选)页面模板
        clientManifest, // (可选)客户端构建 manifest
        runInNewContext: false, // 推荐
    });
    
    server.get('*', (req, res) => {
        res.setHeader("Content-Type", "text/html");
        const handleError = err => {
            if (err.url) {
                res.redirect(err.url)
            } else if(err.code === 404) {
                res.status(404).send('404 | Page Not Found')
            } else {
                // Render Error Page or Redirect
                res.status(500).send('500 | Internal Server Error')
                console.error(`error during render : ${req.url}`)
                console.error(err.stack)
            }
        };
    
        const context = {
            url: req.url
        };
        renderer.renderToString(context, (err, html) => {
            if (err) {
                return handleError(err)
            }
            res.send(html)
        })
    });
    
    let port = 8090;
    // 监听端口, 启动服务
    server.listen(port);
    console.log(`server is runing: http://localhost:${port}`);
    
  • package.json 文件中添加启动服务的命令

    "scripts": {
     "server": "node server.js",
     "build:ssr:client": "webpack --config build/webpack.client.conf.js --progress --hide-modules",
     "build:ssr:server": "webpack --config build/webpack.server.conf.js --progress --hide-modules"
    },
    
  • 执行命令,启动服务

    npm run server
    
  • 服务启动后,浏览器访问 http://localhost:8090 页面出现APP字样则大功告成。

6.添加路由支持

  • 添加 vue-router 的依赖包

    npm install vue-router -S
    
  • src目录下新建router文件夹,并在该文件夹下新建index.js

    // src/router/index.js
    import Vue from 'vue';
    import Router from 'vue-router';
    import list from './../views/list.vue';
    import detail from './../views/detail.vue';
    
    Vue.use(Router);
    
    const routes = [
        {
            path: "/",
            redirect: "/list"
        },
        {
            path: "/list",
            component: list
        }, {
            path: "/detail",
            component: detail
        }
    ];
    
    
    export function createRouter () {
        return new Router({
            mode: 'history',
            routes
        })
    }
    
    类似于 createApp,我们也需要给每个请求一个新的 router 实例,所以文件导出一个 createRouter 函数
    
  • src目录下新建views文件夹,用于存放我们的页面,并在该文件夹下新建 list.vue 和 detail.vue 两个文件

    // src/views/list.vue
    <template>
      <div>
        <div>list page</div>
        <div @click="$router.push('/detail')">to detail</div>
      </div>
    </template>
    
    <script>
        export default {
            name: "list"
        }
    </script>
    
    <style scoped>
    
    </style>
    
    // src/views/detail.vue
    <template>
      <div>
        <div>detail page</div>
        <div @click="$router.push('/list')">to list</div>
      </div>
    </template>
    
    <script>
        export default {
            name: "detail"
        }
    </script>
    
    <style scoped>
    
    </style>
    
  • 更改app.js,注入 router 到根 Vue 实例

    // src/app.js
    import Vue from 'vue';
    import App from './app.vue';
    import {createRouter} from './router';
    
    export function createApp() {
        let router = createRouter();
        const app = new Vue({
            // 注入 router 到根 Vue 实例
            router,
            // 根实例简单的渲染应用程序组件。
            render: h => h(App)
        });
        return { app, router };
    }
    
  • 在 entry-server.js 中实现服务器端路由逻辑

    // src/entry-server.js
    import { createApp } from './app';
    
    export default context => {
        // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
        // 以便服务器能够等待所有的内容在渲染前,
        // 就已经准备就绪。
        return new Promise((resolve, reject) => {
            const { app, router } = createApp();
    
            // 设置服务器端 router 的位置
            router.push(context.url);
    
            // 等到 router 将可能的异步组件和钩子函数解析完
            router.onReady(() => {
                const matchedComponents = router.getMatchedComponents();
                // 匹配不到的路由,执行 reject 函数,并返回 404
                if (!matchedComponents.length) {
                    return reject({ code: 404 })
                }
    
                // Promise 应该 resolve 应用程序实例,以便它可以渲染
                resolve(app)
            }, reject)
        })
    }
    
  • 由于每次更改代码都需要build客户端和服务端,所有我们修改package.json,添加一个build:ssr命令,用于一条命令同时build客户端和服务端

    "scripts": {
        "server": "node server.js",
        "build:ssr": "rimraf dist && npm run build:ssr:client && npm run build:ssr:server",
        "build:ssr:client": "webpack --config build/webpack.client.conf.js --progress --hide-modules",
        "build:ssr:server": "webpack --config build/webpack.server.conf.js --progress --hide-modules"
      },
    
  • 使用build:ssr 之前还需要添加一个 rimraf 的依赖包

    npm install rimraf -D
    
  • 执行命令,编译客户端和服务端代码

    npm run build:ssr
    
  • 重新启动服务器

    npm run server
    

    打开浏览器访问 http://localhost:8090 发现页面可以正常访问list.vue页面的内容,但点击 to detail 却没有任何反应,通过chrome开发工具调试发现,客户端生成的一个main.js是404,如下图所示:

    由此发现是静态资源没有正确引入。下面我们修改server.js 来引入静态资源。

  • 修改server.js 增加静态资源路径

    // server.js
    ...
    // 增加静态文件路径
    server.use('/dist', express.static('./dist'));
    
    server.get('*', (req, res) => {
        res.setHeader("Content-Type", "text/html");
        const handleError = err => {
            if (err.url) {
                res.redirect(err.url)
            } else if(err.code === 404) {
                res.status(404).send('404 | Page Not Found')
            } else {
                // Render Error Page or Redirect
                res.status(500).send('500 | Internal Server Error');
                console.error(`error during render : ${req.url}`);
                console.error(err.stack)
            }
        };
    
        const context = {
            url: req.url
        };
        renderer.renderToString(context, (err, html) => {
            if (err) {
                return handleError(err)
            }
            res.send(html)
        })
    });
        
    let port = 8090;
    // 监听端口, 启动服务
    server.listen(port);
    console.log(`server is runing: http://localhost:${port}`);
    

    重新启动服务并访问页面,发现路由可以正常跳转啦。。

7.数据预取和状态

在SPA应用中,我们都是通过AJAX异步获取数据后在对数据进行处理,但在服务端渲染应用中,我们要在渲染之前对数据进行处理,否则也失去了服务端渲染的意义。也就是说我们需要先预取和解析好这些数据。

VUE-SSR官网描述如下:

在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container))"中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。

下面我们根据官网文档一步一步实现数据的预取和状态。

实现数据的预取和状态官方文档使用的是VUEX状态管理库,我们首先添加VUEX的依赖库。

  • 添加VUEX 和 axios 依赖库

    npm install vuex axios vuex-router-sync -S
    
  • src目录下新建store文件夹,并在该文件夹下新建index.js

    // src/store/index.js
    import Vue from 'vue';
    import Vuex from 'vuex';
    Vue.use(Vuex);
    
    export function createStore() {
        return new Vuex.Store({
            state: {
                list: []
            },
            mutations: {
    
            },
            actions: {
    
            }
        })
    };
    
  • 在store文件夹下新建modules文件夹,并在该文件夹下新建list.js

    import axios from 'axios';
    export default {
        namespaced: true,
        state: {
           data: []
        },
        mutations: {
            setData(state, data) {
                state.data = data;
            }
        },
        actions: {
            fetchData({commit}) {
                return axios.get('https://www.fastmock.site/mock/efe6b65f01415f1a367e50b7e717c609/mock/get/list').then((data) => {
                    let result = data.data || [];
                    commit('setData', result);
                });
            }
        }
    };
    https://www.fastmock.site/mock 是一个在线mock数据的网站,具体可以参考fastmock官网
    https://www.fastmock.site/mock/efe6b65f01415f1a367e50b7e717c609/mock/get/list 返回的是一个列表数据,数据格式如下:
    [{
    	"id": 1,
    	"date": "2020-06-07",
    	"name": "张三",
    	"addr": "河南省郑州市"
    }, {
    	"id": 2,
    	"date": "2020-06-08",
    	"name": "李四",
    	"addr": "河南省郑州市"
    }, {
    	"id": 3,
    	"date": "2020-06-09",
    	"name": "王五",
    	"addr": "河南省郑州市"
    }]
    
  • 修改 app.js 注入store到根Vue实例

    // src/app.js
    import Vue from 'vue';
    import App from './app.vue';
    import {createRouter} from './router';
    import {createStore} from "./store";
    import { sync } from 'vuex-router-sync';
    
    export function createApp() {
        let router = createRouter();
        let store = createStore();
    
        // 同步路由状态(route state)到 store
        sync(store, router);
    
        const app = new Vue({
            router,
            // 注入store
            store,
            // 根实例简单的渲染应用程序组件。
            render: h => h(App)
        });
        return { app, router, store };
    }
    
  • 修改路由组件,暴露asyncData方法

    根据官网描述:

    那么,我们在哪里放置「dispatch 数据预取 action」的代码?

    我们需要通过访问路由,来决定获取哪部分数据 - 这也决定了哪些组件需要渲染。事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。

    我们将在路由组件上暴露出一个自定义静态函数 asyncData。注意,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去。

    修改list.vue文件, 暴露asyncData方法:

    // src/views/list.vue
    <template>
      <div>
        <table cellspacing="0" cellpadding="0" border="0" class="my_table">
          <thead>
          <tr>
            <td>日期</td>
            <td>姓名</td>
            <td>地址</td>
            <td>操作</td>
          </tr>
          </thead>
          <tr v-for="item in list" :key="item.id">
            <td>{{item.date}}</td>
            <td>{{item.name}}</td>
            <td>{{item.addr}}</td>
            <td @click="toDetail(item.id)"><span>详情</span></td>
          </tr>
        </table>
      </div>
    </template>
    
    <script>
        // 在这里导入模块,而不是在 `store/index.js` 中
        import listStoreModule from '../store/modules/list';
        export default {
            name: "list",
            data() {
                return {}
            },
            asyncData({ store, route }) {
                store.registerModule('list', listStoreModule);
                return store.dispatch('list/fetchData');
            },
            computed: {
                list() {
                    return (this.$store.state.list || {}).data || [];
                }
            },
            methods: {
                toDetail(id) {
                    this.$router.push('detail/' + id);
                }
            }
        }
    </script>
    
    <style lang="less" scoped>
      .my_table {
        width: 500px;
        font-size: 13px;
        margin: 0 auto;
      }
      .my_table tr td:last-child {
        border-right: 1px solid #0094ff;
      }
      .my_table td {
        height: 35px;
        line-height: 35px;
        text-align: center;
        border: 1px solid #0094ff;
        border-right: none;
      }
      .my_table td span {
        color: blue;
        cursor: pointer;
      }
    </style>
    
  • 服务器端数据预取

    在 entry-server.js 中,我们可以通过路由获得与 router.getMatchedComponents() 相匹配的组件,如果组件暴露出 asyncData,我们就调用这个方法。然后我们需要将解析完成的状态,附加到渲染上下文(render context)中。

    // src/entry-server.js
    import { createApp } from './app.js';
    
    export default context => {
        // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
        // 以便服务器能够等待所有的内容在渲染前,
        // 就已经准备就绪。
        return new Promise((resolve, reject) => {
            const { app, router, store } = createApp();
    
            // 设置服务器端 router 的位置
            router.push(context.url);
    
            // 等到 router 将可能的异步组件和钩子函数解析完
            router.onReady(() => {
                const matchedComponents = router.getMatchedComponents();
                // 匹配不到的路由,执行 reject 函数,并返回 404
                if (!matchedComponents.length) {
                    return reject({ code: 404 })
                }
                Promise.all(matchedComponents.map(Component => {
                    if (Component.asyncData) {
                        return Component.asyncData({
                            store,
                            route: router.currentRoute
                        })
                    }
                })).then(() => {
                    // 在所有预取钩子(preFetch hook) resolve 后,
                    // 我们的 store 现在已经填充入渲染应用程序所需的状态。
                    // 当我们将状态附加到上下文,
                    // 并且 `template` 选项用于 renderer 时,
                    // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
                    context.state = store.state;
                    // Promise 应该 resolve 应用程序实例,以便它可以渲染
                    resolve(app);
                }).catch(reject);
            }, reject)
        })
    };
    
  • 客户端数据预取

    在路由导航之前解析数据:

    使用此策略,应用程序会等待视图所需数据全部解析之后,再传入数据并处理当前视图。好处在于,可以直接在数据准备就绪时,传入视图渲染完整内容,但是如果数据预取需要很长时间,用户在当前视图会感受到"明显卡顿"。因此,如果使用此策略,建议提供一个数据加载指示器 (data loading indicator)。

    我们可以通过检查匹配的组件,并在全局路由钩子函数中执行 asyncData 函数,来在客户端实现此策略。注意,在初始路由准备就绪之后,我们应该注册此钩子,这样我们就不必再次获取服务器提取的数据。

    // src/entry-client.js
    import { createApp } from './app.js';
    
    const {app, store, router} = createApp();
      
    if (window.__INITIAL_STATE__) {
      store.replaceState(window.__INITIAL_STATE__);
    }
      
    router.onReady(() => {
          // 添加路由钩子函数,用于处理 asyncData.
          // 在初始路由 resolve 后执行,
          // 以便我们不会二次预取(double-fetch)已有的数据。
          // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
          router.beforeResolve((to, from, next) => {
              const matched = router.getMatchedComponents(to);
              const prevMatched = router.getMatchedComponents(from);
              // 我们只关心非预渲染的组件
              // 所以我们对比它们,找出两个匹配列表的差异组件
              let diffed = false;
              const activated = matched.filter((c, i) => {
                  return diffed || (diffed = (prevMatched[i] !== c))
              });
              if (!activated.length) {
                  return next()
              }
              // 这里如果有加载指示器 (loading indicator),就触发
              Promise.all(activated.map(c => {
                  if (c.asyncData) {
                      return c.asyncData({ store, route: to })
                  }
              })).then(() => {
      
                  // 停止加载指示器(loading indicator)
      
                  next()
              }).catch(next)
          });
          app.$mount('#app');
    });
    
    
  • 执行命令,编译客户端和服务端代码

    npm run build:ssr
    
  • 重新启动服务器

    npm run server
    

    服务启动后,效果如下图:

    右键查看源代码可以看到异步数据是和网页一块渲染出来的,至此服务端渲染完成。

8.实现服务端渲染热加载

待续....