【第一期】基於 @vue/cli3 與 koa 建立 ssr 工程 ----【SSR第一篇】

水滴前端發表於2019-05-29

什麼是基於同構程式碼的 SSR 服務(Server-side rendering based on isomorphic code)

首先,我們需要先明白什麼是 spa (single page application),以及基於 vue 的 spa 是如何工作的,這裡不展開,請參考:單頁應用vue 例項

基於同構程式碼的 SSR 指的是同一份程式碼(spa程式碼),既能在客戶端執行,並渲染出頁面,也可以在伺服器端渲染為 html 字串,並響應給客戶端。

它與傳統的伺服器直出不同,傳統的伺服器直出指的是路由系統只存在於伺服器端,在伺服器端,任何一個頁面都需要伺服器響應內容。

SSR 有什麼好處?

  • 相比 spa 應用,ssr 應用對搜尋引擎更友好
  • 理論上,TTI 更短(TTI ,time to interactive,指使用者從第一個請求發出,到能夠與頁面互動,這之間的時間差)

下圖是一個實際專案中,在弱網環境(3g)中接入 ssr 服務之前和之後的請求耗時對比:

工程背景:實際專案在微信環境內提供h5頁面,為提高使用者體驗,我們將其接入 ssr 服務,並代理微信 OAuth 的部分過程

測量範圍:新客戶從第一個http請求發出,到入口頁面的內容下載完畢為止

接入 ssr 服務前,此測量範圍內會經歷:

  1. 客戶端下載入口檔案、js、css等資源
  2. 客戶端跳轉微信授權服務,獲取授權 code
  3. 客戶端跳回源地址,進行授權登入(客戶可看到頁面)

接入 ssr 服務後,此測量範圍內會經歷:

  1. 伺服器跳轉微信授權服務,獲取授權 code
  2. 客戶端下載入口檔案、js、css等資源(客戶可看到頁面)
  3. 客戶端授權登入

我們可以看到,接入 ssr 服務後,客戶理論上能更早得看到頁面了

【第一期】基於 @vue/cli3 與 koa 建立 ssr 工程 ----【SSR第一篇】

根據上圖可以看到,在接入 ssr 服務後,客戶能更早得看到頁面內容,客戶感知到的效能提高了。

SSR 有什麼風險?

  • 加重伺服器負載
  • 通常用於 SSR 的服務都是基於 NodeJS 環境,需要額外的研發成本(例如:日誌、監控、追蹤)
  • SSR 的服務通常都由前端工程師研發和維護,增加了更多的心智負擔
  • 基於同構程式碼的 SSR 應用是同一份程式碼,既在瀏覽器執行,也在伺服器執行,程式碼層面的問題造成的影響更大

今天,我們使用新版的 cli 工具(v3.x),搭建一個基於 vue 同構程式碼的 ssr 工程專案。

我們的目標:使用 @vue/cli v3.x 與 koa v2.x 建立一個 ssr 工程

我們的步驟如下:

  1. 安裝 @vue/cli
  2. 使用 @vue/cli 建立 spa 工程
  3. 將 spa 工程逐步改造成 ssr 工程

我們需要的工具如下:

  • @vue/cli v3.x
  • koa v2.x
  • koa-send v5.x
  • vue-server-renderer v2.x
  • memory-fs v0.x
  • lodash.get v4.x
  • lodash.merge v4.x
  • axios v0.x
  • ejs v2.x

第一步:安裝 @vue/cli v3.x

yarn global add @vue/cli

筆者安裝的 @vue/cli 的版本為: v3.6.2

第二步:使用 @vue/cli 建立一個 spa 應用

vue create ssr-demo

建立完畢之後, ssr-demo 的目錄結構如下:

./ssr-demo
├── README.md
├── babel.config.js
├── package.json
├── public
│   ├── favicon.ico
│   └── index.html
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── main.js
│   ├── router.js
│   ├── store.js
│   └── views
│       ├── About.vue
│       └── Home.vue
└── yarn.lock
複製程式碼

進入 srr-demo ,安裝 vue-server-renderer

yarn add vue-server-renderer
複製程式碼

筆者建立的 ssr-demo 中,各主要工具庫的版本如下:

  • vue v2.6.10
  • vue-router v3.0.3
  • vuex v3.0.1
  • vue-template-compiler v2.5.21
  • vue-server-renderer v2.6.10

執行 yarn serve ,在瀏覽器上看一下效果。

至此,spa 工程就建立完畢了,接下來我們在此基礎上,將此 spa 工程逐步轉換為 ssr 工程模式。

第三步:單例模式改造

在 spa 工程中,每個客戶端都會擁有一個新的 vue 例項。

因此,在 ssr 工程中,我們也需要為每個客戶端請求分配一個新的 vue 例項(包括 router 和 store)。

我們的步驟如下:

  1. 改造狀態儲存 src/store.js
  2. 改造路由 src/router.js
  3. 改造應用入口 src/main.js

改造步驟一:改造狀態儲存

改造前,我們看下 src/store.js 的內容:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
  },
  mutations: {
  },
  actions: {
  }
})
複製程式碼

src/store.js 的內部只返回了一個 store 例項。

如果這份程式碼在伺服器端執行,那麼這個 store 例項會在服務程式的整個生命週期中存在。

這會導致所有的客戶端請求都共享了一個 store 例項,這顯然不是我們的目的,因此我們需要將狀態儲存檔案改造成工廠函式,程式碼如下:

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    state: {
    },
    mutations: {
    },
    actions: {
    }
  })
}
複製程式碼

目錄結構同樣有變化:

# 改造前
./src
├── ...
├── store.js
├── ...

# 改造後
./src
├── ...
├── store
│   └── index.js
├── ...
複製程式碼

改造步驟二:改造路由

改造前,我們看下 src/router.js 的內容:

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'

Vue.use(Router)

export default new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (about.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import(/* webpackChunkName: "about" */ './views/About.vue')
    }
  ]
})
複製程式碼

類似 src/store.js, 路由檔案:src/router.js 的內部也只是返回了一個 router 例項。

如果這份程式碼在伺服器端執行,那麼這個 router 例項會在服務程式的整個生命週期中存在。

這會導致所有的客戶端請求都共享了一個 router 例項,這顯然不是我們的目的,因此我們需要將路由改造成工廠函式,程式碼如下:

import Vue from 'vue'
import Router from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(Router)

export function createRouter () {
  return new Router({
    mode: 'history',
    base: process.env.BASE_URL,
    routes: [
      {
        path: '/',
        name: 'home',
        component: Home
      },
      {
        path: '/about',
        name: 'about',
        // route level code-splitting
        // this generates a separate chunk (about.[hash].js) for this route
        // which is lazy-loaded when the route is visited.
        component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
      }
    ]
  })
}
複製程式碼

目錄結構也有變化:

# 改造前
./src
├── ...
├── router.js
├── ...

# 改造後
./src
├── ...
├── router
│   └── index.js
├── ...
複製程式碼

改造步驟三:改造應用入口

因為我們需要在伺服器端執行與客戶端相同的程式碼,所以免不了需要讓伺服器端也依賴 webpack 的構建過程。

借用官方文件的示意圖:

【第一期】基於 @vue/cli3 與 koa 建立 ssr 工程 ----【SSR第一篇】

我們看到:

原始碼分別為客戶端和伺服器提供了獨立的入口檔案:server entry 和 client entry

通過 webpack 的構建過程,構建完成後,也對應得輸出了兩份 bundle 檔案,分別為客戶端和伺服器提供了:

  • chunk 檔案對映路徑
  • 原始碼定位
  • 原始碼打包(伺服器端的 bundle 檔案包含了所有打包後的客戶端程式碼)

等功能。

因此,我們接下來先改造 src/main.js,然後再建立 entry-client.jsentry-server.js

改造 src/main.js 前,我們先來看看 src/main.js 的內容:

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')
複製程式碼

src/store.jssrc/router.js 類似,src/main.js 同樣也是單例模式,因此我們將它改造為工廠函式:

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

export function createApp () {
  const router = createRouter()
  const store = createStore()

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

  return { app, router, store }
}
複製程式碼

src/main.js 改造完畢後,我們來分別建立 entry-client.jsentry-server.js

我們先來看 entry-client.js

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

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

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

router.onReady(() => {
  app.$mount('#app')
})
複製程式碼

在伺服器端渲染路由元件樹,所產生的 context.state 將作為脫水資料掛載到 window.__INITIAL_STATE__

在客戶端,只需要將 window.__INITIAL_STATE__ 重新注入到 store 中即可(通過 store.replaceState 函式)

最後,我們需要將 mount 的邏輯放到客戶端入口檔案內。

建立完畢客戶端入口檔案後,讓我們來看服務端的入口檔案 entry-server.js

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

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)
  })
}
複製程式碼

上面的 context.rendered 函式會在應用完成渲染的時候呼叫

在伺服器端,應用渲染完畢後,此時 store 可能已經從路由元件樹中填充進來一些資料。

當我們將 state 掛載到 context ,並在使用 renderer 的時候傳遞了 template 選項,

那麼 state 會自動序列化並注入到 HTML 中,作為 window.__INITIAL_STATE__ 存在。

接下來,我們來給 store 新增獲取資料的邏輯,並在首頁呼叫其邏輯,方便後面觀察伺服器端渲染後的 window.__INITIAL_STATE__

改造 store: 新增獲取資料邏輯

改造後的目錄結構:

src/store
├── index.js
└── modules
    └── book.js
複製程式碼

src/store/index.js

import Vue from 'vue'
import Vuex from 'vuex'

import { Book } from './modules/book.js'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    modules: {
      book: Book
    },
    state: {
    },
    mutations: {
    },
    actions: {
    }
  })
}
複製程式碼

src/store/modules/book.js

import Vue from 'vue'

const getBookFromBackendApi = id => new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve({ name: '《地球往事》', price: 100 })
  }, 300)
})

export const Book = {
  namespaced: true,

  state: {
    items: {}
  },

  actions: {
    fetchItem ({ commit }, id) {
      return getBookFromBackendApi(id).then(item => {
        commit('setItem', { id, item })
      })
    }
  },

  mutations: {
    setItem (state, { id, item }) {
      Vue.set(state.items, id, item)
    }
  }
}
複製程式碼

改造首頁:預取資料

改造前,我們先看一下 src/views/Home.vue 的程式碼

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'home',
  components: {
    HelloWorld
  }
}
</script>
複製程式碼

改造後的程式碼如下:

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
    <div v-if="book">{{ book.name }}</div>
    <div v-else>nothing</div>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'home',

  computed: {
    book () {
      return this.$store.state.book.items[this.$route.params.id || 1]
    }
  },
  // 此函式只會在伺服器端呼叫,注意,只有 vue v2.6.0+ 才支援此函式
  serverPrefetch () {
    return this.fetchBookItem()
  },
  // 此生命週期函式只會在客戶端呼叫
  // 客戶端需要判斷在 item 不存在的場景再去呼叫 fetchBookItem 方法獲取資料
  mounted () {
    if (!this.item) {
      this.fetchBookItem()
    }
  },

  methods: {
    fetchBookItem () {
      // 這裡要求 book 的 fetchItem 返回一個 Promise
      return this.$store.dispatch('book/fetchItem', this.$route.params.id || 1)
    }
  },

  components: {
    HelloWorld
  }
}
</script>
複製程式碼

至此,客戶端原始碼的改造告一段落,我們接下來配置構建過程

配置 vue.config.js

基於 @vue/cli v3.x 建立的客戶端工程專案中不再有 webpack.xxx.conf.js 這類檔案了。

取而代之的是 vue.config.js 檔案,它是一個可選的配置檔案,預設在工程的根目錄下,由 @vue/cli-service 自動載入並解析。

我們對於 webpack 的所有配置,都通過 vue.config.js 來實現。

關於 vue.config.js 內部配置的詳細資訊,請參考官方文件:cli.vuejs.org/zh/config/#…

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const merge = require('lodash.merge')

const TARGET_NODE = process.env.TARGET_NODE === 'node'
const DEV_MODE = process.env.NODE_ENV === 'development'

const config = {
  publicPath: process.env.NODE_ENV === 'production'
    // 在這裡定義產品環境和其它環境的 publicPath
    // 關於 publicPath 請參考:
    // https://webpack.docschina.org/configuration/output/#output-publicpath
    ? '/'
    : '/',
  chainWebpack: config => {

    if (DEV_MODE) {
      config.devServer.headers({ 'Access-Control-Allow-Origin': '*' })
    }

    config
      .entry('app')
      .clear()
      .add('./src/entry-client.js')
      .end()
      // 為了讓伺服器端和客戶端能夠共享同一份入口模板檔案
      // 需要讓入口模板檔案支援動態模板語法(這裡選了 ejs)
      .plugin('html')
      .tap(args => {
        return [{
          template: './public/index.ejs',
          minify: {
            collapseWhitespace: true
          },
          templateParameters: {
            title: 'spa',
            mode: 'client'
          }
        }]
      })
      .end()
      // webpack 的 copy 外掛預設會將 public 資料夾中所有的檔案拷貝到輸出目錄 dist 中
      // 這裡我們需要將 index.ejs 檔案排除
      .when(config.plugins.has('copy'), config => {
        config.plugin('copy').tap(([[config]]) => [
          [
            {
              ...config,
              ignore: [...config.ignore, 'index.ejs']
            }
          ]
        ])
      })
      .end()

    // 預設值: 當 webpack 配置中包含 target: 'node' 且 vue-template-compiler 版本號大於等於 2.4.0 時為 true。
    // 開啟 Vue 2.4 服務端渲染的編譯優化之後,渲染函式將會把返回的 vdom 樹的一部分編譯為字串,以提升服務端渲染的效能。
    // 在一些情況下,你可能想要明確的將其關掉,因為該渲染函式只能用於服務端渲染,而不能用於客戶端渲染或測試環境。
    config.module
      .rule('vue')
      .use('vue-loader')
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        })
      })

    config.plugins
      // Delete plugins that are unnecessary/broken in SSR & add Vue SSR plugin
      .delete('pwa')
      .end()
      .plugin('vue-ssr')
      .use(TARGET_NODE
        // 這是將伺服器的整個輸出構建為單個 JSON 檔案的外掛。
        // 預設檔名為 `vue-ssr-server-bundle.json`
        ? VueSSRServerPlugin
        // 此外掛在輸出目錄中生成 `vue-ssr-client-manifest.json`
        : VueSSRClientPlugin)
      .end()

    if (!TARGET_NODE) return

    config
      .entry('app')
      .clear()
      .add('./src/entry-server.js')
      .end()
      .target('node')
      .devtool('source-map')
      .externals(nodeExternals({ whitelist: /\.css$/ }))
      .output.filename('server-bundle.js')
      .libraryTarget('commonjs2')
      .end()
      .optimization.splitChunks({})
      .end()
      .plugins.delete('named-chunks')
      .delete('hmr')
      .delete('workbox')

  }
}

module.exports = config
複製程式碼

至此,客戶端部分的改造告一段落,當前 ssr-demo 的目錄如下:

./ssr-demo
├── README.md
├── babel.config.js
├── package.json
├── public
│   ├── favicon.ico
│   └── index.ejs
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── entry-client.js
│   ├── entry-server.js
│   ├── main.js
│   ├── router
│   │   └── index.js
│   ├── store
│   │   ├── index.js
│   │   └── modules
│   │       └── book.js
│   └── views
│       ├── About.vue
│       └── Home.vue
├── vue.config.js
└── yarn.lock
複製程式碼

接下來,讓我們來搭建 NodeJS 服務端部分。

第四步:NodeJS 服務端搭建

在搭建服務端之前,我們先安裝服務端需要的依賴:

yarn add koa koa-send memory-fs lodash.get axios ejs
複製程式碼

安裝完畢後,對應的版本如下:

  • koa v2.7.0
  • koa-send v5.0.0
  • memory-fs v0.4.1
  • lodash.get v4.4.2
  • axios v0.18.0
  • ejs v2.6.1

生產環境服務搭建

ssr-demo 跟目錄下建立資料夾 app,然後建立檔案 server.js,內容如下:

const Koa = require('koa')
const app = new Koa()
const host = '127.0.0.1'
const port = process.env.PORT
const productionEnv = ['production', 'test']
const isProd = productionEnv.includes(process.env.NODE_ENV)
const fs = require('fs')
const PWD = process.env.PWD

// 產品環境:我們在服務端程式啟動時,將客戶端入口檔案讀取到記憶體中,當 發生異常 或 需要返回客戶端入口檔案時響應給客戶端。
const getClientEntryFile = isProd => isProd ? fs.readFileSync(PWD + '/dist/index.html') : ''
const clientEntryFile = getClientEntryFile(isProd)

app.use(async (ctx, next) => {
  if (ctx.method !== 'GET') return

  try {
    await next()
  } catch (err) {
    ctx.set('content-type', 'text/html')

    if (err.code === 404) {
      ctx.body = clientEntryFile
      return
    }

    console.error(' [SERVER ERROR] ', err.toString())

    ctx.body = clientEntryFile
  }
})

app.use(require('./middlewares/prod.ssr.js'))

app.listen(port, host, () => {
  console.log(`[${process.pid}]server started at ${host}:${port}`)
})
複製程式碼

其中,需要注意的是:應該捕獲服務端丟擲的任何異常,並將客戶端入口檔案響應給客戶端。

app 內建立資料夾 middlewares,並建立檔案 prod.ssr.js:

const path = require('path')
const fs = require('fs')
const ejs = require('ejs')
const get = require('lodash.get')

const resolve = file => path.resolve(__dirname, file)
const PWD = process.env.PWD
const enableStream = +process.env.ENABLESTREAM

const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(PWD + '/dist/vue-ssr-server-bundle.json')
const clientManifest = require(PWD + '/dist/vue-ssr-client-manifest.json')
const tempStr = fs.readFileSync(resolve(PWD + '/public/index.ejs'), 'utf-8')
const template = ejs.render(tempStr, { title: '{{title}}', mode: 'server' })

const renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest,
  basedir: PWD
})

const renderToString = context => new Promise((resolve, reject) => {
  renderer.renderToString(context, (err, html) => err ? reject(err) : resolve(html))
})

const renderToStream = context => renderer.renderToStream(context)

const main = async (ctx, next) => {
  ctx.set('content-type', 'text/html')

  const context = {
    title: get(ctx, 'currentRouter.meta.title', 'ssr mode'),
    url: ctx.url
  }

  ctx.body = await renderToString(context)
}

module.exports = main
複製程式碼

然後,我們為 package.json 配置新的打包命令和啟動 ssr 服務的命令:

...
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build && TARGET_NODE=node vue-cli-service build --no-clean",
    "start": "NODE_ENV=production TARGET_NODE=node PORT=3000 node ./app/server.js"
  },
...
複製程式碼

這裡需要注意一下:

build 命令中,先執行客戶端的構建命令,然後再執行服務端的構建命令。

服務端的構建命令與客戶端的區別只有一個環境變數:TARGET_NODE,當將此變數設定值為 node,則會按照服務端配置進行構建。

另外,在服務端構建命令中有一個引數:--no-clean,這個引數代表不要清除 dist 資料夾,保留其中的檔案。

之所以需要 --no-clean 這個引數,是因為服務端構建不應該影響到客戶端的構建檔案。

這樣能保證客戶端即使脫離了服務端,也能通過 nginx 提供的靜態服務向使用者提供完整的功能(也就是 spa 模式)。

至此,生產環境已經搭建完畢。接下來,讓我們來搭建開發環境的服務端。

開發環境服務搭建

開發環境的服務功能實際上是生產環境的超集。

除了生產環境提供的服務之外,開發環境還需要提供:

  • 靜態資源服務
  • hot reload

搭建靜態資源服務

生產環境中的靜態資源因為都會放置到 CDN 上,因此並不需要 NodeJS 服務來實現靜態資源伺服器,一般都由 nginx 靜態服務提供 CDN 的回源支援。

但生產環境如果依賴獨立的靜態伺服器,可能導致環境搭建成本過高,因此我們建立一個開發環境的靜態資源服務中介軟體來實現此功能。

我們的 spa 模式在開發環境通過命令 serve 啟動後,就是一個自帶 hot reload 功能的服務。

因此,服務端在開發環境中提供的靜態資源服務,可以通過將靜態資源請求路由到 spa 服務,來提供靜態服務功能。

需要注意的是:開發環境中,服務端在啟動之前,需要先啟動好 spa 服務。

稍後我們會在 package.js 中建立 dev 命令來方便啟動開發環境的 spa 與 ssr 服務。

./ssr-demo/app/middlewares/ 中建立檔案 dev.static.js,內容如下:

const path = require('path')
const get = require('lodash.get')
const send = require('koa-send')
const axios = require('axios')
const PWD = process.env.PWD
const clientPort = process.env.CLIENT_PORT || 8080
const devHost = `http://localhost:${clientPort}`
const resolve = file => path.resolve(__dirname, file)

const staticSuffixList = ['js', 'css', 'jpg', 'jpeg', 'png', 'gif', 'map', 'json']

const main = async (ctx, next) => {

  const url = ctx.path

  if (url.includes('favicon.ico')) {
    return send(ctx, url, { root: resolve(PWD + '/public') })
  }

  // In the development environment, you need to support every static file without CDN
  if (staticSuffixList.includes(url.split('.').pop())) {
    return ctx.redirect(devHost + url)
  }

  const clientEntryFile = await axios.get(devHost + '/index.html')

  ctx.set('content-type', 'text/html')
  ctx.set('x-powered-by', 'koa/development')
  ctx.body = clientEntryFile.data
}

module.exports = main
複製程式碼

然後將中介軟體 dev.static.js 註冊到服務端入口檔案 app/server.js 中:

...

if (process.env.NODE_ENV === 'production') {
  app.use(require('./middlewares/prod.ssr.js'))
}else{
  app.use(require('./middlewares/dev.static.js'))
  // TODO:在這裡引入開發環境請求處理中介軟體
}

app.listen(port, host, () => {
  console.log(`[${process.pid}]server started at ${host}:${port}`)
})
複製程式碼

因為我們需要在開發環境同時啟動 spa 服務和 ssr 服務,因此需要一個工具輔助我們同時執行兩個命令。

我們選擇 concurrently,關於此工具的具體細節請參照:github.com/kimmobrunfe…

安裝 concurrently

yarn add concurrently -D
複製程式碼

然後改造 package.json 中的 serve 命令:

...
 "scripts": {
   "serve": "vue-cli-service serve",
   "ssr:serve": "NODE_ENV=development PORT=3000 CLIENT_PORT=8080 node ./app/server.js",
   "dev": "concurrently 'npm run serve' 'npm run ssr:serve'",
...
複製程式碼

其中:

  • serve 開發環境啟動 spa 服務
  • ssr:serve 開發環境啟動 ssr 服務
  • dev 開發環境同時啟動 spa 服務於 ssr 服務

啟動 ssr 服務的命令中:

  • NODE_ENV 是環境變數
  • PORT 是 ssr 服務監聽的埠
  • CLIENT_PORT 是 spa 服務監聽的埠

因為靜態資源需要從 spa 服務中獲取,所以 ssr 服務需要知道 spa 服務的 host 、埠 和 靜態資源路徑

至此,靜態伺服器搭建完畢,接下來我們來搭建開發環境的請求處理中介軟體。(此中介軟體包含 hot reload 功能)

實現 hot reload

./ssr-demo/app/middlewares/ 中建立檔案 dev.ssr.js,內容如下:

const path = require('path')
const fs = require('fs')
const ejs = require('ejs')
const PWD = process.env.PWD

const webpack = require('webpack')
const axios = require('axios')
// memory-fs is a simple in-memory filesystem.
// Holds data in a javascript object
// See: https://github.com/webpack/memory-fs
const MemoryFS = require('memory-fs')

// Use parsed configuration as a file of webpack config
// See: https://cli.vuejs.org/zh/guide/webpack.html#%E5%AE%A1%E6%9F%A5%E9%A1%B9%E7%9B%AE%E7%9A%84-webpack-%E9%85%8D%E7%BD%AE
const webpackConfig = require(PWD + '/node_modules/@vue/cli-service/webpack.config')
// create a compiler of webpack config
const serverCompiler = webpack(webpackConfig)
// create the memory instance
const mfs = new MemoryFS()
// set the compiler output to memory
// See: https://webpack.docschina.org/api/node/#%E8%87%AA%E5%AE%9A%E4%B9%89%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F-custom-file-systems-
serverCompiler.outputFileSystem = mfs

let serverBundle
// Monitor webpack changes because server bundles need to be dynamically updated
serverCompiler.watch({}, (err, stats) => {
  if (err) throw err

  stats = stats.toJson()
  stats.errors.forEach(error => console.error('ERROR:', error))
  stats.warnings.forEach(warn => console.warn('WARN:', warn))

  const bundlePath = path.join(webpackConfig.output.path, 'vue-ssr-server-bundle.json')

  serverBundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))

  console.log('vue-ssr-server-bundle.json updated')
})

const resolve = file => path.resolve(__dirname, file)

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

const renderToString = (renderer, context) => new Promise((resolve, reject) => {
  renderer.renderToString(context, (err, html) => err ? reject(err) : resolve(html))
})

const tempStr = fs.readFileSync(resolve(PWD + '/public/index.ejs'), 'utf-8')
const template = ejs.render(tempStr, { title: '{{title}}', mode: 'server' })

const clientHost = process.env.CLIENT_PORT || 'localhost'
const clientPort = process.env.CLIENT_PORT || 8080
const clientPublicPath = process.env.CLIENT_PUBLIC_PATH || '/'

const main = async (ctx, next) => {
  if (!serverBundle) {
    ctx.body = 'Wait Compiling...'
    return
  }

  ctx.set('content-type', 'text/html')
  ctx.set('x-powered-by', 'koa/development')

  const clientManifest = await axios.get(`http://${clientHost}:${clientPort}${clientPublicPath}vue-ssr-client-manifest.json`)

  const renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false,
    template: template,
    clientManifest: clientManifest.data,
    basedir: process.env.PWD
  })

  const context = {
    title: 'ssr mode',
    url: ctx.url
  }

  const html = await renderToString(renderer, context)

  ctx.body = html
}

module.exports = main
複製程式碼

在開發環境,我們通過 npm run dev 命令,啟動一個 webpack-dev-server 和一個 ssr 服務

通過官方文件可知,我們可以通過一個檔案訪問解析好的 webpack 配置,這個檔案路徑為:

node_modules/@vue/cli-service/webpack.config.js

使用 webpack 編譯此檔案,並將其輸出接入到記憶體檔案系統(memory-fs)中

監聽 webpack,當 webpack 重新構建時,我們在監聽器內部獲取最新的 server bundle 檔案

並從 webpack-dev-server 獲取 client bundle 檔案

在每次處理 ssr 請求的中介軟體邏輯中,使用最新的 server bundle 檔案和 client bundle 檔案進行渲染

最後,將中介軟體 dev.ssr.js 註冊到服務端入口檔案 app/server.js

...

if (process.env.NODE_ENV === 'production') {
  app.use(require('./middlewares/prod.ssr.js'))
}else{
  app.use(require('./middlewares/dev.static.js'))
  app.use(require('./middlewares/dev.ssr.js'))
}

app.listen(port, host, () => {
  console.log(`[${process.pid}]server started at ${host}:${port}`)
})
複製程式碼

至此,我們基於 @vue/cli v3 完成了一個簡易的 ssr 工程專案,目錄結構如下:

./ssr-demo
├── README.md
├── app
│   ├── middlewares
│   │   ├── dev.ssr.js
│   │   ├── dev.static.js
│   │   └── prod.ssr.js
│   └── server.js
├── babel.config.js
├── package.json
├── public
│   └── index.ejs
├── src
│   ├── App.vue
│   ├── assets
│   │   └── logo.png
│   ├── components
│   │   └── HelloWorld.vue
│   ├── entry-client.js
│   ├── entry-server.js
│   ├── main.js
│   ├── router
│   │   └── index.js
│   ├── store
│   │   ├── index.js
│   │   └── modules
│   │       └── book.js
│   └── views
│       ├── About.vue
│       └── Home.vue
├── vue.config.js
└── yarn.lock
複製程式碼

以上,是我們基於 @vue/cli v3 構建 ssr 工程的全部過程。

雖然我們已經有了一個基礎的 ssr 工程,但這個工程專案還有以下缺失的地方:

  • 沒有降級策略,如果 ssr 服務出現異常,整個服務就會受到影響,我們需要考慮在 ssr 服務出現問題時,如何將其降級為 spa 服務
  • 沒有日誌系統,ssr 服務內部接收到的請求資訊、出現的異常資訊、關鍵業務的資訊,這些都需要記錄日誌,方便維護與追蹤定位錯誤。
  • 沒有快取策略,我們搭建的 ssr 服務對於每一次的請求,都會耗費伺服器資源去渲染,這對於那些一段時間內容不會變化的頁面來說,浪費了資源。
  • 沒有監控系統,ssr 服務是常駐記憶體的,我們需要儘可能實時得知道它當前的健康狀況,力求在出現問題之前,得到通知,並快速做出調整。
  • 沒有弱網支援,對於弱網使用者,我們需要給出功能完備,但更加輕盈的頁面,以便讓弱網環境下的使用者也能正常使用服務。

因此,將此工程應用到產品專案中之前,還需要對 ssr 工程再做一些改進,未來,我們會逐步為 ssr 服務提供以下配套設施:

  • 降級
  • 日誌
  • 快取
  • 監控
  • 弱網

下一篇文章,我們講解如何研發一個基於 @vue/cli v3 的外掛,並將 ssr 工程專案中伺服器端的功能整合進外掛中。


水滴前端團隊招募夥伴,歡迎投遞簡歷到郵箱:fed@shuidihuzhu.com

【第一期】基於 @vue/cli3 與 koa 建立 ssr 工程 ----【SSR第一篇】

相關文章