Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vue服务端渲染 #61

Open
huangchucai opened this issue Jan 21, 2020 · 0 comments
Open

Vue服务端渲染 #61

huangchucai opened this issue Jan 21, 2020 · 0 comments

Comments

@huangchucai
Copy link
Owner

Vue服务端渲染

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

本质

vue-ssr

  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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant