解密Vue SSR

百度外賣大前端技術團隊發表於2018-05-24

作者:百度外賣 耿彩麗 李宗原
轉載請標明出處複製程式碼


引言

最近筆者和小夥伴在研究Vue SSR,但是市面上充斥了太多的從0到1的文章,對大家理解這其中的原理幫助並不是很大,因此,本文將從Vue SSR的構建流程、執行流程、SSR的特點和利弊這幾方面對Vue SSR有一個較為詳細的介紹。最後還將附上一個筆者實現的去除Vue全家桶的Demo案例。

剖析構建流程

首先我們鎮上一張官網給出的構建圖:

解密Vue SSR
Vue SSR構建流程

app.js入口檔案

app.js是我們的通用entry,它的作用就是構建一個Vue的例項以供服務端和客戶端使用,注意一下,在純客戶端的程式中我們的app.js將會掛載例項到dom中,而在ssr中這一部分的功能放到了Client entry中去做了。


兩個entry

接下里我們來看Client entry和Server entry,這兩者分別是客戶端的入口和服務端的入口。Client entry的功能很簡單,就是掛載我們的Vue例項到指定的dom元素上;Server entry是一個使用export匯出的函式。主要負責呼叫元件內定義的獲取資料的方法,獲取到SSR渲染所需資料,並儲存到上下文環境中。這個函式會在每一次的渲染中重複的呼叫


webpack打包構建

然後我們的服務端程式碼和客戶端程式碼通過webpack分別打包,生成Server Bundle和Client Bundle,前者會執行在伺服器上通過node生成預渲染的HTML字串,傳送到我們的客戶端以便完成初始化渲染;而客戶端bundle就自由了,初始化渲染完全不依賴它了。客戶端拿到服務端返回的HTML字串後,會去“啟用”這些靜態HTML,是其變成由Vue動態管理的DOM,以便響應後續資料的變化。

剖析執行流程

到這裡我們該談談ssr的程式是怎麼跑起來的了。首先我們得去構建一個vue的例項,也就是我們前面構建流程中說到的app.js做的事情,但是這裡不同於傳統的客戶端渲染的程式,我們需要用一個工廠函式去封裝它,以便每一個使用者的請求都能夠返回一個新的例項,也就是官網說到的避免交叉汙染了

然後我們可以暫時移步到服務端的entry中了,這裡要做的就是拿到當前路由匹配的元件,呼叫元件裡定義的一個方法(官網取名叫asyncData)拿到初始化渲染的資料,而這個方法要做的也很簡單,就是去呼叫我們vuex store中的方法去非同步獲取資料

接下來node伺服器如期啟動了,跑的是我們剛寫好的服務端entry裡的函式。在這裡還要做的就是將我們剛剛構建好的Vue例項渲染成HTML字串,然後將拿到的資料混入我們的HTML字串中,最後傳送到我們客戶端。

開啟瀏覽器的network,我們看到了初始化渲染的HTML,並且是我們想要初始化的結構,且完全不依賴於客戶端的js檔案了。再仔細研究研究,裡面有初始化的dom結構,有css,還有一個script標籤。script標籤裡把我們在服務端entry拿到的資料掛載了window上。原來只是一個純靜態的HTML頁面啊,沒有任何的互動邏輯,所以啊,現在知道為啥子需要服務端跑一個vue客戶端再跑一個vue了,服務端的vue只是混入了個資料渲染了個靜態頁面,客戶端的vue才是去實現互動的!

解密Vue SSR
chrome network

順著前面的思路,我們該看客戶端的entry了。在這裡客戶端拿到存在window中的資料混入我們客戶端的vuex中,然後分析資料去執行我們熟悉的其餘客戶端操作了。

SSR獨特之處

在SSR中,建立Vue例項、建立store和建立router都是套了一層工廠函式的,目的就是避免資料的交叉汙染

在服務端只能執行生命週期中的created和beforeCreate,原因是在服務端是無法操縱dom的,所以可想而知其他的週期也就是不能執行的了。

服務端渲染和客戶端渲染不同,需要建立兩個entry分別跑在服務端和客戶端,並且需要webpack對其分別打包

SSR服務端請求不帶cookie,需要手動拿到瀏覽器的cookie傳給服務端的請求。實現方式戳這裡

SSR要求dom結構規範,因為瀏覽器會自動給HTML新增一些結構比如tbody,但是客戶端進行混淆服務端放回的HTML時,不會新增這些標籤,導致混淆後的HTML和瀏覽器渲染的HTML不匹配。

效能問題需要多加關注。

  • vue.mixin、axios攔截請求使用不當,會記憶體洩漏。原因戳這裡
  • lru-cache向記憶體中快取資料,需要合理快取改動不頻繁的資源。

可能是把雙刃劍

SSR的優點

  • 更利於SEO。

不同爬蟲工作原理類似,只會爬取原始碼,不會執行網站的任何指令碼(Google除外,據說Googlebot可以執行javaScript)。使用了Vue或者其它MVVM框架之後,頁面大多數DOM元素都是在客戶端根據js動態生成,可供爬蟲抓取分析的內容大大減少。另外,瀏覽器爬蟲不會等待我們的資料完成之後再去抓取我們的頁面資料。服務端渲染返回給客戶端的是已經獲取了非同步資料並執行JavaScript指令碼的最終HTML,網路爬中就可以抓取到完整頁面的資訊。

  • 更利於首屏渲染

首屏的渲染是node傳送過來的html字串,並不依賴於js檔案了,這就會使使用者更快的看到頁面的內容。尤其是針對大型單頁應用,打包後檔案體積比較大,普通客戶端渲染載入所有所需檔案時間較長,首頁就會有一個很長的白屏等待時間。


SSR的侷限

  • 服務端壓力較大

本來是通過客戶端完成渲染,現在統一到服務端node服務去做。尤其是高併發訪問的情況,會大量佔用服務端CPU資源;

  • 開發條件受限

在服務端渲染中,created和beforeCreate之外的生命週期鉤子不可用,因此專案引用的第三方的庫也不可用其它生命週期鉤子,這對引用庫的選擇產生了很大的限制;

  • 學習成本相對較高

除了對webpack、Vue要熟悉,還需要掌握node、Express相關技術。相對於客戶端渲染,專案構建、部署過程更加複雜。

去除VUEX的SSR實踐

先附上demo地址,戳這裡

說在前面:

  • vue-router不是必須的,不用router其實做個vue的preRender就可以了,完全沒必要做ssr;
  • vuex不是必須的,vuex是實現我們客戶端和服務端的狀態共享的關鍵,我們可以不使用vuex,但是我們得去實現一套資料預取的邏輯;

官網的demo大而全,整合了vue-router和vuex,想想我們的專案如果沒有使用到這兩者,光引入就又需要改造成本,這並不是我們想搞的“絲滑般”過渡,接下來筆者將帶領大家一步一步的做個“啥都沒有的”demo。

在此筆者的思路是:構造一個Vue的例項,那麼我們可以用這個例項的data來儲存我們的預取資料,而用methods中的方法去做資料的非同步獲取,這樣我們只在需要預取資料的元件中去呼叫這個方法就可以了

首先我們需要讓我們的元件“共享”這個EventBus,為此筆者簡單的封裝了一個plugin:

export default {
 install (Vue) {
   const EventBus = new Vue({
     data () {
       return {
	      list: [],
	      nav: []
       }
     },
     methods: {
       getList () {
	      // get list
		},
       getNav () {
         // get nav
       }
     }
   })
   
   Vue.prototype.$events = EventBus
   Vue.$events = EventBus
 }
}
複製程式碼

然後我們需要在main.js中export出我們的EventBus以便兩個entry使用。這樣我們的main.js就像下面這樣:

import Vue from 'vue'
import App from './App'
import EventBus from './event'

Vue.use(EventBus)
Vue.config.devtools = true

export function createApp () {
 const app = new Vue({
   // 注入 router 到根 Vue 例項
   router,
   render: h => h(App)
 })
 
 return { app, router, eventBus: app.$events }
}
複製程式碼

接下來是我們的兩個entry了。server用來匹配我們的元件並呼叫元件的asyncData方法去獲取資料,client用來將預渲染的資料儲存到我們eventBus中的data中。

// server
import { createApp } from './main'

export default context => {
 return new Promise((resolve, reject) => {
   const { app, eventBus, App } = createApp()
   // 這裡筆者的demo比較簡單,僅app元件需要預取資料,複雜業務可以遞迴遍歷哈;
   const matchedComponents = [App]

   Promise.all(matchedComponents.map(({ asyncData }) => asyncData && asyncData({
     eventBus
   }))).then(() => {
     context.state = eventBus._data
     resolve(app)
   }).catch(reject)
 })
}


// client
import Vue from 'vue'
import { createApp } from './main'
const { app, eventBus } = createApp()

if (window.__INITIAL_STATE__) {
 eventBus._data = window.__INITIAL_STATE__
}

app.$mount('#app')
複製程式碼

然後我們需要改造我們的元件了,只需要定義一個async方法去呼叫EventBus中的方法獲取,考慮到服務端只會執行beforeCreate和created兩個生命週期而beforeCreate不能拿到data,所以我們需要在created中去做資料的獲取。

// 服務端渲染資料預取;
asyncData ({ store, eventBus }) {
 return eventBus.getNav()
}
// 將服務端拿到的資料混入vue元件中;
created () {
 this.nav = this.$events.nav
}
複製程式碼


然後是webpack的改造了,webpack的配置其實和純客戶端應用類似,為了區分客戶端和服務端兩個環境我們將配置分為base、client和server三部分,base就是我們的通用基礎配置,而client和server分別用來打包我們的客戶端和服務端程式碼。

首先是webpack.server.conf.js,用於生成server bundle來傳遞給createBundleRenderer函式在node伺服器上呼叫,入口檔案是我們的entry-server:


const webpack = require('webpack')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
// 去除打包css的配置
baseConfig.module.rules[1].options = ''

module.exports = merge(baseConfig, {
 entry: './src/entry-server.js',
 // 以 Node 適用方式匯入
 target: 'node',
 // 對 bundle renderer 提供 source map 支援
 devtool: '#source-map',
 output: {
   filename: 'server-bundle.js',
   libraryTarget: 'commonjs2'
 },
 externals: nodeExternals({
   whitelist: /\.css$/
 }),
 plugins: [
   new webpack.DefinePlugin({
     'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
     'process.env.VUE_ENV': '"server"'
   }),
   // 這是將伺服器的整個輸出
   // 構建為單個 JSON 檔案的外掛。
   // 預設檔名為 `vue-ssr-server-bundle.json`
   new VueSSRServerPlugin()
 ]
})
複製程式碼

其次是webpack.client.conf.js,這裡我們可以根據官方的配置生成clientManifest,自動推斷和注入資源預載入,以及 css 連結 / script 標籤到所渲染的 HTML。入口是我們的client-server:


const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.conf')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

const config = merge(base, {
 entry: {
   app: './src/entry-client.js'
 },
 plugins: [
   new webpack.DefinePlugin({
     'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
     'process.env.VUE_ENV': '"client"'
   }),
   new webpack.optimize.CommonsChunkPlugin({
     name: 'vendor',
     minChunks: function (module) {
       return (
         /node_modules/.test(module.context) &&
         !/\.css$/.test(module.request)
       )
     }
   }),
   // 這將 webpack 執行時分離到一個引導 chunk 中,
   // 以便可以在之後正確注入非同步 chunk。
   // 這也為你的 應用程式/vendor 程式碼提供了更好的快取。
   new webpack.optimize.CommonsChunkPlugin({
     name: 'manifest'
   }),
   new VueSSRClientPlugin()
 ]
})
複製程式碼


從localhost中我們看到ssr預取的資料已經成功出來了,大功告成!

解密Vue SSR

結語

本文介紹了Vue的SSR的構建和執行流程,也分析了SSR的特點和利弊,希望對大家瞭解SSR有一定的幫助。最後針對不使用vuex的SSR實現方案進行了介紹,如果感興趣或者有疑問,歡迎大家留言交流。



相關文章