1. 程式人生 > >關於 Virtual Dom 的簡單瞭解(snabbdom,Vue, React)

關於 Virtual Dom 的簡單瞭解(snabbdom,Vue, React)

  1. Virtual Dom 即根據最終狀態在記憶體中繪製出一棵 Virtual Dom Tree,使用 Diff 演算法與現存的 Dom Tree 對比並更新。
  2. Virtual Dom 並不能提升效能, 直接操作 Dom 理論上是最快的。

2. 深入淺出

1.) Virtual Node

/**
sel [string]: 選擇器, 比如 'div#id.class1.class2'
data [any]: 該節點屬性(包括style、class等)
children (Array[]Vnode): 子節點(也由此函式建立)
text [string]: 節點內部的 text
ele [HTMLElement]: Dom 元素
**/
function vnode(sel, data, children, text, elm) ( // 是否包含key,在list中元素變動時有些許效能影響 let key = data === undefined ? undefined : data.key; return {sel: sel, data: data, children: children, text: text, elm: elm, key: key}; }

並不是每次建立虛擬節點都需要那麼多引數,下面會根據傳入的引數來調整 vnode 的函式的產出:

/**
sel: 元素選擇器
b: 如果是陣列或包含sel屬性的object, 則為子節點; 如果是string, 則是文字節點; 否則就是data
c: 如果存在, 就肯定是子節點,同時b是data(型別判斷早於b)
**/
function
h(sel, b, c) { var data = {}, children, text, i; if (c !== undefined) { // ... } else if (b !== undefined) { // ... } if (is.array(children)) { for (i = 0; i < children.length; ++i) { // 如果該元素是字串或數字, 那麼就是個純文字節點 if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined); } } return
vnode(sel, data, children, text, undefined); }

通過以上方法,就可以創建出一整棵 Dom Tree 了, 如下:

var data = [
  {id: 65, content: 'A'},
  {id: 66, content: 'B'},
  {id: 67, content: 'C'},
];

var tree = h('ul', data.map(node => {
  return h('li', {key: node.id}, node.content)
}))

// 得到的虛擬dom結構如下:
// <ul>
//   <li>A</li>
//   <li>B</li>
//   <li>C</li>
// </ul>

2.) 從Virtual Dom 到 Real Dom

這是從 Virtual Dom 對映到 Real Dom 的 main 函式:

// oldVNode: 可以是 HTMLElement(第一次呼叫) 也可以是上一次生成的虛擬Dom tree
// Vnode: 根據最新狀態形成的虛擬Dom Tree
function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;

    // 用於生命週期 inserted 階段,記錄下所有新插入的節點以備呼叫
    const insertedVnodeQueue: VNodeQueue = [];

    // 整個 Diff 過程模組可註冊的鉤子(跳過) 
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    if (!isVnode(oldVnode)) {
      // 將 HTMLElement 轉換成 VNode
      oldVnode = emptyNodeAt(oldVnode);
    }

    // 如果兩個節點相似(節點的 sel 與 key 完全相等)則更新
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else { // 否則直接替換
      elm = oldVnode.elm as Node;
      parent = api.parentNode(elm);

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        // 取代 oldNode 的位置
        api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    // 整個 Diff 過程所有節點可註冊的節點插入後呼叫的鉤子(跳過)
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
    }
    // 整個 Diff 過程模組可註冊的鉤子(跳過)
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };

可見,整個流程比較清晰,替換節點或者在節點相似時更新節點(註釋中提到,程式碼通過比較key以及選擇器來判斷是否相似,當沒有指定key時,那麼元素只能通過選擇器來判斷)。下面處理節點更新細節的 patchVnode 函式:

function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    let i: any, hook: any;
    // 更新前呼叫的節點生命週期鉤子
    if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
      i(oldVnode, vnode);
    }
    const elm = vnode.elm = (oldVnode.elm as Node);
    let oldCh = oldVnode.children;
    let ch = vnode.children;

    // 完全是同一個物件,不作處理
    if (oldVnode === vnode) return;

    // 更新時呼叫的生命週期鉤子,包括模組以及節點自身的
    if (vnode.data !== undefined) {
      // 這裡如果引入了class模組,那麼就會更新class屬性;引入style模組,則會更新元素的樣式
      // 此節點的更新都再這個迴圈裡處理了
      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);
    }

    // 開始處理子節點
    // 如果不是純文字
    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);
    }

    // 生命週期鉤子,更新完成後呼叫
    if (isDef(hook) && isDef(i = hook.postpatch)) {
      i(oldVnode, vnode);
    }
  }

整個框架的模組也是重要的組成部分。使用者可以載入不同的模組讓框架選擇性的更新 Dom 中元素的屬性。
下面是某節點的所有子節點更新函式:

function updateChildren(parentElm: Node,
                          oldCh: Array<VNode>,
                          newCh: Array<VNode>,
                          insertedVnodeQueue: VNodeQueue) {

    // 幾個變數:
    // 1. 舊子節點陣列的 startIndex, endIndex, startNode, endNode
    // 2. 新子節點陣列的 startIndex, endIndex, startNode, endNode
    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
        // 1,2,3,4,5 --> 2,3,4,5,1  (處理一些特殊情況?reverse也可以用到)
        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
        // 1,2,3,4,5  --> 5,1,2,3,4 (處理一些特殊情況?reverse也可以用到)
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } else {
        // 如果每個子元素都有 key(可識別),那麼亂序後大概率會進這裡(list)
        if (oldKeyToIdx === undefined) {
          // 建立一個關於舊子元素陣列的, key --> index 的對映(map)
          // 沒有key則不存在於map中
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }

        // 新子元素,獲取該新元素在舊陣列中的位置(如果有key)
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        if (isUndef(idxInOld)) { // New element
          // 不存在在舊子元素陣列中(如果沒有指定key也會進入這裡)
          // 根據這個新元素建立dom元素插入
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 說明不是新元素,只是位置變了
          elmToMove = oldCh[idxInOld];   // 與新元素對應的舊元素
          if (elmToMove.sel !== newStartVnode.sel) {
            // 雖然key相同,但是元素tag已經不同了
            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 || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      } else {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }
  }

整個子節點 Diff 的過程也比較簡單。這樣也可以解答使用過程中的一個疑惑了,“為什麼給list中的元素新增唯一的key可以提升效能”?
因為在比對列表元素的過程中,一旦有了key值就可以複用之前的元素本身,而免去更新多數元素的過程了。比如原本是 [A, B, C, D, E], 此時往B與C之間插入元素F。
如果沒有攜帶 key 值,整個過程就是將C更新為F,D更新為C,E更新為D,最後新增元素E;
如果攜帶了key值,則會進入最後一個 ifelse,直接在B之後插入元素F,也不用更新其他任何元素。