Virtual Dom庫snabbdom代碼解析

前言

DOM是很慢的。真正的 DOM 元素非常龐大埂蕊,這是因為標(biāo)準(zhǔn)就是這么設(shè)計的往弓。而且操作它們的時候你要小心翼翼疏唾,輕微的觸碰可能就會導(dǎo)致頁面重排產(chǎn)生回流重繪,這可是殺死性能的罪魁禍?zhǔn)住?/p>

Virtual Dom的原理是用 JavaScript 對象表示 DOM 信息和結(jié)構(gòu)函似,當(dāng)狀態(tài)變更的時候槐脏,重新渲染這個 JavaScript 的對象結(jié)構(gòu)。然后通過新渲染的對象樹去和舊的樹進行對比使用一個diff算法計算差異撇寞,記錄下來的不同就是我們需要對頁面真正的 DOM 操作顿天,然后把它們應(yīng)用在真正的 DOM 樹上,從而減少了頁面的回流和重繪次數(shù)蔑担。

image.png

Vue選擇的virtual dom庫是snabbdom牌废,本文是對這個庫的源代碼進行解析,核心會放在diff算法上钟沛。

代碼

項目地址:snabbdom
代碼是typescript畔规,不過我解析的時候會說一些補充的東西讓讀者不會出現(xiàn)因為是typescript所以看不懂的情況。

解析

解析我會從三個大方向上來說恨统,第一個是js模擬的dom節(jié)點vnode的結(jié)構(gòu)叁扫,第二個是diff算法,第三個是有了diff如何打patch的畜埋。

vnode結(jié)構(gòu)(用JS對象模擬DOM樹)

vnode的定義在項目中src文件夾的vnode.ts上

import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'

export type Key = string | number;

export interface VNode {
  sel: string | undefined;
  data: VNodeData | undefined;
  children: Array<VNode | string> | undefined;
  elm: Node | undefined;
  text: string | undefined;
  key: Key | undefined;
}

export interface VNodeData {
  props?: Props;
  attrs?: Attrs;
  class?: Classes;
  style?: VNodeStyle;
  dataset?: Dataset;
  on?: On;
  hero?: Hero;
  attachData?: AttachData;
  hook?: Hooks;
  key?: Key;
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: Array<any>; // for thunks
  [key: string]: any; // for any other 3rd party module
}

export function vnode(sel: string | undefined,
                      data: any | undefined,
                      children: Array<VNode | string> | undefined,
                      text: string | undefined,
                      elm: Element | Text | undefined): VNode {
  let key = data === undefined ? undefined : data.key;
  return {sel: sel, data: data, children: children,
          text: text, elm: elm, key: key};
}

export default vnode;

代碼中定義了兩個interface莫绣,VNode和VNodeData,暴露了一個vnode的構(gòu)造函數(shù)

vnode對象屬性

vnode對象有6個屬性
sel 可以是custom tag,可以是'div','span',etc,代表這個virtual dom的tag name
data, virtual dom數(shù)據(jù),它們與dom element的prop悠鞍、attr的語義類似对室。但是virtual dom包含的數(shù)據(jù)可以更靈活。比如利用./modules/class.js插件,我們在data里面輕松toggle一個類名h('p', {class: {'hide': hideIntro}})
children, 對應(yīng)element的children,但是這是vdom的children咖祭。vdom的實現(xiàn)重點就在對children的patch上
text, 對應(yīng)element.textContent,在children里定義一個string,那么我們會為這個string創(chuàng)建一個textNode
elm, 對dom element的引用
key 用于提示children patch過程,隨后面詳細說明

vnode的創(chuàng)建與渲染

在接下來的說明之前先介紹一個豆知識掩宜,在vue文檔中,同時在這個snabbdom中我們都會看到有個h函數(shù)么翰,這個函數(shù)我之前一直沒理解是什么意思牺汤。

wthat is 'h'
It comes from the term "hyperscript", which is commonly used in many virtual-dom implementations. "Hyperscript" itself stands for "script that generates HTML structures" because HTML is the acronym for "hyper-text markup language".
所以基本上h函數(shù)的意義差不多就是createElement的意思
在snabbdom里面h函數(shù)創(chuàng)建一個vnode并返回,具體實現(xiàn)就不細說了

diff算法(patch)

之前我們在snabbdom的事例里面看到

var snabbdom = require('snabbdom');
var patch = snabbdom.init([ // Init patch function with chosen modules
  require('snabbdom/modules/class').default, // makes it easy to toggle classes
  require('snabbdom/modules/props').default, // for setting properties on DOM elements
  require('snabbdom/modules/style').default, // handles styling on elements with support for animations
  require('snabbdom/modules/eventlisteners').default, // attaches event listeners
]);
var h = require('snabbdom/h').default; // helper function for creating vnodes

var container = document.getElementById('container');

var vnode = h('div#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 into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

var newVnode = h('div#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!')
]);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

可以看到patch函數(shù)是snabbdom.init出來的浩嫌,而且傳入的參數(shù)既可以是用h函數(shù)返回的一個vnode又可以是實際的dom元素檐迟,現(xiàn)在我們看看init方法的代碼,一些實現(xiàn)鉤子之類的代碼我們就不看了

  // snabbdom的init方法
  ...
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  ...
  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode);
    }

    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      elm = oldVnode.elm as Node;
      parent = api.parentNode(elm);

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };
}

先會傳入一個modules码耐,就是一個模組追迟,這些模組會在一些階段加一些鉤子,以此實現(xiàn)一個模組功能骚腥。說到模組功能我就想起了chartjs的模組敦间。模組為第三方開發(fā)者提供了個擴展程序的一個接口,在chartjs里面它使用的是觀察者模式,在一開始會register各個模組每瞒,通過在圖表創(chuàng)建金闽,更新,銷毀等地方寫了notify來通知各個模組剿骨,以此實現(xiàn)了一個模組功能。

回歸patch埠褪,我們它會先判斷是不是sameVnode(通過vnode的key和sel屬性是不是一樣的來判斷)浓利,如果原來不是同一個key的vnode的話那就沒必要用什么diff了,只能直接創(chuàng)建一個新元素钞速。這里我們專注于看如果是同一key同一sel下的vnode發(fā)送了某些變化之后怎么進行patch操作贷掖。

// patchVnode函數(shù)
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  let i: any, hook: any;
  ... prepatch的鉤子我也刪了
  const elm = vnode.elm = (oldVnode.elm as Node);
  let oldCh = oldVnode.children;
  let ch = vnode.children;
  if (oldVnode === vnode) return;
  ...update的鉤子,我刪了
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
    } else if (isDef(ch)) {
      if (isDef(oldVnode.text)) api.setTextContent(elm, '');
      addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
    } else if (isDef(oldVnode.text)) {
      api.setTextContent(elm, '');
    }
  } else if (oldVnode.text !== vnode.text) {
    api.setTextContent(elm, vnode.text as string);
  }
  ... postpatch鉤子
}

可以看到主要邏輯里面先做了isUndef(vnode.text)的判斷渴语,因為如果不是文本節(jié)點的話是不會有這個屬性的苹威,如果是文本節(jié)點直接setTextContent就行了,如果不是的話我們還要繼續(xù)看驾凶。
在vnode不是文本節(jié)點的時候做了四個判斷
前三個是判斷vnode和oldVnode是不是都有兒子牙甫,或者一個有一個沒有。
第一種情況如果都有兒子的話會調(diào)用updateChildren然后遞歸的調(diào)用patchVnode函數(shù)调违。
第二種情況vnode有兒子而oldVnode沒有窟哺,那差異就可以直接調(diào)用addVnodes在DOM上插入兒子并更新insertedVnodeQueue記錄了。
第三種情況是vnode沒有兒子而oldVnode有技肩,說明差異是兒子的移除且轨,直接調(diào)用removeVnodes在DOM上移除兒子并更新insertedVnodeQueue。
第四種情況就是這個節(jié)點是個文本節(jié)點虚婿,然后差異是oldVnode有text旋奢,vnode沒有了,直接調(diào)用setTextContent設(shè)置值為空

比較核心的還是前后vnode都有孩子的情況也就是updateChildren里面然痊,在進updateChildren函數(shù)之前我還有一點想說的至朗。
兩個樹的完全的 diff 算法是一個時間復(fù)雜度為 O(n^3) 的問題。但是在前端當(dāng)中玷过,你很少會跨越層級地移動DOM元素爽丹。所以 Virtual DOM 只會對同一個層級的元素進行對比:


image.png

updateChildren更新兒子

這段邏輯是主要的diff算法部分,有點復(fù)雜辛蚊,我看了2個多小時還結(jié)合其他資料才理解為什么要這樣寫粤蝎。
先貼一下代碼

  function updateChildren(parentElm: Node,
                          oldCh: Array<VNode>,
                          newCh: Array<VNode>,
                          insertedVnodeQueue: VNodeQueue) {
    let oldStartIdx = 0, newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];
    let oldKeyToIdx: any;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } 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, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          newStartVnode = newCh[++newStartIdx];
        } else {
          elmToMove = oldCh[idxInOld];
          if (elmToMove.sel !== newStartVnode.sel) {
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          } else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }
    }
    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

我開始閱讀的時候主要的疑惑點就是這一大坨if else,我們先來考慮一下以下幾種情況
newVnode 5 1 2 3 4
oldVnode 1 2 3 4 5
在代碼中首先會進入
else if (sameVnode(oldEndVnode, newStartVnode))
會先遞歸調(diào)用patchNode對這個子vnode打patch
然后把例子中oldVnode的5插入到1前面

       } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      }

有了個例子之后我們看一下我盜的幾個圖袋马,實際的DOM操作只有下面三種情況

image.png

上面這種情況的例子是
newVnode 0 2 3 1
oldVnode 0 1 2 3

      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      }
image.png

上面這種情況就是我之前舉的例子
newVnode 0 3 12
oldVnode 0 1 2 3
對應(yīng)代碼

      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      }
image.png

上面的情況的例子是newVnode里面的節(jié)點oldVnode里面沒有
newVnode 0 x 1 2 3
oldVnode 0 1 2 3
對應(yīng)代碼

       else {
        // 這一塊不用在意初澎,這只是一個根據(jù)key去找index的表
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        // 看看當(dāng)前newStartVnode在不在oldVnode里面
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        // 這里就是圖中所示插入新節(jié)點到dom
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 如果在newStartVnode在oldVnode中,
          elmToMove = oldCh[idxInOld];
          // 如果已經(jīng)不是一個vnode的東西了,直接新建節(jié)點插入到old頭探針之前
          if (elmToMove.sel !== newStartVnode.sel) {
            api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          } else {
            // 如果這個vnode還能搶救碑宴,遞歸調(diào)用patchVnode软啼,把對應(yīng)的elmToMove插入到old頭探針之前
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }

結(jié)束的時候,也就是當(dāng)oldVnode或者newVnode有一個遍歷完的時候

image.png

如上圖延柠,如果是old先遍歷完祸挪,則剩余的new里面的肯定要插進來啊
對應(yīng)代碼

    if (oldStartIdx > oldEndIdx) {
      before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    }
image.png

同理,如果new先遍歷完贞间,說明old里面有些元素要被移除
對應(yīng)代碼

    else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }

到此最麻煩的updateChildren就算解析完了贿条。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市增热,隨后出現(xiàn)的幾起案子整以,更是在濱河造成了極大的恐慌,老刑警劉巖峻仇,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件公黑,死亡現(xiàn)場離奇詭異,居然都是意外死亡摄咆,警方通過查閱死者的電腦和手機凡蚜,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來豆同,“玉大人番刊,你說我怎么就攤上這事∮靶猓” “怎么了芹务?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長鸭廷。 經(jīng)常有香客問我枣抱,道長,這世上最難降的妖魔是什么辆床? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任佳晶,我火速辦了婚禮,結(jié)果婚禮上讼载,老公的妹妹穿的比我還像新娘轿秧。我一直安慰自己,他們只是感情好咨堤,可當(dāng)我...
    茶點故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布菇篡。 她就那樣靜靜地躺著,像睡著了一般一喘。 火紅的嫁衣襯著肌膚如雪驱还。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天,我揣著相機與錄音议蟆,去河邊找鬼闷沥。 笑死,一個胖子當(dāng)著我的面吹牛咐容,可吹牛的內(nèi)容都是我干的舆逃。 我是一名探鬼主播,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼疟丙,長吁一口氣:“原來是場噩夢啊……” “哼颖侄!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起享郊,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎孝鹊,沒想到半個月后炊琉,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡又活,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年苔咪,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片柳骄。...
    茶點故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡团赏,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出耐薯,到底是詐尸還是另有隱情舔清,我是刑警寧澤,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布曲初,位于F島的核電站体谒,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏臼婆。R本人自食惡果不足惜抒痒,卻給世界環(huán)境...
    茶點故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望颁褂。 院中可真熱鬧故响,春花似錦挎扰、人聲如沸董虱。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽巩踏。三九已至署穗,卻和暖如春辟灰,著一層夾襖步出監(jiān)牢的瞬間剂桥,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工坯墨, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留寂汇,地道東北人。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓捣染,卻偏偏與公主長得像骄瓣,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子耍攘,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,592評論 2 353

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