VirtualDOM的簡單實現

虛擬DOM(Virtual DOM)是Vue和React框架實現數據動態更新視圖的關鍵技術,它利用JS運算速度優與DOM從而大大提升的視圖渲染性能。因爲虛擬DOM是用一個普通對象來表示視圖節點結果,因此能夠利用這個對象來渲染到不一樣平臺,生成對應的原生控件來實現跨平臺。node

預期效果

咱們先來看下面一個例子:算法

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

var vnode = h('div', { key: 'app' }, [h('h1', {}, 'I am old vnode')]);
var newVnode = h('div', { key: 'app' }, [
  h('span', {}, 'after two second'),
  h('h1', {}, 'I am new vnode')
]);

// 首次掛載
patch(container, vnode);

setTimeout(() => {
  patch(vnode, newVnode);
}, 2000);
複製代碼

咱們建立一個虛擬節點vnode掛載到一個<div id="app"></div> 上,兩秒後和新的虛擬節點newVnode比對,更新視圖。其中h()函數是建立虛擬節點,patch()函數是對比兩個虛擬節點,實現更新DOM。app

建立虛擬節點

由於虛擬節點就是一個JS對象,因此咱們能夠定義一個VNode類來表示節點。由於考慮到參數處理,能夠定義一個建立節點函數,在函數內對參數進行處理並用new VNode()返回一個虛擬節點:框架

// VNode類
function VNode(tag, data, ch, text, elm) {
  this.tag = tag || '';
  this.data = data || {};
  this.children = ch;
  this.text = text;
  this.key = data.key || '';
  this.elm = elm || null;
}

// 建立vnode函數
function h(tag, data, ch) {
  var text, children;
  if (ch) {
    // 考慮子節點是文本
    if (typeof ch === 'string') {
      text = ch;
    } else {
      children = ch;
    }
  }

  if (children && children.length) {
    for (var i = 0; i < children.length - 1; i++) {
      if (typeof children[i] === 'string') {
        // 把子節點所有轉爲vnode
        children[i] = new VNode(undefined, undefined, undefined, children[i]);
      }
    }
  }

  return new VNode(tag, data, children, text);
}
複製代碼

輔助函數

由於咱們須要對DOM進行操做,預先定義一些操做函數:dom

var domApi = {
  createElement: function(tag) {
    return document.createElement(tag);
  },
  createTextNode: function(text) {
    return document.createTextNode(text);
  },
  appendChild: function(node, child) {
    node.appendChild(child);
  },
  removeChild: function(node, child) {
    node.removeChild(child);
  },
  insertBefore: function(parent, node, before) {
    parent.insertBefore(node, before);
  },
  setTextContent: function(node, text) {
    node.textContent = text;
  },
  parentNode: function(node) {
    return node.parentNode;
  },
  nextSibling: function(node) {
    return node.nextSibling;
  }
};
複製代碼

另外,建立一個判斷是否爲同一個虛擬節點的函數:函數

// 簡單判斷兩個vnode是不是同一個節點
function sameVnode(oldVnode, vnode) {
  return oldVnode.tag === vnode.tag && oldVnode.key === vnode.key;
}
複製代碼

patch函數

patch()函數是對比兩個虛擬節點更新視圖的入口。因爲考慮到第一次掛載的時候oldVnode是DOM元素,須要一個DOM轉爲vnode的函數:post

// 把DOM元素轉爲簡單的vnode
function toVnode(elm) {
  var tag = elm.tagName.toLowerCase();
  return new VNode(tag, {}, [], undefined, elm);
}
複製代碼

若是兩個虛擬節點不是同一個節點,直接用vnode建立一個DOM元素,替換原來的DOM便可。替換是用新DOM利用insertBefore()插入的老DOM的旁邊,而後移除老的DOM元素:性能

function patch(oldVnode, vnode) {
  if (oldVnode.tag === undefined) {
    oldVnode = toVnode(oldVnode);
  }

  if (sameVnode(oldVnode, vnode)) {
    // 進行比對 
    patchVnode(oldVnode, vnode);
  } else {
    var elm = oldVnode.elm;
    var parentElm = domApi.parentNode(elm);

    // 建立DOM元素
    createElm(vnode);

    if (parentElm) {
      // 插入新DOM
      domApi.insertBefore(parentElm, vnode.elm, elm);
      // 移除老的DOM
      removeVnodes(parentElm, [oldVnode], 0, 0);
    }
  }
}
複製代碼

節點操做函數

增長createElm()函數把虛擬節點vnode轉爲真實的DOM,這裏要對虛擬節點的子節點children進行遞歸轉化:優化

function createElm(vnode) {
  if (vnode.tag) {
    var elm = (vnode.elm = domApi.createElement(vnode.tag));

    if (vnode.children && vnode.children.length) {
      // 遞歸轉化children
      vnode.children.forEach(function(child) {
        domApi.appendChild(elm, createElm(child));
      });
    } else {
      domApi.appendChild(elm, domApi.createTextNode(vnode.text));
    }
  } else {
    // 文本節點
    vnode.elm = domApi.createTextNode(vnode.text);
  }

  return vnode.elm;
}
複製代碼

增長addVnodes()函數批量插入虛擬節點到真實的DOM中:ui

function addVnodes(parent, before, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    var vnode = vnodes[startIdx];
    if (vnode) {
      domApi.insertBefore(parent, createElm(vnode), before);
    }
  }
}
複製代碼

增長removeVnodes()函數批量刪除DOM中的節點:

function removeVnodes(parent, vnodes, startIdx, endIdx) {
  var vnode, elm;
  for (var i = startIdx; i <= endIdx; ++i) {
    vnode = vnodes[i];
    elm = vnode.elm;
    domApi.removeChild(parent, elm);
  }
}
複製代碼

節點Diff

patch()函數中若是是同一個節點,咱們要進行節點的diff。如下是Diff算法的大概流程:

  • 新虛擬節點有文本屬性:移除老虛擬節點的子節點,插入新文本。

  • 新虛擬節點無文本屬性:

    • 新節點有children:若是老節點也有children,須要進行下一輪的比對。若是沒有children,則清空老節點的文本後插入新節點的children。

    • 新節點無children:刪除老節點的children或者文本。

算法的流程圖以下:

實現節點對比函數patchVnode()

function patchVnode(oldVnode, vnode) {
  if (oldVnode === vnode) return;
  var elm = (vnode.elm = oldVnode.elm);
  var oldCh = oldVnode.children;
  var newCh = vnode.children;

  if (!vnode.text) {
    if (oldCh && newCh) {
      updateChildren(elm, oldCh, newCh);
    } else if (newCh) {
      // 只有新的vnode有children
      if (oldVnode.text) {
        domApi.setTextContent(elm, '');
      }
      addVnodes(elm, newCh, 0, newCh.length - 1);
    } else if (oldCh) {
      // 只有老的vnode有children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    } else {
      domApi.setTextContent(elm, '');
    }
  } else {
    // 新的vnode爲文本節點
    if (oldCh) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    }
    domApi.setTextContent(elm, vnode.text);
  }
}
複製代碼

子節點的更新

上面咱們提到當vnode和oldVnode都有子節點時,須要比對更新子節點。比對的大體流程爲循環新的子節點,找出在老子節點中相同的節點:

  • 若是找到了,遞歸調用patchVnode()函數進行更新。而後判斷是否須要移動子節點。
  • 若是沒找到則爲新增的子節點,建立DOM插入到相應的位置便可。

以上算法的時間複雜度爲O(n^2),能夠用下面快速查找的方式優化到O(n)複雜度。

在每輪循環中,先進行下面4種比較:

  • 第一個老子節點和第一個新子節點
  • 最後一個老子節點和最後一個新子節點
  • 第一個老子節點和最後一個新子節點
  • 最後一個老子節點和第一個新子節點

上面的第一個和最後一個都是針對未處理範圍的而言。在正確循環體內的子節點都是未處理的。若是上面4種快捷查找都沒找到,則遍歷查找老的子節點。

很明顯,上面的優化策略咱們須要4個索引進行跟蹤。在退出循環後,存在下面兩種狀況:

  • 若是老子節點存在未處理的,則在DOM中刪除這些廢棄的節點
  • 若是新子節點存在未處理的,則在DOM中批量添加這些新節點

代碼實現updateChildren()以下:

function updateChildren(parentElm, oldCh, newCh) {
  if (oldCh === newCh) return;
  var oldStartIdx = 0,
    oldEndIdx = oldCh.length - 1,
    oldStartVnode = oldCh[0],
    oldEndVnode = oldCh[oldEndIdx],
    newStartIdx = 0,
    newEndIdx = newCh.length - 1,
    newStartVnode = newCh[0],
    newEndVnode = newCh[newEndIdx],
    oldKeyToIdx,
    idxInOld;

  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)) {
      parentNode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode);
      // 把節點移動到爲處理的最後面
      domApi.insertBefore(parentElm, oldStartVnode.elm, oldEndVnode.elm);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode);
      // 把節點移動到未處理的最前面
      domApi.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 老的vnode的key到idx的映射
      // 這裏只求出爲爲處理節點的映射,一點小優化
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToIdx(oldCh, oldStartIdx, oldEndIdx);
      }

      idxInOld = oldKeyToIdx[newStartVnode.key];
      if (idxInOld === undefined) {
        // 子節點是新增的節點
        domApi.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
      } else {
        var moveVnode = oldCh[idxInOld];
        if (moveVnode.tag !== newStartVnode.tag) {
          // key相同可是tag不一樣也爲新增
          domApi.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
        } else {
          patchVnode(moveVnode, newStartVnode);
          // 把節點移動到未處理的最前面
          domApi.insertBefore(parentElm, moveVnode.elm, oldStartVnode.elm);
          oldCh[idxInOld] = null;
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }

  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      // 插入剩餘新增的子節點
      var before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx);
    } else {
      // 刪除廢棄的子節點
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}
複製代碼

參考

snabbdom 源碼閱讀分析

>>>原文地址

相關文章
相關標籤/搜索