最近想了解一下React和Vue框架分別在virtual dom部分的實現,以及他們的不一樣之處。因而先翻開Vue的源碼去找virtual dom 的實現,看到開頭,它就提到了Vue的virtual dom更新算法是基於Snabbdom實現的。因而,又去克隆了Snabbdom的源碼,發現它的源碼並非很複雜而且星星🌟還不少,因此就仔細看了一遍了,這裏就將詳細學習一下它是如何實現virtual dom的。javascript
在Snabbdom的GitHub上就解釋了,它是一個實現virtual dom的庫,簡單化,模塊化,以及強大的特性和性能。html
A virtual DOM library with focus on simplicity, modularity, powerful features and performance.vue
這裏是Snabbdom的倉庫地址。java
Snabbdom的簡單是基於它的模塊化,它對virtual dom的設計很是巧妙,在覈心邏輯中只會專一於vNode的更新算法計算,而把每一個節點具體要更新的部分,好比props
,class
,styles
,datalist
等放在獨立的模塊裏,經過在不一樣時機觸發不一樣module的鉤子函數去完成。經過這樣的方式解耦,不只可使代碼組織結構更加清晰,更可使得每一部分都專一於實現特定的功能,在設計模式中,這個也叫作單一職責原則。在實際場景使用時,能夠只引入須要用到的特定模塊。好比咱們只會更新節點的類名和樣式,而不關心屬性以及事件,那麼就只須要引用class和style的模塊就能夠了。例以下面這樣,node
// 這裏咱們只須要用到class和style模塊,因此就能夠只須要引用這2個模塊
var patch = snabbdom.init([
require('snabbdom/modules/class').default,
require('snabbdom/modules/style').default,
]);
複製代碼
它的核心方法就是這個init
,咱們先來簡單看一下這個函數的實現,react
//這裏是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
來完成的。git
流程圖以下,github
最爲複雜也最爲耗時的部分就是如何實現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
邏輯能夠簡化爲下面:typescript
patchVnode
流程圖以下,
在進行第3步時,當oldVnode與vnode不相同,是直接拋棄了舊的節點,建立新的節點來替換,在用新vnode來建立節點時會檢查當前vnode有沒有children,若是有,則也會遍歷children建立出新的element。這意味oldVnode以及包含的全部子節點將被做爲一個總體被新的vnode替換。示意圖以下,
若是B與B'不相同,則B在被B'替換的過程當中,B的子節點D也就被B'的子節點D'和E'一塊兒替換掉了。
咱們再來看看第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
邏輯能夠簡化爲下面:
流程圖以下,
在patchVnode
更新時,vnode會先是經過觸發定義在data數據上的鉤子函數來更新本身節點上的信息,好比class或者styles等,而後再去更新children節點信息。
更新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
函數邏輯能夠簡化爲,
patchVnode
更新,當類型不一樣時,則直接新建new vnode的dom 元素,並插入到合適的位置流程圖以下,
在updateChildren
函數中,逐個更新children中節點時,當比較的兩個節點類型相同時,又會反過來調用patchVnode
來更新節點,這樣,實際上存在了間接的遞歸調用。
在使用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中去更新,經過鉤子函數來觸發,這一點很是的優雅。