1.项目背景

需要将一个旧的用vue+svg搭建的地铁图h5改造成有个ssr项目,以提升首屏渲染时间。

2.分析

项目现状,项目组已有koa搭建的业务中间层,且需要改造的项目为一个旧项目,综合考虑,将旧项目进行改造,而非使用nuxt重写。

3.SSR原理

1.所谓SSR就是将一个项目通过两种打包配置,分别生成两份打包代码,一份在服务端(nodejs)执行,另一份在客户端(browser)上执行。当用户请求这个h5页面时,nodejs会获取页面需要的数据,并调用之前打包好的服务上执行的代码,生产一个由dom点组成的string,再将这个string插入到一个html的模板中,返回给用户。用户拿到的是一个包含数据和渲染结果的html,浏览器可以直接展示给用户,这样就减少了首页白屏时间。
2.在浏览器拿到的nodejs输出的html文件中与我们之前的spa代码的不同点有两个,一个是这个html中包含了在服务端已经运行SSR打包的服务端代码所生成的dom,另一个是状态数据。其他的是一样的,也就是说在浏览器上,这个html还会像之前的spa代码一样请求并执行打包的js的bundle代码(既SSR打包的客户端代码),那么如何确保在运行js代码时,不会出现应为初始状态为空而引起的白屏问题(因为初始状态为空,先将已经渲染好的dom清空,等待请求状态回来后再二次渲染),这就需要前面说到的状态数据的作用了。状态数据在服务端输出的html中会挂载在window下的一个固定的全局变量中(),在js运行时,首先就是将这个全局变量的数据初始化到vuex中,这样之后的vue代码执行时,组件的初始状态就和已经渲染好的dom是相同的,不会中间出现白屏的问题了。

4.准备服务端环境

选用koa2作为服务框架,首先初始化项目并安装koa2,路由使用koa-mount:npm init -y && npm install koa koa-mount -S
之后我们将旧项目的src代码放在新项目的src/client目录下, 并将原项目依赖的npm包逐个在新项目中安装。

5.搭建BFF层

src目录下创建server文件夹,即src/server,并在此文件夹创建server.js文件。
并键入如下代码:

const koa = require('koa');
const config = require(`../config/${process.env.NODE_ENV}.js`);
const app = new koa();

app.listen(config.port)

这里有两个点,一.是我们从config目录引入了配置,二.是配置是根据环境变量process.env.NODE_ENV引入的。process.env中并没有NODE_ENV这个变量,需要引入一个插件cross-env来处理。
先安装:

npm install cross-env --save

再修改package.json中脚本命令:

  "scripts": {
    "local": "cross-env NODE_ENV=local node src/server/server",
    "prod": "cross-env NODE_ENV=production node src/server/server"
}

这样,在运行对应npm命令启动服务时,cross-env就会在process.env中插入NODE_ENV变量了。
同时我们需要在项目config目录下,创建两个配置文件local.jsproduction.js。将开发和生产环境的配置分别写在这两个文件中,举例如:

module.exports = {
   port: 80
}

6.使用vue-server-renderer进行同构渲染

要使用vue进行ssr渲染,就需要使用官方提供的渲染插件vue-server-renderer
安装:

npm install vue-server-renderer --save

vue-server-renderer包中有createBundleRenderer函数,它接受两个参数:
1.第一个参数为serverBundle, 这个参数就是vue工程打包成服务端渲染的代码。
2.第二个参数是一个对象,对象中主要有两个属性; 一个是template,这个参数是一个html模板,服务端渲染得到的字符串会被插入到这个模板中,最终一起输出给客户端;第二个是manifest,这个是客户端代码打包生成的mainfest,createBundleRenderer生成的代码在客户端运行时引入的客户端代码的引用就是根据这个文件分析所得。
那么就需要将我们旧项目的template稍做改造,如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta name="apple-mobile-web-app-title" content="地铁">
    <meta charset="UTF-8" />
    <meta name="renderer" content="webkit">
    <meta name="force-rendering" content="webkit">
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">

    <meta name="HandheldFriendly" content="True">
    <meta name="MobileOptimized" content="320">
    <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1,viewport-fit=cover">
    <title>Document</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

注意此处的<!--vue-ssr-outlet-->不可省略,这是vue-server-renderer代码嵌入点。

7.BFF层调整

因为整个node项目还会做一些服务接口中间计算等业务逻辑,所以需要将地图图的ssr按照路由单独下发到ssr的中间件去处理。修改如下:

const koa = require('koa');
const mount = require('koa-mount');

const ssrMiddle = require('./ssr/index.js');
const config = require(`../config/${process.env.NODE_ENV}.js`);
const app = new koa();

app.use(mount('/subway', ssrMiddle)); 

app.listen(config.port)

这样就将访问地铁图(/subway)的请求转到了ssr目录下的index.js文件中去处理了,同时我们在src/server/ssr目录下创建index.js文件。

8.构建ssr

我们再在ssr.js中创建一个koa实例,然后讲ssr相关的逻辑都在这个koa实例中进行处理。

const koa = require('koa');
const fs = require('fs');
const { createBundleRenderer } = require('vue-server-renderer');

const ssrKoa = new koa();



async function subway(ctx, next) {
    console.log('get subway.');
    next();
}

ssrKoa.use(subway);

module.exports = ssrKoa;

接下来运行npm run dev, 项目启动成功,浏览器访问http://localhost/subway可以看到终端打印日志get subway.表明框架搭建成功。但是可以看到,我们服务端渲染相关逻辑都在注释中,因为打包相关改造还没有做,所以我们需要先修改打包,之后再回头修改这里。

9.修改vue项目,适配ssr

首先我们需要知道,之前的vue项目是一个只在客户端运行的spa项目,所以vuestorerouter这三个一般的写法都是直接export一个实例,以保证全局唯一。但在现在的代码要在node上也运行,如果全局一个实例的话,不同用户请求会造成状态污染,所以这三个对象的导出和使用都要改成工厂模式。

1).router/index修改

import Router from 'vue-router';

export default function createRouter(ctx) {
    const routes = {
        path: '/',
        name: 'subway',
        component: import('../routes/subway/index.vue')
    };
}

2).store/index修改

import vue from 'vue';
import vuex from 'vuex';
import * as actions from './actions';
import getters from './getters';
import cstate from './state';
import mutations from './mutations';

vue.use(vuex);

export function createStore () {
  return new vuex.Store({
    actions,
    getters,
    state:  cstate,
    mutations,
  });
}

3).修改store/state文件

//  将state也从导出单例改为导出函数
export default function() {
  return {
     status: 0,
     data: null,
  }
}

4).修改main.js

import Vue from 'vue';
import App from './App.vue';
import {createRouter} from './router';
import { createStore } from './store';

export function createApp() {
  const router = createRouter()//创建router实例
  const store = createStore();
  const app = new Vue({
    el: '#app',
    router,
    store,
    render: h => h(App),
  });

  return {router, app, store};
}

可以看到,这里也将之前的直接export出vue实例改为了export一个函数,并且将之前new Vue中的component,template两个属性改为了render属性,原因可参考:vue中render函数的作用

5).entry-server.js

src/server/ssr目录创建entry-server.js文件,并键入内如。

import { createApp } from '../client/subway/main'

export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    // 设置服务器端 router 的位置
    const { url, query, data } = context
    const { fullPath } = router.resolve(url).route
    app.$store = store;
    console.log('context', context);
    //    将外部传入数据同步到store中
    store.commit('updateSubData', data);
    if (fullPath !== url) {
        return reject({ url: fullPath })
    }

    // set router's location
    router.push(url)
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
       resolve(app)
      // const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      // console.log(url)
      // if (!matchedComponents.length) {
        // eslint-disable-next-line
      //  return reject({ code: 404 })
      // }
      // 使用Promise.all执行匹配到的Component的asyncData方法,即预取数据
      // Promise.all(matchedComponents.map(({ asyncData }) => asyncData && // asyncData({
//        store,
//        route: router.currentRoute
//      })))
//        .then(() => {
          // isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
          // 把vuex的state设置到传入的context.state
 //         context.state = store.state
          // 返回state、router已经设置好的Vue实例app
 //         resolve(app)
 //       })
 //       .catch(reject)
 //   }, reject)

  })
}

6).entry-client.js

在src/client目录下创建entry-client.js文件,键入如下

import { createApp } from './subway/main'

const { app, router, store } = createApp();

if(window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  app.$mount('#app')
});

在客户端代码因为不存在状态污染的情况,所以不需要写成工程模式。这里需要注意的是处理window.__INITIAL_STATE__这个变量,这个变量是vue的createBundleRenderer在服务端将store数据保存的全局变量,所以客户端代码首先就是将这个状态同步到客户端代码的store中。

7).修改vue项目中生命周期

因为次套代码要确保在服务端和客户端都可运行,在服务端运行是,没有mounted等生命周期函数的调用,所以需要修改为created等。

8).修改svg创建方式

旧项目中,地铁图svg是直接通过document.createElementNS直接创建的dom节点,所以这部分代码不做改造的话,代码在服务端运行会报错。并且改造后的代码在服务端运行时,要保证生成的svg字符串直接插入到了整个项目生成的字符串的对应位置上。那么就需要将这部分逻辑修改为:
1. 对svg的创建使用自定义字符串拼接的方案(或者可以使用jsdom)。
我们的地铁数据整体被抽象成了SVGLine(线路)、SVGStation(站点)和Subway来进行数据维护,Subway实例中包含所有SVGLineSVGStation实例,并且维护整个svg根节点和根节点上绑定的画布拖动和缩放等事件的处理。

//    将之前通过document创建dom并绑定手势响应的逻辑拆成两部分,
//    toString创建string类型的svg节点,
//    bindEvent供外层判断打开环境,动态获取节点并绑定手势事件。
class Subway {
    toString() {
        const {svgLines, svgstations} = this;
        const lineStrs = svgLines.map(line => {
          return line.toString();
        }).join('');
        const tationStrs = svgstations.map(station => {
          return station.toString();
        }).join('');

        return lineStrs + tationStrs;
    }

    bindEvent() {
        if(typeof document !== 'object') {
                return;
        }

        //    处理图区初始位置和层级
        this.initSvg();
        const ele = document.getElementById(this.display_id);

        if(ele) {
            ele.addEventListener('touchstart', this.onTouchStart.bind(this))
            ele.addEventListener('touchmove', this.onTouchMove.bind(this))
            ele.addEventListener('touchend', this.onTouchEnd.bind(this))
            ele.addEventListener('touchcancle', this.onTouchEnd.bind(this))
        }
    }
}

class SVGLine {

    toString() {
        const { x, y } = this.boundary
        let station;

        if (this.trans) {
            station = `<image x="${x - r}" y="${y - r}" sid="${this.sid}" href="${imageUrl}" width="${BASIC_SCALE_SIZE * r * 2}" height="${BASIC_SCALE_SIZE * r * 2}"></image>`;
        } else {
            station = `<circle cx="${x}" cy="${y}" r="${STATION_OUT_CIRCLE_R * BASIC_SCALE_SIZE}" stroke="rgb(${this.color})" stroke-width="${2}" fill="${colors[1]}"></circle>`
        }

        let stationLabel = this.labelString()
        return station+stationLabel;
    }
}

class SVGStation {
    toString() {
        var svgpath = "M" + this.segments[0] + " "; 
        for (var i = 1; i < this.segments.length; i++) {
              if (this.segments[i] instanceof PointBoundary) svgpath += "Q" + this.segments[i] + " ";
            else svgpath += "L" + this.segments[i] + " ";
        }

        let labels = [], rects = [];
        this.labelPts.forEach(label => {
            const lineLabelText = `<text x="${label.x}" y="${label.y - 5}" text-anchor="start" font-size="${FONTSIZE}" fill="#ffffff">
                    <tspan>${this.name}</tspan>
                </text>`

            labels.push(lineLabelText);
            const height = FONTSIZE, width = measureText(this.name, FONTSIZE)
            const labelRect = `<rect x="${label.x - 5 }" y="${label.y - 5 - 13}" rx="4" ry="4" width="${width+10}" height="${height+6}"
                stroke-width="0" stroke-opacity="0.8" fill="${this.color}" fill-opacity="0.8" ></rect>`;
            
            rects.push(labelRect);

        })
        
        
        return `<path d="${svgpath}" stroke="${this.color}" stroke-width="${TRACK_THICKNESS * BASIC_SCALE_SIZE}" stroke-linejoin="round" fill="none" ></path>` + rects.join('') + labels.join('')
    }
}

2. 创建一个新的vue组件并使用v-html命令将拼接好的svg插入到vue组件中。
src/client/subway/components目录下创建创建svg.vue文件

<template>
  <svg v-html="svgContent" version="1.1" :width="svgWidth" :height="svgHeight" ></svg>
</template>
<script>
    import {Subway} from "../common/js/subway.js";
    export default {
        props: {
          subwayData: {
            type: Object
          }
        },
        data: {
            subwayIns: new Subway(),
            svgContent: '',
            svgWidth: 0,
            svgHeight: 0,
        },
        watch: {
            subwayData(v) {
                this.subwayIns.setData(v);
                this.svgContent = this.subwayIns.toString();
                const {width, height}  = this.subwayIns.Size();
                this.svgWidth = width;
                this.svgHeight = height;
                this.$nextTick(() => {
                    this.subwayIns.bindEvent();
                })
            }
        }
    }
</script>

10.修改webpack打包

因为ssr是同一套代码,打包后分别运行在服务端和客户端,所以至少需要两套不同的webpack打包配置来对代码分别进行打包。

1).安装webpack、webpack-cli、webpack-merge 三个包

npm install webpack webpack-cli webpack-merge --save-dev

2).在build目录下创建webpack.base.js、webpack.loc.js、webpack.pro.js、webpack.client.js、webpack.server.js。

3).webpack.base.js

const VueLoaderPlugin = require('vue-loader/lib/plugin');
const resolve = (path) => require('path').resolve(__dirname, path);

module.exports = {
  output: {
    //打包后生成的文件夹
    path: resolve('../server/public/dist'),
    //静态目录,可以直接从这里取文件
    publicPath: '/dist',
    //打包后生成的文件名
    filename: 'js/[name].js',
    chunkFilename: 'js/[name].js',
  },
  resolve: {
    extensions: ['.ts', '.tsx', '.js', '.json', '.vue'],
    alias: {
      '@': resolve('../src'),
      '~@': resolve('../src'),
    },
  },
  plugins: [new VueLoaderPlugin()],
};

4).webpack.loc.js

const merge = require('webpack-merge');
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin');
const baseConfig = require('./webpack.base');
const webpack = require('webpack');
const env =  {
  NODE_ENV: '"production"',
  STATIC_PREFIX: '"/dist/"'
};
const resolve = (path) => require('path').resolve(__dirname, path);

let devConfig = merge(baseConfig, {
  //打包入口
  entry: {
    app: [resolve('../client/client-entry.js')],
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.less$/,
        use: [
          'vue-style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
          'less-loader',
        ],
      },
      {
        //页面中import css文件打包需要用到
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
        ],
      },
      //添加ts文件解析
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          appendTsSuffixTo: [/\.vue$/],
        },
      },
      {
        test: /\.(png|jpe?g|gif|svg|webp)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10240,
          name: 'img/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'media/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'fonts/[name].[hash:7].[ext]',
        },
      },
    ],
  },
  plugins: [
    //线程变量的配置
    new webpack.DefinePlugin({
      process: {
        env,
      },
    }),
    new FriendlyErrorsPlugin(),
  ],
  mode: 'development',
  stats: 'errors-only',
  devtool: 'eval-source-map',
});

module.exports = devConfig;

5).webpack.pro.js

生产环境的打包,涉及到一些css抽离、代码分割、压缩、gzip、css兼容性、babel。

const merge = require('webpack-merge');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
  .BundleAnalyzerPlugin;
const ExtractCssChunksPlugin = require('extract-css-chunks-webpack-plugin');
const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin');
const CompressionWebpackPlugin = require('compression-webpack-plugin');
const baseConfig = require('./webpack.base');
//    const buildingConf = require('./building-config');
const resolve = (path) => require('path').resolve(__dirname, path);

let prodConfig = merge(baseConfig, {
  //打包入口
  entry: {
    app: ['@babel/polyfill', resolve('../client/client-entry.js')],
  },
  output: {
    filename: 'js/[name].[contenthash].js',
    chunkFilename: 'js/[name].[contenthash].js',
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.less$/,
        use: [
          'vue-style-loader',
          {
            loader: ExtractCssChunksPlugin.loader,
          },
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
          'less-loader',
        ],
      },
      {
        //页面中import css文件打包需要用到
        test: /\.css$/,
        use: [
          {
            loader: ExtractCssChunksPlugin.loader,
          },
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              config: {
                path: resolve('../src'),
              },
            },
          },
        ],
      },
      //添加ts文件解析
      {
        test: /\.tsx?$/,
        loader: 'ts-loader',
        exclude: /node_modules/,
        options: {
          appendTsSuffixTo: [/\.vue$/],
        },
      },
      {
        test: /\.(png|jpe?g|gif|svg|webp)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10240,
          name: 'img/[name].[hash:7].[ext]',
          esModule: false,
        },
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'media/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'fonts/[name].[hash:7].[ext]',
        },
      },
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            //    options: buildingConf.babelConfig,
          },
        ],
        /* 排除模块安装目录的文件 */
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    //css抽离
    new ExtractCssChunksPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].chunk.css',
    }),
    //压缩css
    new OptimizeCSSPlugin(),
    //需要分析打包结果的话请开启这个注释!
    //new BundleAnalyzerPlugin(),
    new CompressionWebpackPlugin(),
  ],
  optimization: {
    splitChunks: {
      name: 'vendors',
      /**
       * chunks可以填写三个值:initial,async,all
       * initial: 对于匹配文件,非动态模块打包进该vendor,动态模块优化打包
       * async: 对于匹配文件,动态模块打包进该vendor,非动态模块不进行优化打包
       * all: 匹配文件无论是否动态模块,都打包进该vendor
       */
      chunks: 'initial',
      cacheGroups: {
        //将引用到2次以上的模块打包到common bundle
        common: {
          chunks: 'initial',
          name: 'common',
          minSize: 0,
          minChunks: 2, // 重复2次才能打包到此模块
        },
        async: {
          test: /node_modules/,
          name: 'vendors-async',
          chunks: 'async',
        },
      },
    },
    minimizer: [
      //压缩js
      new UglifyJsPlugin({
        uglifyOptions: {
          output: {
            comments: false,
          },
        },
      }),
    ],
  },
  devtool: 'none',
  mode: 'production',
});

module.exports = prodConfig;

6).webpack.client.js

客户端构建配置文件。

  1. 需要根据不同的环境去merge不同的webpack配置。
  2. 需要在环境变量中注入一个isBrowser的变量,值为true,表明现在是浏览器环境。
  3. plugins列表中需要加入一个vue-server-renderer/client-plugin插件,用于生成client-manifest文件。
const merge = require('webpack-merge');
const webpack = require('webpack');
const WebpackBar = require('webpackbar');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const env = {
  STATIC_PREFIX: "/subway/dist/",
};
let config; //动态导入不同环境的配置
if (process.env.NODE_ENV === 'local') {
  config = require(`./webpack.loc`);
  env['NODE_ENV'] = "local";
} else {
  config = require(`./webpack.pro`);
  env['NODE_ENV'] = "production";
}

module.exports = merge(config, {
  output: {
    //  注意路径,会影响客户端加载js等静态资源的路劲,
    //  因为这个项目地铁的路由在subway下,所以publicPath需要修改到/subway/dist/
    publicPath: env.STATIC_PREFIX.replace(/"/g, ''),
  },
  plugins: [
    new webpack.DefinePlugin({
      process: {
        env,
        isBrowser: true,
      },
    }),
    // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin(),
    new WebpackBar({
      name: 'client',
      color: '#00B101',
    }),
  ],
});

7).webpack.server.js

服务器构建配置文件要稍微复杂一些

  1. 也是需要根据不同的环境去merge不同的webpack配置。
  2. target为node,表明当前为node环境并且会告知vue-loader需要面向服务端输送代码。
  3. output.libraryTarget为commonjs2,表明以commonjs2模块输出代码。
  4. externals中使用webpack-node-externals插件,屏蔽nodejs原生模块,如fs等。
  5. plugins中要添加vue-server-renderer/server-plugin插件,用于生成server-bundle。
  6. devtool为eval-source-map,用于日志记录友好的报错信息。
const merge = require('webpack-merge');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const WebpackBar = require('webpackbar');
const resolve = (path) => require('path').resolve(__dirname, path);
const env = {
  STATIC_PREFIX: '"/dist/"',
};
let config; //动态导入不同环境的配置
if (process.env.NODE_ENV === 'local') {
  config = require(`./webpack.loc`);
  env['NODE_ENV'] = "local";
} else {
  config = require(`./webpack.pro`);
   env['NODE_ENV'] = "production";
}

module.exports = merge(config, {
  //打包入口
  entry: [resolve('../src/server/ssr/server-entry.js')],
  // 这允许 webpack 以 Node 适用方式处理模块加载
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',
  output: {
    publicPath: env.STATIC_PREFIX.replace(/"/g, ''),
    filename: 'server-bundle.js',
    // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
    libraryTarget: 'commonjs2',
  },
  // 不打包 node_modules 第三方包,而是保留 require 方式直接加载
  externals: [
    nodeExternals({
      // 白名单中的资源依然正常打包
      allowlist: [/\.css$/,/vant\/lib/],
    }),
  ],
  plugins: [
    new webpack.optimize.LimitChunkCountPlugin({
      maxChunks: 1,
    }),
    new webpack.DefinePlugin({
      process: {
        env,
        isBrowser: false,
      },
    }),
    // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin(),
    new WebpackBar({
      name: 'server',
      color: '#F3A702',
    }),
  ],
  devtool: 'eval-source-map',
});

8).修改package.json构建命令

"scripts": {
    "local": "cross-env NODE_ENV=local node src/server/server",
    "prod": "cross-env NODE_ENV=production node src/server/server",
    "build-prod": "rm -rf dist && cross-env NODE_ENV=production webpack --config build/webpack.client.js && cross-env NODE_ENV=production webpack --config build/webpack.server.js",
    "bulid-local": "rm -rf dist && cross-env NODE_ENV=local webpack --config build/webpack.client.js && cross-env NODE_ENV=local webpack --config build/webpack.server.js"
}

11.补全ssr.js

现在服务渲染的三要素,template、boundle、manifest都已齐备,可以将ssr.js中空缺代码补齐了

const fs = require('fs');
const log4js = require('log4js');
const path = require('path');
// const LRU = require('lru-cache');
const koa = require('koa');
const mount = require('koa-mount');
const { createBundleRenderer } = require('vue-server-renderer');
const axios = require('axios');

const ssrKoa = new koa();
const logger = log4js.getLogger();
const bundle = require('../dist/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/vue-ssr-client-manifest.json')
const template = fs.readFileSync(resolve('../client/transitmap/layout.html'), 'utf-8');

const resolve = (path) => require('path').resolve(__dirname, path);
log4js.configure(resolve('./config/local.json'));


const queryOpts = {
  url: '...',
}

async function fetchData(params) {
  try {
    let p = {
      currentCity,
      ...
    }
    const res = await axios.post(queryOpts.url, p)
    return res.data.data
  } catch (err) {
    logger.error(err);
  }
}

const renderer = createBundleRenderer(bundle, {
  template,
  clientManifest,
});

function parseUrl(url) {
  let ret = {};
  let tmp = url.split('?');

  if(tmp.length > 1) {
    tmp = tmp[1].split('&');
    tmp.forEach(str => {
      let [key, val] = str.split('=');
      ret[key] = val;
    })
  }

  return ret;
}


async function subway(ctx, next) {
  let context = {}
  try{
    let query = parseUrl(ctx.url);
    const data = await fetchData(query);
    const html = await renderer.renderToString({ url: ctx.url, query, data });
    ctx.body = html;
  } catch(e) {
    logger.error(e);
    await next();
  }
}
ssrKoa.use(serve(resolve('../../dist')));
ssrKoa.use(subway);

module.exports = ssrKoa;

注意这里地铁的数据我们是通过服务端使用http直接向后端请求所得,然后传入renderer的服务端代码去处理。因为全国地铁数据也就38个,而且变动缓慢,可以考虑直接将ssr服务代码生成的html缓存到内存中,这样可以极大的提审qps。

12.ssr常见问题


牛刀杀鸡
3 声望0 粉丝