如何理解與掌握虛擬DOM 的精髓?我推薦大家學習Snabbdom 這個專案。
Snabbdom 是一個虛擬DOM 實作函式庫,推薦的原因一是程式碼比較少,核心程式碼只有幾百行;二是Vue 就是藉用此專案的想法來實現虛擬DOM 的;三是這個專案的設計/實作和擴展思路值得參考。
snabb /snab/,瑞典語,意思是快速的。
調整好舒服的坐姿,打起精神我們要開始啦~ 要學習虛擬DOM,我們得先知道DOM 的基礎知識和用JS 直接操作DOM 的痛點在哪裡。
DOM(Document Object Model)是一種文檔物件模型,用一個物件樹的結構來表示一個HTML/XML 文檔,樹的每個分支的終點都是一個節點(node),每個節點都包含著物件。 DOM API 的方法讓你可以用特定方式操作這個樹,用這些方法你可以改變文件的結構、樣式或內容。
DOM 樹中的所有節點首先都是一個Node
, Node
是一個基底類別。 Element
, Text
和Comment
都繼承於它。
換句話說, Element
, Text
和Comment
是三種特殊的Node
,它們分別叫做ELEMENT_NODE
,
TEXT_NODE
和COMMENT_NODE
,代表的是元素節點(HTML 標籤)、文字節點和註解節點。其中Element
還有一個子類別是HTMLElement
,那麼HTMLElement
和Element
有什麼差別呢? HTMLElement
代表HTML 中的元素,如: <span>
、 <img>
等,而有些元素並不是HTML 標準的,例如<svg>
。可以用下面的方法來判斷這個元素是不是HTMLElement
:
document.getElementById('myIMG') instanceof HTMLElement;
瀏覽器創建DOM 是很「昂貴」的。來一個經典範例,我們可以透過document.createElement('p')
建立一個簡單的p 元素,將屬性都列印出來康康:
可以看到列印出來的屬性非常多,當頻繁地去更新複雜的DOM 樹時,會產生效能問題。虛擬DOM 就是用原生的JS 物件來描述一個DOM 節點,所以建立一個JS 物件比建立一個DOM 物件的代價小很多。
VNode 是Snabbdom 中描述虛擬DOM 的物件結構,內容如下:
type Key = string | number | symbol; interface VNode { // CSS 選擇器,例如:'p#container'。 sel: string | undefined; // 透過modules 操作CSS classes、attributes 等。 data: VNodeData | undefined; // 虛擬子節點數組,數組元素也可以是string。 children: Array<VNode | string> | undefined; // 指向已建立的真實DOM 物件。 elm: Node | undefined; /** * text 屬性有兩種情況: * 1. 沒有設定sel 選擇器,表示這個節點本身就是一個文字節點。 * 2. 設定了sel,說明這個節點的內容是一個文字節點。 */ text: string | undefined; // 用於給已存在的DOM 提供標識,在同級元素之間必須唯一,有效避免不必要地重建操作。 key: Key | undefined; } // vnode.data 上的一些設置,class 或生命週期函數鉤子等等。 interface VNodeData { props?: Props; attrs?: Attrs; class?: Classes; style?: VNodeStyle; dataset?: Dataset; on?: On; attachData?: AttachData; hook?: Hooks; key?: Key; ns?: string; // for SVGs fn?: () => VNode; // for thunks args?: any[]; // for thunks is?: string; // for custom elements v1 [key: string]: any; // for any other 3rd party module }
例如這樣定義一個vnode 的物件:
const vnode = h( 'p#container', { class: { active: true } }, [ h('span', { style: { fontWeight: 'bold' } }, 'This is bold'), ' and this is just normal text' ]);
我們透過h(sel, b, c)
函數來建立vnode 物件。 h()
程式碼實作中主要是判斷了b 和c 參數是否存在,並處理成data 和children,children 最終會是陣列的形式。最後透過vnode()
函數傳回上面定義的VNode
類型格式。
先來一張運作流程的簡單範例圖,先有個大概的流程概念:
diff 處理是用來計算新舊節點之間差異的處理過程。
再來看一段Snabbdom 運行的範例程式碼:
import { init, classModule, propsModule, styleModule, eventListenersModule, h, } from 'snabbdom'; const patch = init([ // 透過傳入模組初始化patch 函數classModule, // 開啟classes 功能propsModule, // 支援傳入props styleModule, // 支援內聯樣式同時支援動畫eventListenersModule, // 新增事件監聽]); // <p id="container"></p> const container = document.getElementById('container'); const vnode = h( 'p#container.two.classes', { on: { click: someFn } }, [ h('span', { style: { fontWeight: 'bold' } }, 'This is bold'), ' and this is just normal text', h('a', { props: { href: '/foo' } }, "I'll take you places!"), ] ); // 傳入一個空的元素節點。 patch(container, vnode); const newVnode = h( 'p#container.two.classes', { on: { click: anotherEventHandler } }, [ h( 'span', { style: { fontWeight: 'normal', fontStyle: 'italic' } }, 'This is now italic type' ), ' and this is still just normal text', h('a', { props: { href: ''/bar' } }, "I'll take you places!"), ] ); // 再次呼叫patch(),將舊節點更新為新節點。 patch(vnode, newVnode);
從流程示意圖和範例程式碼可以看出,Snabbdom 的運作流程描述如下:
先呼叫init()
進行初始化,初始化時需要設定需要使用的模組。例如classModule
模組用來使用物件的形式來配置元素的class 屬性; eventListenersModule
模組 用來配置事件監聽器等等。 init()
呼叫後會傳回patch()
函數。
透過h()
函數建立初始化vnode 對象,呼叫patch()
函數去更新,最後透過createElm()
建立真正的DOM 對象。
當需要更新時,建立一個新的vnode 對象,呼叫patch()
函數去更新,經過patchVnode()
和updateChildren()
完成本節點和子節點的差異更新。
Snabbdom 是透過模組這種設計來擴展相關屬性的更新而不是全部寫到核心程式碼中。那這是如何設計與實現的呢?接下來就先來康康這個設計的核心內容,Hooks-生命週期函數。
Snabbdom 提供了一系列豐富的生命週期函數也就是鉤子函數,這些函數適用在模組中或可以直接定義在vnode 上。例如我們可以在vnode 上這樣定義鉤子的執行:
h('p.row', { key: 'myRow', hook: { insert: (vnode) => { console.log(vnode.elm.offsetHeight); }, }, });
全部的生命週期函數宣告如下:
名稱 | 觸發節點 | 回呼參數 |
---|---|---|
pre | patch 開始執行 | none |
init | vnode 被添加 | vnode |
create | 一個基於vnode 的DOM 元素被創建 | emptyVnode, vnode |
insert | 元素被插入到DOM | vnode |
prepatch | 元素即將patch | oldVnode, vnode |
postpatch
oldVnode, vnode | ||
update | 被 | oldVnode, vnode |
postpatch | , | oldVnode, vnode |
destroy | 元素被直接或間接得移除 | vnode |
remove | 元素已從DOM 中移除 | vnode, removeCallback |
post | 已完成patch 過程 | none |
: pre
, create
, update
, destroy
, remove
, post
。適用於vnode 聲明的是: init
, create
, insert
, prepatch
, update
, postpatch
, destroy
, remove
。
我們來康康是如何實現的,例如我們以classModule
模組為例,康康它的宣告:
import { VNode, VNodeData } from "../vnode"; import { Module } from "./module"; export type Classes = Record<string, boolean>; function updateClass(oldVnode: VNode, vnode: VNode): void { // 這裡是更新class 屬性的細節,先不管。 // ... } export const classModule: Module = { create: updateClass, update: updateClass };
可以看到最後導出的模組定義是一個對象,對象的key 就是鉤子函數的名稱,模組對象Module
的定義如下:
import { PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook, } 從 "../hooks"; export type Module = Partial<{ pre: PreHook; create: CreateHook; update: UpdateHook; destroy: DestroyHook; remove: RemoveHook; post: PostHook; }>;
TS 中Partial
表示物件中每個key 的屬性都是可以為空的,也就是說模組定義中你關心哪個鉤子,就定義哪個鉤子就好了。鉤子的定義有了,流程是怎麼執行的呢?接著我們來看init()
函數:
// 模組中可能定義的鉤子有哪些。 const hooks: Array<keyof Module> = [ "create", "update", "remove", "destroy", "pre", "post", ]; export function init( modules: Array<Partial<Module>>, domApi?: DOMAPI, options?: Options ) { // 模組中定義的鉤子函數最後會存在這裡。 const cbs: ModuleHooks = { create: [], update: [], remove: [], destroy: [], pre: [], post: [], }; // ... // 遍歷模組中定義的鉤子,並存起來。 for (const hook of hooks) { for (const module of modules) { const currentHook = module[hook]; if (currentHook !== undefined) { (cbs[hook] as any[]).push(currentHook); } } } // ... }
可以看到init()
執行時先遍歷各個模組,然後把鉤子函數存到了cbs
這個物件中。執行的時候可以康康patch()
函數裡面:
export function init( modules: Array<Partial<Module>>, domApi?: DOMAPI, options?: Options ) { // ... return function patch( oldVnode: VNode | Element | DocumentFragment, vnode: VNode ): VNode { // ... // patch 開始了,執行pre 鉤子。 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); // ... } }
這裡以pre
這個鉤子舉例, pre
鉤子的執行時機是在patch 開始執行時。可以看到patch()
函數在執行的開始處去循環調用了cbs
中儲存的pre
相關鉤子。其他生命週期函數的呼叫也跟這個類似,大家可以在原始碼中其他地方看到對應生命週期函數呼叫的地方。
這裡的設計思路是觀察者模式。 Snabbdom 把非核心功能分佈在模組中來實現,結合生命週期的定義,模組可以定義它自己感興趣的鉤子,然後init()
執行時處理成cbs
對象就是註冊這些鉤子;當執行時間到來時,調用這些鉤子來通知模組處理。這樣就把核心程式碼和模組程式碼分離了出來,從這裡我們可以看出觀察者模式是一種程式碼解耦的常用模式。
接下來我們來康康核心函數patch()
,這個函數是在init()
呼叫後回傳的,作用是執行VNode 的掛載和更新,簽章如下:
function patch(oldVnode: VNode | Element | DocumentFragment , vnode: VNode): VNode { // 為簡單起見先不關注DocumentFragment。 // ... }
oldVnode
參數是舊的VNode 或DOM 元素或文件片段, vnode
參數是更新後的物件。這裡我直接貼出整理的流程描述:
呼叫模組上註冊的pre
鉤子。
如果oldVnode
是Element
,則將其轉換為空的vnode
對象,屬性裡面記錄了elm
。
這裡判斷是不是Element
是判斷(oldVnode as any).nodeType === 1
是完成的, nodeType === 1
顯示是一個ELEMENT_NODE,定義在這裡。
然後判斷oldVnode
和vnode
是不是相同的,這裡會呼叫sameVnode()
來判斷:
function sameVnode(vnode1: VNode, vnode2: VNode): boolean { // 同樣的key。 const isSameKey = vnode1.key === vnode2.key; // Web component,自訂元素標籤名,看這裡: // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement const isSameIs = vnode1.data?.is === vnode2.data?.is; // 同樣的選擇器。 const isSameSel = vnode1.sel === vnode2.sel; // 三者都相同即是相同的。 return isSameSel && isSameKey && isSameIs; }
patchVnode()
做diff 更新。createElm()
建立新的DOM 節點;建立完畢後插入DOM 節點並刪除舊的DOM 節點。呼叫上述操作中涉及的vnode 物件中註冊的insert
鉤子佇列, patchVnode()
createElm()
都可能會有新節點插入。至於為什麼這樣做,在createElm()
會說到。
最後調用模組上註冊的post
鉤子。
流程基本上就是相同的vnode 就做diff,不同的就創建新的刪除舊的。接下來先看createElm()
是如何建立DOM 節點的。
createElm()
是根據vnode 的配置來建立DOM 節點。流程如下:
呼叫vnode 物件上可能存在的init
鉤子。
然後分幾個情況來處理:
如果vnode.sel === '!'
,這是Snabbdom 用來刪除原節點的方法,這樣會新插入一個註解節點。因為createElm()
後會刪除舊節點,所以這樣設定就可以達到卸載的目的。
如果vnode.sel
選擇器定義是存在的:
解析選擇器,得到id
、 tag
和class
。
呼叫document.createElement()
或document.createElementNS
建立DOM 節點,並記錄到vnode.elm
中,並根據上一步的結果來設定id
、 tag
和class
。
呼叫模組上的create
鉤子。
處理children
子節點數組:
如果children
是數組,則遞歸調用createElm()
建立子節點後,呼叫appendChild
掛載到vnode.elm
下。
如果children
不是數組但vnode.text
存在,表示這個元素的內容是個文本,這個時候調用createTextNode
創建文本節點並掛載到vnode.elm
下。
呼叫vnode 上的create
鉤子。並將vnode 上的insert
鉤子加入insert
鉤子佇列。
剩下的情況就是vnode.sel
不存在,說明節點本身就是文本,那就呼叫createTextNode
建立文本節點並記錄到vnode.elm
。
最後返回vnode.elm
。
整個過程可以看出createElm()
是根據sel
選擇器的不同設定來選擇如何建立DOM 節點。這裡有個細節是補一下: patch()
中提到的insert
鉤子佇列。需要這個insert
鉤子佇列的原因是需要等到DOM 真正被插入後才執行,而且也要等到所有子孫節點都插入完成,這樣我們可以在insert
中去計算元素的大小位置資訊才是準確的。結合上面建立子節點的過程, createElm()
建立子節點是遞歸調用,所以佇列會先記錄子節點,再記錄自己。這樣在patch()
的結尾執行這個佇列時就可以保證這個順序。
接下來我們來看看Snabbdom 如何用patchVnode()
來做diff 的,這是虛擬DOM 的核心。 patchVnode()
的處理流程如下:
先執行vnode 上prepatch
鉤子。
如果oldVnode 和vnode 是同一個物件引用,則不會處理直接回傳。
呼叫模組和vnode 上的update
鉤子。
如果沒有定義vnode.text
,則處理children
的幾種情況:
如果oldVnode.children
和vnode.children
均存在且不相同。則呼叫updateChildren
去更新。
vnode.children
存在而oldVnode.children
不存在。如果oldVnode.text
存在則先清空,然後呼叫addVnodes
去新增新的vnode.children
。
vnode.children
不存在而oldVnode.children
存在。呼叫removeVnodes
移除oldVnode.children
。
如果oldVnode.children
和vnode.children
均不存在。如果oldVnode.text
存在則清空。
如果有定義vnode.text
且與oldVnode.text
不同。如果oldVnode.children
存在則呼叫removeVnodes
清除。然後透過textContent
來設定文字內容。
最後執行vnode 上的postpatch
鉤子。
從過程可以看出,diff 中對於自身節點的相關屬性的改變比如class
、 style
之類的是依靠模組去更新的,這裡不過多展開了大家有需要可以去看下模組相關的程式碼。 diff 的主要核心處理是集中在children
上,接下來康康diff 處理children
的幾個相關函數。
這個很簡單,先呼叫createElm()
創建,然後插入到對應的parent 中。
移除的時候會先呼叫destory
和remove
鉤子,這裡重點講講這兩個鉤子的呼叫邏輯和區別。
destory
,先呼叫這個鉤子。邏輯是先呼叫vnode 物件上的這個鉤子,再呼叫模組上的。然後對vnode.children
也按照這個順序遞歸呼叫這個鉤子。remove
,這個hook 只有在當前元素從它的父級中刪除才會觸發,被移除的元素中的子元素則不會觸發,並且模組和vnode 對像上的這個鉤子都會調用,順序是先調用模組上的再呼叫vnode 上的。而且比較特殊的是等待所有的remove
都會呼叫後,元素才會真正移除,這樣做可以實現一些延遲刪除的需求。以上可以看出這兩個鉤子呼叫邏輯不同的地方,特別是remove
只在直接脫離父級的元素上才會被呼叫。
updateChildren()
是用來處理子節點diff 的,也是Snabbdom 中比較複雜的一個函數。總的想法是對oldCh
和newCh
各設定頭、尾共四個指針,這四個指針分別是oldStartIdx
、 oldEndIdx
、 newStartIdx
和newEndIdx
。然後在while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
循環中對兩個陣列進行對比,找到相同的部分進行複用更新,並且每次比較處理最多移動一對指標。詳細的遍歷過程按以下順序處理:
如果這四個指標有任何一個指向的vnode == null,則這個指標往中間移動,例如:start++ 或end--,null 的產生在後面情況有說明。
如果新舊開始節點相同,也就是sameVnode(oldStartVnode, newStartVnode)
傳回true,則以patchVnode()
執行diff,並且兩個開始節點都向中間前進一步。
如果新舊結束節點相同,也採用patchVnode()
處理,兩個結束節點往中間後退一步。
如果舊開始節點與新結束節點相同,先用patchVnode()
處理更新。然後需要移動oldStart 對應的DOM 節點,移動的策略是移動到oldEndVnode
對應DOM 節點的下一個兄弟節點之前。為什麼是這樣移動呢?首先,oldStart 與newEnd 相同,說明在當前循環處理中,舊數組的開始節點是往右移動了;因為每次的處理都是首尾指標往中間移動,我們是把老數組更新成新的,這個時候oldEnd 可能還沒處理,但這個時候oldStart 已確定在新數組的當前處理中是最後一個了,所以移動到oldEnd 的下一個兄弟節點之前是合理的。移動完畢後,oldStart++,newEnd--,分別向各自的陣列中間移動一步。
如果舊結束節點與新開始節點相同,也是先用patchVnode()
處理更新,然後把oldEnd 對應的DOM 節點移動oldStartVnode
對應的DOM 節點之前,移動理由同上一步一樣。移動完畢後,oldEnd--,newStart++。
如果以上情況都不是,則透過newStartVnode 的key 去找在oldChildren
的下標idx,根據下標是否存在有兩種不同的處理邏輯:
如果下標不存在,說明newStartVnode 是新建立的。透過createElm()
建立新的DOM,並插入到oldStartVnode
對應的DOM 之前。
如果下標存在,也要分成兩種情況處理:
如果兩個vnode 的sel 不同,也還是當做新建立的,透過createElm()
建立新的DOM,並插入到oldStartVnode
對應的DOM 之前。
如果sel 是相同的,則透過patchVnode()
處理更新,並把oldChildren
對應下標的vnode 設定為undefined,這也是為什麼前面雙指標遍歷中會出現== null 的原因。然後把更新完畢後的節點插入到oldStartVnode
對應的DOM 之前。
以上操作完後,newStart++。
遍歷結束後,還有兩種情況要處理。一種是oldCh
已經全部處理完成,而newCh
中還有新的節點,需要對newCh
剩下的每個都創建新的DOM;另一種是newCh
全部處理完成,而oldCh
中還有舊的節點,需要將多餘的節點移除。這兩種情況的處理如下:
function updateChildren( parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue ) { // 雙指標遍歷過程。 // ... // newCh 中還有新的節點需要建立。 if (newStartIdx <= newEndIdx) { // 需要插入到最後一個處理好的newEndIdx 之前。 before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm; addVnodes( parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue ); } // oldCh 中還有舊的節點要移除。 if (oldStartIdx <= oldEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } }
我們用一個實際例子來看updateChildren()
的處理過程:
初始狀態如下,舊子節點數組為[A, B, C],新節點數組為[B, A, C, D]:
第一輪比較,開始和結束節點都不一樣,於是看newStartVnode 在舊節點中是否存在,找到了在oldCh[1] 這個位置,那麼先執行patchVnode()
進行更新,然後把oldCh[1] = undefined ,並把DOM 插入oldStartVnode
之前, newStartIdx
向後移動一步,處理完後狀態如下:
第二輪比較, oldStartVnode
和newStartVnode
相同,執行patchVnode()
更新, oldStartIdx
和newStartIdx
向中間移動,處理完後狀態如下:
第三輪比較, oldStartVnode == null
, oldStartIdx
向中間移動,狀態更新如下:
第四輪比較, oldStartVnode
和newStartVnode
相同,執行patchVnode()
更新, oldStartIdx
和newStartIdx
向中間移動,處理完後狀態如下:
此時oldStartIdx
大於oldEndIdx
,循環結束。此時newCh
中還有沒處理完的新節點,需要呼叫addVnodes()
插入,最終狀態如下:
到這裡虛擬DOM 的核心內容已經梳理完畢,Snabbdom 的設計和實現原理我覺得挺好的,大家有空可以去康康源碼的細節再細品下,其中的思想很值得學習。