[IT 鐵人賽] 為何我不用 SSR? Day 18

昨天提到了關於 SEO 的狀況,那麼,我們是不是使用 SSR 就能解決那些問題?關於這件事情我還是持保留態度。我們今天會帶大家來看一下 Vue 的 SSR 是怎麼運作的。

至於為何我沒有採用,沒有為什麼,因為我不會用啊(啊哈哈哈哈)


SSR 要如何開始?

SSR 的全名為 Server Side Render,顧名思義,就是先在伺服器端渲染的意思。那麼,跟原本使用 JavaScript 前端渲染,到底為何又要牽扯到後端?最主要的目的還是為了解決 SEO 的問題,而其他的事情大概是連帶的一些好處。

比起使用預先渲染,或是別人幫我渲染,對於 SSR 來說,他的好處多半是你自己可以控制所需要渲染的區塊,以及最終渲染的結果來做後續處理。對於 Vue 來說,爾或是各種前端渲染框架,這樣的作法可以讓你不用寫兩次程式碼,然後一次就能搞定(理想狀況下)。

Vue.js Server-Side Rendering Guide

官方有些先決條件必須要留意:

  • vue & vue-server-renderer 2.3.0+
  • vue-router 2.5.0+
  • vue-loader 12.0.0+ & vue-style-loader 3.0.0+

滿足了以上這些條件之後,我們就可以開始準備使用 SSR 的工作。但是在開始之前,有些事情還是必須要跟大家說明一下。SSR 並不是萬靈丹,他只是提供了比單純前端渲染,對於 SEO 更友善,且對於內容呈現的效率更好一點的方式,其他就沒有了。缺點倒也是不少,對於各位影響比較大的,多數在於許多第三方套件,並不一定能完全支援 SSR 的架構,所以這一點必須自己去衡量。

再者,如果你想要支援 SSR,你所撰寫的程式碼會跟平常有一點不一樣。官方也有提及,你的程式碼必須是所謂的通用程式碼( Universal Code ),差異在於,當我們使用伺服器端渲染你的程式碼時,當你用了 window 或是 document 這一類 瀏覽器端 才會有的全域變數時,在 NodeJS 執行過程中,就會拋出錯誤。

請記得,因為 SSR 是在 伺服器端 運作,所以,所有關於 瀏覽器端 的事情,對他來說都是未知的東西,因此,倘若你,或是你所使用的第三方套件當中,有使用瀏覽器端的專屬操作的時候,那麼你的 SSR 就會因此而中斷。所以,你就必須想辦法替換,或是在這個區塊避開 SSR 的運作。


結構更動

首先,在使用 SSR 之前,有幾個項目是必須要注意的:

  • 預設入口依舊為 main.js ,但是他不做 $mount
  • main.js 需要提供一個建立 App 的函式,提供後續使用。
  • 需要額外建立兩個入口檔案,用以區分前端與後端渲染:
    • entry-client.js 給前端用的檔案。
    • entry-server.js 給後端用的檔案。
  • 你的所有程式碼必須符合通用程式碼規範,連同你所使用的套件也是。除非,你的套件在後端上不會用到。
  • 準備一台 NodeJS 來幫你做後端渲染,例如 ExpressKoa

首先我們來看 main.js 會變成什麼樣子,這邊盡量精簡程式碼,主要讓你們方便理解為主:

import Vue from 'vue'
import Vuex from 'vuex'
import Router from 'vue-router'
import App from './App.vue'

Vue.use(Vuex)

Vue.config.productionTip = false

export function createApp() {
  const router = new Router({
    mode: 'history',
    routes: [
      {
        path: '/hello',
        name: 'Hello',
        component: () => import('@/components/Hello.vue')
      }
    ]
  })

  const app = new Vue({
    router,
    render: h => h(App)
  });
  return { app, router };
}

原本的 new Vue 不見了,取而代之的是一個建立 App 的函式 createApp(),然後就沒有然後了。

接著我們要提供兩個入口檔案,分別給前後端使用,以下是 entry-client.js 的部分:

import { createApp } from './main'
const { app, router } = createApp()

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

這裡是 entry-server.js 的部分:

import { createApp } from './main'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    router.push(context.url)
    router.onReady(() => {
      resolve(app)
    }, reject)
  })
}

你會發現我有使用 Router,是的,如果你不要用也可以,這裡會提及,是因為 Router 在 SSR 的部分有幾個地方需要注意,所以我直接使用 Router 的範例給大家做參考。再者,如果你真的做要簡單的 SPA ( Single Page Application ) 的話,jQuery 就很好用了, 不要學人家用什麼 Vue 而且還用 SSR 你不覺得奇怪我都覺得很奇怪。

至於為何要提及 Router 這個部分,你留意到我的 Router 是使用懶加載了嗎?是的,當我們使用 SSR 的時候,建議在 Router 的部分使用懶加載操作,一方面可以減低客戶端在初始化的時間,也能有效降低伺服器的壓力。

然後這樣就可以跑了嗎? 當然不行。 我們還有不少設定檔需要跟著做調整,而且還 真的不少 ,所以,我說那個 醬汁 設定檔呢?我們以 vue.config.js 來看(注意!前方高能!)

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const webpack = require('webpack')
const CopyWebpackPlugin = require('copy-webpack-plugin')
const path = require('path')
const resolve = file => path.resolve(__dirname, file)

// 正式環境或是開發環境
const isProd = process.env.NODE_ENV === 'production'
// 用環境變數來區隔建立的目標。
const TARGET_NODE = process.env.BUILD_TARGET === 'node'
// 一個是伺服器端,一個是客戶端。
const target = TARGET_NODE ? 'server' : 'client'

module.exports = {
  publicPath: '/',
  assetsDir: 'static',
  css: {
    extract: true,
    sourceMap: !isProd
  },
  devServer: {
    headers: { 'Access-Control-Allow-Origin': '*' },
    disableHostCheck: true
  },
  productionSourceMap: !isProd,
  transpileDependencies: [],
  configureWebpack: config => ({
    entry: `./src/entry-${target}.js`,
    target: TARGET_NODE ? 'node' : 'web',
    node: TARGET_NODE ? undefined : false,
    output: {
      libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
    },
    externals: TARGET_NODE
      ? nodeExternals({
        whitelist: [/\.css$/, /\?vue&type=style/]
      })
      : undefined,
    optimization: {
      splitChunks: TARGET_NODE ? false : undefined
    },
    plugins: [
      TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin(),
      new webpack.DefinePlugin({
        'process.env.VUE_ENV': `'${target}'`,
        'process.env.NODE_DEPLOY': `'${process.env.NODE_DEPLOY}'`
      })
    ].concat(
      !isProd
        ? []
        : new CopyWebpackPlugin([
          {
            from: resolve('./server'),
            to: resolve('./dist/server'),
            toType: 'dir',
            ignore: [
              '.DS_Store'
            ]
          },
          {
            from: resolve('./package.json'),
            to: resolve('./dist')
          },
          {
            from: resolve('./yarn.lock'),
            to: resolve('./dist')
          },
          {
            from: resolve('./package-lock.json'),
            to: resolve('./dist')
          }
        ])
    )
  }),
  chainWebpack: config => {
    if (TARGET_NODE) {
      const isExtracting = config.plugins.has('extract-css')
      if (isExtracting) {
        const langs = ['css', 'postcss', 'scss', 'sass', 'less', 'stylus']
        const types = ['vue-modules', 'vue', 'normal-modules', 'normal']
        for (const lang of langs) {
          for (const type of types) {
            const rule = config.module.rule(lang).oneOf(type)
            rule.uses.delete('extract-css-loader')
            rule
              .use('vue-style')
              .loader('vue-style-loader')
              .before('css-loader')
          }
        }
        config.plugins.delete('extract-css')
      }

      config.module
        .rule('vue')
        .use('cache-loader')
        .tap(options => {
          // Change cache directory for server-side
          options.cacheIdentifier += '-server'
          options.cacheDirectory += '-server'
          return options
        })
    }
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        if (TARGET_NODE) {
          options.cacheIdentifier += '-server'
          options.cacheDirectory += '-server'
        }
        options.optimizeSSR = TARGET_NODE
        return options
      })
    if (TARGET_NODE) {
      config.plugins.delete('hmr')
    }
  }
}

第一次執行

上述設定都好了之後,你可以試著跑跑看:

NODE_ENV=production BUILD_TARGET=node yarn build

你會收到兩個警告,一個是 server 資料夾不存在,另一個是 package-lock.json 不存在。不過沒關係,由於我們是使用 yarn 所以 package-lock.json 不存在可以忽略,或是你乾脆把 vue.congif.js 裡面拷貝這個檔案的設定拿掉也可以。

至於 server 我們後續會繼續提到,他主要是放置我們 NodeJS 的服務器設定的地方,例如你要使用 Express 或是 Koa,相關的執行檔案會放在這裡面。

接著我們跑一下非伺服器端,也就是前端渲染的建置:

NODE_ENV=production yarn build --no-clean

請注意,後者執行的必須要加上 --no-clean 的參數,不然你會把之前 dist/ 的東西清除。

這是我們第一次執行建置,是不是能跑我們還不知道,你會發現我們總共多出了兩個檔案:

  • vue-ssr-server-bundle.json
  • vue-ssr-client-manifest.json

這兩個檔案,其實之後是要餵給 NodeJS 的服務使用的,這個時候,我們可以來看看 server 裡面需要什麼東西。首先,我們需要一個 js 檔案來執行服務,我們這邊採用 Koa 來當作網頁伺服的服務提供,所以,我們建立好 server 資料夾後,可以建立一個 index.js 來撰寫程式碼。

詳細內容我就不貼出來了,這邊就片段的介紹執行的部分。

// 寫個變數來判斷是正式環境還是開發環境。
const isProd = process.env.NODE_ENV === 'production'

// 讀取 index 樣版檔案。
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')

// 這是建立伺服器端元件的工具。
const { createBundleRenderer } = require('vue-server-renderer')

// 把剛剛的檔案讀取進來
const bundle = require("../vue-ssr-server-bundle.json")
const clientManifest = require("../vue-ssr-client-manifest.json")

然後關於 Koa 的部分就不多著墨,我們先把靜態檔案路徑的地方搞定,

app.use(mount('/static', koaStatic(resolve('../static'))))

接著,我們做一個簡單的 async 函式來幫我們處理 SSR 的請求,

async function ssrRequestHandle(ctx, next) {
  ctx.set('Content-Type', 'text/html')
  const context = {
    title: 'SSR PAGE TITLE',
    description: '',
    keywords: '',
    ssrHeadAddInfo: '',
    url: ctx.url,
    cookies: ctx.cookie || {},
    userAgent: ctx.header['user-agent']
  }

  try {
    ctx.body = await renderer.renderToString(context)
  } catch (err) {
    handleError(ctx, err)
  }
}

當中的 handleError 是處理例外錯誤的地方,你可以自由發揮。最後我們把這個 ssrRequestHandle 丟去給 Koa 使用。

app.use(async (ctx, next) => {
  await ssrRequestHandle(ctx, next)
})

請注意!我們這邊並沒有判斷所謂的 特定使用者頁面 ,換句話說,有些不適合做 SSR 的頁面你必須要在這邊就先過濾掉。不然,你可能會發現,例如,使用者登入後,畫面跟其他使用者好像長得差不多,然後資料可能也一樣之類的狀況。

接著,我們需要在 server 資料夾,放上一個 index.template.html 來讓他當作樣版使用,

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>{{ title }}</title>
    <meta name="description" content="{{ description }}">
    <meta name="keywords" content="{{ keywords }}"> {{{ ssrHeadAddInfo }}}
  </head>
  <body data-server-rendered-page="true">
    <!--vue-ssr-outlet-->
  </body>
</html>

請注意,這個文件中有 <!--vue-ssr-outlet--> 請不要忽略,他是 Vue SSR 套件用來將資料注入的地方,如果你拿掉了,會有錯誤。

Using a Page Template

然後我們可以把 Koa 跑起來看一下,

node dist/server/index.js

我們上面有提過了,對於使用 SSR 的部分,不能使用瀏覽器方面的變數,所以你就會像這個樣子出現了錯誤。我們把資訊修正過之後,你就可以看到正常渲染的頁面了。

你會發現 HelloKitty 被呼叫了兩次,原因是 favicon.ico 因為找不到檔案的關係,所以他就直接再次去呼叫 index.html,所以 Koa 就很乖的再次幫你渲染一次了。不過這個靜態檔案的問題,你可以用 Koa 的套件去解決,我們這邊就不多著墨:

https://github.com/koajs/favicon

然後我們剛剛不是有使用 Router 嗎?我們設定了一個叫做 Hello 的頁面,我們現在切換到那個頁面看看:

看起來運作正常,但是你會發現,欸?我的 <meta>title 怎麼好像沒改變?是的,這個部分你需要自己製作工具來連帶更新 TDK( Title, Description, Keyword )。


更新頁面資料

那麼,我們要怎麼更新畫面上的資料呢?首先,我們的 index.template.html 已經有先放好了 TDK 與其他擴充的變數( ssrHeadAddInfo ),我以當我們在使用伺服器端渲染時,我們的元件,特別是 Router 底下的元件,必須要能夠去更新 TDK 才行。

官方文件有提及這一點:

Logic Collocation with Components

所以你有 serverPrefetch 這個方法可以用,對了,上述文件不要覺得是英文的很難懂,因為你若是切到中文,不好意思,我等一下講的東西,中文資料都沒有。

serverPrefetch 是伺服器渲染專用的方法,他必須要回傳一個 Prmoise 物件,在擁有此方法的物件中,該物件會等待這個方法完成後,才會進行渲染的動作。

export default {
  name: 'Hello',
  components: {
  },
  data () {
    return {
      foo: '爽 2'
    }
  },
  serverPrefetch () {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve()
      }, 3000)
    })
  },
  mounted () {
    console.log('Hello mounted')
  }
}

如果我們這麼做,那我們直接在 Hello 頁面重新整理的話,他會等 3 秒之後,才會將頁面返回。也就是說,我們在這邊可以用來取得物件。然後,有一個雷點在這裡:

serverPrefetch 雖然可以使用 this ,但是你無法直接更改元件內的數值,例如,你想把上述的 foo: '爽 2' ,透過 serverPrefetch 來改成 foo: '爽 100' 是沒有用的。

再說一次,這樣改是沒有用的!請搭配 Vuex 來操作這些事情。

好的,現在你知道了 serverPrefetch 這件事情,那麼我們要怎麼更新 TDK 呢?我們在 Vue 這個物件當中,有一個屬性叫做 $ssrContext,他代表了幾件事情:

  1. 可替換你在 index.template.html 裡面的變數。
  2. 你需要在 server/index.js 對於 ssrRequestHandle 的函式做修改。
  3. 他只能在伺服器端運作。
  4. 所以你的 entry-server.js 拿到的 context 也是從 Koa 進去的。
  5. 從前端看這個屬性,會永遠都是 undefined

所以,我們剛剛如果要換 TDK 的話,可以這樣做:

export default {
  name: 'Hello',
  components: {
  },
  data () {
    return {
      foo: '爽 2'
    }
  },
  serverPrefetch () {
    this.$ssrContext.title = 'SSR HELLO PAGE'
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve()
      }, 3000)
    })
  },
  mounted () {
    console.log('Hello mounted')
  }
}

我們的 $ssrContext 其實來源是上面的 Koa 的設定:

async function ssrRequestHandle(ctx, next) {
  ctx.set('Content-Type', 'text/html')
  const context = {
    title: 'SSR PAGE TITLE',
    description: '',
    keywords: '',
    ssrHeadAddInfo: '',
    url: ctx.url,
    cookies: ctx.cookie || {},
    userAgent: ctx.header['user-agent']
  }

  try {
    ctx.body = await renderer.renderToString(context)
  } catch (err) {
    handleError(ctx, err)
  }
}

這樣你應該知道你可以設定什麼東西了。所以剛剛的例子裡面,我們就可以把網頁的 title 修改成 SSR HELLO PAGE 的標題了。


搭配 Vuex

剛剛提到你無法直接修改元件中的變數,所以,你還是得把 Vuex 拿進來使用,操作方式跟一般的 Vuex 沒有太大的差異,差別只在於,你的元件與伺服器之間,必須要先把儲存的事情做好,這樣才能正確的顯示資料。

所以我們改一下 main.js 加入 Vuex 的套件,然後我們寫一個簡單的資料:

import Vue from 'vue'
import Vuex from 'vuex'
import Router from 'vue-router'
import App from './App.vue'

Vue.use(Router)
Vue.use(Vuex)

Vue.config.productionTip = false

export function createApp() {
  const router = new Router({
    mode: 'history',
    routes: [
      {
        path: '/hello',
        name: 'Hello',
        component: () => import('@/components/Hello.vue')
      }
    ]
  })

  const store = new Vuex.Store({
    state: {
      age: 18
    },
    mutations: {
      incrementAge: function (state) {
        state.age++
      }
    },
    actions: {
      ohMyAge: function ({ commit }) {
        commit('incrementAge')
      }
    },
    getters: {
      getAge: function (state) {
        return state.age
      }
    }
  })

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });
  return { app, router, store };
}

接著我們需要更換 entry-server.js 的部分:

import { createApp } from './main'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    router.push(context.url)
    router.onReady(() => {
      // 加入這件事情
      context.rendered = () => {
        context.state = store.state
      }
      resolve(app)
    }, reject)
  })
}

最後修改 entry-client.js 的地方:

import { createApp } from './main'
const { app, router, store } = createApp()

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

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

以上的修改在官方文件也是有說明:

Final State Injection

最後我們將應用程式重新建置,然後跑一次試試看:

然後你就會看到資料從原本的 18 變成了 19,那麼我們 Hello.vue 做了什麼事情呢?

import { mapActions, mapGetters } from 'vuex'

export default {
  name: 'Hello',
  components: {
  },
  data () {
  },
  serverPrefetch () {
    return new Promise((resolve, reject) => {
      this.$ssrContext.title = 'SSR HELLO PAGE'
      setTimeout(() => {
        // 從這邊呼叫 Vuex Actions
        this.ohMyAge()
        resolve()
      }, 3000)
    })
  },
  mounted () {
    console.log('Hello mounted')
  },
  computed: {
    changeFoo () {
      return '爽 ' + this.getAge
    },
    ...mapGetters({
      getAge: 'getAge'
    })
  },
  methods: {
    ...mapActions({
      ohMyAge: 'ohMyAge'
    })
  }
}

然後我們會想說 serverPrefetch 是從伺服器後端所操作,那麼,當我使用前端的 Router 的時候,原本的 this.ohMyAge() 就不會被呼叫到了。所以,我們還是需要把這件事情,放到 mounted 或是 beforeRouteEnter 裡面,這樣對於前端渲染的動作才會有動作。

我們先來看看結果:

有沒有發現哪裡怪怪的?是的,原本的 18 突然變成 21 了,這個狀況在 Hello 頁面重新整理之後,就會發生這種怪事。照理來說,我們在 Vuex.Store 的預設值是 18,進入了 Hello 頁面,理論上應該是 19,但是,由於我們調整了 mountedrouteEnterBefore,所以,原本的 ohMyAge()再次 被呼叫一次。

那麼,理論上應該是 20 才對,為何是 21 呢?

我把 console.log() 埋到 Vuex 當中,印出來看看發生了什麼事情:

有沒有一種超想罵髒話的感覺,有沒有!有沒有啊!

我們再來看看伺服器端的渲染,印出了什麼東西來。

然後我們換到 mounted 這個生命週期的勾子,再試一次:

有沒有一種超想罵髒話的感覺,有沒有!有沒有啊!

剛剛有提到過,mounted 是屬於 前端渲染 的事件,所以,他並不會在後端跑一次。而剛剛的 beforeRouteEnter 會在 後端先跑一次 ,然後 前端再跑一次 ,所以你會得到 21 的這個結果。但是,其實 20 應該也不對,照理說我們需要的是剛剛的 19 ,其他應該都不是正確答案。

那麼,我們要怎麼確保這些事情只做一次呢?首先,我們可以告訴他,這個呼叫 Actions 的動作是從那邊來的,舉例來說:

export default {
  // 前略...
  serverPrefetch () {
    return new Promise((resolve, reject) => {
      this.$ssrContext.title = 'SSR HELLO PAGE'
      setTimeout(() => {
        this.setSource('server')
        this.ohMyAge()
        resolve()
      }, 3000)
    })
  },
  computed () {
    needInitialize () {
      return this.getSource === 'client' || this.getSoruce === null
    },
    ...mapGetters({
      getAge: 'getAge',
      getSource: 'getSource'
    })
  },
  mounted () {
    console.log('Hello mounted')
    if (this.needInitialize) {
      this.ohMyAge()
    }
  },
  methods: {
    ...mapActions({
      ohMyAge: 'ohMyAge'
    }),
    ...mapMutations({
      setSource: 'setSource'
    })
  },
  // 後略...
}

然後我們在 Vue.Store 裡面動個手腳,

const store = new Vuex.Store({
    state: {
      age: 18,
      source: null
    },
    mutations: {
      incrementAge: function (state) {
        state.age += 1
      },
      setSource: function (state, source) {
        state.source = source
      }
    },
    actions: {
      ohMyAge: function ({ commit }) {
        commit('incrementAge')
      }
    },
    getters: {
      getAge: function (state) {
        return state.age
      },
      getSource: function (state) {
        return state.source
      }
    }
  })

所以,這樣你最後會拿到的結果就是 19 了。

有沒有一種超想罵髒話的感覺,有沒有!有沒有啊!


最後加起來再來一次。這裡有一個專案都寫好了,可以參考一下:

https://github.com/ediaos/vue-cli3-ssr-project

我不保證上面的專案你一定可以跑,因為我的設定也跟他有點不太一樣。至於會踩到什麼雷點?可能每個人的環境不同,所以多少會有點差異。這個專案裡面的諸多設定,大概都是從大家回報的問題中,慢慢累積而來的。


雷點

SSR app with async components, 500s when using component styles
normalizeFile get undefined file
Vuejs SSR Issues
SSR build says “document is not defined” for a Vuejs cli3 SPA app using a Vue cli3 library
Stackoverflow tagged vue-ssr

我沒有不敬的意思。


小結

對於 SSR 的部分,大抵上只要有大框架,基本上不會有太大問題,只是必須要留意你的程式碼必須要能前後端間容。且必須要留意哪些生命週期會在前端還是後端還是都會執行。

  • beforeRouterEnter
  • beforeCreate
  • created

以上生命週期,在重新整理路由時,前後端都會執行,還會不會有,這就留給你自己了(燦笑)。我就跟你說我不會用了,所以如果有什麼奇怪的問題,我可以回答的我會盡量回答,不能回答的我可以幫你 Google 一下。而且,其實為了要跑這個東西,前端 NodeJS 後端 API 跑 PHP,其實挺累的。

ITHome 鐵人賽同步刊登 為何我不用 SSR? Day 18

Hina Chen
偏執與強迫症的患者,算不上是無可救藥,只是我已經遇上我的良醫了。
Taipei