Vue2服務端渲染實踐以及相關解讀

小深刻的秋鼠發表於2017-05-02

由於前端渲染SEO的問題,所以首先部落格優化點先把服務端渲染(Server-Side Rendering)放在首位,折騰了段時間將部落格前臺部分以及服務端koa2部分改版,成功實現服務端渲染,這篇文章旨在記錄下本次部落格的升級以及實現vue2koa2配合服務端渲染的相關經驗和小結。

先睹為快

Talk is cheap. Show me the code

大家可以開啟network看下渲染出的html是否實現了SSR

這個部落格專案會持續更新,追求更完美的部落格體驗,歡迎star、fork,提出你寶貴的意見啊~?

上一篇文章:基於vue2、koa2、mongodb的個人部落格

再談技術架構

這是原來的

Vue2服務端渲染實踐以及相關解讀
上一版本

更新後
Vue2服務端渲染實踐以及相關解讀
新版本

可以對比一下上述兩圖的區別

部落格主要更新點

  • 考慮到部落格前臺應用日後可能變複雜,將前臺front端由vue event bus也改成了vuex, 並且用vuex的話直接前端拿到服務端渲染後的資料後直接替換store也比較方便
  • 當然重點還在於SSR,後臺部分還是沿用之前的方案,使用historycallback,通過自己強化historycallback中介軟體,增加可以不匹配的引數,只匹配/admin(這部分後面會講到),然後前臺部分則是使用vue-server-renderer的方法通過讀取template和vue-ssr-server-bundle.json來渲染出html返回實現服務端渲染

Server-Side Rendering

相關概念

我們都在說SSR,其實就是Server-Side Rendering(服務端渲染)的縮寫,它可以解決前端渲染的兩個痛點SEO(搜尋引擎優化)以及首屏渲染效能
因為前端渲染往往初始頁面基本上幾個div,然後其他什麼資料之類都是需要用js渲染到dom上,SEO方面有些爬蟲就只會到html(雖然現在有些爬蟲已經能識別到js載入出的,不過還是按照它的url), 首屏渲染方面更加顯而易見,很多前端渲染的應用往往js相對較大,就得等到js載入解析完成才能載入到首屏,影響體驗。

相關分類

這篇文章寫得不錯 實測Vue SSR的渲染效能:避開20倍耗時

文章中講到分為兩種

  • string-based (基於字串拼接)
  • virtual-dom-based(基於虛擬dom物件)

前一種是我們之前很常見到的,通過ejs或者pug等等這些引擎通過它們一些規則實現一些資料的填充
第二種則是往往和前端渲染相配合的服務端同構渲染(isomorphic),同構即前後端共用一套程式碼,後端通過編寫一些規則將前端程式碼轉成virtual-dom物件,呼叫render再取資料渲染出HTML出來

具體也不深入介紹這部分,上面那篇文章有比較詳細的分析,有興趣的同學可以前往觀看

這是尤大對這兩者的看法

Thanks for the suggestion. We are obviously aware of the fact that the virtual-dom-based SSR is slower than string-based ones; but one important reason for Vue's SSR to be virtual-dom-based is so that it fully supports manually written render functions as well. This is critical for advanced components such as <transition>, <keep-alive>, <router-view> etc. to work properly - a lot of these features are simply impossible with plain string templates.

It may be possible to use a hybrid strategy where we render simple components using string concatenation, but advanced ones using the current vdom-based algorithm. If the user's app contains large amount of template-only components this should still result in significant perf win.

vue2與koa2配合實現服務端渲染

這裡開始步入本文的重點

vue SSR整套流程

有可能有些朋友還不怎麼了解這部分的流程,這裡我簡單介紹一下

這裡講述是生產環境下的

  • 比如,使用者輸入瀏覽器位址列輸入網址,傳送一個get請求
  • 服務端收到這個請求後,按我們以前的想法,就是找到html直接返回,或者render模板,這裡通過的是vue-server-renderercreateRenderer,讀取兩個檔案,一個客戶端相關的html模板(現在也可以使用生成的json),一個服務端相關的json, (這兩個檔案都是webpack生成,這裡看不懂不要緊,後面會接著講),然後通過createRenderer構造出一個渲染器
  • 這個渲染器呼叫renderToString或者renderToStream在上下文中傳入req.url,剛剛不是傳入個服務端相關的json(其實它是由我們自定義的一個entry-server.js生成,這裡面寫著如何去提前取資料),然後呼叫後就進入構造初始化store,初始化router,初始化App,進入提前取資料的邏輯,通過匹配路由的元件,然後呼叫我們在元件事先寫好的preFetch方法去取資料,最後將app resolve出來,這樣提前請求資料的步驟完成
  • 上面那個步驟完成其實就可以將完整的首屏返回了,這裡很多人其實還有一個疑問,我服務端拿到了資料然後前端還要不要拿,我前端那些邏輯怎麼跟後端渲染好的資料相配合,其實上一步拿到資料後還有關鍵的一步,context.state = store.state,這部分上下文拿到取好的資料後,會在html裡嵌入一段window.__INITIAL_STATE__={//...},然後前端部分我們可以這樣store.replaceState(window.__INITIAL_STATE__),然後我們就初始化的store資料就是服務端已經請求渲染好的資料,就達到了匹配,關於前端還要不要拿資料,如果服務端渲染的資料已經滿足了其實就不用拿了,不過有些時候因為我們需要更快的響應速度,可以讓服務端取一部分資料,前端取大資料來提升速度,不過這裡也要注意到頁面的那些元素的匹配問題,假如渲染出的跟前端部分不匹配的話,vue部分會報出warning
    [Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.
    warn複製程式碼

上部分其實就是整個SSR流程,當然上面也略寫了很多背後的渲染深層原理以及部分細節,想看細節讀者可以繼續啦~?

如何實現

webpack入口檔案部分

這裡有兩個很關鍵的檔案,一個是entry-client.js和entry-server.js

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

// store替換使client rendering和server rendering匹配
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

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

entry-client.js主要起到的作用是替換store來跟服務端匹配,可以通過閱讀上一節的流程看到

// entry-server.js
import { createApp } from './app'

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

export default context => {
  console.log(context)
  const s = isDev && Date.now()
  // 注意下面這句話要寫在export函式裡供服務端渲染呼叫,重新初始化那store、router
  const { app, router, store } = createApp()
  return new Promise((resolve, reject) => {
    router.push(context.url)
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        reject({ code: 404 })
      }
      Promise.all(matchedComponents.map(component => {
        if(component.preFetch) {
          // 呼叫元件上的preFetch(這部分只能拿到router第一級別元件,子元件的preFetch拿不到)
          return component.preFetch(store)
        }
      })).then(() => {
        isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
        // 暴露資料到HTMl,使得客戶端渲染拿到資料,跟服務端渲染匹配
        context.state = store.state
        context.state.posts.forEach((element, index) => {
          context.state.posts[index].content = '';
        })
        resolve(app)
      }).catch(reject)
    })
  })
}複製程式碼

entry-server.js通過webpack中的vue-server-renderer/server-plugin打包成一個json供服務端vue-server-renderercreateRenderer讀取,主要起到每一次SSR服務端請求重新createApp以及匹配路由提前取資料渲染的作用

服務端關鍵部分

構造渲染器部分

 const bundle = require('../client/dist/vue-ssr-server-bundle.json')
 const template = fs.readFileSync(resolve('../client/dist/front.html'), 'utf-8')
 renderer = createRenderer(bundle, template)複製程式碼

router匹配路由部分renderToString或者renderToStream

router.get('*', async(ctx, next) => {
  let res = ctx.res;
  let req = ctx.req;
  // 由於koa內有處理type,此處需要額外修改content-type
  ctx.type = 'html';
  const s = Date.now();
  let context = { url: req.url };
  // let r = renderer.renderToStream(context)
  //   .on('end', () => console.log(`whole request: ${Date.now() - s}ms`))
  // ctx.body = r
  function renderToStringPromise() {
    return new Promise((resolve, reject) => {
      renderer.renderToString(context, (err, html) => {
        if (err) {
          console.log(err);
        }
        if (!isProd) {
          console.log(`whole request: ${Date.now() - s}ms`)
        }
        resolve(html);
      })
    })
  }
  ctx.body = await renderToStringPromise();
})複製程式碼

vue元件關鍵部分

vue方面我們得提前定義好preFetch邏輯, entry-server.js會傳入store然後呼叫action等就可以提前取資料

export default {
  name: 'list',
    //...
  preFetch(store) {
    store.dispatch('getAllTags')
    return store.dispatch('getAllPosts',{page:store.state.route.params.page}).then(()=>{
    })
  }
    //...
}複製程式碼

如何與koa配合

改寫express中介軟體

網上很多例子都是圍繞著express來的,雖然koa的非同步處理很優秀,但不得不承認express的生態比koa好太多,很多中介軟體都有express版本,但是沒有koa版本。
不過改寫那些中介軟體並不是很複雜,我們只要搞清楚express和koa中的req、res、ctx、next這些相關概念以及瞭解koa對req與res的封裝,就能去改寫

比如對connect-history-api-fallback

function historyApiFallback (options) {
  const expressMiddleware = require('connect-history-api-fallback')(options)
  const url = require('url')
  return (ctx, next) => {
      let parseUrl = url.parse(ctx.req.url)
    // 新增path match,讓不匹配的路由可以直接穿過中介軟體
      if(!parseUrl.pathname.match(options.path)) {
          return next()
      }
    // 修改content-type
    ctx.type = 'html'
    return expressMiddleware(ctx.req, ctx.res, next)
  }
}

module.exports = historyApiFallback複製程式碼

比如對webpack-dev-middleware

const devMiddleware = require('webpack-dev-middleware');

module.exports = (compiler, opts) => {
  const expressMiddleware = devMiddleware(compiler, opts)
  let nextFlag = false;
  function nextFn() {
    nextFlag = true;
  }
  function devFn(ctx, next) {
    expressMiddleware(ctx.req, {
        end: (content) => {
          ctx.body = content
        },
        setHeader: (name, value) => {
          ctx.headers[name] = value
        }
      }, nextFn)
    if(nextFlag) {
      nextFlag = false;
      return next();
    }
  }
  devFn.fileSystem = expressMiddleware.fileSystem
  return devFn;
}複製程式碼

經驗之談其實就是返回一個(ctx, next)=>{}類似的函式,然後我們取看express中介軟體的原始碼,看它對req和res有什麼相關操作,然後我們根據這些操作傳入koa的處理方式,比如下面

expressMiddleware(ctx.req, {
        end: (content) => {
          ctx.body = content
        },
        setHeader: (name, value) => {
          ctx.headers[name] = value
        }
      }, nextFn)複製程式碼

然後看一下它呼叫next的邏輯,選擇我們手動呼叫或者直接將koa的next傳入

這種改寫問題具體情況具體分析~上面也只是寫了個大概思路

開發環境

其實就是使用我們改寫好的webpack-dev-middlewarewebpack-hot-middleware然後在記憶體中拿檔案,然後hot監聽檔案修改reload頁面

// 開發環境下使用hot/dev middleware拿到bundle與template
  require('../client/build/setup-dev-server')(app, (bundle, template) => {
    renderer = createRenderer(bundle, template)
  })複製程式碼

生產環境

其實上面也已經說到了,這裡已經提前生成好templatejson讀取到然後呼叫渲染器render方法即可

一些經驗之談

避開服務端和瀏覽器端的環境差異

服務端和客戶端同構,但是服務端並沒有windowdocument這些方法怎麼辦

環境判斷

可以通過全域性window的存在與否去判斷

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

其實服務端渲染vue-server-renderer並沒有所有鉤子都呼叫,所以這部分我們就可以利用這個,將一些需要操作window以及dom相關的放入類似beforeMount等等這些鉤子裡,具體可以看vue文件,都有介紹是否支援服務端渲染

遇到not match問題怎麼辦

[Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.
warn複製程式碼
  • 檢查是否entry-client.js是否替換store
  • 檢查客戶端其他生命週期鉤子是否影響到頁面資料的顯示,比如用到一些關於資料的v-if等等

renderToString還是renderToStream?

這兩個我都試過,可能是由於我的應用複雜程度較低,兩者差異不大,有興趣的讀者也可以把我的原始碼clone下來本地跑一下試試,目前使用的是renderToString,註釋部分有renderToStream
由於差異不大,考慮到可擴充套件性,相對string可能可擴充套件的程度較高一點,並且SSR文件寫的如下,
大致意思就是雖然流式響應獲取到第一塊資料能第一時間返回,但是那時子元件還沒有例項化,就沒辦法在它們的生命週期鉤子裡拿到資料渲染,還有因為前面的head頭部資訊以及內嵌style有可能很多的緣故,所以最後它表述的是不建議當你的元件生命週期鉤子依賴於上下文資料的時候使用stream模式

In stream rendering mode, data is emitted as soon as possible when the renderer traverses the Virtual DOM tree. This means we can get an earlier "first chunk" and start sending it to the client faster.

However, when the first data chunk is emitted, the child components may not even be instantiated yet, neither will their lifecycle hooks get called. This means if the child components need to attach data to the render context in their lifecycle hooks, these data will not be available when the stream starts. Since a lot of the context information (like head information or inlined critical CSS) needs to be appear before the application markup, we essentially have to wait until the stream to complete before we can start making use of these context data.

It is therefore NOT recommended to use streaming mode if you rely on context data populated by component lifecycle hooks.

官方文件出來啦!!

現在大家可以閱讀vue ssr服務端指南 ssr.vuejs.org/en/

最後

謝謝閱讀~
歡迎follow我哈哈github.com/BUPT-HJM
看到這裡,不star不行了?
github.com/BUPT-HJM/vu…
歡迎繼續觀光我的部落格~

歡迎關注

相關文章