vue伺服器端渲染(SSR)實戰

高清無碼發表於2018-06-30

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

SSR(Server-Side Rendering),在SPA(Single-Page Application)出現之前,網頁就是在服務端渲染的。伺服器接收到客戶端請求後,將資料和模板拼接成完整的頁面響應到客戶端,客戶端將響應結果渲染出來。如果使用者需要瀏覽新的頁面,則需要重複這個過程。隨著Angular、React和Vue的興起,SPA開始流行,單頁面應用可以在不過載整個頁面的情況下,通過ajax和伺服器進行互動,高效更新部分頁面,這無疑帶來了良好的使用者體驗。然而,對於需要SEO、追求首屏速度的頁面,使用SPA是糟糕的。如果我們想使用Vue,又需要考慮到SEO、首屏渲染速度,那該怎麼辦?好在Vue是支援服務端渲染的,接下來我們主要說的是Vue的服務端渲染。

Vue SSR適用場景及解決的問題

我們主要在管理後臺系統和內嵌H5電商頁中使用Vue,對於管理後臺系統,不需要考慮SEO和首屏渲染時間,所以是否用SPA的方式其實問題不大。而對於電商頁,雖然不需要SEO,但是首屏渲染變得十分重要。一般的SPA頁面開啟時,HTML大體的結構如下:

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="utf-8">
  <title></title>
</head>

<body>
  <div id="app"></div>
  <script type="text/javascript" src="/app.js"></script>
</body>
</html>
複製程式碼

這種情況下,HTML和JS載入成功後通過JS再發起請求,再將響應的內容填入到div容器中,這就存在頁面最開始白屏的問題。服務端渲染將這個過程放在了服務端,請求獲取響應後服務端將HTML填充好直接返回給瀏覽器,瀏覽器將整個完整的HTML直接渲染出來。顯而易見,服務端渲染少了在瀏覽器載入的過程,解決了頁面最開始白屏的問題,明顯的提高了首屏渲染的速度。

目前我們主要在電商導購頁、挖客分享頁中使用Vue的SSR,接下來我們主要講SSR的實現。

實現原理

實現流程

Vue SSR

如上圖所示有兩個入口檔案Server entry和Client entry,分別經webpack打包成服務端用的Server Bundle和客戶端用的Client Bundle。

服務端:當Node Server收到來自客戶端的請求後, BundleRenderer 會讀取Server Bundle,並且執行它,而 Server Bundle實現了資料預取並將填充資料的Vue例項掛載在HTML模版上,接下來BundleRenderer將HTML渲染為字串,最後將完整的HTML返回給客戶端。

客戶端:瀏覽器收到HTML後,客戶端載入了Client Bundle,通過app.$mount('#app')的方式將Vue例項掛載在服務端返回的靜態HTML上。如:

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

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

目錄結構

.
├── build
│   ├── setup-dev-server.js          # dev伺服器端設定 增加中介軟體支援
│   ├── webpack.base.config.js       # 基本配置
│   ├── webpack.client.config.js     # 客戶端配置
│   └── webpack.server.config.js     # 服務端配置
├── cache_key.js                     # 根據引數判斷是否從快取中獲取
├── package.json                     # 專案依賴
├── process.debug.json               # debug環境下的pm2配置檔案
├── process.json                     # 生產環境下pm2配置檔案
├── server.js                        # express 服務端入口檔案
├── src
│   ├── api
│   │   ├── create-api-client.js	 # 客戶端請求相關配置
│   │   ├── create-api-server.js	 # 伺服器請求相關配置
│   │   └── index.js                 # api請求
│   ├── app.js                       # 主入口檔案
│   ├── config                       # 相關配置
│   ├── entry-client.js              # 客戶端入口檔案
│   ├── entry-server.js              # 服務端入口檔案
│   ├── router                       # 路由
│   ├── store                        # store
│   ├── templates                    # 模版
│   └── views
複製程式碼

相關檔案

server.js

// 建立express應用
const app = express()
// 讀取模版檔案
const template = fs.readFileSync(resolve('./src/templates/index.template.html'), 'utf-8')
// 呼叫vue-server-renderer的createBundleRenderer方法建立渲染器,並設定HTML模板,之後將服務端預取的資料填充至模板中
function createRenderer (bundle, options) {
  return createBundleRenderer(bundle, Object.assign(options, {
    template,
	 basedir: resolve('./dist'),
    runInNewContext: false
  }))
}

let renderer
let readyPromise
if (!isDev) {
  // 生產環境下,引入由webpack vue-ssr-webpack-plugin外掛生成的server bundle
  const bundle = require('./dist/vue-ssr-server-bundle.json')
  // 引入由 vue-server-renderer/client-plugin 生成的客戶端構建 manifest 物件。此物件包含了 webpack 整個構建過程的資訊,從而可以讓 bundle renderer 自動推導需要在 HTML 模板中注入的內容。
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  // vue-server-renderer建立bundle渲染器並繫結server bundle
  renderer = createRenderer(bundle, {
    clientManifest
  })
} else {
  // 開發環境下,使用dev-server來通過回撥把記憶體中的bundle檔案取回
  // 通過dev server的webpack-dev-middleware和webpack-hot-middleware實現客戶端程式碼的熱更新
  readyPromise = require('./build/setup-dev-server')(app, (bundle, options) => {
    renderer = createRenderer(bundle, options)
  })
}
// 設定靜態資源訪問
const serve = (path, cache) => express.static(resolve(path), {
  maxAge: cache && isDev ? 0 : 1000 * 60 * 60 * 24 * 30
})

// 相關中介軟體 壓縮響應檔案 處理靜態資源等
app.use(...)

// 設定快取時間
const microCache = LRU({
  maxAge: 1000 * 60 * 1
})

const isCacheable = req => useMicroCache

function render (req, res) {
  const s = Date.now()

  res.setHeader('Content-Type', 'text/html')

  // 錯誤處理
  const handleError = err => {}
  // 根據path和query獲取cacheKey
  let cacheKey = getCacheKey(req.path, req.query)
  // 生產環境下預設開啟快取
  const cacheable = isCacheable(req)
  if (cacheable) {
    const hit = microCache.get(cacheKey)
    if (hit) {
    // 從快取中獲取
      console.log(`cache hit! key: ${cacheKey} query: ${JSON.stringify(req.query)}`)
      return res.end(hit)
    }
  }
  // 設定請求的url
  const context = {
    title: '', 
    url: req.url,
  }
  // 將Vue例項渲染為字串,傳入上下文物件。
  renderer.renderToString(context, (err, html) => {
    if (err) {
      return handleError(err)
    }
    res.end(html)
    // 設定快取
    if (cacheable) {
      if (!isProd) {
        console.log(`set cache, key: ${cacheKey}`)
      }
      microCache.set(cacheKey, html)
    }
    if (!isProd) {
      console.log(`whole request: ${Date.now() - s}ms`)
    }
  })
}

// 啟動一個服務並監聽8080埠
app.get('*', !isDev ? render : (req, res) => {
  readyPromise.then(() => render(req, res))
})

const port = process.env.PORT || 8080
const server = http.createServer(app)
server.listen(port, () => {
  console.log(`server started at localhost:${port}`)
})

複製程式碼

整個流程大致如下:

  1. 建立渲染器,設定渲染模版、繫結Server Bundle
  2. 依次裝載一系列Express中介軟體,用於壓縮響應、處理靜態資源等
  3. 渲染器將裝載好的Vue的例項渲染為字串,響應到客戶端,並設定快取(以cacheKey為標識)
  4. 再次訪問時以cacheKey為標識,判斷是否從快取中獲取

entry.server.js

import { createApp } from './app'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    const { url, req } = context
    const fullPath = router.resolve(url).route.fullPath

    if (fullPath !== url) {
      return reject({ url: fullPath })
    }
	// 切換路由到請求的url
    router.push(url)

	 // 在路由完成初始導航時呼叫,可以解析所有的非同步進入鉤子和路由初始化相關聯的非同步元件,有效確保服務端渲染時服務端和客戶端輸出的一致。
    router.onReady(() => {
    // 獲取該路由相匹配的Vue components
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        reject({ code: 404 })
      }
	 // 執行匹配元件中的asyncData
      Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
        store,
        route: router.currentRoute,
        req
      }))).then(() => {
        // 在所有預取鉤子(preFetch hook) resolve 後,
        // 我們的 store 現在已經填充入渲染應用程式所需的狀態。
        // 當我們將狀態附加到上下文,
        // 並且 `template` 選項用於 renderer 時,
        // 狀態將自動序列化為 `window.__INITIAL_STATE__`,並注入 HTML。
		  context.state = store.state
        if (router.currentRoute.meta) {
          context.title = router.currentRoute.meta.title
        }
        // 返回一個初始化完整的Vue例項
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

複製程式碼

entry-client.js


import 'es6-promise/auto'
import { createApp } from './app'

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

// 由於服務端渲染時,context.state 作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中。在客戶端,在掛載到應用程式之前,state為window.__INITIAL_STATE__。
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
	// 新增路由鉤子函式,用於處理 asyncData.
	// 在初始路由 resolve 後執行,
	// 以便我們不會二次預取(double-fetch)已有的資料。
	// 使用 `router.beforeResolve()`,以便確保所有非同步元件都 resolve。  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)
    })
    const asyncDataHooks = activated.map(c => c.asyncData).filter(_ => _)
    if (!asyncDataHooks.length) {
      return next()
    }

    Promise.all(asyncDataHooks.map(hook => hook({ store, route: to })))
      .then(() => {
        next()
      })
      .catch(next)
  })

	// 掛載在DOM上
  app.$mount('#app')
})

複製程式碼

遇到的問題

1. 本地儲存

以往在使用SPA時,我們一般使用localStorage和sessionStorage進行部分資訊的本地儲存,有時候發起請求的時候需要帶上這些資訊。然而在使用SSR時,我們在asyncData這個鉤子中發起請求獲取資料,此時並不能獲取到window物件下的localStorage這個物件。 我們將資訊儲存在cookie中,在asyncData獲取資料時,通過req.headers獲取cookie。

2. 避開服務端與瀏覽器差異

這個問題其實和第一個問題有些類似,服務端和瀏覽器最大的差別在於有無window物件。我們可以通過判斷去避開:

// 解決移動端300ms延遲問題
if (typeof window !== "undefined") {
  const Fastclick = require('fastclick')
  Fastclick.attach(document.body)
}
複製程式碼

其實更好的解決方式是在entry-client.js中:

import FastClick from 'fastclick'

FastClick.attach(document.body)
複製程式碼

3. not matching

[vue warn]The client-side rendered virtual DOM tree is not matching server-rendered content
複製程式碼

這個問題是服務端與客戶端渲染的HTML不一致導致的。很大可能是出現{{ msg }}這樣的寫法中的多餘空格導致的,我們要盡力避免在template中使用多餘的空格。

相關文章