Vue 服務端渲染技術

尋夢環遊發表於2018-04-24

目錄

  • 什麼是服務端渲染技術及其應用場景
  • 什麼是預渲染技術,服務端渲染 VS 預渲染
  • 服務端渲染實現的原理
  • 搭建一個服務端渲染專案
  • 服務端渲染效能優化

什麼是Vue服務端渲染(SSR)

所謂的Vue服務端渲染就是,將vue例項在服務端渲染成HTML字串,將它們直接傳送給瀏覽器,最後將靜態標記“混合”為客戶端上完全互動的應用程式。

為什麼需要使用服務端渲染(SSR)

  • 更好的SEO,由於搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面。
  • 更快的首屏渲染速度。特別是對於網路速度慢或者執行緩慢的裝置,無需等待所有的js都下載和解析完成才渲染頁面,而是在服務端渲染好直接傳送給客戶端渲染頁面。

服務端渲染(SSR) VS 預渲染(Prerendering)

  • 相同點:都是解決單頁面SEO的問題,更快的內容到達時間。
  • 不同點: 1、實現原理和方案不同:SSR的實現依賴於node.js伺服器做服務端構建靜態資源, prerender的實現依賴於webpack整合為prerender-spa-plugin,將靜態資源提取出來展示給前端。 2、服務端渲染可以做到服務端的實時編譯,prerender只是在構建時簡單的生成針對特定路由的靜態HTML檔案,來達到SEO的效果,prerender的優點是配置更簡單,並可以把前端作為一個完全靜態的站點。

Prerender的webpack配置

const path = require('path')
const PrerenderSPAPlugin = require('prerender-spa-plugin')

module.exports = {
  plugins: [
    ...
    new PrerenderSPAPlugin({
      // Required - The path to the webpack-outputted app to prerender.
      staticDir: path.join(__dirname, 'dist'),
      // Required - Routes to render.
      routes: [ '/', '/about', '/some/deep/nested/route' ],
    })
  ]
}
複製程式碼

服務端渲染原理圖解

enter image description here

SSR實現技術棧

服務端:Nodejs 前端框架 Vue2.0+ 前端構建工具:webpack 程式碼檢查:eslint 原始碼:es6 前端路由:vue-router 狀態管理:vuex 服務端通訊:axios 日誌管理:log4js 專案自動化部署工具:jenkins

服務端渲染一個Vue例項

// 第 1 步:建立一個 Vue 例項
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello World</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 World</div>
})
複製程式碼

整合Express的node服務

const Vue = require('vue')
const server = require('express')()
const renderer = require('vue-server-renderer').createRenderer()
server.get('*', (req, res) => {
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>訪問的 URL 是: {{ url }}</div>`
  })
  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
  })
})
server.listen(8080)
複製程式碼

服務端渲染專案目錄結構

Alt text

app.js 程式碼結構

import Vue from 'vue'
import App from './App.vue'
import store from './store'
import router from './router'
import { sync } from 'vuex-router-sync'
import Element from 'element-ui'
Vue.use(Element)

// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)

/**
 * 建立vue例項
 * 在這裡注入 router  store 到所有的子元件
 * 這樣就可以在任何地方使用 `this.$router` and `this.$store`
 * @type {Vue$2}
 */
const app = new Vue({
  router,
  store,
  render: h => h(App)
})

/**
 * 匯出 router and store.
 * 在這裡不需要掛載到app上。這裡和瀏覽器渲染不一樣
 */
export { app, router, store }
複製程式碼

entry-client.js程式碼結構

import 'es6-promise/auto'
import { app, store, router } from './app'

// prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup.
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

/**
 * 非同步元件
 */
router.onReady(() => {
  // 開始掛載到dom上
  app.$mount('#app')
})

// service worker
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js')
}
複製程式碼

server-entry.js程式碼結構

import { app, router, store } from './app'

const isDev = process.env.NODE_ENV !== 'production'

// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
  const s = isDev && Date.now()

  return new Promise((resolve, reject) => {
    // set router's location
    router.push(context.url)

    // wait until router has resolved possible async hooks
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // no matched routes
      if (!matchedComponents.length) {
        reject({ code: 404 })
      }
      // Call preFetch hooks on components matched by the route.
      // A preFetch hook dispatches a store action and returns a Promise,
      // which is resolved when the action is complete and store state has been
      // updated.
      Promise.all(matchedComponents.map(component => {
        return component.preFetch && component.preFetch(store)
      })).then(() => {
        isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
        // After all preFetch hooks are resolved, our store is now
        // filled with the state needed to render the app.
        // Expose the state on the render context, and let the request handler
        // inline the state in the HTML response. This allows the client-side
        // store to pick-up the server-side state without having to duplicate
        // the initial data fetching on the client.
        context.state = store.state
        resolve(app)
      }).catch(reject)
    })
  })
}
複製程式碼

編寫通用程式碼注意事項

我們的通用程式碼是一套程式碼可以分別在瀏覽器環境和node.js環境跑起來的,所以書寫程式碼有些事情需要注意: 1、服務端渲染過程中只有beforeCreate和created生命週期函式會被呼叫。其他生命週期只能在瀏覽器環境惰性呼叫,node.js 會忽略掉這部分生命週期函式。 2、通用程式碼不可接受特定平臺的 API(比如document、window),使用跨平臺的axios(對瀏覽器和node.js暴露相同的API)做瀏覽器和node.js環境的請求傳送。 3、大多數自定義指令直接操作 DOM,因此會在伺服器端渲染(SSR)過程中導致錯誤

路由和程式碼分割

1、引入vue-router來做頁面的單頁應用 2、程式碼分割:應用程式的程式碼分割或惰性載入,有助於減少瀏覽器在初始渲染中下載的資源體積,可以極大地改善大體積 bundle 的可互動時間 (TTI - time-to-interactive)。這裡的關鍵在於,對初始首屏而言,”只載入所需"。

// 這裡進行修改……
import Foo from './Foo.vue'
// 改為這樣:
const Foo = () => import('./Foo.vue')
複製程式碼

資料預取和狀態

在伺服器端渲染(SSR)期間,我們本質上是在渲染我們應用程式的"快照",所以如果應用程式依賴於一些非同步資料,那麼在開始渲染過程之前,需要先預取和解析好這些資料。

另一個需要關注的問題是在客戶端,在掛載(mount)到客戶端應用程式之前,需要獲取到與伺服器端應用程式完全相同的資料 - 否則,客戶端應用程式會因為使用與伺服器端應用程式不同的狀態,然後導致混合失敗。

為了解決這個問題,獲取的資料需要位於檢視元件之外,即放置在專門的資料預取儲存容器(data store)或"狀態容器(state container))"中。首先,在伺服器端,我們可以在渲染之前預取資料,並將資料填充到 store 中。此外,我們將在 HTML 中序列化(serialize)和內聯預置(inline)狀態。這樣,在掛載(mount)到客戶端應用程式之前,可以直接從 store 獲取到內聯預置(inline)狀態。

為此,我們將使用官方狀態管理庫 Vuex。

客戶端混合

所謂客戶端啟用,指的是 Vue 在瀏覽器端接管由服務端傳送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。 如果你檢查伺服器渲染的輸出結果,你會注意到應用程式的根元素有一個特殊的屬性:

<div id="app" data-server-rendered="true">
複製程式碼

data-server-rendered 特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,並且應該以啟用模式進行掛載。

在開發模式下,Vue 將推斷客戶端生成的虛擬 DOM 樹 (virtual DOM tree),是否與從伺服器渲染的 DOM 結構 (DOM structure)匹配。如果無法匹配,它將退出混合模式,丟棄現有的 DOM 並從頭開始渲染。在生產模式下,此檢測會被跳過,以避免效能損耗。

客戶端構建配置

客戶端配置(client config)和基本配置(base config)大體上相同。顯然你需要把 entry 指向你的客戶端入口檔案。除此之外,如果你使用 CommonsChunkPlugin,請確保僅在客戶端配置(client config)中使用,因為伺服器包需要單獨的入口 chunk

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.config.js')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
module.exports = merge(baseConfig, {
  entry: '/path/to/entry-client.js',
  plugins: [
    // 重要資訊:這將 webpack 執行時分離到一個引導 chunk 中,
    // 以便可以在之後正確注入非同步 chunk。
    // 這也為你的 應用程式/vendor 程式碼提供了更好的快取。
    new webpack.optimize.CommonsChunkPlugin({
      name: "manifest",
      minChunks: Infinity
    }),
    // 此外掛在輸出目錄中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})
複製程式碼

服務端構建配置

伺服器配置,是用於生成傳遞給 createBundleRenderer 的 server bundle。它應該是這樣的:

const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(baseConfig, {
  // 將 entry 指向應用程式的 server entry 檔案
  entry: '/path/to/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$/
  }),
  // 這是將伺服器的整個輸出
  // 構建為單個 JSON 檔案的外掛。
  // 預設檔名為 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin()
  ]
})
複製程式碼

快取

在大多數情況下,伺服器渲染的應用程式依賴於外部資料,因此本質上頁面內容是動態的,不能持續長時間快取。然而,如果內容不是使用者特定(user-specific)(即對於相同的 URL,總是為所有使用者渲染相同的內容),我們可以利用名為 micro-caching 的快取策略,來大幅度提高應用程式處理高流量的能力。

const microCache = LRU({
  max: 100,
  maxAge: 1000 // 重要提示:條目在 1 秒後過期。
})
const isCacheable = req => {
  // 實現邏輯為,檢查請求是否是使用者特定(user-specific)。
  // 只有非使用者特定(non-user-specific)頁面才會快取
}
server.get('*', (req, res) => {
  const cacheable = isCacheable(req)
  if (cacheable) {
    const hit = microCache.get(req.url)
    if (hit) {
      return res.end(hit)
    }
  }
  renderer.renderToString((err, html) => {
    res.end(html)
    if (cacheable) {
      microCache.set(req.url, html)
    }
  })
})
複製程式碼

流式渲染

對於 vue-server-renderer 的基本 renderer 和 bundle renderer 都提供開箱即用的流式渲染功能。所有你需要做的就是,用 renderToStream 替代 renderToString:

const stream = renderer.renderToStream(context)
複製程式碼

返回的值是 Node.js stream:

let html = ''
stream.on('data', data => {
  html += data.toString()
})
stream.on('end', () => {
  console.log(html) // 渲染完成
})
stream.on('error', err => {
  // handle error...
})
複製程式碼

Nuxt.js

從頭搭建一個服務端渲染的應用是相當複雜的。Nuxt 是一個基於 Vue 生態的更高層的框架,為開發服務端渲染的 Vue 應用提供了極其便利的開發體驗

相關文章