Vue服务端渲染

282 阅读1分钟

Vue服务端渲染

前言: 现在浏览器对于spa页面的抓取能力还是比较差,本文从零开始搭建一个简单的Vue服务端渲染的案例。

本质

  1. 服务端和客户端共用代码和入口文件
  2. 通过webpack分开打包服务端和客户端代码 , 然后打包
  3. 通过webpack打包后服务端的bundle后,通过服务端模板server-index.htmlvue-server-renderer生成一个渲染bundler,返回返回给用户,其中服务端模板需要引入客户端打包的js来进行一下事件和交互。

项目构建

  1. 创建项目 mkdir vue-ssr && cd vue-ssr

  2. 初始化项目 npm init -y

  3. 添加对应的依赖

    ## 安装webpack依赖和插件
    npm install -D webpack webpack-cli webpack-dev-server webpack-merge
    
    # 安装对应的babel处理js文件
    npm install -D babel-loader @babel/core @babel/preset-env 
    
    # 安装css依赖
    npm install -D vue-style-loader css-loader 
    
    # 安装对应的vue依赖
    npm install vue vue-template-compiler 
    
  4. 创建目录结构

    ├── config  # 配置文件
    │   ├── webpack.base.js  # webpack基础配置文件
    │   ├── webpack.client.js  # 客户端配置文件
    ├── package-lock.json
    ├── package.json
    ├── public  # 存放模板目录
    │   ├── index.html
    ├── src  # 源码目录
    │   ├── App.vue  # 入口文件vue
    │   ├── app.js   # 入口打包工厂函数文件
    │   ├── component  # 组件目录
    │   │   ├── Bar.vue
    │   │   └── Foo.vue
    │   ├── entry-client.js  # webpack打包文件
    
    
  5. 编写webpack.base.js

    const path = require('path');
    const VueLoaderPlugin = require('vue-loader/lib/plugin');
    
    module.exports = {
        mode: 'production',
        output: {
            filename: '[name].bundle.js',
            path: path.resolve(__dirname, '../dist')
        },
        module: {
            rules: [
                {
                    test: /\.js$/,
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: ['@babel/preset-env'],
                        }
                    },
                    exclude: /node_modules/,
                },
                {
                    test: /\.vue$/,
                    use: ['vue-loader']
                },
                {
                    test: /\.css$/,
                    use: ['vue-style-loader', 'css-loader']
                }
            ]
        },
        plugins: [
            new VueLoaderPlugin()
        ]
    };
    
  6. 编写客户端打包文件webpack.client.js

    const path = require('path');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const merge = require('webpack-merge');
    const base = require('./webpack.base.js');
    module.exports = merge(base, {
        entry: {
            client: path.resolve(__dirname, '../src/entry-client.js'),
        },
        plugins: [
            new HtmlWebpackPlugin({
                filename: 'index.html',
                template: path.resolve(__dirname, '../public/index.html'),
            }),
        ]
    });
    
  7. public目录下存放index.html

    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Document</title>
    </head>
    <body>
        <div id="app"></div>
    </body>
    </html>
    
  8. 简单的编写2个组件 Bar.vue Foo.vue

    # Bar.vue
    <template>
        <div style="background: red;" @click="show">
            bar
        </div>
    </template>
    
    <script>
        export default {
            methods: {
                show() {
                    alert(1);
                }
            }
        };
    </script>
    
    # Foo.vue
    <template>
        <div style="background: #abcdef;">
            Foo
        </div>
    </template>
    
  9. app.vue的编写

    <template>
        <div id="app">
            app的vue
            <Bar></Bar>
            <Foo></Foo>
        </div>
    </template>
    
    <script>
        import Bar.vue form './component/bar.vue' 
        import Foo.vue form './component/foo.vue' 
        export default {
            metaInfo: {
                title: 'My Awesome Webapp',
            },
            components: {
              Bar,
              Foo
            },
        }
    </script>
    
    

核心部分

入口文件

  1. 入口文件app.js

    import Vue from 'vue';
    import App from './App.vue';
    
    export default () => {
        const app = new Vue({
            render: (h) => h(App),
        });
        return { app };
    }
    

    解析:为什么这里要导出一个函数,而不是直接导出一个实例,如果不导出一个函数,在客户端,我们每一个用户都是新建了一个实例,这样不会有问题,但是在服务器,由于程序一直运行,所以的用户都会共享同一个实例,这样就不会出现混乱,所以要每次访问都要返回一个新的实例,这样就不会有冲突

客户端部分

  1. 客户端webpack打包文件 webpack.client.js

    const path = require('path');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const merge = require('webpack-merge');
    const base = require('./webpack.base.js');
    module.exports = merge(base, {
        entry: {
            client: path.resolve(__dirname, '../src/entry-client.js'),
        },
        plugins: [
            new HtmlWebpackPlugin({
                filename: 'index.html',
                template: path.resolve(__dirname, '../public/index.html'),
            }),
        ]
    });
    

    到这一步我们已经初步搭建了客户端开发的流程,启动webpack-dev-server就可以看到页面了

  2. 配置脚步package.json

      "scripts": {
        "client:dev": "webpack-dev-server --config ./config/webpack.client.js",
        "client:build": "webpack --config ./config/webpack.client.js",
        "server:build": "webpack --config ./config/webpack.server.js"
      }
    

服务端部分

  1. 通过vue-server-renderer来进行服务端渲染

    npm install vue-server-renderer --save
    
  2. 服务端入口entry-server.js

    import createApp from './app';
    
    // 服务器每次请求调用此函数,产生一个新的APP实例
    export default () => {
        let { app, router } = createApp();
        return app;
    }
    
  3. 服务端的模板server-index.html

    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport"
              content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        {{{ meta.inject().title.text() }}}
    </head>
    <body>
    <!--vue-ssr-outlet-->
    <script src="/client.bundle.js"></script>
    </body>
    </html>
    
    
  4. 服务端webpack配置文件webpack.server.js

    const path = require('path');
    const HtmlWebpackPlugin = require('html-webpack-plugin');
    const merge = require('webpack-merge');
    const base = require('./webpack.base.js');
    module.exports = merge(base, {
        target: 'node',
        entry: {
            server: path.resolve(__dirname, '../src/entry-server.js'),
        },
        output: {
            libraryTarget: 'commonjs2'
        },
        plugins: [
            new HtmlWebpackPlugin({
                filename: 'server-index.html',
                template: path.resolve(__dirname, '../public/server-index.html'),
                excludeChunks: ['server']
            }),
        ]
    });
    

    解析: 由于打包后的bundler是给服务端使用的,而webpack默认的target是客户端,所以需要指明target是node,打包后,在内部会被vue-server-renderer通过require(xxxx)使用,所以要指定webpack打包后通过commonjs2导出

    打包文件

    # 运行脚本打包客户端和服务端代码
    npm run client:build
    npm run server:build
    

    启动node服务

    • 安装express依赖

      npm i express 
      
    • 编写node-server.js

      // vue-server-renderer
      
      const express = require('express');
      const VueServerRenderer = require('vue-server-renderer');
      const fs = require('fs');
      # 引入服务器打包后的文件和模板
      const serverBundle = fs.readFileSync('./dist/server.bundle.js', 'utf-8');
      const template = fs.readFileSync('./dist/server-index.html', 'utf-8');
      
      const app = express();
      # 创建渲染函数
      const render = VueServerRenderer.createBundleRenderer(serverBundle, {
          template
      });
      
      app.get('/', (req, res) => {
          render.renderToString(function (err, html) {
              res.send(html);
          });
      });
      
      app.listen(4000);
      

      解析:

      1. 通过VueServerRenderer创建渲染函数,其中通过模板template把服务端打包的代码放入指定的位置中()
      2. 然后在调用render.renderToString 发送给用户
    注意点
    1. 注意我们打包后并没有使用客户端的index.html, 而我们的客户端打包的js有需要根元素是#app,所以我们再app.vue中的根元素中添加了#app的div根标签,因为APP.vue也会被服务端引入到渲染函数中,返回给客户端

    引入Vue-router

    核心: 本质上当用户在浏览器上输入localhost:4000/foo的时候,服务端只是访问了 / , 然后通过router.push('/foo') 跳转到指定页面。

    通过VueServerRenderer的render.renderToString传递访问路径参数给服务端的入口文件entry-server.js

    1. 安装依赖

      npm i vue-router
      
    2. 创建初始文件router.js

      #router.js
      import Vue from 'vue';
      import Router from 'vue-router';
      import Bar from './component/Bar.vue';
      import Foo from './component/Foo.vue';
      
      Vue.use(Router);
      
      export default () => {
          const router = new Router({
              mode: 'history',
              routes: [
                  {
                      path: '/',
                      component: Bar
                  },
                  {
                      path: '/foo',
                      component: Foo
                  }
      
              ]
          });
          return router;
      }
      

      解析: 和 vue实例一样,我们需要创建一个函数,来让每一次访问都生成一个新的router实例

    3. 修改app.js

      import Vue from 'vue';
      import App from './App.vue';
      import createRouter from './router.js';
      
      export default () => {
          const store = createVuex();
          const app = new Vue({
              render: (h) => h(App),
              router,
          });
          return { app, router };
      }
      
      
    4. 修改app.vue

      <template>
          <div id="app">
              app的vue
              <router-link to="/">Bar组件</router-link>
              <router-link to="/foo">foo组件</router-link>
              <router-view></router-view>
          </div>
      </template>
      
      <script>
          export default {
          }
      </script>
      
      
    5. 修改entry-server.js

      import createApp from './app';
      
      
      // 服务器调用此函数,产生一个新的APP实例
      export default (context) => {
          // 如果服务端访问/foo,  会首先访问首页,然后通过路由跳转到指定的路径到/foo
          return new Promise(((resolve, reject) => {
              let { app, router } = createApp();
              console.log('entry-server');
              router.push(context.url); // 跳转到指定路由
              resolve(app);
          }));
      
      }
      
    6. 修改node-server.js

      ........
      
      // 如果访问的路径不存在
      app.get('*', (req, res) => {
          const context = {
              url: req.url
          };
          render.renderToString(context, (err, html) => {
              if (err) {
                  if (err.code === 404) {
                      res.status(404).end('Page not found')
                  } else {
                      res.status(500).end('Internal Server Error')
                  }
              } else {
                  res.end(html)
              }
          });
      });
      app.listen(4000);
      ........
      

    引入Vuex

    1. 安装依赖

      npm i vuex
      
    2. 创建初始化文件store.js

      import Vue from 'vue';
      import Vuex from 'vuex';
      
      Vue.use(Vuex);
      export default () => {
          const store = new Vuex.Store({
              state: {
                  username: 'yx'
              },
              mutations: {
                  set_username(state) {
                      state.username = 'hcc';
                  }
              },
              actions: {
                  set_username({ commit }) {
                      return new Promise((resolve, reject) => {
                          setTimeout(() => {
                              commit('set_username');
                              resolve();
                          }, 1000);
                      });
                  }
              }
          });
      
          // 如果是浏览器环境,就直接用已经注入到全局环境的对象替换掉state
          if (typeof window === 'object' && window.__INITIAL_STATE__) {
              store.replaceState(window.__INITIAL_STATE__);
          }
          return store;
      }
      
    3. 添加Vuex,为每一个实例创建一个store,不同的用户维护不同的store

      # app.js
      import Vue from 'vue';
      import App from './App.vue';
      import createVuex from './store.js';
      import createRouter from './router.js';
      export default () => {
          const router = createRouter();
          const store = createVuex();
          const app = new Vue({
              render: (h) => h(App),
              router,
              store
          });
          return { app, router, store };
      }
      
      
    4. 修改服务端入口entry-server.js

      import createApp from './app';
      // 服务器调用此函数,产生一个新的APP实例
      export default (context) => {
          // 如果服务端访问/foo,  会首先访问首页,然后通过路由跳转到指定的路径到/foo
          return new Promise(((resolve, reject) => {
              let { app, router, store } = createApp();
              router.push(context.url); // 跳转到指定路由
              console.log('entry-server的入口');
              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 });
                      }
                  })).then(() => {
                      context.state = store.state;
                      context.meta = app.$meta();
                      resolve(app);
                  }).catch((e) => {
                      console.log(e);
                      return reject({ code: 404 });
                  });
                  // Promise 应该 resolve 应用程序实例,以便它可以渲染
              });
          }));
      }
      
      
      ## Bar.vue
      <template>
          <div style="background: red;" @click="show">
              bar vuex数据 {{$store.state.username}}
          </div>
      </template>
      
      <script>
          export default {
      				# 服务端特有的方法
              asyncData({ store }) {
                  return store.dispatch('set_username');
              },
              # 客户端特有 
              mounted() {
                  this.$store.dispatch('set_username');
              },
              methods: {
                  show() {
                      alert(1);
                  }
              }
          };
      </script>
      

代码地址