动态

详情 返回 返回

服務端渲染SSR - 动态 详情

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常見問題

Add a new 评论

Some HTML is okay.