Virtual DOM和snabbdom.js

什么是虛擬dom

虛擬DOM是一種使用js對象來描述真實DOM的技術(shù),通過這種技術(shù),一方面我們能精確知道哪些真實DOM改變了,從而盡量減少DOM操作的性能開銷。另外一方面由于真實DOM都通過js對象來描述了,所以我們可以嘗試使用數(shù)據(jù)來驅(qū)動DOM開發(fā),比如著名的react就是這樣做的。

為什么要減少操作DOM開銷呢?

首先這個和瀏覽器的架構(gòu)有關(guān)系,在webkit的瀏覽器架構(gòu)中DOM模塊和js的模塊是互相獨立且分割的,因此每次操作DOM的開銷要比單純的操作一次js開銷要大。
其次在整個前端項目中,瀏覽器的重繪和重排性能開銷最大,因此盡量減少瀏覽器的重繪和重排是前端項目在做性能優(yōu)化的時候的重點。
最后目前大型頁面/單頁應(yīng)用里動態(tài)創(chuàng)建/銷毀 DOM 很常見,而在之前的jquery時代,經(jīng)常會出現(xiàn)因為列表中的一項改變而重置整個列表的dom,因此減少操作DOM開銷在現(xiàn)在的前端開發(fā)中顯得十分必要。

為什么是snabbdom.js

由于虛擬dom有那么多的好處而且現(xiàn)代前端框架中react和vue均不同程度的使用了虛擬dom的技術(shù),因此通過一個簡單的 庫賴學(xué)習(xí)虛擬dom技術(shù)就十分必要了,至于為什么會選擇snabbdom.js這個庫呢?原因主要有兩個:

  1. 源碼簡短,總體代碼行數(shù)不超過500行。
  2. 著名的vue的虛擬dom實現(xiàn)也是參考了snabbdom.js的實現(xiàn)。

帶著問題看snabbdom.js

如果要我們自己去實現(xiàn)一個虛擬dom,大概過程應(yīng)該有以下三步:

  1. compile,如何把真實DOM編譯成vnode。
  2. diff,我們要如何知道oldVnode和newVnode之間有什么變化。
  3. patch, 如果把這些變化用打補(bǔ)丁的方式更新到真實dom上去。

通過這三個階段我們就可以觀察一波snabbdom.js的源碼。

snabbdom.js結(jié)構(gòu)總覽


src
├── helpers     
│   └── attachto.ts # 定義了AttachData,VNodeDataWithAttach ,VNodeWithAttachData 等數(shù)據(jù)結(jié)構(gòu)
├── modules # 該文件夾中主要存放一些在更新dom差異的時候需要的操作     
│   ├── attributes.ts # 在vnode更新的時候,更新dom中的attrs操作。
│   ├── class.ts  # 在vnode更新的時候,更新dom中的class操作。
│   ├── dataset.ts # 在vnode更新的時候,更新dom中的dataset(自定義數(shù)據(jù)集)操作。
│   ├── eventlisteners.ts  # 在vnode更新的時候,更新dom中的eventlisteners(自定義數(shù)據(jù)集)操作。
│   ├── hero.ts # 在vnode更新的時候,和動畫效果有關(guān)的支持
│   ├── module.ts # 定義的module結(jié)構(gòu)
│   ├── props.ts # 在vnode更新的時候,更新dom中的props操作。
│   └── style.js # 在vnode更新的時候,更新dom中的style操作。
├── h.ts   # 幫助函數(shù)主要用來操作生成vnode的。
├── hooks.ts   # 定義snabbdom在運行的過程中hooks的模型。    
├── htmldomapi.ts # 對瀏覽器的dom的api進(jìn)行二次包裝,可以直接操作,html的dom的api。
├── is.ts # is函數(shù)主要是針對做一些數(shù)據(jù)類型判斷,分 primitive和array類型。 
├── snabbdom.bundle.ts # snabbdom.ts、attributes、class、props、style 、eventListenersModule和h組成了這個ts文件。
├── snabbdom.ts # 主要文件,程序的主線邏輯都在這個文件里。
├── thunk.ts # thunk這個文件不知道干什么的,但是不影響理解主線邏輯。   
├── tovnode.ts   # 提供了toVNode的方法,把真實dom轉(zhuǎn)化為vnode。
└── vnode.ts # 定義了vnode的模型和轉(zhuǎn)化成為vnode的工具方法。 
 

從上面的代碼結(jié)構(gòu),我們可以看到關(guān)于snabbdom.js中最主要的代碼幾個文件是h.tssnabbdom.ts,tovnode.tsvnode.ts。

vnode.ts

vnode 是對 DOM 節(jié)點的抽象,既然如此,我們很容易定義它的形式:

{
  type:String, // String,DOM 節(jié)點的類型,如 'div'/'span'
  data:Object,  // Object,包括 props,style等等 DOM 節(jié)點的各種屬性
  children : Array // Array,子節(jié)點(子 vnode)
}

所以讓我們來看下snabbdom.js中對vnode的實際定義又是怎么做的:

export interface VNode {
  sel: string | undefined; // VNode的選擇器,nodeName+id+class的組合
  data: VNodeData | undefined; // 存放VNodeData的地方,具體見下面的VNodeData定義
  children: Array<VNode | string> | undefined; // vnode的子vnode的地方
  elm: Node | undefined; // 存儲vnode對應(yīng)的真實的dom的地方
  text: string | undefined; // vnode的text文本,和children只能二選一
  key: Key | undefined; // vnode的key值,主要用于后續(xù)vnode的diff過程
}

export interface VNodeData {
  props?: Props; // vnode上傳遞的其他屬性
  attrs?: Attrs; // vnode上的其他dom屬性,可以通過setAttribute來設(shè)置或刪除的。
  class?: Classes; // vnode上的class的屬性集合
  style?: VNodeStyle; // vnode上的style屬性集合
  dataset?: Dataset; // vnode掛載的數(shù)據(jù)集合
  on?: On;  // 監(jiān)聽的事件集合
  hero?: Hero; 
  attachData?: AttachData; // 額外附加的數(shù)據(jù)
  hook?: Hooks; // vnode的鉤子函數(shù)集合,主要用于在不同階段調(diào)用不通過的鉤子函數(shù)
  key?: Key; 
  ns?: string; // for SVGs 命名空間,主要用于SVG
  fn?: () => VNode; // for thunks
  args?: Array<any>; // for thunks
  [key: string]: any; // for any other 3rd party module
}

繼續(xù)往下看我們發(fā)現(xiàn)vnode.ts中不僅僅存在Vnode和VnodeData兩個數(shù)據(jù)模型,還有一個vnode的工具方法,用于生成vnode。

// 參數(shù)是sel,data,children,text,elm,返回值是一個VNode的對象
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};
}

好了,現(xiàn)在我們已經(jīng)有vnode和一個生成vnode的函數(shù),接下來我們看看,如何把真實的dom轉(zhuǎn)為vnode

tovnode.ts

真實的dom轉(zhuǎn)為vnode的方法存放在tovnode.ts的文件里,方法名是toVNode。

// 參數(shù)是要求一個真實的dom對象
export function toVNode(node: Node, domApi?: DOMAPI): VNode {
  // 這邊定義了一個變量叫api,主要是一些用于dom操作的api接口。
  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
  // 定義了text的變量
  let text: string;
  // 如果node是一個element類型的dom對象就進(jìn)行如下的操作
  if (api.isElement(node)) {
    // 獲得node的id,變成#id 
    const id = node.id ? '#' + node.id : '';
   // 獲得class,變成.class1.class2這樣的形式
    const cn = node.getAttribute('class');
    const c = cn ? '.' + cn.split(' ').join('.') : '';
   // sel 變成tagName+id+class的形式,比如<div id=id class=class></div>的sel的值就變成了div#id.class
    const sel = api.tagName(node).toLowerCase() + id + c;
   // 定義一系列后續(xù)需要使用的attrs,children等對象。
    const attrs: any = {};
    const children: Array<VNode> = [];
    let name: string;
    let i: number, n: number;
    // 獲得元素里所有的attrs
    const elmAttrs = node.attributes;
   // 獲得元素中所有子節(jié)點,這邊不用children
    const elmChildren = node.childNodes;
    for (i = 0, n = elmAttrs.length; i < n; i++) {
      name = elmAttrs[i].nodeName;
      if (name !== 'id' && name !== 'class') {
        // 把非id和class的屬性值放到attrs中 
        attrs[name] = elmAttrs[i].nodeValue;
      }
    }
    for (i = 0, n = elmChildren.length; i < n; i++) {
      // 通過遞歸的方式把子節(jié)點翻譯成vnode放入children數(shù)組中
      children.push(toVNode(elmChildren[i], domApi));
    }
   // 生成完整的vnode并返回
    return vnode(sel, {attrs}, children, undefined, node);
  } else if (api.isText(node)) {
   // 如果node是一個textContent類型的就返回文本的vnode
    text = api.getTextContent(node) as string;
    return vnode(undefined, undefined, undefined, text, node);
  } else if (api.isComment(node)) {
   // 如果node是一個comment類型的就返回sel是"!"的文本的vnode
    text = api.getTextContent(node) as string;
    return vnode('!', {}, [], text, node as any);
  } else {
    // 如果什么都不是就返回一個空的vnode 
    return vnode('', {}, [], undefined, node as any);
  }
}

OK,現(xiàn)在我們已經(jīng)知道了如何把一個真實的dom節(jié)點轉(zhuǎn)化成為vnode,用js對象的方式生成vnode,可以看到有vnode的方法題目,但是單純使用vnode函數(shù)來創(chuàng)建vnode比較繁瑣,所以snabbdom就提供了相應(yīng)的幫助函數(shù)來方便我們創(chuàng)建vnode,在h.ts中。

h.ts

 // 以下所有代碼到函數(shù)體為止都做了一件事情,對h這個函數(shù)進(jìn)行重載,看不懂的可以去理解下typescript
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
  // 以下一串if判斷主要用于規(guī)范參數(shù)的形式,統(tǒng)一轉(zhuǎn)化。
  if (c !== undefined) {
    data = b;
    if (is.array(c)) { children = c; }
    else if (is.primitive(c)) { text = c; }
    else if (c && c.sel) { children = [c]; }
  } else if (b !== undefined) {
    if (is.array(b)) { children = b; }
    else if (is.primitive(b)) { text = b; }
    else if (b && b.sel) { children = [b]; }
    else { data = b; }
  }
 // 對文本或者數(shù)字類型的子節(jié)點進(jìn)行轉(zhuǎn)化
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
    }
  }
  // 針對svg的node進(jìn)行特別的處理
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
    addNS(data, children, sel);
  }
 // 返回一個正常的vnode對象。
  return vnode(sel, data, children, text, undefined);
};

以上,我們已經(jīng)有了各種方法來生成一個vnode,包括從普通js對象生成,從真實的dom來生成。但是我們怎么從vnode生成真實的dom呢?接下來讓我們來看看snabbdom.js中最重要的主代碼snabbdom.ts。

snabbdom.ts

利用vnode生成真實dom在snabbdom中主要是通過createElm方法來實現(xiàn),該方法放在snabbdom.ts中。

  //根據(jù)VNode創(chuàng)建element
  function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any, data = vnode.data;
    if (data !== undefined) {
      //如果VNodeData存在且hooks里有init函數(shù),則執(zhí)行init函數(shù),然后重新賦值VNodeData
      if (isDef(i = data.hook) && isDef(i = i.init)) {
        i(vnode);
        data = vnode.data;
      }
    }
    // 子虛擬dom,
    let children = vnode.children, sel = vnode.sel;
    // 當(dāng)sel == "!"的時候表示這個vnode就是一個comment
    if (sel === '!') {
      if (isUndef(vnode.text)) {
        vnode.text = '';
      }
      vnode.elm = api.createComment(vnode.text as string);
    } else if (sel !== undefined) {
      // Parse selector 這么一段就是為了從sel中獲得tag值,id值,class值
      const hashIdx = sel.indexOf('#');
      const dotIdx = sel.indexOf('.', hashIdx);
      const hash = hashIdx > 0 ? hashIdx : sel.length;
      const dot = dotIdx > 0 ? dotIdx : sel.length;
      const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
      const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
                                                                               : api.createElement(tag);
      // 設(shè)置元素的id
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
      // 設(shè)置元素的class
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));
      // 調(diào)用create鉤子
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            //深度遍歷
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }
      i = (vnode.data as VNodeData).hook; // Reuse variable
      if (isDef(i)) {
        if (i.create) i.create(emptyNode, vnode);
        //當(dāng)insert的hook存在,就在插入Vnode的隊列中加入該vnode
        if (i.insert) insertedVnodeQueue.push(vnode);
      }
    } else {
      // 其他的情況就當(dāng)vnode是一個簡單的TextNode
      vnode.elm = api.createTextNode(vnode.text as string);
    }
    return vnode.elm;
  }

OK到現(xiàn)在為止,我們已經(jīng)把complie的階段弄的差不多了,現(xiàn)在只剩下,怎么比較 oldVnode 與 newVnode 兩個 vnode,并實現(xiàn) DOM 樹更新?也就是我們前面提到的diff方法和patch過程,在snabbdom.ts,diff和patch都寫在一起,我們繼續(xù)往下看。

  // patch過程
  function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    let i: any, hook: any;
    // 調(diào)用全局hook里定義的事件的地方。
    if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
      i(oldVnode, vnode);
    }
    // 因為 vnode 和 oldVnode 是相同的 vnode,所以我們可以復(fù)用 oldVnode.elm。
    const elm = vnode.elm = (oldVnode.elm as Node);
    let oldCh = oldVnode.children;
    let ch = vnode.children;
    if (oldVnode === vnode) return;
    if (vnode.data !== undefined) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
      i = vnode.data.hook;
      if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
    }
    // 如果 vnode.text 是 undefined
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        // 核心邏輯(最復(fù)雜的地方):怎么比較新舊 children 并更新,對應(yīng)上面
        // 的數(shù)組比較
        if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
        // 添加新 children
      } else if (isDef(ch)) {
        // 首先刪除原來的 text
        if (isDef(oldVnode.text)) api.setTextContent(elm, '');
        // 然后添加新 dom(對 ch 中每個 vnode 遞歸創(chuàng)建 dom 并插入到 elm)
        addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
      } else if (isDef(oldCh)) {
        // 相反地,如果原來有 children 而現(xiàn)在沒有,那么我們要刪除 children。
        removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
      } else if (isDef(oldVnode.text)) {
        // 最后,如果 oldVnode 有 text,刪除。
        api.setTextContent(elm, '');
      }
      // 否則 (vnode 有 text),只要 text 不等,更新 dom 的 text。
    } else if (oldVnode.text !== vnode.text) {
      api.setTextContent(elm, vnode.text as string);
    }
    if (isDef(hook) && isDef(i = hook.postpatch)) {
      i(oldVnode, vnode);
    }
  }

  // diff算法的重點
  function updateChildren(parentElm: Node,
                          oldCh: Array<VNode>,
                          newCh: Array<VNode>,
                          insertedVnodeQueue: VNodeQueue) {
    // parentElm:Node
    // oldCh: Array<VNode>
    // newCh: Array<VNode>
    // insertdVnodeQuenen: VNodeQuenen
    // 和patchVnode形成了精巧遞歸
    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;

    // 當(dāng)oldCh和newCh其中還有一個沒有比較完的話,就執(zhí)行下的函數(shù)
    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
        // 把獲得更新后的 (oldStartVnode/newEndVnode) 的 dom 右移,移動到
        // oldEndVnode 對應(yīng)的 dom 的右邊。為什么這么右移?
        // (1)oldStartVnode 和 newEndVnode 相同,顯然是 vnode 右移了。
        // (2)若 while 循環(huán)剛開始,那移到 oldEndVnode.elm 右邊就是最右邊,是合理的;
        // (3)若循環(huán)不是剛開始,因為比較過程是兩頭向中間,那么兩頭的 dom 的位置已經(jīng)是
        //     合理的了,移動到 oldEndVnode.elm 右邊是正確的位置;
        // (4)記住,oldVnode 和 vnode 是相同的才 patch,且 oldVnode 自己對應(yīng)的 dom
        //     總是已經(jīng)存在的,vnode 的 dom 是不存在的,直接復(fù)用 oldVnode 對應(yīng)的 dom。
        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
        //更新新舊vnode的值,然后vnode左移
        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];
        // 新的children中的startVnode元素沒有在舊children中找到
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 新的children中的startNode元素在舊children中找到元素
          elmToMove = oldCh[idxInOld];
          // 如果sel不相等則必須重新創(chuàng)建一個新的ele
          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];
        }
      }
    }

在上述的操作中,有一個重要的函數(shù)叫sameVnode,十分重要,對diff算法的影響十分巨大,通常情況下,找到兩棵任意的樹之間最小修改的時間復(fù)雜度是 O(n^3),這不可接受。幸好,我們可以對 Virtual DOM 樹有這樣的假設(shè):
如果 oldVnode 和 vnode 不同(如 type 從 div 變到 p,或者 key 改變),意味著整個 vnode 被替換(因為我們通常不會去跨層移動 vnode ),所以我們沒有必要去比較 vnode 的 子 vnode(children) 了。基于這個假設(shè),我們可以 按照層級分解 樹,這大大簡化了復(fù)雜度,大到接近 O(n) 的復(fù)雜度:


此外,對于 children (數(shù)組)的比較,因為同層是很可能有移動的,順序比較會無法最大化復(fù)用已有的 DOM。所以我們通過為每個 vnode 加上 key 來追蹤這種順序變動。

因為以上的兩個假設(shè),所以sameVnode方法的源代碼如下:

// 只要這兩個虛擬元素的sel(選擇器)和key一樣就是same的
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

至此,源代碼的主要邏輯已經(jīng)梳理完畢,想要看完整的snabbdom源代碼或者更有有關(guān)Virtual DOM可以查看參考文章。

參考文章

snabbdom源代碼
探索Virtual DOM的前世今生
Why Turbine doesn't use virtual DOM
如何看待 snabbdom 的作者開發(fā)的前端框架 Turbine 拋棄了虛擬DOM?

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容