天天看點

Vue 服務端渲染(SSR)

一、什麼是服務端渲染(SSR)

Server Side Render簡稱SSR,服務端渲染。在ajax興起之前,特别是在SPA(單頁面應用(Single-Page Application))技術流行之前,大部分的web應用都采用的是服務端渲染。即伺服器端在接收到使用者請求網頁的時候,由服務端先調用資料庫,獲得資料之後,将資料和頁面元素進行拼裝,組合成完整的 html 頁面,再直接傳回給浏覽器,以便使用者浏覽。

近幾年在前後端分離的理念影響下,大部分的web應用都采用了前後端分離的模式,後端專注于資料接口的服務,前端則主要進行頁面渲染、接口調用。也就是在這時SPA得到了廣泛應用(以Vue、React、Angular為代表)。本文以Vue.js為對象來進行說明。

Vue是建構用戶端應用程式的架構,預設情況下,可以在浏覽器中輸出 Vue 元件,進行生成 DOM 和操作 DOM。請求過程如下:

  1. 浏覽器加載所有靜态資源(html,css,js等),此時用戶端拿到一個沒有被資料渲染的空頁面
  2. js 發起請求擷取資料 
  3. 渲染頁面 

然而,也可以将同一個元件渲染為伺服器端的 HTML 字元串,将它們直接發送到浏覽器,最後将這些靜态标記"激活"為用戶端上完全可互動的應用程式。伺服器渲染的 Vue.js 應用程式也可以被認為是"同構"或"通用",因為應用程式的大部分代碼都可以在伺服器和用戶端上運作。

二、使用服務端渲染(SSR)的優劣勢

與傳統SPA相比的優勢:

  1. 更友好的SEO。這是因為使用服務端渲染搜尋引擎爬蟲抓取工具可以直接檢視完全渲染的頁面,而傳統SPA則對SEO不太友好。如果SEO對一個站點非常重要,頁面也是異步擷取内容,那麼應該使用SSR。
  2. 首屏渲染速度更快。因為無需等待所有的javaScript都完成下載下傳并執行。對于内容到達時間與轉化率直接相關的站點,SSR尤為重要。

劣勢:

  1. 開發條件所限。對于浏覽器特定的代碼,隻能在某些生命周期鈎子使用的;一些外部擴充庫可能需要特殊處理,才能在伺服器渲染應用程式中運作。
  2. 涉及建構設定和部署的更多要求。與可以部署在任何靜态檔案伺服器上的完全靜态SPA不同,伺服器渲染應用程式,需要處于 Node.js server 運作環境。
  3. 更多的伺服器端負載。

三、基本使用

要想搭建一個複雜的Vue SSR需要用到很多的工具和插件,過程也比較繁瑣。我們不妨先從一個簡單的例子來從整體熟悉下Vue SSR的基本用法。

安裝:

npm install vue vue-server-renderer express --save
           

我們先建立一個模版html檔案以便待會使用,命名index.template.html。注意 

<!--vue-ssr-outlet-->

 注釋 -- 這裡将是應用程式 HTML 标記注入的地方。

<!DOCTYPE html>
<html >
<head>
  <!-- 使用雙花括号(double-mustache)進行 HTML 轉義插值(HTML-escaped interpolation) -->
  <title>{{title}}</title>
  <!-- <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> -->
  <!-- 使用三花括号(triple-mustache)進行 HTML 不轉義插值(non-HTML-escaped interpolation) -->
  {{{meta}}}
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>
           

通過與伺服器內建來建立vue執行個體,建立server.js檔案。使用express作為node.js的架構。

const  Vue = require('vue')
const fss = require('fs')
const server = require('express')()
const templateHtml = fss.readFileSync('./index.template.html','utf-8')
const renderer= require('vue-server-renderer').createRenderer({
  template: templateHtml
})
server.get('*',(req,res)=>{
  //建立Vue對象
  const app = new Vue({
    data:{
      url:req.url
    },
    template:`<div>您通路的URL是::::{{url}}</div>`
  })
  const context={
    title:'hello from context',
    meta:`
      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    `
  }
  //context的内容将用于templateHtml模版檔案中,提供插值資料
  renderer.renderToString(app,context,(err,html)=>{
    if(err){
      res.status(500).end('Internal server error')
      return
    }
    //此時傳回的html将是注入應用程式内容的完整頁面
    res.end(html)
  })
    // 在 2.5.0+,如果沒有傳入回調函數,則會傳回 Promise:
  renderer.renderToString(app).then(html => {
      console.log(html)
  }).catch(err => {
      console.error(err)
  })
})

server.listen(8080,()=>console.log('ssr vue app is listening on port 8080'))
           

啟動nodejs服務:node server.js 。通路http://localhost:8080/path/to/some。就可以看到建立好的服務端渲染應用。

Vue 服務端渲染(SSR)
Vue 服務端渲染(SSR)

四、進階使用(項目工程化)

接下來讨論下如何将相同的Vue應用程式提供給用戶端,也就是說如何将項目工程化。我們将通過webpack來打包我們的Vue應用程式,包括用戶端和服務端應用程式。 伺服器需要「伺服器 bundle」然後用于伺服器端渲染(SSR),而「用戶端 bundle」會發送給浏覽器,用于混合靜态标記。基本流程如下圖。

Vue 服務端渲染(SSR)

要使用伺服器端渲染,需要使用server-entry.js和client-entry.js兩個入口檔案,兩者都會使用到app.js進行打包,其中通過server-entry.js打包的代碼是運作在node端,二通過client-entry.js打包代碼運作在用戶端。

一個工程化的項目其目錄結構可能如下:

Vue 服務端渲染(SSR)

在建立router、store、vue執行個體的時候,考慮到node.js伺服器是一個長期運作的程序。當我們的代碼進入該程序時,它将進行一次取值并留存在記憶體中。這意味着如果建立一個單例對象,它将在每個傳入的請求之間共享。這顯然是不可取的。是以,我們不應該直接建立一個應用程式執行個體,而是應該暴露一個可以重複執行的工廠函數,為每個請求建立新的應用程式執行個體:

app.js檔案内容如下:

//app.js
// const Vue = require('vue')
import Vue from 'vue'
import App from './App.vue'
import {createRouter} from './router'
import {createStore} from './store'
import {sync} from 'vuex-router-sync'
// 導出一個工廠函數,用于建立新的
// 應用程式、router 和 store 執行個體
export function createApp(){
  //建立路由執行個體
  const router=createRouter()
  const store = createStore()
    // 同步路由狀态(route state)到 store
  sync(store,router)
  // 例如,從 user/1 到 user/2)時,也應該調用 asyncData 函數。我們也可以通過純用戶端 (client-only) 的全局 mixin 來處理這個問題:
  Vue.mixin({
    beforeRouteUpdate (to, from, next) {
      const { asyncData } = this.$options
      if (asyncData) {
        asyncData({
          store: this.$store,
          route: to
        }).then(next).catch(next)
      } else {
        next()
      }
    }
  })
  const app = new Vue({
    router,
    store,
    render:h=>h(App)
  })
  //傳回app 和 router
  return {app,router,store}
}
           

由于我将路由和狀态管理分離到了單獨的檔案中,是以我建立了router.js和store.js。當然如果項目比較複雜可以将路由和狀态檔案進行分割,這裡就不再深究。

//以下為 router.js 的内容
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export function createRouter(){
  return new Router({
    mode:'history',
    routes:[
      {path:'/',component:()=>import('./components/Home.vue')},
      {path:'/item/:id',component:()=>import('./components/Item.vue')}
    ]
  })
}
           
//以下為 store.js 内容
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
import {fetchItem} from './api'
export function createStore(){
  return new Vuex.store({
    state:{
      items:{}
    },
    actions:{
      fetchItem({commit},id){

        return fetchItem(id).then(item=>{
          commit('setItem',{id,item})
        })
      }
    },
    mutations:{
      setItem(state,{id,item}){
        Vue.set(state.items,id,item)
      }
    }
  })
}
           

接下來我們需要在 

entry-client.js

 中實作用戶端路由邏輯 (client-side routing logic):

import {createApp} from './app'
//用戶端特定引導邏輯
const {app,router,store} = createApp()
if(window.__INITIAL_STATE__){
  store.replaceState(window.__INITIAL_STATE__)
}
// 這裡假定 App.vue 模闆中根元素具有 `id="app"`
router.onReady(()=>{
 // 添加路由鈎子函數,用于處理 asyncData.
  // 在初始路由 resolve 後執行,
  // 以便我們不會二次預取(double-fetch)已有的資料。
  // 使用 `router.beforeResolve()`,以便確定所有異步元件都 resolve。
  router.beforeResolve((to, from, next) => {
    // to and from are both route objects. must call `next`.
    const matched = router.getMatchedComponents(to)
    const preMatched = router.getMatchedComponents(from)
     // 我們隻關心非預渲染的元件,是以我們對比它們,找出兩個比對清單的差異元件
    let diffed = false
    const activated=matched.filter((c,i)=>{
      return diffed || (diffed=(preMatched[i]!==c))
    })
    if(!activated.length){
      return next()
    }
        // 這裡如果有加載訓示器 (loading indicator),就觸發
    Promise.all(activated.map(c=>{
      if(c.asyncData){
        return c.asyncData({store,route:to})
      }
    })).then(()=>{
       // 停止加載訓示器(loading indicator)
      next()
    }).catch(next)
  })
  app.$mount('#app',true)
})
           

然後我們需要在 

entry-server.js

 中實作伺服器端路由邏輯 (server-side routing logic):

import {createApp} from './app'
export default context=>{
    // 因為有可能會是異步路由鈎子函數或元件,是以我們将傳回一個 Promise,以便伺服器能夠等待所有的内容在渲染前, 就已經準備就緒。
  return new Promise((resolve,reject)=>{
    const {app,router} = createApp()
    // 設定伺服器端 router 的位置
    router.push(context.url)
    // 等到 router 将可能的異步元件和鈎子函數解析完
    router.onReady(()=>{
      const matchedComponents = router.getMatchedComponents()
      if(!matchedComponents.length){
        return reject({code:404})
      }
      // 對所有比對的路由元件調用 `asyncData()`
      Promise.all(matchedComponents.map(Component=>{
        if(Component.asyncData){
          return Component.asyncData({
            store,
            route:router.currentRoute
          })
        }
      })).then(()=>{
        // 在所有預取鈎子(preFetch hook) resolve 後, 我們的 store 現在已經填充入渲染應用程式所需的狀态。
        // 當我們将狀态附加到上下文, 并且 `template` 選項用于 renderer 時,
        // 狀态将自動序列化為 `window.__INITIAL_STATE__`,并注入 HTML
        context.state=store.state
        resolve(app)
      }).catch(reject)
    },reject)
  })
}
           

緊接着我們進行webpack的配置,同樣分為服務端和用戶端的配置webpack.server.config.js和webpack.client.config.js。具體配置詳情如下所示:

//以下為 webpack.server.config.js 的内容
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()
  ]
})
           
//以下為 webpack.client.config.js 的内容
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()
  ]
})
           

配置好webpack之後我們就可以來進行server.js的編寫了:

//server.js
const server = require('express')()
const {createBundleRenderer} = require('vue-server-renderer')
const template = fss.readFileSync('./template.html','utf-8')
const serverBundle=require('/path/to/vue-ssr-server-bundle.json')
const clientManifest = require('path/to/vue-ssr-client-manifest.json')
const renderer= createBundleRenderer(serverBundle,{
  runInNewContext: false, // 推薦
  template, // (可選)頁面模闆
  clientManifest // (可選)用戶端建構 manifest
})
server.get('*',(req,res)=>{
  const context = {url:req.url}
  renderer.renderToString(context,(err,html)=>{
    if(err){
      if(err.code===404){
        res.status(404).end('Page not found')
      }
      else{
        res.status(500).end('Internal server error')
      }
    }else{
      res.end(html)
    }
  })
})
           

項目的整體結構基本上就搭建完成了,其他的一些細節,例如元件的開發、webpack.base.config.js的配置就不再此詳述。

五、總結幾點

  1. 伺服器端index.js流程是如何的? 即npm run dev之後,就會進入index.js,然後引入express作為node伺服器,并引入vue-server-renderer來內建,進一步将vue的app來伺服器渲染,但是如何在伺服器端擷取這個打包之後的app呢? 即通過entry-server.js即可,這個入口檔案就會打包node端運作的vue,打包之後,node端會生成了html标記,然後需要一層html外殼,即套用index.html模闆template,這個模闆中有一個<!--vue-ssr-outlet>注釋,表示。
  2. 伺服器端擷取打包之後的代碼是和用戶端一樣都是js檔案嗎? 不是的,一般來說,伺服器端在擷取webpack打包的代碼應該是 built-server-bundle.js,但是這樣每次在編輯過應用程式代碼之後都需要再重新重新開機,會影響開發效率,另外nodejs不支援source map。是以,我們可以使用 bundlerender,這種方式和render是類似的,但它支援sourcemap,熱重載等。在webpack.server.config檔案中配置了插件new VueSSRServerPlugin(),這個插件的作用是作為整個伺服器的輸出為json檔案,而不再是js檔案,預設檔案名為 

    vue-ssr-server-bundle.json。

  3. 整體過程到底是怎樣的?即首先寫好各種元件、路由、store等,接着app.js中開始進行彙聚,然後entry-client.js和entry-server.js分别進行對兩者的整合。接下來就可以build了,在build用戶端代碼的時候即通過webpack.client.js進入,入口檔案為entry-client.js,最後會打包完整的代碼;在build伺服器端代碼的時候通過webpack.server.js進入,入口檔案為entry-server.js,會打包出vue-ssr-server-bundle.json檔案;當然這些打包後的檔案都會打包到dist檔案夾下。build之後,就可以把代碼放在伺服器上運作了,即通過node建立一個伺服器進行伺服器端渲染。

繼續閱讀