帶你走近Vue伺服器端渲染(VUE SSR)

小牧_QAQ發表於2018-08-14

上篇文章(《伺服器端渲染與Nuxt.js》)介紹了伺服器端渲染和一些Nuxt.js的概念,現在我們就Vue SSR方面,從基礎開始,分低、中、高三個層面,來手寫實現下傳說中的服務端渲染。

前言

在正式搭建專案之前,我們還是要回顧下vue伺服器端渲染的一些特性。
伺服器端渲染的 Vue.js 應用程式,是使vue應用既可以在客戶端(瀏覽器)執行,也可以在伺服器端執行,我們稱之為“同構”或“通用”。

Vue.js is a framework for building client-side applications. By default, Vue components produce and manipulate DOM in the browser as output. However, it is also possible to render the same components into HTML strings on the server, send them directly to the browser, and finally "hydrate" the static markup into a fully interactive app on the client.

帶你走近Vue伺服器端渲染(VUE SSR)
之所以能夠實現同構,是因為在客戶端和服務端都建立了vue應用程式,並都用webpack進行打包,生成了server bundle和client bundle。server bundle用於伺服器渲染,client bundle是一個客戶端的靜態標記,伺服器渲染好html頁面片段後,會傳送給客戶端,然後混合客戶端靜態標記,這樣應用就具有vue應用的特性。
需要注意是:

  • 伺服器端渲染過程中,只會呼叫beforeCreatecreated兩個鉤子函式,其它的只會在客戶端執行。那麼以前spa應用中,在created中建立一個setInterval,然後在destroyed中將其銷燬的類似操作就不能出現了,伺服器渲染期間不會呼叫銷燬鉤子函式,所以這個定時器會永遠保留下來,伺服器很容易就崩了。
  • 由於伺服器可客戶端是兩種不同的執行平臺環境,那麼一些特定平臺的API就不能用了,比如windowdocument,在node.js(比如created鉤子函式)中執行就會報錯。並且,我們使用的第三方API中,需要確保能在node和瀏覽器都能正常執行,比如axios,它向伺服器和客戶端都暴露相同的 API(瀏覽器的源生XHR就不行)。

第一層:伺服器渲染從0到1

我們先不考慮同構、不考慮各種配置,先實現一個基礎的伺服器端渲染demo。

準備

npm install vue vue-server-renderer express --save
複製程式碼

vue-server-renderervue伺服器端渲染的核心模組,它需要匹配你的vue版本。安裝express是因為我們等會會使用它來起個服務看到我們的頁面效果。

三步渲染一個Vue例項

// 第 1 步:建立一個 Vue 例項
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello Vue SSR</div>`
})

// 第 2 步:建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:將 Vue 例項渲染為 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)   // <div data-server-rendered="true">Hello Vue SSR</div>
})
複製程式碼

使用模板

上面只是生產了一個html程式碼片段,一般來說,需要將html片段插入一個模板檔案裡。OK,那我們就來寫一個模板檔案index.html

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>
複製程式碼

在渲染的時候,html片段會被插入到<!--vue-ssr-outlet-->這個註釋標記這裡。

const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello Vue SSR</div>`
})
const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.html', 'utf-8')
})
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html) // html 將是注入應用程式內容的完整頁面
})
複製程式碼

我們用fs模組將檔案讀取進來丟入render的template中,再重複上述步驟將html片段插入到我們的標記位。

在node.js伺服器中使用

現在,我們將使用express來啟動一個node服務,驗證一下頁面效果。

const Vue = require('vue')
// 第一步: 建立一個 express 應用
const server = require('express')()

// 第二步: 建立一個 Vue 例項
const app = new Vue({
  data: {
    msg: 'Hello Vue SSR'
  },
  template: `<div>{{msg}}</div>`
})

// 第三步: 建立一個 renderer
const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.html', 'utf-8')
})

// 第四步: 設定路由,"*" 表示任意路由都可以訪問它
server.get('*', (req, res) => {
  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(html)
  })
})

// 第五步: 啟動服務並監聽從8080埠進入的所有連線請求
server.listen(8080)
複製程式碼

這樣,我們的一個簡單的頁面渲染就完成了,看下頁面效果和Response資料。

帶你走近Vue伺服器端渲染(VUE SSR)

第二層:改造 —— 從SPA到SSR

知道了怎麼在伺服器端渲染出一個頁面,下一步就是實現同構啦。為了跳過各種專案配置,我們就從熟悉的vue-cli模板下手。
官方提供了vue-cli的專案快速構建工具,可以用它也進行SPA專案的快速搭建,我們現在就把這個模板,改造成一個能夠整合SSR的模板。

第二部分內容參考讓vue-cli初始化後的專案整合支援SSR,侵刪。

準備

安裝vue-cli (至少v2.x版本)後,使用基礎模板搭建個專案

vue init webpack spa_ssr
cd spa_ssr
複製程式碼

跑一下確保專案能夠正常執行,然後記得安裝vue-server-renderer模組

npm install vue-server-renderer --save-dev
複製程式碼

安裝完成,我們就開始進入下一步。

改造src下的檔案

我們需要在src目錄下建立兩個js。

src
├── router
│   └── index.js
├── components
│   └── HelloSsr.vue
├── App.vue
├── main.js
├── entry-client.js # 僅執行於瀏覽器
└── entry-server.js # 僅執行於伺服器
複製程式碼

這兩個entry之後會進行配置,先來改造main.js
在改造main.js之前,需要說明一下,因單執行緒的機制,在伺服器端渲染時,過程中有類似於單例的操作,那麼所有的請求都會共享這個單例的操作,所以應該使用工廠函式來確保每個請求之間的獨立性。比如在main.js中,我們原先直接建立一個Vue例項,並直接掛載到DOM。現在的main.js作為通用entry檔案,它應該改造成一個可以重複執行的工廠函式,為每個請求建立新的應用程式例項。掛載的工作,是由之後的客戶端entry來完成。

import Vue from 'vue'
import App from './App'
import { CreateRouter } from './router'

export function createApp () {
  const router = new CreateRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return { app, router }
}
複製程式碼

/router/index.js中,我們同樣需要使用工廠函式來建立路由例項。然後將路由配置改為history模式(因為雜湊不支援)

import Vue from 'vue'
import Router from 'vue-router'
import HelloSsr from '@/components/HelloSsr'

Vue.use(Router)

export function CreateRouter () {
  return new Router({
    mode: 'history',
    routes: [{
      path: '/ssr',
      name: 'HelloSsr',
      component: HelloSsr
    }]
  })
}
複製程式碼

接下來我們來寫客戶端的entry和伺服器端的entry。客戶端的entry要做的很簡單,就是將vue例項掛載到DOM上,只不過,考慮到可能存在非同步元件,需要等到路由將非同步元件載入完畢,才進行此操作。

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

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

伺服器entry要做的有兩步:1.解析伺服器端路由;2.返回一個vue例項用於渲染。

// entry-server.js
import { createApp } from './main'
export default context => {
  // 因為有可能會是非同步路由鉤子函式或元件,所以我們將返回一個 Promise,
  // 以便伺服器能夠等待所有的內容在渲染前,
  // 就已經準備就緒。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    // 設定伺服器端 router 的位置
    router.push(context.url)
    // 等到 router 將可能的非同步元件和鉤子函式解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,執行 reject 函式,並返回 404
      if (!matchedComponents.length) {
        // eslint-disable-next-line
        return reject({ code: 404 })
      }
      // Promise 應該 resolve 應用程式例項,以便它可以渲染
      resolve(app)
    }, reject)
  })
}
複製程式碼

webpack配置

vue相關程式碼已處理完畢,接下來就需要對webpack打包配置進行修改了。 官方推薦了下面配置:

 build
  ├── webpack.base.conf.js  # 基礎通用配置
  ├── webpack.client.conf.js  # 客戶端打包配置
  └── webpack.server.conf.js  # 伺服器端打包配置
複製程式碼

我們的專案中的配置檔案是basedevprod,現在我們仍然保留這三個配置檔案,只需要增加webpack.server.conf.js即可。

webpack.base.conf.js修改

我們首先修改webpack.base.conf.jsentry入口配置為:./src/entry-client.js,來生成客戶端的構建清單client manifest。伺服器端的配置由於引用base配置,entry會通過merge覆蓋,來指向server-entry.js

// webpack.base.conf.js
module.exports = {
  entry: {
    // app: './src/main.js'
    app: './src/entry-client.js'   // <-修改入口檔案改為
  },
  // ...
}
複製程式碼

webpack.prod.conf.js修改

在客戶端的配置prod中,我們需要引入一個伺服器端渲染的外掛client-plugin,用來生成vue-ssr-client-manifest.json(用作靜態資源注入),同時,我們需要把HtmlWebpackPlugin給去掉,在SPA應用中,我們用它來生成index.html檔案,但是這裡我們有vue-ssr-client-manifest.json之後,伺服器端會幫我們做好這個工作。

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
// ...
  plugins: [
    new webpack.DefinePlugin({
      'process.env': env,
      'process.env.VUE_ENV': '"client"' // 增加process.env.VUE_ENV
    }),
    // ...
    // 以下內容註釋(或去除)
    // 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'
    // }),
    // ...
    // 此外掛在輸出目錄中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
// ...
複製程式碼

webpack.server.conf.js配置

server配置基本參考官方的配置,這裡還是說明下:

  1. 我們需要去掉baseConfig中的打包css的配置;
  2. 這裡使用了webpack-node-externals來加快構建速度和減小打包體積,所以我們要先安裝一下它:npm install webpack-node-externals --save-dev
  3. prod配置一樣,這裡需要引入並使用server-plugin外掛來生成vue-ssr-server-bundle.json。這東西是用來等會做伺服器端渲染的。
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')
// 去除打包css的配置
baseConfig.module.rules[1].options = ''

module.exports = merge(baseConfig, {
  // 將 entry 指向應用程式的 server entry 檔案
  entry: './src/entry-server.js',
  // 這允許 webpack 以 Node 適用方式(Node-appropriate fashion)處理動態匯入(dynamic import),
  // 並且還會在編譯 Vue 元件時,
  // 告知 `vue-loader` 輸送面向伺服器程式碼(server-oriented code)。
  target: 'node',
  // 對 bundle renderer 提供 source map 支援
  devtool: 'source-map',
  // 此處告知 server bundle 使用 Node 風格匯出模組(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },
  // https://webpack.js.org/configuration/externals/#function
  // https://github.com/liady/webpack-node-externals
  // 外接化應用程式依賴模組。可以使伺服器構建速度更快,
  // 並生成較小的 bundle 檔案。
  externals: nodeExternals({
    // 不要外接化 webpack 需要處理的依賴模組。
    // 你可以在這裡新增更多的檔案型別。例如,未處理 *.vue 原始檔案,
    // 你還應該將修改 `global`(例如 polyfill)的依賴模組列入白名單
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    // 這是將伺服器的整個輸出
    // 構建為單個 JSON 檔案的外掛。
    // 預設檔名為 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin()
  ]
})
複製程式碼

package.json打包命令修改

"scripts": {
    //...
    "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"
} 
複製程式碼

這裡需要先安裝cross-env。(cross-env用來防止使用NODE_ENV =production 來設定環境變數時,Windows命令提示會報錯)

npm install --save-dev cross-env
複製程式碼

修改index.html

如第一層說的,我們需要在這個index.html外層模板檔案中,插入一個<!--vue-ssr-outlet-->註釋標記,用來標識伺服器渲染的html程式碼片段插入的地方,同時刪掉原先的<div id="app">
伺服器端會在這個標記的位置自動生成一個<div id="app" data-server-rendered="true">,客戶端會通過app.$mount('#app')掛載到服務端生成的元素上,並變為響應式的。

  • ps:這裡單純將模板改為伺服器端渲染適用的模板,但是在dev模式下,會因為找不到#app而報錯,這裡就不做dev下的處理,如果需要,可以為dev模式單獨建立一個html模板。

打包構建

npm run build
複製程式碼

在dist目錄下會生成兩個json檔案:vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json,用於服務端端渲染和靜態資源注入。

構建伺服器端

這裡還是採用express來作為伺服器端,先進行安裝:

npm install express --save
複製程式碼

之後在根目錄下建立server.js,程式碼主要分為3步:

  1. 採用createBundleRenderer來建立renderer,我們引入之前生成好的json檔案,並讀取index.html作為外層模板;
  2. 設定路由,當請求指定路由的時候,設定請求頭,呼叫渲染函式,將渲染好的html返回給客戶端;
  3. 監聽3001埠。
const express = require('express')
const app = express()

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

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

// 生成服務端渲染函式
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
  // 模板html檔案
  template: fs.readFileSync(resolve('./index.html'), 'utf-8'),
  // client manifest
  clientManifest: require('./dist/vue-ssr-client-manifest.json')
})

function renderToString (context) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}
app.use(express.static('./dist'))

app.use(async(req, res, next) => {
  try {
    const context = {
      title: '服務端渲染測試', // {{title}}
      url: req.url
    }
    // 設定請求頭
    res.set('Content-Type', 'text/html')
    const render = await renderToString(context)
    // 將伺服器端渲染好的html返回給客戶端
    res.end(render)
  } catch (e) {
    console.log(e)
    // 如果沒找到,放過請求,繼續執行後面的中介軟體
    next()
  }
})

app.listen(3000)
複製程式碼

完事後啟動服務命令:

node server.js
複製程式碼

訪問localhost:3000/ssr,就能獲取我們之前定義好的頁面。

帶你走近Vue伺服器端渲染(VUE SSR)

第三層:Nuxt.js原始碼初探

Nuxt.js是什麼

Nuxt.js是Vue官方推薦的一個專案,它是一個基於 Vue.js 的通用應用框架。預設了伺服器端渲染所需的各種配置,如非同步資料,中介軟體,路由,只要遵循其中的規則就能輕鬆實現SSR。開箱即用,體驗友好。通過對客戶端/服務端基礎架構的抽象組織,Nuxt.js 主要關注的是應用的 UI渲染。

Nuxt.js的一小小小部分原始碼解讀

Nuxt.js原始碼涉及的內容比較多,我們不一一細說(好吧,是我功力不夠,吃不透 = =||)。我們就來看看,Nuxt.js作為中介軟體的時候,整個流程都幹了些什麼。
Nuxt.js官方提供的examples裡有一個custom-serverNuxt會作為中介軟體傳入express中,我們來看下程式碼:

import express from 'express'
import { Nuxt, Builder } from 'nuxt'

const app = express()

const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || 3000

// Import and set Nuxt.js options
let config = require('./nuxt.config.js')
config.dev = !(process.env.NODE_ENV === 'production')

const nuxt = new Nuxt(config)

// Start build process in dev mode
if (config.dev) {
  const builder = new Builder(nuxt)
  builder.build()
}

// Give nuxt middleware to express
app.use(nuxt.render)

// Start express server
app.listen(port, host)
複製程式碼

這段程式碼有部分地方和我們之前寫的相似,都用express起了一個服務,這裡涉及Nuxt.js的程式碼有2處:

  1. 引入nuxt.config.js配置檔案,並作為引數,建立一個Nuxt例項;
  2. 例項化一個Builder,構建路由。

Nuxt是根據pages資料夾下的目錄結構來生成對應的路由的,這是Nuxt的特點之一,這部分內容我們這裡不展開細說,以後再詳細討論。我們主要來說說new Nuxt()裡的事。
上面我們看的,我們是將Nuxt的例項化物件的render屬性值傳作為中介軟體傳給了express,我們在原始碼中全域性搜尋找到Nuxt建構函式:

帶你走近Vue伺服器端渲染(VUE SSR)

我們看到,這裡又建立了一個Renderer例項,那我們繼續找到這個Renderer建構函式。程式碼太長我就不全貼了。這麼多內容感覺還是無從下手,不慌,既然之前說了,vue-server-renderer是SSR伺服器端渲染的核心模組,那我們嘗試在這裡搜尋vue-server-renderer,果然,搜到如下內容:

帶你走近Vue伺服器端渲染(VUE SSR)
帶你走近Vue伺服器端渲染(VUE SSR)
在createRenderer函式中,我們通過註釋可以看到,為伺服器端渲染建立了一個bundle renderer,這不就是我們之前自己實現的伺服器渲染函式麼,它的第一個引數this.resources.serverBundle,我們在檔案中可以搜到:

帶你走近Vue伺服器端渲染(VUE SSR)
我們的nuxt專案會在執行的時候自動構建,生成一個.nuxt的資料夾,裡面就包含了:

帶你走近Vue伺服器端渲染(VUE SSR)
這一串程式碼聯合起來,我們可以看到,Nuxt.js同樣是使用預編譯的應用程式包createBundleRenderer來建立了渲染器,其中所需的server-bundle.jsonclient-mainfest.json會由nuxt在執行的時候自動構建生成。
下面是呼叫裡面的renderToString方法,和我們上面寫的demo一樣,來生成html片段。我們在renderRoute方法中找到了它:

帶你走近Vue伺服器端渲染(VUE SSR)
這段程式碼向我們展示了一段比較完整的渲染流程,呼叫渲染函式生成HTML片段,拼接HTML片段,拼接HEAD片段,丟進預置的模板中渲染出完整的html。由此可以看出,Nuxt.js的核心伺服器端渲染原理和我們之前寫的demo基本相同。

總結

伺服器端渲染的優劣都非常明顯,如果僅僅是為了優化網頁的SEO,我們還可以嘗試Vue官方給我們推薦的預渲染(Prerendering),這裡就不多贅述。 以上Demo僅僅是作為伺服器端渲染的一種實現demo,如果需要正式用到專案中,還需要更加複雜的配置。如果只是使用伺服器端渲染來開發簡單的專案,我們可以直接用Nuxt.js即可。 以上有些知識點在概念上有偏差,歡迎指正。

參考文獻

  1. Vue SSR官方指南:ssr.vuejs.org/zh/
  2. 讓vue-cli初始化後的專案整合支援SSR: blog.csdn.net/ligang25851…
  3. vue-server-renderer: www.jianshu.com/p/8e7099aed…

相關文章