作者:梅旭光 @Mayo
為提高小程式的開發效率,百度APP移動團隊開發了Mars 架構。該架構支援使用 Vue 文法開發小程式,同時支援生成對應的 H5 頁面。在 Mars 架構的
0.3.x 版本中,我們極大簡化了 Vue 的 render 過程,去掉了 VNode 建構,省略了 patch 過程,進而獲得了性能提升。
Mars 架構原理簡介,為什麼要去除 VNode?
目前基于 Vue 的小程式開發架構原理差異不大。為友善大家了解,這裡簡單說一下 Mars 架構的原理。
Mars 的原理如下圖所示:
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLicmbw5CMyIDZyEjN5IzNlRjYkZWZ3YGZ5YGNmhTZ2EGO3MmYw8CX0JXZ252bj91Ztl2Lc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
上圖中,左半部分表示小程式的執行部分。粉紅色區域代表小程式視圖,藍色部分代表小程式的邏輯執行,視圖與邏輯之間交換的是資料和事件。右邊綠色部分則是在小程式邏輯之外,單獨建立的 Vue 執行個體。小程式邏輯(藍色部分)與 Vue 執行個體(綠色部分)是以如下方式工作的:
- 在小程式的 Page 建立時,同步 new 一個 Vue 執行個體。
- 在 Vue 執行個體的
變量中綁定小程式執行個體,小程式執行個體中也會使用.$mp.scope
變量來綁定 Vue 執行個體,用于後續的資料傳遞。.$vue
- 使用
方法代理小程式中的事件,當小程式事件觸發時,對應執行 Vue 執行個體中相應的 Method。handleProxy
- 頁面中的邏輯代碼執行在 Vue 部分,每當資料發生變化觸發 Vue 的視圖更新時,會在 Updated 階段将資料的變化使用
方法同步給小程式執行個體,觸發小程式視圖的重新整理。setData
可以看到
優化前我們基本保留了 Vue 的所有渲染過程,僅僅删除了 Vue 中的 DOM 操作部分。由于 Vue 執行個體與小程式之間交換的其實隻有資料,是以 Vue 中的視圖層其實是沒有用到的。 我們需要的隻是
執行 Vue 中的邏輯,判斷資料修改是否會造成視圖更新,視圖更新時把變化的資料同步給小程式。而 Vue 視圖層相關的内容:VNode、render、patch 這些操作在這種場景下是沒有必要的,可以通過精簡這些不必要的操作來提升性能。
優化前 render 和 patch 過程所起的作用
想要精簡 render 和 patch 過程,我們就需要先搞清楚 render 和 patch 過程在 Vue 中起到了什麼作用:
- 在 Vue 中,當資料發生變化時,會通知視圖渲染依賴這一資料的所有執行個體,依次執行這些執行個體的 render 函數。render 函數執行過程中又會重新收集依賴,用于下一次資料發生變化時的依賴追蹤。
- render 函數執行後會傳回一個該執行個體對應的 VNode 樹, render 過程中并不會建立子元件執行個體 ,僅僅是生成了一個占位符。這個 VNode 樹随後會傳遞給 patch 過程。
- patch 過程會将目前 VNode 樹與舊 VNode 樹進行 diff,之後根據 diff 建立、銷毀子元件執行個體,修改 DOM 完成渲染。
在小程式架構這個情境下,需要的是
資料依賴追蹤和
元件執行個體建立、銷毀,其他部分的内容則可以進行删減。
可以精簡哪些内容?
- render 函數部分 ,我們隻需要進行必要的依賴追蹤,不需要建立 VNode 節點。
- patch 部分 ,由于沒有 VNode 了,我們也不需要進行耗時的 diff 操作了!
但是等一下,剛才我們說過子元件執行個體是在 diff VNode 樹的過程中建立的,現在沒有 VNode 樹了,子元件執行個體如何建立呢?
解決方法是:
在小程式子元件的生命周期中建立對應的 Vue 執行個體。也就是說,單個 Vue 執行個體隻會建立它自己,不會再繼續建立子元件執行個體。 之前的結構為小程式執行個體樹和 Vue 執行個體樹,元件執行個體間互相綁定。現在的結構變為隻有小程式執行個體樹,每個小程式執行個體節點單獨對應一個 Vue 執行個體。
開始實踐!
下面介紹去除 VNode 所做的具體工作。
createComponent 中建立 Vue 執行個體
由于把 patch 過程幹掉了,是以需要手動建立子元件的 Vue 執行個體。同 Page 一樣,我們在 Component 的生命周期函數中 new 一個 Vue 執行個體,并與目前小程式執行個體綁定:
this.$vue = new VueComponent(options);
this.$vue.$mp = {
scope: this
};
在小程式元件中建立 Vue 執行個體,缺少了 Vue 執行個體間的父子對應關系。維護這一關系需要解決兩個問題:
父元素綁定、
properties 傳遞。
父元素綁定
在 patch 過程中,Vue 建立子元件時會傳遞以下三個參數:
const options: InternalComponentOptions = {
_isComponent: true,
_parentVnode: vnode,
parent
}
-
用于優化 options 的合并,可以直接設定成 true。_isComponent
-
用于在 render 過程中擷取父元素資訊,例如 scope-slot 等,由于已經把 VNode 删掉了,是以不再需要了。_parentVnode
-
用于擷取根元素、綁定 $children 等操作,Vue 就是通過這個參數來維護執行個體間的父子關系的。parent
我們需要找到目前 Vue 執行個體的父執行個體,作為 parent 參數,進而完成父元素綁定過程。 小程式目前沒有機制來直接擷取父元素,需要我們自己想辦法來查找。在開發 Mars 過程中,為了進行小程式元件執行個體和 Vue 元件執行個體間的比對,已經對小程式執行個體樹和 Vue 執行個體樹中的元件節點都進行了标記,我們可以通過這個标記來查找父元素:
- 由于 Page 元素可能在同一時間不唯一(由于頁面切換),是以每建立一個 Page 執行個體,都需要綁定一個唯一的 rootUID,我們将其存儲在了
中。rootUID 會逐層傳給每個小程式自定義元件執行個體。getApp().__pages__
- 每次有小程式自定義元件執行個體建立,我們都将該執行個體以标記的 id 為 key 存儲在
中。getApp().__pages__[rootUID].__vms__
- 根據 rootUID 找到根元素,進而找到 page 中的
。__vms__
- 根據 compId 算出父執行個體的 compId。
- 根據父執行個體的 compid從
中找到父元素,作為 parent。__vms__
properties 傳遞
除了需要設定的初始化屬性外,還需要傳遞子元件的 properties,否則父元素的資料沒辦法傳遞給子元件。這其中涉及兩個過程:資料初始化和資料更新:
-
:可以在 Vue 建立時傳入 propsData 來作為 props 的初始資料。 由于小程式自定義元件的參數和 Vue 子元件執行個體的參數是相同的,是以我們可以直接将程式自定義元件的參數作為資料初始化
在 new Vue 時傳入:propsData
const options = {
mpType: 'component',
mpInstance: this,
propsData: properties,
parent
};
// 初始化 vue 執行個體
this.$vue = new VueComponent(options);
- 資料更新:仿照 Vue 給子元件傳參數的機制,每次 render 時,将 props 重新給子元件指派一遍。
這裡隻需要更新第一層,因為 properties 如果是對象,那麼它在父元素中已經做過變化追蹤了。
事件傳遞
對于 template 上綁定的事件,由于我們本身已經使用了
handleProxy
來處理,是以不會受到影響。
需要處理的是
.$emit
、
.$on
方法。
- 對于
,我們利用小程式機制,使用.$emit
在小程式層面給父元素傳遞事件。triggerEvent
- 對于
,使用 Vue 現成的機制就好,不需要做額外工作,不過這也造成 Vue 的事件機制不能删除。.$on
這裡有個小坑:triggerEvent 方法傳遞的參數,需要從 event.detail 中擷取,Mars 相容了這個不一緻。
render 函數精簡
render 函數目前我們不能完全删除,因為還需要以下兩個功能:
依賴收集、
複雜表達式和filter 計算。
依賴收集
Vue 在初始化時會對執行個體上的 data 進行響應式處理,設定 set 和 get 方法。元件執行 render 函數時,會讀取變量觸發 get 方法,進而在 get 方法中将目前執行個體收集為這個資料的依賴。下次資料更新時 Vue 會通知依賴進行更新。
為了收集依賴,我們需要在 render 函數中讀取一遍資料。這裡我們将 VNode 樹編譯為數組樹的形式,隻留下資料,剩下的内容都可以删除。
比如這樣的一個 template:
<template>
<view class="hello">
<view @tap="tapHandler">
<text>https://github.com/max-team/Mars</text>
</view>
<view>{{ aaa }}</view>
<view>{{ ccc }}</view>
<name :name="nameOutter"></name>
<view>{{ aaaComp }}</view>
</view>
</template>
Vue 産出的 render 函數是這樣的:
// 修改前的 render 函數
_c('view',{staticClass:"hello"},[_c('view',{on:{"tap":_vm.tapHandler}},[_c('text',[_vm._v("https://github.com/max-team/Mars")])]),_c('view',[_vm._v(_vm._s(_vm.aaa))]),_c('view',[_vm._v(_vm._s(_vm.ccc))]),_c('name',{attrs:{"name":_vm.nameOutter,"compId":(_vm.compId ? _vm.compId : '$root') + ',0'}}),_c('view',[_vm._v(_vm._s(_vm.aaaComp))])],1)
精簡後得到的 render 函數是這樣的:
// 修改後的 render 函數
[,[,,[(_vm.aaa)],,[(_vm.ccc)],,[[_vm.nameOutter,(_vm.compId ? _vm.compId : '$root') + ',0']],,[(_vm.aaaComp)]]]
可以看到 Vue 中的大量 render helper 調用,例如
_c
、
_v
、
_s
等都可以省略了!
有些 render helper 還是不能去掉,例如 v-for 循環,我們還是保留了 _l 函數,因為 v-for 循環的對象可能為數組、字元串、數字等多種情況。
複雜表達式和filter 計算
在 Vue 的 template 中,是可以像 JS 一樣執行很多計算的,比如可以執行定義好的 method:
<div :prop="someMethod(data)"></div>
或者執行一個 filter
<div :prop="someMethod | someFilter"></div>
Vue 中這部分的計算是在 render 中随着 VNode 的建構執行的,計算結果存儲在了 VNode 節點中。現在我們沒有 VNode 了,計算出的值怎麼辦呢?
- 計算複雜表達式和 filter 的過程還在 render 過程中保留。
- 計算出的值使用
方法包裹。每個計算值産生一個唯一的 id,_ff
方法将這些值按照 id 存儲下來 setData 給小程式,小程式直接使用這些計算結果來進行渲染。_ff
patch 過程
patch 過程已經完全不需要了,我們将這一過程完全删除。
順帶解決的一個坑
在之前的方案中,從 Page 開始建立的小程式元件執行個體樹,與 Vue 元件執行個體樹互相獨立。為了讓小程式元件執行個體與 Vue 元件執行個體之間能夠對應(否則無法在元件層面 setData),我們需要對每個元件執行個體進行标記,通過标記來尋找對應關系。這在一些特殊情景下是會有問題的,例如元件快速生成又銷毀等,可能出現執行個體間不比對的問題。
修改後的方案 Vue 執行個體以元件級别建立,是以不再會出現執行個體無法比對的情況。
結果和總結
去 VNode 優化後,使用線上業務驗證,渲染時間減少了 16%。此外,由于精簡了 Vue 的部分功能,架構整體的體積也減少了 11%。