高觀點下的 Vue.js 框架

本文是我在學習、使用 Vue.js 過程中的一些芻議,有些想法僅為我個人的推測,故難免有紕漏甚至偏頗。所謂高觀點,只是希望 Standpoint 盡可能高一些,從設計層面看待 Vue.js 這樣一個框架,而不陷入具體的 API 講解或源代碼分析中,另外,也是為文章標題贏得一些噱頭而已。


Web 前端編程最本質(zhì)的工作內(nèi)容其實僅有兩條:

  1. 處理數(shù)據(jù)
  2. 更新 DOM

這里的“更新”當然包括首次加載、新增和刪除 DOM 。DOM 被更新了,剩下的渲染、繪制過程均由瀏覽器完成。一直以來,我們的編程工作也是圍繞這兩件事展開。然而事情的細枝末節(jié)太多,繁瑣的業(yè)務場景讓代碼量日增,我們的編碼方式本身也亟需改進。第一次大變革自然是 jQuery 這類聚焦于“更新 DOM”的庫或框架,它們幫我們消除瀏覽器差異、提供更便利的 API 等。第二次大變革則是前端的全面 MVC 化。

前端 MVC 歷經(jīng)了 MVC --> MVP --> MVVM 的過程,這里不去述說它的歷史。現(xiàn)在我們說到前端 MVC , 說的是它的變種:MVVM 。而 Vue.js ,不論官方將其描述得如何高大上,其本質(zhì)仍只是 MVVM 框架。

現(xiàn)在來細想 MVVM ,其中 M 為模型(Model),在前端其存在形式只可能是 JavaScript 對象——不管是語言層面最直接的 JavaScript 對象,還是經(jīng)過一番折騰后封裝的 JavaScript 對象——似乎無大可為,只能依賴 JavaScript 解釋器來做更多的事情。而對 JavaScript 這樣一種動態(tài)弱類型語言,模型過分地“充血”似乎也不妥。第二個字母 V 為視圖(View),它就是我們所更新的那個 “DOM”,這一切都交由瀏覽器處理。似乎只有在它們中間放個夾層才能做些大事,這便是 VM ,視圖模型層。

至此,我們在“數(shù)據(jù)”和“DOM”間建立了橋梁。

1. 鳥瞰

統(tǒng)觀 Vue.js 框架,它所放置的這個夾層,便是被叫作 “虛擬 DOM” 的東西。其它一切圍繞它展開:

image.png

我想,Vue.js 所面臨的第一個決策便是其所采用的編程范式是“命令式”還是“聲明式”的。聲明式顯然在用戶層面有其優(yōu)勢,用戶代碼只需要關注視圖最終結果。不管結果存在幾種狀態(tài),只需要通過某種方式(如 Vue.js 的模板)描述出來。實現(xiàn)聲明式后,模型層的數(shù)據(jù)應當自動流經(jīng)虛擬 DOM 進而進入真實 DOM 。

因此,框架中需要一個功能模塊,用它來監(jiān)聽用戶數(shù)據(jù)的變化,繼而更新虛擬 DOM。這樣一來,在引擎層面引入的該功能模塊使得用戶代碼僅為處理數(shù)據(jù),而不需要直接與虛擬 DOM 打交道。能夠自動捕獲用戶數(shù)據(jù)變化并更新虛擬 DOM 的功能模塊便是學習 Vue.js 的開發(fā)人員最為熟知的響應系統(tǒng)

image.png

響應系統(tǒng)是用戶代碼進入框架引擎的發(fā)端。 如果從一開始便是可響應的,后續(xù)引擎中引入的一切功能,便有了“自動”執(zhí)行的可能。另一方面我們清醒地知道,程序并沒有那么多所謂的“自動”,總歸是要存在一個觸發(fā)點的。而響應系統(tǒng)的觸發(fā),直接面向 JavaScript 語言層面的對象修改、刪除或數(shù)組、集合的增刪改等操作。這樣一個大的觀察者模式的被觀察對象直接是語言層面最原始的東西,這可能讓很多不熟悉語言規(guī)范的人感到陌生而神奇,此點后文詳說。

有了虛擬 DOM 這個夾層后,很多事情便有了切入點,方便框架施展手腳。然而虛擬 DOM 是不能被瀏覽器識別并渲染成可視的界面的。在虛擬 DOM 與真實 DOM 之間必然要存在一層轉(zhuǎn)換機制,這便是渲染器,渲染器本質(zhì)是把虛擬 DOM 這種靜態(tài)的描述,變成對真實 DOM 的掛載、卸載與更新等操作。

image.png

至此,一個完整的聲明式的框架系統(tǒng)便可以工作了。然而我們更進一步來看,模型層的數(shù)據(jù)可以直接使用語言層面的 JavaScript 對象,框架沒入侵我們的編碼習慣,沒有要求我們遵從特殊的約定寫代碼(這是響應系統(tǒng)的功勞)。而視圖層面呢?從我們熟知的 DOM 變成了一個叫“虛擬 DOM”的東西。可我們還不知道虛擬 DOM 究竟是什么。現(xiàn)在有必要揭開虛擬 DOM 的面紗。

其實,它只是一個用來描述真實 DOM 的 JavaScript 對象。舉個例子便可知,如真實 DOM 結構:

<div id="foo">bar</div>

對應的虛擬 DOM 為:

const vnodeDiv = {
    tag: 'div',
    props: {
        id: 'foo'
    },
    children: 'bar'
}

整個 Vue.js 框架居然是圍繞這么一個簡單的東西在運作,想起來有些微妙。

現(xiàn)在回到剛才的問題,視圖層如何表示?當應用系統(tǒng)需求復雜后,這種 JavaScript 對象的表示方式顯然過于丑陋(用過 ExtJS 等框架的開發(fā)者顯然了解這種方式的痛苦所在)。Vue.js 框架的設計者想讓開發(fā)者的使用體驗回歸到編寫 HTML 的方式,這是一個很好的設想,如何實現(xiàn)呢?

image.png

經(jīng)驗非常自然地告訴我們,此處需要一個編譯器,用它來架起 HTML 和虛擬 DOM 的橋梁,將用戶寫的 HTML 翻譯成虛擬 DOM。值得注意的是,此處的 HTML 已不再是直接交給瀏覽器解析的 HTML,而只是借鑒了它的語法形式,本質(zhì)上只是一些字符串而已。既然決定引入編譯器這種復雜的結構,對于這個形如 HTML 的字符串,何不做做手腳讓它更為豐富呢?于是框架設計者建立了一套模板系統(tǒng),可以用它擴展 HTML 的語法。

image.png

現(xiàn)在還有兩個小問題未解決,它們在框架架構上是小問題,在用戶使用上卻至關重要。第一個問題是如何讓 HTML 靈動起來。例如如何重復書寫 100 遍 div ,如何在特定條件下輸出某 span ?HTML 并不是圖靈完備的語言,而 JavaScript 卻是,虛擬 DOM 剛好也是用 JavaScript 表示。那么,如果用一個 JavaScript 函數(shù)來輸出虛擬 DOM,而編譯器的編譯目標不再是虛擬 DOM,改為這個函數(shù),問題便可迎刃而解。

image.png

這便是渲染函數(shù)在框架中存在的位置及其存在的最根本意義。當虛擬 DOM 的存在形式從普通對象上升為函數(shù),一切便“靈動”起來。函數(shù)中使用 for 或者 if 語句,可以解決我們剛才提出的問題。

第二個小問題。龐大的應用系統(tǒng)中,勢必存在繁多的虛擬 DOM ,如何維持其秩序?最簡單的方式當然是把這個出力不討好的活交給開發(fā)者本人。Vue.js 框架層面只給開發(fā)者提供管理制度,具體的管理方式讓其用戶(即開發(fā)者)自行決定。這套管理制度被叫作組件系統(tǒng)。所謂組件系統(tǒng),本質(zhì)上是針對虛擬 DOM 進行的打包捆綁方式。我們已經(jīng)知道了虛擬 DOM 的樣貌,在此不妨也來看看組件的樣貌(注意,這不是 Vue.js API 層面的組件形式):

const MyComponent = {
    name: 'foo',
    myProperty: 'bar',
    ...
    render () {
        return {
            tag: 'div',
            ...
        }
    }
}

可以看到,只要組件里包含一個渲染函數(shù)(示例中的 render),它便可以通過渲染函數(shù)成為一個或一組虛擬 DOM 。而組件對象本身則可以在渲染函數(shù)之外附加更多的屬性或其它處理函數(shù)。這樣一來,我們通過提供渲染函數(shù)把一組虛擬 DOM 進行打包,附加的屬性則像這個包裹上貼的標簽一樣,描述了這組虛擬 DOM 的特征,讓它們成為一個共同體,與其它組件合作構成整個應用系統(tǒng)。由于組件的組織方式交給了用戶,我們經(jīng)常可以看到卓越的程序員封裝出卓越的組件庫,低劣程序員寫的組件卻支離破碎。

至此,我們圍繞著虛擬 DOM 完成了 Vue.js 框架的全部頂層設計。用戶側(cè)模型層的代碼,圍繞語言層面最普通的 JavaScript 對象展開,而視圖層的代碼,聚焦在一種類似 HTML 的模板中。

image.png

等等!也就是說虛擬 DOM 在 MVVM 框架中是不可或缺的嗎?并非如此。響應系統(tǒng)完全可能直接工作于真實 DOM 之上,而編譯器也可以直接編譯出真實 DOM 。事實上確實也有其它框架在這么做。這中間夾雜了諸多性能、易用性各方面的問題,而 Vue.js 大概是一種穩(wěn)妥折中的決策結果吧。

2. 響應系統(tǒng)

現(xiàn)在更近一步來思考響應系統(tǒng)。響應系統(tǒng)是網(wǎng)絡上資料最為繁多的一部分。有諸多文章甚至以“深入 Vue 原理”之名,內(nèi)容卻只講了 Object.defineProperty 的用法,再寫個示例代碼便結束。我這里自然不去討論 JavaScript 的語法細節(jié),只看響應系統(tǒng)的設計過程。

設計思路

有必要先弄清楚根本目標:響應系統(tǒng)是為了實現(xiàn)用戶模型層數(shù)據(jù)到視圖層界面的聲明式編程。 思考 Vue.js 的工作方式,或者說 Web 前端開發(fā)的工作方式:首先拿到用戶數(shù)據(jù),然后使用數(shù)據(jù)掛載頁面,這是初次加載的情況。后續(xù)界面操作進行過程中,數(shù)據(jù)產(chǎn)生變動,拿到最新的用戶數(shù)據(jù),更新或卸載頁面。

產(chǎn)生響應的重點,顯然是后續(xù)數(shù)據(jù)更新的過程。Vue.js 的設計者并沒有其它更高明的辦法,他使用的是我們很容易想到的觀察者模式——觀察數(shù)據(jù),通知視圖。觀察者 Observer 時刻關心響應式數(shù)據(jù)的變化,一有變化便通知更新視圖(通過渲染器完成)。其接口中應該包含用于調(diào)用渲染器以更新視圖的方法,這里遵從 Vue3 的命名,叫它 effect 函數(shù)。 唯一特殊的一點是, 注冊觀察者的過程似乎不需要用戶來執(zhí)行,是否可以在掛載頁面或者說是首次執(zhí)行 effect 時進行注冊呢?答案是肯定的。effect 函數(shù)內(nèi)部勢必會去獲取用來渲染視圖的數(shù)據(jù),如果在獲取數(shù)據(jù)的時候注冊觀察者,后面修改數(shù)據(jù)的時候便可以通知觀察者,即執(zhí)行 effect 函數(shù)。此時我們引入一個存放 effect 函數(shù)的桶:

image.png

示例代碼如下,請先忽略代碼可能產(chǎn)生的 BUG,僅關心其所蘊含的調(diào)用關系:

const bucket = new Set()
function effect () {
    let data = getData()  // 獲取數(shù)據(jù),用于后續(xù)更新視圖
    updateView(data)
}
function getData() {
    arr.push(effect)  // 注冊觀察者,即 effect 函數(shù)
    return data
}
function setData(val) {
    // 更新數(shù)據(jù)時通知觀察者,即重新執(zhí)行 effect 函數(shù)
    bucket.forEach(effect => {
        effect()
    })
    data = val
}

現(xiàn)在聚焦“讀取數(shù)據(jù)”和“更新數(shù)據(jù)”操作。可以對其進行封裝。使用代理模式,從模型層面為原始數(shù)據(jù)提供代理對象:

class DemoProxy {
    constructor (data) {
        this.raw = data
    },
    getData () {
        // ...
    },
    setData (val) {
        // ...
    }
}
let data = new DemoProxy(data)
data.setData('123')

而 JavaScript 在語言層面,提供了一個函數(shù)對象 Proxy 用于代理(ECMAScript 2015 開始提供,在此之前可以使用 Object.defineProperty 實現(xiàn)類似效果),使用它可以實現(xiàn)語言層面的代理,從而用戶側(cè)代碼不需要調(diào)用形如 data.foo.setData('bar') 的函數(shù),而是可以直接進行賦值,如 data.foo = 'bar'。 只是有一點要保持清醒,代理后的 data 并非用戶一開始提供的那個 data , 對它的任何操作都可能被進行了攔截。另外也可以看出,即便 JavaScript 語言層面不提供 Object.definePropertyProxy ,框架仍然可能實現(xiàn)響應系統(tǒng),只是用戶側(cè)代碼相對約束會多一些。

由此可見, 響應系統(tǒng)在設計層面極其簡單:代理模式 + 觀察者模式。 代理普通 JavaScript 對象,調(diào)用 effect 函數(shù)從而觸發(fā)對象讀取操作,在讀取操作的代理方法中注冊 effect 函數(shù)作為觀察者,之后當對象產(chǎn)生更新操作時,執(zhí)行 effect 函數(shù)從而促使后續(xù)視圖層重新渲染。

技術實現(xiàn)

使用 Proxy 是無法處理原始值的。如 String、Number、Symbol、null 等,它們只傳值而不傳引用。此特殊情況下,Vue.js 框架引入了特殊的函數(shù) ref 。該函數(shù)的原理是給原始值包裹一層對象。當然用戶可以自己來包裹對象,但使用 ref 的方式更為統(tǒng)一,更為要緊的是讓框架知道用戶的意圖是將原始值轉(zhuǎn)為可響應的代理對象,如此框架便可以在特定的場合幫助用戶自動脫去這層包裹,讓代碼看起來更為優(yōu)雅。閱讀 Refs 相關的一組 API 文檔可知,它還會額外處理響應丟失的問題。不管怎么說,引入額外的函數(shù)始終會讓響應系統(tǒng)的工作模式顯得不一致,但現(xiàn)階段受技術限制,想必未有其它更好的解決辦法。

Proxy 在代理對象、數(shù)組、Map、Set 時, 主要通過攔截相應內(nèi)置方法實現(xiàn),foo.bar 操作對應著內(nèi)置方法 [[Get]] ,API 層面叫作 get ,相應的還有 sethasdeleteProperty 等等。通過查閱 JavaScript 語言規(guī)范我們可以知道所有語法細節(jié)并處理所有可能產(chǎn)生響應變化的操作。這是一個非常細碎的工作,框架應該已經(jīng)幫我們處理過了,但面對龐雜的語言細節(jié),也許在某個角落還存在某個特殊的 BUG 為框架所未及。但要相信,Vue.js 框架確實已經(jīng)努力考慮周全了,比如這些情況:

effect 函數(shù)使用 for...in 訪問對象時,對對象屬性的增刪改操作要被響應;當使用 findmapforEach等方法訪問數(shù)組時,對數(shù)組元素的操作要能夠被響應;當獲取從原型鏈中繼承的屬性時,要正確處理響應等等。

邊界情況

響應系統(tǒng)中有很多邊界情況要考慮。比如分支切換問題:

let foo = obj.isFoo ? obj.foo : 'bar'

分支切換可能使 effect 函數(shù)被遺留在桶中。又如 effect 函數(shù)的嵌套問題,可以使用 stack 結構解決。另外還有無限遞歸、循環(huán)調(diào)用等問題。

另外有一種特殊情況,當用戶執(zhí)行了修改數(shù)據(jù)的操作但數(shù)據(jù)卻沒有變化:

let foo = 'foo'
foo = 'foo'
foo = 'fo' + 'o'

此時是不應該再觸發(fā)響應的。這些邊界情況的處理都屬于框架細節(jié),此文不深入討論,但我們應該感受到,一個功能完善的框架要考慮的問題確實很多。

API 設計

基于響應系統(tǒng)的設計,框架在 API 層面設計實現(xiàn)了計算屬性與 watch 函數(shù)。watch 的目的是給用戶一個介入響應系統(tǒng)的控制點,其實質(zhì)為對 effect 函數(shù)的二次封裝。另外由于響應存在響應深度的問題,API 提供了深響應和淺響應兩種方式。相應的,對于只讀數(shù)據(jù)提供深只讀和淺只讀。

3. 渲染器

雖然“鳥瞰”一章節(jié)中,我在畫圖時把“響應系統(tǒng)”指向了“虛擬 DOM”,事實上響應系統(tǒng)中的 effect 函數(shù)執(zhí)行時,會使用渲染器進行頁面渲染,而渲染器中所使用的虛擬 DOM 是由響應系統(tǒng)提供數(shù)據(jù)。嚴格來說,渲染器是由響應系統(tǒng)直接調(diào)用的。 由于其在引擎層面被自動觸發(fā)執(zhí)行,渲染器通常不為普通開發(fā)者所知。

設計思路

渲染器的輸入為虛擬 DOM,輸出為真實 DOM。虛擬 DOM 英文寫做 virtual DOM ,后文將用 vdom 簡寫它。相應的,vdom 中的節(jié)點被稱作 virtual node ,簡寫做 vnode 。

我們可以使用 class 表示一個渲染器,不同的作用空間下 new 出多個渲染器。也可以使用 function 直接創(chuàng)建返回一個渲染器,多次函數(shù)調(diào)用則返回多個渲染器(當然也可以返回單例,這取決于設計者對渲染器作用范圍的設定)。為了方便描述,我們選用函數(shù)來表示。

函數(shù)輸入虛擬 DOM 的某節(jié)點 ,輸出真實 DOM ,因此形如:

function createRenderer (vnode) {
    let div = document.createElement('div')
    ...
    return div   // 也可能生成一組 dom ,視 vnode 而定
}

進一步想,輸出的真實 DOM 是需要掛載到某個容器上的,也就是某個父節(jié)點。如果 createRenderer 能夠接管掛載操作,同時獲得父節(jié)點的信息,似乎可以做更多工作。同時,用戶側(cè)代碼也更為簡潔。因此,我們把渲染器設計為:

function createRenderer (vnode, container) {
    ...
}

至此,我們需要約定一下在本文中對 DOM 操作時的四類情況及叫法:

  • 掛載( mount ):將新生成的節(jié)點增加到空的容器節(jié)點中;
  • 卸載( unmount ):將容器節(jié)點下的節(jié)點刪除;
  • 更新( patch ):相同節(jié)點類型時,更新節(jié)點內(nèi)容;
  • 移動( move ):將容器節(jié)點下的兄弟節(jié)點交換位置。

細節(jié)處理

寫個函數(shù)生成 DOM 并非難事,Vue.js 之所以要將渲染器作為獨立模塊,是因為其處理的技術細節(jié)太多。我們先把細碎的點羅列一下,再專注于核心問題。如果不感興趣,可以跳過此節(jié)。

一個相對簡單的情況:vnode 中包含子節(jié)點,此時可以用遞歸迭代的方式處理。

第二個問題是:DOM 的 property 和 HTML 的 Attribute 基本存在對應關系,但卻不盡相同。例如 HTML 的 class 屬性,在 DOM 中叫作 className (因為 class 是 JavaScript 關鍵字)。而有些 DOM 中的屬性在 HTML 中不存在,有些 HTML 中的屬性在 DOM 中不存在。Vue.js 框架使用 in 操作符先檢測某屬性在 DOM 中是否存在,如不存在再使用 setAttribute 處理。

第三個問題是:某些屬性是枚舉值(如 <input>type),只接受特定的取值范圍,而某些屬性是只讀的,不能后續(xù)修改。另外,值為 bool 型時,HTML 中的設置方式是不同的,如 <button disabled> ,如果設置為 <button disabled="false"> 該按鈕將不可用。

第四個問題來自事件。首先要解決如何在 vnode 中描述事件。由于事件名也只是普通的屬性,所以需要一個命名約定,如 click 事件的屬性名叫 onClickonclickhandleClickclickHandler ,總歸可以通過統(tǒng)一的前綴或后綴實現(xiàn)。這樣一來框架引擎層面就可以按約定解析出正確的事件名并進行綁定。其次是事件的更新問題。HTML 中綁定了事件,如果 HTML 產(chǎn)生更新操作,相應地需要解除事件綁定再重新綁定新事件處理函數(shù)。此問題可以通過綁定一個偽造的函數(shù) invoker,在 invoker 內(nèi)部調(diào)用真實的事件處理函數(shù),后續(xù)更新時只更新處理函數(shù)而不解除對 invoker 的綁定。最后,如果更新操作發(fā)生在事件冒泡之前,會產(chǎn)生我們意想不到的結果,這種情況用代碼示例一下比較容易說明:

const vnode = {
    type: 'div',
    props: bol.isOk ? {
        onClick: () => {...}
    } : {},
    children: [{
        type: 'p',
        props: {
            onClick: () => {
                bol.isOk = true
            }
        }
    }]
}

第 10 行代碼執(zhí)行后,會造成第 3 行中父組件事件的綁定。這樣一來,子組件的事件仍會冒泡至父組件中執(zhí)行。為應對此情況,Vue.js 選擇屏蔽了綁定時間晚于觸發(fā)時間的事件處理函數(shù)。個人覺得,是否屏蔽它是件偏主觀的決定,只能說大多數(shù)情況下,屏蔽后更易于避免用戶寫出詭異的 BUG 。

以上問題雖被我說成“細節(jié)”,但高視角下的細節(jié)往往還會包含更多復雜的真正細節(jié)。

核心問題

現(xiàn)在可以專注于渲染器的兩個核心問題,首先是對 vnode 類型的區(qū)分。vnode 可以描述普通標簽:

const vnode = {
    type: 'div',
    props: {}
}

也可描述文本節(jié)點。文本節(jié)點沒有明確的標簽名,那么它的 type 如何定義?注釋節(jié)點也存在同樣的問題。事實上它們只要是框架可識別的唯一類型便可,因而可以通過 Symbol 類型來定義。

const Text = Symbol()
const vnode = {
    type: Text,
    props: {}
}

另一種類型是組件。組件最終會被轉(zhuǎn)化成 vdom ,但在渲染器中需要首先將其區(qū)分開來,才可能使其正確渲染。那么何為組件類型?vnode.type 為對象而已。

值得一提的是,Vue.js 3.x 中增加了一個特殊的 vnode 類型:Fragment 。在 2.x 版本中,根節(jié)點不能是多個而 3.x 卻可以,這并非什么高端技術。2.x 時,框架設計者認為不管 vdom 層級多深,萬物總歸要有一個根,而 3.x 時呢?不要根了嗎?當然不可能,設計者靈機一動,這個 Fragment 類型節(jié)點就是它們的根啊!渲染器識別到 Fragment 類型時,不再渲染節(jié)點自身,而只處理其子節(jié)點,同理卸載時則遍歷卸載所有子節(jié)點。相當于人為造了一個根節(jié)點,而它在 JavaScript 語言層面也可以用 Symbol 類型表示。

綜合起來,可以推測 createRenderer 的結構應該形如:

function createRenderer (vnode, container) {
    const type = vnode.type
    if (typeof type === 'string') {

    } else if (type = Text) {

    } else if (type = Comment) {

    } else if (type = Fragment) {
        
    } else if (typeof type === 'object') {
        
    }
    ...
}

識別出 vnode 的類型后,就可以依據(jù)不同情況處理 DOM 了。DOM 操作可以概括為前面總結的四類。我們都知道,DOM 是樹形結構,vnode 的描述中,除了包含自己的特征描述,還包含了一個 childen 屬性。childen 屬性可能有 3 種情況:

  • 空,表示沒有子節(jié)點
  • 字符串,表示文本節(jié)點或注釋節(jié)點
  • 數(shù)組,表示一個或多個子節(jié)點,而子節(jié)點的類型也是 vnode

這樣一來,更新子節(jié)點時一共需要處理 9 種情況:

新節(jié)點 舊節(jié)點 操作
字符串 unmount
數(shù)組 unmount
字符串 mount
字符串 字符串 patch
字符串 數(shù)組 unmount + mount
數(shù)組 mount
數(shù)組 字符串 unmount + mount
數(shù)組 數(shù)組 <span style="color:red">?</span>

新舊節(jié)點均為數(shù)組時,當然也可以先將舊節(jié)點 unmount 再將新節(jié)點進行 mount 操作。然而這里牽扯到性能問題。如果新舊節(jié)點差異不大,1000 個節(jié)點只有 2、3 處變化,全量地更新必然有太多無謂的性能消耗。此處便牽扯到包含 Vue.js 框架在內(nèi)、基于 vdom 的框架被討論的最多的 Diff 算法問題。我們能想到的最直接的比較方法是:遍歷舊節(jié)點,依次與各新節(jié)點進行對比,找出哪些節(jié)點需要 move、哪些需要 unmount 或 mount、哪些需要 patch 。但是這樣的策略顯然不會是最優(yōu),直接來看框架的實現(xiàn)方法, Vue.js 2.x 版本使用雙端 Diff,3.x 版本使用快速 Diff 。 接下來對其原理做簡單介紹。

這里有個共同的前提,需要用戶為每個節(jié)點標記一個唯一的 key 值,后續(xù)算法中便可認為相同節(jié)點類型且相同 key 值時,即為相同節(jié)點。

雙端 Diff

雙端 Diff 的核心思路是從新舊節(jié)點的兩端同時開始對比,多輪步進。

image.png

如果在本輪中命中,則進行相應處理并將下一輪索引指向后續(xù)的節(jié)點。具體地說:

  • 如果情況 1 命中,則進行 patch 操作,并將 newStart、oldStart 加 1 ;
  • 如果情況 2 命中,則進行 patch 操作,并將 newEnd、oldEnd 減 1 ;
  • 如果情況 3 命中,則進行 patch + move 操作,并將 newEnd 減 1 ,oldStart 加 1;
  • 如果情況 4 命中,則進行 patch + move 操作,并將 oldEnd 減 1 ,newStart 加 1;

經(jīng)過一輪一輪的操作,就會像啃甘蔗一樣,一節(jié)一節(jié)地把新舊節(jié)點啃光。但其實還有第 5 種情況,那就是都未命中時。此時可以通過特殊處理進行補救,具體做法為:在舊節(jié)點中找到與新節(jié)點頭部節(jié)點相對應的節(jié)點,將其 move 至頭部并標記(Vue.js 使用 undefined 標記)。

image.png

此時說明第一個新節(jié)點曾是舊節(jié)點,只需將其移動至開頭。我想說的是,這只是補救策略,是為了將甘蔗繼續(xù)啃下去,你用尾節(jié)點甚或隨機節(jié)點去做處理都是可以的,只是設計者使用頭節(jié)點顯然相對簡單。但或許第一個新節(jié)點不曾是舊節(jié)點呢?此時說明該新節(jié)點只需要直接 mount 至所有舊節(jié)點的頭部即可。由于補救措施只處理了第一個新節(jié)點,因此將 newStart 加 1 。

注意,這個算法有個缺陷。如果新節(jié)點為 1,2,4 舊節(jié)點為 1,2,3,4 ,用這個算法從兩端啃下去,3 號舊節(jié)點會剩余。如果新節(jié)點為 1,2,3,4 舊節(jié)點為 1,2,4 ,3 號新節(jié)點會剩余。也就是說,甘蔗沒啃完,因此算法結束后,我們要填補這一缺陷。對于前者舊中有遺留,則 unmount 之,后者新中有遺留則 mount 之。

雙端 Diff 的過程非常簡單,其代碼結構應該形如:

while (/* 根據(jù)四個索引判斷步進情況,進行每一輪的處理 */) {  
    if (/* 存在標記過的節(jié)點 */) {
        // 直接跳過
    } else if (oldStartNode.key === newStartNode.key) {
        // 情況 1 
    } else if (oldEndNode.key === newEndNode.key) {
        // 情況 2
    } else if (oldStartNode.key === newEndNode.key) {
        // 情況 3
    } else if (newStartNode.key === oldEndNode.key) {
        // 情況 4
    } else {
        // 特殊情況
        if (/* 首個新節(jié)點在舊節(jié)點中可找到 */) {
            // 移動,并標記節(jié)點
        } else {
            // 掛載
        }
    }
    // 填補缺陷
    if (/* 新中有遺留 */) {
        // 遍歷遺留的,全掛載上
    } else {
        // 遍歷遺留的,全卸載
    }
}

快速 Diff

快速 Diff 的核心思路是求 最長遞增子序列LIS ,在此之前,先把兩頭多余的節(jié)點切除掉。

image.png

算法查找并處理相同的前置、后置節(jié)點,只對其進行 patch 操作。處理后如果舊節(jié)點有剩余,則全部 unmount ,如果新節(jié)點有剩余,則全部 mount 。之后,只存在兩組都有剩余的情況。接下來找最長遞增子序列,也就是找出最長的、不需要移動的節(jié)點序列。

image.png

示例中找出的最長遞增子序列為 [0, 1] ,則節(jié)點 3、4 不需要移動,只 patch 。節(jié)點 2 move 并 patch 。節(jié)點 5 需要 unmount 。

4. 編譯器

編譯器的實現(xiàn)思路中規(guī)中矩,沒有太多可以說的,主要就是構造、轉(zhuǎn)換抽象語法樹(AST)的過程:

image.png

parse 過程中,使用遞歸下降算法,為每個標簽創(chuàng)建一個狀態(tài)機。過程中解析標簽名、標簽屬性、文本節(jié)點并處理 HTML 實體。由于 Vue.js 的模板大多是 HTML ,直接參考相關規(guī)范處理,并加入框架特殊的約定即可。
標簽屬性處理過程中,可以處理 Vue.js 內(nèi)置的指令。同時還要處理 Vue.js 的插值符號。

在 AST 轉(zhuǎn)換后,將渲染函數(shù)的各個語句,用 JavaScript 對象描述為節(jié)點信息,為下一步生成代碼做準備。生成代碼時處理函數(shù)聲明、參數(shù)、返回語句、String、Array、函數(shù)調(diào)用等等一系列細節(jié)。

編譯優(yōu)化

Vue 3 進行了編譯優(yōu)化。優(yōu)化思路是將模板中的動態(tài)內(nèi)容和靜態(tài)內(nèi)容區(qū)分開。實現(xiàn)層面,通過在編譯階段給 vdom 增加額外的屬性來完成。另外,通過將所有動態(tài)節(jié)點提取到平級的數(shù)組中,在渲染階段直接對該數(shù)組進行更新,省去了 diff 過程,因而可以大幅度提升性能。

但是由于增加了額外的 vdom ,可能造成一定的運行時內(nèi)存負擔。框架設計者絞盡腦汁去減少 vnode 產(chǎn)生的數(shù)量,例如,使用了靜態(tài)提升的方式——把靜態(tài)內(nèi)容(靜態(tài)節(jié)點、靜態(tài)的屬性等)提升到 render 函數(shù)以外。

const staticNode = createNode(...)
function render() {
    return createNode([
            createNode(...),
            staticNode,
            ...
        ])
}

5. 組件系統(tǒng)

組件是 Vue.js 框架提供給開發(fā)者的上層門面。可以說它才是用戶真正需要日常面對的內(nèi)容。我們知道其本質(zhì)也只是用 render 函數(shù)返回 vdom 。

實現(xiàn)原理

組件需要完成自更新,即數(shù)據(jù)變化時,其視圖就要變化。顯然可通過響應系統(tǒng)實現(xiàn)。組件同時還需要完成來自父組件的被動更新,當 props 數(shù)據(jù)變化時,父組件產(chǎn)生自更新,如果需要,其應帶動子組件被動更新。說到 props ,Vue.js 在子組件層面聲明其可接受的所有屬性,算作一種約定,而父組件直接在模板的標簽中賦值即可。框架需要將 props 以外的內(nèi)容算作普通的 attribute 。

<!-- 父組件, foo 被當作普通屬性 -->
<blog-post :title="title" foo="bar"></blog-post>

<!-- 子組件 -->
props: {
    title: String
}

用戶層面看到的組件對象,往往來自框架內(nèi)部構造的對象,出于擴展方便考慮,大多數(shù)框架源碼都會這樣處理。Vue.js 并不例外,其內(nèi)部構造了一個對象實例,作為返回給用戶的真正實例。然而 Vue 3.x 版本的源代碼中,又將該實例作代理,把代理對象返回給用戶,難以推測其意圖。大約覺得這樣便于控制擴展,但該實例位于框架內(nèi)部,擴展能力本便屬于框架。

// 非框架源代碼,僅為示例。下同
function Vue (option) {
    let instance = { }
    return new Proxy(instance, ...)
}

框架內(nèi)部有了這個實例后,一來是可以用于維持組件狀態(tài),如:

function Vue (option) {
    let instance = {
        __isMounted: false
    }
    ...
}

二來可以為其配置參數(shù)整合統(tǒng)一的上下文。例如用戶傳入的 methods 中的 foo 函數(shù):

function Vue (option) {
    let instance = {
        __isMounted: false
    }
    option.methods.foo.call(instance, ...)
    ...
}

這樣一來,用戶層面的 datapropsmethodscomputed 等,均可獲得一致的上下文對象,也即可以通過 this 直接操作相應數(shù)據(jù)或調(diào)用相應方法。

生命周期

組件作為各模塊的統(tǒng)一裝配車間,其流水線上的各個環(huán)節(jié)可以開放給用戶代碼介入,亦即所謂的組件生命周期。生命周期通常通過模板方法模式或直接使用鉤子函數(shù)實現(xiàn),Vue.js 為后者。框架只需要在合適的代碼時機,去調(diào)用用戶傳入的鉤子函數(shù)即可。大致經(jīng)歷了:

image.png

代碼結構可示例為:

function Vue (option) {
    option.beforeCreate()
    // 代理響應數(shù)據(jù)
    reactive(option.data())

    let instance = {
        __isMounted: false,
        ...
    }
    option.created.call(instance, /* 其它參數(shù) */)

    effect(() => {
        ...
        if (instance.__isMounted) {
            option.updated.call(instance, /* 其它參數(shù) */)
        } else {
            option.mounted.call(instance, /* 其它參數(shù) */)
        }
    })
    ...
}

高階組件

異步組件 是對組件的高階封裝,其內(nèi)部使用 Promise 模式,幫用戶解決了超時處理、Error 組件/Loading 組件的顯示、失敗重試的問題。例如其內(nèi)部處理失敗重試時,在 catch 中繼續(xù)返回 Promise ,通過 retry 回調(diào)重新去 load 組件內(nèi)容。

函數(shù)式組件 只是個返回 vdom 的函數(shù),它無狀態(tài)。將 props 作為函數(shù)的靜態(tài)屬性。另外由于無狀態(tài),它無需初始化 data ,也不需要處理生命周期鉤子函數(shù)。

內(nèi)置組件

Vue.js 框架內(nèi)置了 KeepAliveTeleportTransition 等組件,由于這些組件的操作機制需要渲染器作出相應響應才能實現(xiàn),故而框架只能內(nèi)置實現(xiàn)。例如 KeepAlive 并不實際銷毀組件,而是將其掛載到一個隱藏容器中,再次加載時也只是重新移動回來,并不產(chǎn)生真正的掛載操作。這一過程顯然只有由渲染器來處理。如若某天 Vue.js 的渲染器擴展性設計的更進一步,我們也許也可以獲得內(nèi)置組件的特權,來實現(xiàn)更復雜的自定義組件。

6. 生態(tài)

完整的工程開發(fā),僅僅依靠框架本身的力量可能并不理想。因此,Vue.js 建立了比較完善的生態(tài)圈。然而生態(tài)圈必然是依托核心而存在,理解了核心之后,生態(tài)圈的東西只需要對照文檔便能輕易理解。例如 Router ,其實質(zhì)仍是組件,只是該組件根據(jù) URL 動態(tài)切換。又如 Vuex ,其為依托響應系統(tǒng)的狀態(tài)機。

另外一方面是工具鏈的完善。Vue.js 為了編寫形式上的便利,提出了 單文件組件 的概念(即用戶最常用的 .vue 文件形式),官方文檔對其有詳細地說明。其借助三方工具,通過為工具鏈提供插件的方式,實現(xiàn)其約定。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者。

推薦閱讀更多精彩內(nèi)容