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

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

先睹爲快

Talk is cheap. Show me the codehtml

你們能夠打開network看下渲染出的html是否實現了SSR前端

這個博客項目會持續更新,追求更完美的博客體驗,歡迎star、fork,提出你寶貴的意見啊~😸vue

上一篇文章:基於vue二、koa二、mongodb的我的博客java

再談技術架構

這是原來的
webpack

上一版本

更新後
新版本

能夠對比一下上述兩圖的區別

博客主要更新點

  • 考慮到博客前臺應用往後可能變複雜,將前臺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加載解析完成才能加載到首屏,影響體驗。git

相關分類

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

文章中講到分爲兩種web

  • string-based (基於字符串拼接)
  • virtual-dom-based(基於虛擬dom對象)

前一種是咱們以前很常見到的,經過ejs或者pug等等這些引擎經過它們一些規則實現一些數據的填充
第二種則是每每和前端渲染相配合的服務端同構渲染(isomorphic),同構即先後端共用一套代碼,後端經過編寫一些規則將前端代碼轉成virtual-dom對象,調用render再取數據渲染出HTML出來mongodb

具體也不深刻介紹這部分,上面那篇文章有比較詳細的分析,有興趣的同窗能夠前往觀看

這是尤大對這二者的見解

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…
歡迎繼續觀光個人博客~

歡迎關注

相關文章
相關標籤/搜索