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

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

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

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

咱們主要在管理後臺系統和內嵌H5電商頁中使用Vue,對於管理後臺系統,不須要考慮SEO和首屏渲染時間,因此是否用SPA的方式其實問題不大。而對於電商頁,雖然不須要SEO,可是首屏渲染變得十分重要。通常的SPA頁面打開時,HTML大致的結構以下: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

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

實現原理

實現流程

Vue SSR

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

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

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

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

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

目錄結構

.
├── 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}`)
})

複製代碼

整個流程大體以下:express

  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。json

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中使用多餘的空格。

相關文章
相關標籤/搜索