基於vue現有專案的伺服器端渲染SSR改造

小火柴的藍色理想發表於2018-06-12

前面的話

  不論是官網教程,還是官方DEMO,都是從0開始的服務端渲染配置。對於現有專案的伺服器端渲染SSR改造,特別是基於vue cli生成的專案,沒有特別提及。本文就小火柴的前端小站這個前臺專案進行SSR改造

 

效果

  下面是經過SSR改造後的前端小站xiaohuochai.cc的網站效果,github原始碼地址

 

概述

【定義】

  伺服器渲染的Vue應用程式被認為是”同構”或”通用”,因為應用程式的大部分程式碼都可以在伺服器和客戶端上執行

【優點】

  與傳統SPA相比,伺服器端渲染(SSR)的優勢主要在於:

  1、更好的 SEO,搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面

  截至目前,Google 和 Bing 可以很好對同步 JavaScript 應用程式進行索引。但如果應用程式初始展示 loading 菊花圖,然後通過 Ajax 獲取內容,抓取工具並不會等待非同步完成後再行抓取頁面內容

  2、更快的內容到達時間,特別是對於緩慢的網路情況或執行緩慢的裝置

  無需等待所有的 JavaScript 都完成下載並執行,才顯示伺服器渲染的標記,所以使用者將會更快速地看到完整渲染的頁面,通常可以產生更好的使用者體驗

 

思路

  下面以官方的SSR伺服器端渲染流程圖為例,進行概要說明

ssr

  1、universal Application Code是伺服器端和瀏覽器端通用的程式碼

  2、app.js是應用程式的入口entry,對應vue cli生成的專案的main.js檔案

  3、entry-client.js是客戶端入口,僅執行於瀏覽器,entry-server.js是伺服器端入口,僅執行於伺服器

  4、entry-client和entry-server這兩個檔案都需要通過webpack構建,其中entry-client需要通過webpack.server.config.js檔案打包,entry-server需要通過webpack.server.config.js檔案打包

  5、entry-client構建後的client Bundle打包檔案是vue-ssr-client-manifest.json,entry-server構建後的server Bundle打包檔案是vue-ssr-server-bundle.json

  6、server.js檔案將客戶端打包檔案vue-ssr-client-manifest.json、伺服器端打包檔案vue-ssr-server-bundle.json和HTML模板混合,渲染成HTML

 

webpack配置

  基於vue-cli生成的專案的build目錄結構如下

build
    - build.js
    - check-versions.js
    - utils.js
    - vue-loader.conf.js
    - webpack.base.conf.js
    - webpack.dev.conf.js
    - webpack.prod.conf.js

  前面3個檔案無需修改,只需修改*.*.conf.js檔案

  1、修改vue-loader.conf.js,將extract的值設定為false,因為伺服器端渲染會自動將CSS內建。如果使用該extract,則會引入link標籤載入CSS,從而導致相同的CSS資源重複載入

-    extract: isProduction
+    extract: false

  2、修改webpack.base.conf.js

  只需修改entry入門配置即可

...
module.exports = {
  context: path.resolve(__dirname, `../`),
  entry: {
    - app: `./src/main.js`
    + app: `./src/entry-client.js`
  },
...

  3、修改webpack.prod.conf.js

  包括應用vue-server-renderer、去除HtmlWebpackPlugin、增加client環境變數

`use strict`
...
+ const VueSSRClientPlugin = require(`vue-server-renderer/client-plugin`)
const webpackConfig = merge(baseWebpackConfig, {
  ...
  plugins: [
    // http://vuejs.github.io/vue-loader/en/workflow/production.html
    new webpack.DefinePlugin({
      `process.env`: env,
+     `process.env.VUE_ENV`: `"client"`
    }),
    ...// generate dist index.html with correct asset hash for caching.
    // you can customize output by editing /index.html
    // see https://github.com/ampedandwired/html-webpack-plugin
-    new HtmlWebpackPlugin({
-      filename: config.build.index,
-      template: `index.html`,
-      inject: true,
-      minify: {
-        removeComments: true,
-        collapseWhitespace: true,
-        removeAttributeQuotes: true
-        // more options:
-        // https://github.com/kangax/html-minifier#options-quick-reference
-      },
-      // necessary to consistently work with multiple chunks via CommonsChunkPlugin
-      chunksSortMode: `dependency`
-    }),
   ...// copy custom static assets
    new CopyWebpackPlugin([
      {
        from: path.resolve(__dirname, `../static`),
        to: config.build.assetsSubDirectory,
        ignore: [`.*`]
      }
    ]),
+    new VueSSRClientPlugin()
  ]
})
...
module.exports = webpackConfig

  4、新增webpack.server.conf.js

const webpack = require(`webpack`)
const merge = require(`webpack-merge`)
const nodeExternals = require(`webpack-node-externals`)
const baseConfig = require(`./webpack.base.conf.js`)
const VueSSRServerPlugin = require(`vue-server-renderer/server-plugin`)

module.exports = merge(baseConfig, {
  entry: `./src/entry-server.js`,
  target: `node`,
  devtool: `source-map`,
  output: {
    libraryTarget: `commonjs2`
  },
  externals: nodeExternals({
    whitelist: /.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      `process.env.NODE_ENV`: JSON.stringify(process.env.NODE_ENV || `development`),
      `process.env.VUE_ENV`: `"server"`
    }),
    new VueSSRServerPlugin()
  ]
})

 

入口配置

  在瀏覽器端渲染中,入口檔案是main.js,而到了伺服器端渲染,除了基礎的main.js,還需要配置entry-client.js和entry-server.js

  1、修改main.js

import Vue from `vue`
import Vuex from `vuex`
-  import `@/assets/style.css`
import App from `./App`
-  import router from `./router`
+ import createRouter from `./router`
-  import store from `./store`
+ import createStore from `./store`
import async from `./utils/async`
Vue.use(async)
- new Vue({
+ export default function createApp() {
+  const router = createRouter()
+  const store = createStore()
+  const app = new Vue({
-   el: `#app`,
    router,
    store,
-   components: { App },
-   template: `<App/>`
+   render: h => h(App)
  })
+ return { app, router, store } +}

  2、新增entry-client.js

  後面會介紹到asyncData方法,但是asyncData方法只能用於路由繫結的元件,如果是初始資料則可以直接在entry-client.js中獲取

/* eslint-disable */
import Vue from `vue`
import createApp from `./main`

Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

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

/* 獲得初始資料 */
import { LOAD_CATEGORIES_ASYNC } from `@/components/Category/module`
import { LOAD_POSTS_ASYNC } from `@/components/Post/module`
import { LOAD_LIKES_ASYNC } from `@/components/Like/module`
import { LOAD_COMMENTS_ASYNC } from `@/components/Comment/module`
import { LOAD_USERS_ASYNC } from `@/components/User/module`
(function getInitialData() {
  const { postCount, categoryCount, userCount, likeCount, commentCount } = store.getters
  const { dispatch } = store
  // 獲取類別資訊
  !categoryCount && dispatch(LOAD_CATEGORIES_ASYNC),
  // 獲取文章資訊
  !postCount && dispatch(LOAD_POSTS_ASYNC),
  // 獲取點贊資訊
  !likeCount && dispatch(LOAD_LIKES_ASYNC),
  // 獲取評論資訊
  !commentCount && dispatch(LOAD_COMMENTS_ASYNC),
  // 獲取使用者資訊
  !userCount && dispatch(LOAD_USERS_ASYNC)
})()

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

router.onReady(() => {
  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()
    }
    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {
      next()
    }).catch(next)
  })
  app.$mount(`#root`)
})

  3、新增entry-sever.js

/* eslint-disable */
import createApp from `./main`

export default context => new Promise((resolve, reject) => {
  const { app, router, store } = createApp()
  router.push(context.url)
  router.onReady(() => {
    const matchedComponents = router.getMatchedComponents()
    if (!matchedComponents.length) {
      return reject({ code: 404 })
    }
    Promise.all(matchedComponents.map(Component => {
      if (Component.asyncData) {
        return Component.asyncData({
          store,
          route: router.currentRoute
        })
      }
    })).then(() => {
      context.state = store.state
      resolve(app)
    }).catch(reject)
  }, reject)
})

 

元件修改

  由於程式碼需要在伺服器端和瀏覽器端共用,所以需要修改元件,使之在伺服器端執行時不會報錯

  1、修改router路由檔案,給每個請求一個新的路由router例項

import Vue from `vue`
import Router from `vue-router`

Vue.use(Router)
+ export default function createRouter() {
- export default new Router({
+ return new Router({    mode:
`history`,    routes: [    {    path: `/`,    component: () => import(/* webpackChunkName:`home` */ `@/components/Home/Home`),    name: `home`,    meta: { index: 0 }    },     ...    ]   })
+}

  2、修改狀態管理vuex檔案,給每個請求一個新的vuex例項

import Vue from `vue`
import Vuex from `vuex`
import auth from `@/components/User/module`
...

Vue.use(Vuex)
+ export default function createStore() {
- export default new Vuex.Store({
+ return new Vuex.Store({   modules: {   auth,      ...   }   })
+}

  3、使用asyncData方法來獲取非同步資料

  要特別注意的是,由於asyncData只能通過路由發生作用,使用是非路由元件的非同步資料獲取最好移動到路由元件中

  如果要通過asyncData獲取多個資料,可以使用Promise.all()方法

asyncData({ store }) {
    const { dispatch } = store
    return Promise.all([
      dispatch(LOAD_CATEGORIES_ASYNC),
      dispatch(LOAD_POSTS_ASYNC)
    ])
}

  如果該非同步資料是全域性通用的,可以在entry-client.js方法中直接獲取

  將TheHeader.vue通用頭部元件獲取非同步資料的程式碼移動到entry-client.js方法中進行獲取

// TheHeader.vue
  computed: {
    ...
-    ...mapGetters([
-      `postCount`,
-      `categoryCount`,
-      `likeCount`,
-      `commentCount`,
-      `userCount`
-    ])
  },
-  mounted() {
    // 獲取非同步資訊
-    this.loadAsync()
  ...
-  },
...
  methods: {
-    loadAsync() {
-      const { postCount, categoryCount, userCount, likeCount, commentCount } = this
-      const { dispatch } = this.$store
-      // 獲取類別資訊
-      !categoryCount && dispatch(LOAD_CATEGORIES_ASYNC)
-      // 獲取文章資訊
-      !postCount && dispatch(LOAD_POSTS_ASYNC)
-      // 獲取點贊資訊
-      !likeCount && dispatch(LOAD_LIKES_ASYNC)
-      // 獲取評論資訊
-      !commentCount && dispatch(LOAD_COMMENTS_ASYNC)
-     // 獲取使用者資訊
-      !userCount && dispatch(LOAD_USERS_ASYNC)
-    },

  將Post.vue中的非同步資料通過asyncData進行獲取

// post.vue
...
export default {
+  asyncData({ store, route }) {
+    return store.dispatch(LOAD_POST_ASYNC, { id: route.params.postid })
+  },
...
-  mounted() {
-    this.$store.dispatch(LOAD_POST_ASYNC, { id: this.postId })
-  },
...

  4、將全域性css從main.js移動到App.vue中的內聯style樣式中,因為main.js中未設定css檔案解析

// main.js
- import `@/assets/style.css`
// App.vue
...
<style module lang="postcss">
...
</style>

  5、由於post元件的模組module.js中需要對資料通過window.atob()方法進行base64解析,而nodeJS環境下無window物件,會報錯。於是,程式碼修改如下

// components/Post/module
- text: decodeURIComponent(escape(window.atob(doc.content))) 
+ text: typeof window === `object` ? decodeURIComponent(escape(window.atob(doc.content))) : ``

 

伺服器配置

  1、在根目錄下,新建server.js檔案

  由於在webpack中去掉了HTMLWebpackPlugin外掛,而是通過nodejs來處理模板,同時也就缺少了該外掛設定的HTML檔案壓縮功能

  需要在server.js檔案中安裝html-minifier來實現HTML檔案壓縮

const express = require(`express`)
const fs = require(`fs`)
const path = require(`path`)
const { createBundleRenderer } = require(`vue-server-renderer`)
const { minify } = require(`html-minifier`)
const app = express()
const resolve = file => path.resolve(__dirname, file)

const renderer = createBundleRenderer(require(`./dist/vue-ssr-server-bundle.json`), {
  runInNewContext: false,
  template: fs.readFileSync(resolve(`./index.html`), `utf-8`),
  clientManifest: require(`./dist/vue-ssr-client-manifest.json`),
  basedir: resolve(`./dist`)
})
app.use(express.static(path.join(__dirname, `dist`)))
app.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 {
      res.status(500).send(`500 | Internal Server Error`)
      console.error(`error during render : ${req.url}`)
      console.error(err.stack)
    }
  }

  const context = {
    title: `小火柴的前端小站`,
    url: req.url
  }
  renderer.renderToString(context, (err, html) => {
    console.log(err)
    if (err) {
      return handleError(err)
    }
    res.send(minify(html, { collapseWhitespace: true, minifyCSS: true}))
  })
})

app.on(`error`, err => console.log(err))
app.listen(8080, () => {
  console.log(`vue ssr started at localhost: 8080`)
})

  2、修改package.json檔案

-     "build": "node build/build.js",
+    "build:client": "node build/build.js",
+    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js --progress --hide-modules",
+    "build": "rimraf dist && npm run build:client && npm run build:server",

  3、修改index.html檔案

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no">
    <link rel="shortcut icon" href="/static/favicon.ico">
    <title>小火柴的藍色理想</title>
  </head>
  <body>
     <!--vue-ssr-outlet-->
  </body>
</html>

  4、取消代理

  如果繼續使用代理如/api代理到後端介面,則可能會報如下錯誤

error:connect ECONNREFUSED 127.0.0.180

  直接寫帶有http的後端介面地址即可

const API_HOSTNAME = `http://192.168.1.103:4000`

 

測試

  1、安裝依賴包

cnpm install --save-dev vue-server-renderer

  2、構建

npm run build

  3、執行

node server.js

  點選右鍵,檢視網頁原始碼。結果如下,說明網站已經實現了伺服器端渲染

 

部署

【pm2】

  由於該網站需要守護nodejs程式,使用pm2部署較為合適

  在專案根目錄下,新建一個ecosystem.json檔案,內容如下

{
  "apps" : [{
    "name"      : "blog-www",
    "script"    : "./index.js",
    "env": {
      "COMMON_VARIABLE": "true"
    },
    "env_production" : {
      "NODE_ENV": "production"
    }
  }],
  "deploy" : {
    "production" : {
      "user" : "xxx",
      "host" : ["1.2.3.4"],
      "port" : "22",
      "ref"  : "origin/master",
      "repo" : "git@github.com:littlematch0123/blog-client.git",
      "path" : "/home/xxx/www/mall",
      "post-deploy" : "source ~/.nvm/nvm.sh && cnpm install && pm2 startOrRestart ecosystem.json --env production",
      "ssh_options": "StrictHostKeyChecking=no",
      "env"  : {
        "NODE_ENV": "production"
      }
    }
  }
}

【CDN】

  由於專案實際上既有靜態資源,也有nodeJS程式。因此,最好把靜態資源上傳到七牛CDN上

  自行選擇伺服器的一個目錄,新建upload.js檔案

var fs = require(`fs`);
var qiniu = require(`qiniu`);
var accessKey = `xxx`;
var secretKey = `xxx`;
var mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
var staticPath = `/home/www/blog/client/source/`;
var prefix = `client/static`;
var bucket = `static`;

var config = new qiniu.conf.Config();
config.zone = qiniu.zone.Zone_z1;
var formUploader = new qiniu.form_up.FormUploader(config);
var putExtra = new qiniu.form_up.PutExtra();
putExtra = null; // 一定要將putExtra設定為null,否則會出現所有檔案類別都被識別為第一個檔案的型別的情況
// 檔案上傳方法
function uploadFile (localFile) {
  // 配置上傳到七牛雲的完整路徑
  const key = localFile.replace(staticPath, prefix)
  const options = {
     scope: bucket + ":" + key,
 }
  const putPolicy = new qiniu.rs.PutPolicy(options)
  // 生成上傳憑證
  const uploadToken = putPolicy.uploadToken(mac)
  // 上傳檔案
  formUploader.putFile(uploadToken, key, localFile, putExtra, function(respErr, respBody, respInfo) {
    if (respErr) throw respErr
  if (respInfo.statusCode == 200) {
    console.log(respBody);
  } else {
    console.log(respInfo.statusCode);
    console.log(respBody);
  }  
})
}
// 目錄上傳方法
function uploadDirectory (dirPath) {
  fs.readdir(dirPath, function (err, files) {
    if (err) throw err
    // 遍歷目錄下的內容
    files.forEach(item => {
      let path = `${dirPath}/${item}`
      fs.stat(path, function (err, stats) {
        if (err) throw err
        // 是目錄就接著遍歷 否則上傳
         if (stats.isDirectory())  uploadDirectory(path)
         else  uploadFile(path, item)
      })
    })
  })
}
fs.exists(staticPath, function (exists) {
  if (!exists) {
    console.log(`目錄不存在!`)
  }
  else {
    console.log(`開始上傳...`)
    uploadDirectory(staticPath)
  }
})

【post-deploy】

  然後,修改ecosystem.json檔案中的post-deploy項

"source ~/.nvm/nvm.sh && cnpm install && npm run build && node /home/xiaohuochai/blog/client/upload.js&& pm2 startOrRestart ecosystem.json --env production",

  但是,經過實際測試,在伺服器端進行構建build,極其容易造成伺服器當機。於是,還是在本地構建完成後,上傳dist檔案到伺服器再進行相關操作

"source ~/.nvm/nvm.sh && cnpm install && node /home/xiaohuochai/blog/client/upload.js&& pm2 startOrRestart ecosystem.json --env production"

  修改專案的靜態資源地址為CDN地址,API地址為伺服器API地址

// config/index.js
assetsPublicPath: `https://static.xiaohuochai.site/client/`

// src/constants/API.js
const API_HOSTNAME = `https://api.xiaohuochai.cc`

【nginx】

  如果要使用域名對專案進行訪問,還需要進行nginx配置

upstream client {
        server 127.0.0.1:3002;
}
server{
        listen 80;
        server_name www.xiaohuochai.cc xiaohuochai.cc;
    return 301 https://www.xiaohuochai.cc$request_uri;
}
server{
        listen 443 http2;
        server_name www.xiaohuochai.cc xiaohuochai.cc;
        ssl on;
        ssl_certificate /home/blog/client/crt/www.xiaohuochai.cc.crt;
        ssl_certificate_key /home/blog/client/crt/www.xiaohuochai.cc.key;
        ssl_session_timeout 5m;
        ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_prefer_server_ciphers on;
    if ($host = `xiaohuochai.cc`){
        rewrite ^/(.*)$ http://www.xiaohuochai.cc/$1 permanent;
    }
    location / {
        expires 7d;
        add_header Content-Security-Policy "default-src `self` https://static.xiaohuochai.site; connect-src https://api.xiaohuochai.cc; script-src `self` `unsafe-inline` `unsafe-eval` https://static.xiaohuochai.site ; img-src `self` data: https://pic.xiaohuochai.site https://static.xiaohuochai.site; style-src `self` `unsafe-inline` https://static.xiaohuochai.site; frame-src https://demo.xiaohuochai.site https://xiaohuochai.site https://www.xiaohuochai.site;";
        proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forward-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                proxy_set_header X-Nginx-Proxy true;
                proxy_pass http://client;
                proxy_redirect off;

    }
} 

 

瀏覽器渲染

  官網的程式碼中,如果使用開發環境development,則需要進行相當複雜的配置

  能否應用當前的webpack.dev.conf.js來進行開發呢?完全可以,開發環境中使用瀏覽器端渲染,生產環境中使用伺服器端渲染

  需要做出如下三點更改:

  1、更改API地址,開發環境使用webpack代理,生產環境使用上線地址

// src/constants/API
let API_HOSTNAME
if (process.env.NODE_ENV === `production`) {
  API_HOSTNAME = `https://api.xiaohuochai.cc`
} else {
  API_HOSTNAME = `/api`
}

  2、在index.html同級目錄下,新建一個index.template.html檔案,index.html是開發環境的模板檔案,index.template.html是生產環境的模板檔案

// index.html
  <body>
    <div id="root"></div>
  </body>

// index.template.html
  <body>
     <!--vue-ssr-outlet-->
  </body>

  3、更改伺服器端入口檔案server.js的模板檔案為index.template.html

// server.js
const renderer = createBundleRenderer(require(`./dist/vue-ssr-server-bundle.json`), {
  runInNewContext: false,
  template: fs.readFileSync(resolve(`./index.template.html`), `utf-8`),
  clientManifest: require(`./dist/vue-ssr-client-manifest.json`),
  basedir: resolve(`./dist`)
})

  經過簡單的更改,即可實現開發環境使用瀏覽器端渲染,生產環境使用伺服器端渲染的效果

 

相關文章