最近想了解一下React和Vue框架分別在virtual dom部分的實現,以及他們的不同之處。於是先翻開Vue的原始碼去找virtual dom 的實現,看到開頭,它就提到了Vue的virtual dom更新演算法是基於Snabbdom實現的。於是,又去克隆了Snabbdom的原始碼,發現它的原始碼並不是很複雜並且星星?還很多,所以就仔細看了一遍了,這裡就將詳細學習一下它是如何實現virtual dom的。
在Snabbdom的GitHub上就解釋了,它是一個實現virtual dom的庫,簡單化,模組化,以及強大的特性和效能。
A virtual DOM library with focus on simplicity, modularity, powerful features and performance.
init
Snabbdom的簡單是基於它的模組化,它對virtual dom的設計非常巧妙,在核心邏輯中只會專注於vNode的更新演算法計算,而把每個節點具體要更新的部分,比如props
,class
,styles
,datalist
等放在獨立的模組裡,通過在不同時機觸發不同module的鉤子函式去完成。通過這樣的方式解耦,不僅可以使程式碼組織結構更加清晰,更可以使得每一部分都專注於實現特定的功能,在設計模式中,這個也叫做單一職責原則。在實際場景使用時,可以只引入需要用到的特定模組。比如我們只會更新節點的類名和樣式,而不關心屬性以及事件,那麼就只需要引用class和style的模組就可以了。例如下面這樣,
// 這裡我們只需要用到class和style模組,所以就可以只需要引用這2個模組
var patch = snabbdom.init([
require('snabbdom/modules/class').default,
require('snabbdom/modules/style').default,
]);
複製程式碼
它的核心方法就是這個init
,我們先來簡單看一下這個函式的實現,
//這裡是module中的鉤子函式
const hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
export function init(modules:Array<Partial<Module>>, domApi?:DOMAPI){
let i:number, j:number, cbs = ({} as ModuleHooks);
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
//cbs儲存了引入的modules中定義的鉤子函式,
for(i = 0; i < hooks.length; ++i){
cbs[hooks[i]] = [];
for(j = 0; j < modules.length; ++j){
const hook = modules[j][hooks[i]];
if(hook !== undefined){
cbs[hooks[i]].push(hook);
}
}
}
//還定義了一些其他的內部方法,這些方法都是服務於patch
function emptyNodeAt(){/.../};
function createRmCb(){/.../};
function createElm(){/.../};
function addVnodes(){/.../};
function invokeDestroyHook(){/.../};
function removeVnodes(){/.../};
function updateChildren(){/.../};
function patchVnode(){/.../};
//init返回了一個patch函式,這個函式接受2個引數,第一個是將被更新的vNode或者真實dom節點,第二個是用來更新的新的vNode
return function patch(oldVnode: VNode | Element,vnode:VNode):VNode{
//...
}
}
複製程式碼
從init
函式整體來看,它接受一個modules陣列,返回一個新的函式patch
。這不就是我們熟悉的閉包函式嗎?在init
中,它會將引入模組的鉤子函式通過遍歷儲存在cbs
變數裡,後面在執行更新演算法時會相應的觸發這些鉤子函式。只需要初始化一次,後面virtual dom的更新都是通過patch
來完成的。
流程圖如下,
patch
最為複雜也最為耗時的部分就是如何實現virtual dom的更新,更新演算法的好壞直接影響整個框架的效能,比如React中的react-reconciler模組,到vue中的vdom模組,都是最大可能優化這一部分。在Snabbdom中virtual dom的更新邏輯大致如下,
//這個patch就是init返回的
function patch(oldVnode,vnode){
//第一步:如果oldVnode是Element,則根據Element建立一個空的vnode,這個也是vnode tree的根節點
if(!isVnode(oldVnode)){
oldVnode = emptyAtNode(oldVnode);
}
//第二步:判斷oldVnode是否與vnode相同的元素,如果是,則更新元素即可。這裡判斷它們是否相同,是對比了它們的key相同且tagName相同且ID屬性相同且類相同
if(sameVnode(oldVnode,vnode)){
patchVnode(oldVnode,vnode);
}else{
//第三步:如果不相同,則直接用vnode建立新的element元素替換oldVnode,且刪除掉oldVnode。
elm = oldVnode.elm;
parent = api.parentNode(elm);
createElm(vnode);
if(parent !== null){
api.insertBefore(parent,vnode.elm,api.nextSlibing(elm));
removeVnodes(parent,[oldVnode], 0, 0);
}
}
}
複製程式碼
patch
邏輯可以簡化為下面:
- 如果oldVnode是Element型別,則根據oldVnode建立一個空vnode,這個空vnode也是這個vnode tree的root節點
- 比較oldVnode與vnode,如果是同一個vnode(key值相同)或者是相同型別的元素(tagName相同且id相同且class相同),則直接呼叫
patchVnode
- 否則,直接根據vnode建立一個新的element,且用新的element替換掉oldVnode的element,且刪除掉oldVnode
流程圖如下,
在進行第3步時,當oldVnode與vnode不相同,是直接拋棄了舊的節點,建立新的節點來替換,在用新vnode來建立節點時會檢查當前vnode有沒有children,如果有,則也會遍歷children建立出新的element。這意味oldVnode以及包含的所有子節點將被作為一個整體被新的vnode替換。示意圖如下,
如果B與B'不相同,則B在被B'替換的過程中,B的子節點D也就被B'的子節點D'和E'一起替換掉了。
patchVnode
我們再來看看第2步,如果oldVnode與vnode相同,則會複用之前已經建立好的dom,只是更新這個dom上的差異點,比如text,class,datalist,style等。這個是在函式patchVnode
中實現的,下面為它的大致邏輯,
function patchVnode(oldVnode,vnode){
const elm = oldVnode.elm; //獲取oldVnode的dom物件
vnode.elm = elm; //將vnode的elm直接指向elm,複用oldVnode的dom物件,因為它們型別相同
//如果oldVnode與vnode相等,則直接返回,根本不用更新了
if(oldVnode === vnode){
return;
}
//如果vnode是包含text,且不等於oldVnode.text,則直接更新elm的textContent為vnode.text
if(isDef(vnode.text) && vnode.text !== oldVnode.text){
return api.setTextContext(elm,vnode.text);
}
let oldCh = oldVnode.children; //獲取oldVnode的子節點
let ch = vnode.children; //獲取vnode的子節點
//如果oldVnode沒有子節點,而vnode有子節點,則新增vnode的子節點
if(isUndef(oldCh) && isDef(ch)){
// 如果oldVnode有text值,則先將elm的textContent清空
if(idDef(oldVnode.text)){
api.setTextContext(elm,'');
}
addVnodes(elm,null,ch,0,ch.length-1);
}
//如果oldVnode有子節點,而vnode沒有子節點,則刪除oldVnode的子節點
else if(isUndef(ch) && isDef(oldCh)){
reoveVnodes(elm,oldCh,0,oldCh.length-1)
}
//如果它們都有子節點,並且子節點不相同,則更新它們的子節點
else if(ch !== oldCh){
updateChildren(elm,oldCh,ch);
}
//否則就是它們都有子節點,且子節點相同,如果oldVnode有text值,則將elm的textContent清空
else if(ifDef(oldVnode.text)){
api.setTextContext(elm,'');
}
}
複製程式碼
patchVnode
邏輯可以簡化為下面:
- 直接將vnode的elm設定為oldVnode的elm,以達到複用已有的dom物件,避免了建立新的dom物件的開銷
- 比較oldVnode === vnode,如果相等,則直接返回,不同更新,因為它們就是同一個物件
- 如果vnode有text值,則說明elm就只包含了純text文字,無其他型別子節點,如果它的值與oldVnode的text不相同,則更新elm的textContent,並返回。
- 這一步開始,真正比較它們的children了,
- 如果vnode有children,oldVnode沒有children,先清空elm的textContext,再將vnode的children新增進來
- 如果vnode沒有children,oldVnode有children,則直接刪除oldVnode的children
- 如果它們都有children,且不相同,則更新它們的children
- 如果它們都有children,且相同,則清空elm的textContext
流程圖如下,
在patchVnode
更新時,vnode會先是通過觸發定義在data資料上的鉤子函式來更新自己節點上的資訊,比如class或者styles等,然後再去更新children節點資訊。
updateChildren
更新vnode.children資訊是通過updateChildren
函式來完成的。只有當oldVnode上存在children,且vnode上也存在children時,並且oldVnode.children !== vnode.children
時,才會去呼叫updateChildren
。下面來梳理一下updateChildren
的大致邏輯,
function updateChildren(parentElm,oldCh,newCh){
// 舊的children
let oldStartIdx = 0;
let oldEndIdx = oldCh.length-1;
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh(oldEndIdx);
// 新的children
let newStartIdx = 0;
let newEndIdx = newCh.length-1;
let newStartVnode = newCh(newStartIdx);
let newEndVnode = newCh(newEndIdx);
let before = null;
// 迴圈比較
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(oldStartVnode == null){
// 當前節點可能被移動了
oldStartVnode = oldCh[++oldStartIdx];
}else if(oldEndVnode == null){
oldEndVnode = oldCh[--oldEndIdx];
}else if(newStartVnode == null){
newStartVnode = newCh[++newStartIdx];
}else if(newEndVnode == null){
newEndVnode = newCh[--newEndIdx];
}else if(sameVnode(oldStartVnode,newStartVnode)){
patchVnode(oldStartVnode,newStartVnode); // 更新newStartVnode
oldStartVnode = oldCh[++oldStartIdx]; // oldStartIdx 向右移動
newStartVnode = newCh[++newStartIdx]; // newStartIdx 向右移動
}else if(sameVnode(oldEndVnode,newEndVnode)){
patchVnode(oldEndVnode,newEndVnode); // 更新newEndVnode
oldEndVnode = oldCh[--oldEndIdx]; // oldEndIdx 向左移動
newEndVnode = newCh[--newEndIdx]; // newEndIdx 向左移動
}else if(sameVnode(oldStartVnode,newEndVnode)){
patchVnode(oldStartVnode,newEndVnode); //更新newEndVnode
let oldAfterVnode = api.nextSibling(oldEndVnode);
// 將oldStartVnode移動到當前oldEndVnode後面
api.insertBefore(parentElm, oldStartVnode.elm,oldAfterVnode);
oldStartVnode = oldCh[++oldStartIdx]; // oldStartIdx 向右移動
newEndVnode = newCh[--newOldVnode]; // newEndIdx 向左移動
}else if(sameVnode(oldEndVnode,newStartVnode)){
patchVnode(oldEndVnode,newStartVnode); // 更新newStartVnode
//將oldEndVnode移動到oldStartVnode前面
api.insertBefore(parentElm,oldEndVnode.elm,oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx]; // oldEndVnode 向右移動
newStartVnode = newCh[++newStartIdx]; // newStartVnode 向左移動
}else{
//獲取當前舊的children的節點的key與其index的對應值,
if(oldKeyIdx == undefined){
oldKeyIdx = createKeyToOldIdx(oldCh,oldStartIdx,oldEndIdx);
}
//獲取當前newStartVnode的key是否存在舊的children陣列裡
idxInOld = oldKeyIdx[newStartVnode.key];
if(isUndef(idxInOld)){
//如果當前newStartVnode的key不存在舊的children陣列裡,那麼這個newStartVnode就是新的,需要新建dom
let newDom = createElm(newStartVnode);
api.insertBefore(parentElm,newDom,oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}else{
//否則,當前newStartVnode的key存在舊的children裡,說明它們之前是同一個Vnode,
elmToMove = oldCh[idxInOld];
if(elmToMove.sel !== newStartVnode.sel){
//節點型別變了,不是同一個型別的dom元素了,也是需要新建的
let newDom = createElm(newStartVnode);
api.insertBefore(parentElm,newDom,oldStartVnode.elm);
}else{
// 否則,它們是同一個Vnode且dom元素也相同,則不需要新建,只需要更新即可
patchVnode(elmToMove,newStartVnode);
oldCh[idxInOld] = undefined; // 標誌舊的children當前位置的元素被移走了,
api.insertBefore(parentElm,elmToMove,oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
// 如果迴圈之後,還有未處理的children,
if(oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx){
// 如果新的children還有部分未處理,則把多的部分增加進去
if(oldStartIdx > oldEndIdx){
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1];
addVnodes(parentElm,before,newCh,newStartIdx,newEndIdx);
}else{
//如果舊的children還有未處理,則把多的部分刪除掉
removeVnodes(parentElm,oldCh,oldStartIdx,oldEndIdx);
}
}
}
複製程式碼
updateChildren
函式邏輯可以簡化為,
- 初始化迴圈變數
- 根據變數迴圈遍歷old children與new children,並逐個比較更新,當型別相同時,則呼叫
patchVnode
更新,當型別不同時,則直接新建new vnode的dom 元素,並插入到合適的位置 - 迴圈完了之後,增加新增的new vnode節點和移除舊的冗餘的old vnode
流程圖如下,
在updateChildren
函式中,逐個更新children中節點時,當比較的兩個節點型別相同時,又會反過來呼叫patchVnode
來更新節點,這樣,實際上存在了間接的遞迴呼叫。
life cycle hooks
在使用React或者Vue時,你會發現它們都分別定義了元件的生命週期方法,雖然名稱或觸發時機不完全相同,但是基本的順序和目的是差不多的。Snabbdom也提供了相應的生命週期鉤子函式,不同的是它提供了2套,一套是針對virtual dom 的,比如一個Vnode的create
,update
,remove
等;一套是針對modules的,通過在不同時機觸發不同module的鉤子函式去完成當前Vnode的更新操作。
modules的上的鉤子函式如下,
export interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}
複製程式碼
它的觸發時機圖如下,
在觸發modules的hooks函式時,不同的函式會接受不同的引數,下面為modukes中鉤子函式接受引數情況,
Name | Triggered when | Arguments to callback |
---|---|---|
pre |
在patch 函式開始處 |
無 |
create |
在createElm 函式中建立一個element時 |
vnode |
update |
在pathVnode 函式中更新Vnode時, |
oldVnode ,newVnode |
destroy |
在removeVnodes 函式中移除Vnode時, |
vnode |
remove |
在removeVnodes 函式中移除Vnode時, |
vnode ,removeCallback |
post |
在patch 函式最後處, |
無 |
大部分module中都沒有定義pre
函式和post
函式,主要是在create
,update
, destory
,remove
中對當前Vnode進行操作。比如,在class module中在create
函式內對Vnode上的操作如下,
// class modules 中在create鉤子函式中對當前Vnode操作
function updateClass(oldVnode: VNode, vnode: VNode): void {
var cur: any, name: string, elm: Element = vnode.elm as Element,
oldClass = (oldVnode.data as VNodeData).class,// 舊的class
klass = (vnode.data as VNodeData).class; // 新的class
if (!oldClass && !klass) return; // 都不存在class,直接返回
if (oldClass === klass) return; // 相等,直接返回
oldClass = oldClass || {};
klass = klass || {};
// 刪除那些存在oldVnode上而不存在vnode上的
for (name in oldClass) {
if (!klass[name]) {
elm.classList.remove(name);
}
}
// 遍歷當前vnode上的class,
for (name in klass) {
cur = klass[name];
//如果不想等
if (cur !== oldClass[name]) {
// 如果值為true,則新增class,否則移除class
(elm.classList as any)[cur ? 'add' : 'remove'](name);
}
}
}
複製程式碼
其他module的其他hook函式也都會對當前vnode更新,這裡就不一一列舉了。
我們再來看看對Vnode上的鉤子函式如下,
export interface Hooks {
init?: InitHook;
create?: CreateHook;
insert?: InsertHook;
prepatch?: PrePatchHook;
update?: UpdateHook;
postpatch?: PostPatchHook;
destroy?: DestroyHook;
remove?: RemoveHook;
}
複製程式碼
它的觸發時機以及接受引數情況如下,
Name | Triggered when | Arguments to callback |
---|---|---|
init |
在createElm 時會先觸發init |
vnode |
create |
在createElm 時,已經建好了element,已經對應的children都建立完畢,之後在觸發create |
emptyVnode ,vnode |
insert |
當vnode.elm 已經更新到dom文件上了,最後在patch 函式結尾處觸發 |
vnode |
prepatch |
在patchVnode 開始處就觸發了prepatch |
oldVnode ,vnode |
update |
在patchVnode 中,vnode.elm=oldVnode.elm 之後,更新children之前觸發 |
oldVnode ,vnode |
postpatch |
在patchVnode 中結尾處,已經更新為children後觸發, |
oldvnode ,vnode |
destroy |
在removeVnodes 中觸發,此時還沒有被移除 |
vnode |
remove |
在removeVnodes 中,destroy 之後觸發,此時還沒有真正被移除,需呼叫removeCallback 才真正將element移除 |
vnode ,removeCallback |
在Vnode
上的鉤子函式就是我們自己定義的了,定義在data.hooks
中,例如,
h('div.row', {
key: movie.rank,
hook: {
insert: (vnode) => { movie.elmHeight = vnode.elm.offsetHeight; }
}
});
複製程式碼
小結
在看了原始碼之後,其實最為複雜的地方就是updateChildren
中更新子節點,這裡為了避免重複建立element,而做了很多的判斷和比較,以達到最大化的複用之前已經建立好的element。與React和Vue類似,它在比較中也新增了key
來優化這一點。在更新Vnode對應的element時,它將不同資料分解到不同module中去更新,通過鉤子函式來觸發,這一點非常的優雅。