Virtual DOM小敘

往期

前言

本文分爲入門和進階兩部分,建議有經驗的讀者直接閱讀進階部分。javascript

本文主要參考了vue和snabbdom這兩個開源庫,若讀者閱讀過它們的源碼能夠直接跳過本文 :)html

入門

關於Node.insertBefore

首先咱們須要知道Node.insertBefore這個API的用法, 其函數簽名以下:vue

// 使用這個API須要三個元素: 新結點,引用結點以及這兩個結點的共同父結點
var insertedNode = parentNode.insertBefore(newNode, referenceNode);
複製代碼

newNode能夠是用document.createElement等API新建的DOM結點, 也能夠是文檔中已經存在的DOM結點。java

referenceNode除了能夠是文檔中已經存在的DOM結點,也能夠爲null值。當其爲null時newNode將被插入到父結點全部子結點的末尾。node

由這個API咱們能夠輕鬆的作到同一父結點下的子結點位置交換, 舉個例子:app

<section>
  <h1>1</h1>
  <h2>2</h2>
  <h3>3</h3>
</section>
複製代碼
const $ = s => document.querySelector(s);
const parent = $('h2').parentNode;
// 交換三級標題與二級標題的位置
parent.insertBefore($('h3'), $('h2'));
複製代碼

若是你對DOM不太熟悉的話,使用下面的寫法可能會對結果產生困惑框架

// `$('h2').nextSibling`並不等於`$('h3')`, 而是一個空的text結點
parent.insertBefore($('h2').nextSibling, $('h2'));
複製代碼

因爲h2和h3之間存在換行和縮進(或者說空格), 又由於XML解析的時候全部空格都是須要被保留的,DOM的實現就把這些空格放在了text結點中。dom

// 可使用nextElementSibling, 不過IE9如下須要本身用nextSibling模擬
parent.insertBefore($('h2').nextElementSibling, $('h2'));
複製代碼

關於vnode和h函數

vnode本質就是一個對象,若是你使用過vue.js,你可能對下面的寫法很熟悉:函數

// 表明一個className爲foo, 文字顏色爲yellowgreen, innerText爲text的div元素
h('div', {
  class: 'foo',
  style: {
    color: 'yellowgreen',
  },
}, 'text');

// 包含一個span元素的div元素
h('div', {}, [
  h('span', 'span in div'),
]);
複製代碼

注: 爲了行文的簡潔,本文不討論h函數第一個參數中帶選擇器的寫法(即div#container.two.classes), 以及動態class的寫法, 即class: { foo: true, bar: false }post

它們返回的vnode形式大體是這樣的:

{
  children: null,
  data: {
    class: 'foo',
    style: {
      color: 'yellowgreen',
    },
  },
  tag: 'div',
  text: 'text',
}

{
  children: [{
    children: null,
    data: {},
    tag: 'span',
    text: 'span in div',
  }],
  data: {},
  tag: 'div',
  text: null',
}
複製代碼

因此咱們不難寫出h函數, 將第三個參數和第二個參數分別分類討論便可:

function isPrimitive(s) {
  const t = typeof s;
  return t === 'number' || t === 'string';
}

function h(tag, b, c) {
  let data = {};
  let children = null;
  let text = null;

  if (c !== undefined) {
    data = b;
    if (Array.isArray(c)) {
      children = c;
    } else if (isPrimitive(c)) {
      text = c;
    } else if (c && c.tag) {
      children = [c];
    }
  } else if (b !== undefined) {
    if (Array.isArray(b)) {
      children = b;
    } else if (isPrimitive(b)) {
      text = b;
    } else if (b && b.tag) {
      children = [b];
    } else {
      data = b;
    }
  }

  if (Array.isArray(children)) {
    // 若children中存在不以h函數聲明的子項
    children.forEach((child, i) => {
      if (isPrimitive(child)) {
        children[i] = {
          tag: null,
          data: {},
          children: null,
          text: child,
        };
      }
    });
  }

  return {
    tag, data, children, text,
  };
}
複製代碼

讓咱們來試試:

// Array.isArray(c)
const n1 = h('div', {}, ['text']);
// isPrimitive(c)
const n2 = h('div', {}, 'text');
// c && c.tag
const n3 = h('div', {}, h('span', 'text'));
// 固然不會有什麼問題
console.assert(n1.children[0].text === 'text');
console.assert(n2.tag === 'div');
console.assert(n3.children[0].text === 'text');
複製代碼

關於如何將vnode轉換爲DOM結點

因爲DOM結點本質上是多叉樹上的一個結點,因此利用遞歸能夠簡單地將vnode轉換成一個DOM結點:

function isPlainObject(obj) {
  return ({}).toString.call(obj) === '[object Object]';
}

function createElm(vnode) {
  const {
    tag, data = {}, children, text,
  } = vnode;
  
  if (tag) {
    const elm = document.createElement(tag);

    // class
    if (data.class) {
      elm.className = data.class;
    }

    const {
      style, props, on, attrs,
    } = data;
    // 將全部樣式應用到元素
    if (isPlainObject(style)) {
      Object.keys(style).map(k => {
        elm.style[k] = style[k];
      });
    }

    // 將全部props應用到元素, 如href等
    if (isPlainObject(props)) {
      Object.keys(props).map(k => {
        elm[k] = props[k];
      });
    }

    // 將全部attributes應用到元素,如id等
    if (isPlainObject(attrs)) {
      Object.keys(attrs).map(k => {
        elm[k] = attrs[k];
      });
    }

    // 在元素上綁定全部事件, 如click等
    if (isPlainObject(on)) {
      Object.keys(on).map(k => {
        if (typeof on[k] === 'function') {
          elm.addEventListener(k, on[k]);
        }
      });
    }

    if (Array.isArray(children)) {
      children.forEach((child) => {
        // 遞歸建立子元素,並添加到父元素
        elm.appendChild(createElm(child));
      });
    }

    if (text) {
      elm.appendChild(document.createTextNode(text));
    }

    vnode.elm = elm;
  } else if (text) {
    // 不使用h函數的text結點
    vnode.elm = document.createTextNode(text);
  }

  return vnode.elm;
}
複製代碼

進階

關於結點類型不一樣時的patch

patch函數接收兩個參數: oldVnode和vnode, 當oldVnode爲真實DOM結點時,將其轉換爲elm屬性不爲空的vnode, 而後判斷oldVnode和vnode的tag屬性值是否相等; 不相等則須要建立新的元素,即調用上文中的createElm函數

function isRealElement(node) {
  return node.nodeType !== undefined;
}
// 轉換爲elm屬性不爲空的vnode
function emptyNodeAt(elm) {
  return {
    tag: elm.tagName.toLowerCase(),
    data: {},
    children: [],
    text: null,
    elm,
  };
}

function sameVnode(vnode1, vnode2) {
  return vnode1.tag === vnode2.tag;
}

function patch(oldVnode, vnode) {
  // 將真實DOM結點轉爲vnode
  if (isRealElement(oldVnode)) {
    oldVnode = emptyNodeAt(oldVnode);
  }

  if (sameVnode(oldVnode, vnode)) {
    // 讓咱們暫時忽略這個函數, 先關注下面的邏輯 :)
    patchVnode(oldVnode, vnode);
  } else {
    const { elm } = oldVnode;
    const parent = elm.parentNode;

    if (parent) {
      const newNode = createElm(vnode);
      const referenceNode = elm.nextSibling;
      
      // 將新建的DOM結點插入到oldVnode所在結點的下個兄弟結點前
      parent.insertBefore(newNode, referenceNode);
      // 刪除oldVnode所在結點
      parent.removeChild(elm);
    }
  }
}
複製代碼

關於結點類型相同時的patch

咱們能夠先根據vnode的text屬性來更新,若是vnode的text屬性存在,咱們便不需關心oldVnode,只須要設置元素的textContent值便可。

當vnode的text屬性不存在時, 則須要分類討論:

  • oldVnode.childrenvnode.children都存在且不等,這種狀況是最複雜的,須要diff同一層的新舊結點(具體見updateChildren函數的註釋)
  • oldVnode.children不存在而vnode.children存在, 這種狀況比較簡單,只須要構建vnode.children的因此DOM結點並添加到oldVnode.elm上便可。須要注意的是當oldVnode的text屬性存在時須要將oldVnode.elm的textContext置空
  • vnode.children不存在而oldVnode.children存在, 刪除原有結點和監聽的事件便可

patchVnode函數就是完成上文分類討論的函數,注意註釋裏的內容!

function patchVnode(oldVnode, vnode) {
  // 爲後文的updateStyle等函數,將vnode.elm置爲oldVnode.elm
  vnode.elm = oldVnode.elm;
  const { elm } = oldVnode;
  const oldCh = oldVnode.children;
  const ch = vnode.children;
  
  if (oldVnode === vnode) {
    return;
  }
  
  // update props/attributes相似,爲了行文簡潔這裏就省略了
  updateStyle(oldVnode, vnode);

  if (vnode.text == null) {
    if (oldCh && ch && oldCh !== ch) {
      updateChildren(elm, oldCh, ch);
    } else if (ch) {
      // 若oldVnode存在text結點則置空
      if (oldVnode.text) {
        elm.textContent = '';
      }
      // oldVnode.children不存在, 添加新結點
      addVnodes(elm, ch);
    } else if (oldCh) {
      // vnode.children不存在, 刪除原有結點和監聽的事件
      removeVnodes(elm, oldCh);
    } else if (oldVnode.text) {
      elm.textContent = '';
    }
  } else if (vnode.text !== oldVnode.text) {
    // vnode的text屬性值不爲空且已改變, 即便原有結點存在諸多子元素也不須要逐個調用removeChild
    elm.textContent = vnode.text;
    // 刪除監聽的事件
    removeVnodes(null, oldCh);
  }
}
複製代碼
function updateStyle(oldVnode, vnode) {
  const { elm } = vnode;
  let { data: { style: oldStyle } } = oldVnode;
  let { data: { style } } = vnode;

  if (!oldStyle && !style) {
    return;
  }
  if (oldStyle === style) {
    return;
  }

  // 有可能爲undefined
  style = style || {};
  oldStyle = oldStyle || {};

  Object.keys(oldStyle).forEach((name) => {
    if (!style[name]) {
      elm.style[name] = '';
    }
  });

  Object.keys(style).forEach((name) => {
    if (style[name] !== oldStyle[name]) {
      elm.style[name] = style[name];
    }
  });
}

function removeVnodes(parentNode, vnodes) {
  vnodes.forEach(({ elm, data }) => {
    if (!elm) {
      return;
    }
    
    if (data) {
      if (isPlainObject(data.on)) {
        Object.keys(data.on).forEach((event) => {
          elm.removeEventListener(event, data.on[event]);
        });
      }

      if (parentNode) {
        parentNode.removeChild(elm);
      }
    }
  });
}

function addVnodes(parentNode, vnodes) {
  vnodes.forEach((vnode) => {
    if (vnode) {
      parentNode.appendChild(createElm(vnode));
    }
  });
}
複製代碼

diff同一層的全部新舊vnode, 若tag屬性值相等則遞歸更新,不相等則增長/刪除元素, 注意註釋裏的內容!

function updateChildren(parentNode, oldVnodes, vnodes) {
  let oldStartIdx = 0;
  let oldEndIdx = oldVnodes.length - 1;
  let oldStartVnode = oldVnodes[oldStartIdx];
  let oldEndVnode = oldVnodes[oldEndIdx];

  let startIdx = 0;
  let endIdx = vnodes.length - 1;
  let startVnode = vnodes[startIdx];
  let endVnode = vnodes[endIdx];

  while (oldStartIdx <= oldEndIdx && startIdx <= endIdx) {
    // 記舊子結點中第一個爲A, 最後一個爲B
    // 記新子結點中第一個爲C, 最後一個爲D
    if (sameVnode(oldStartVnode, startVnode)) {
      // AC類型相等
      patchVnode(oldStartVnode, startVnode);
      oldStartVnode = oldVnodes[oldStartIdx += 1];
      startVnode = vnodes[startIdx += 1];
    } else if (sameVnode(oldEndVnode, endVnode)) {
      // BD類型相等
      patchVnode(oldEndVnode, endVnode);
      oldEndVnode = oldVnodes[oldEndIdx -= 1];
      endVnode = vnodes[endIdx -= 1];
    } else if (sameVnode(oldStartVnode, endVnode)) {
      // AD類型相等
      patchVnode(oldStartVnode, endVnode);
      // oldStartVnode所在DOM元素插到全部子元素最末尾, 保證endVnode的位置正確
      parentNode.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldVnodes[oldStartIdx += 1];
      endVnode = vnodes[endIdx -= 1];
    } else if (sameVnode(oldEndVnode, startVnode)) {
      // BC類型相等
      patchVnode(oldEndVnode, startVnode);
      // oldEndVnode所在DOM元素插到全部子元素最前, 保證startVnode的位置正確
      parentNode.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldVnodes[oldEndIdx -= 1];
      startVnode = vnodes[startIdx += 1];
    } else {
      // ABCD類型都不相等
      parentNode.insertBefore(createElm(startVnode), oldStartVnode.elm);
      startVnode = vnodes[startIdx += 1];
    }
  }
  
  // oldVnodes元素個數大於vnodes元素個數, 即添加了某些元素
  if (oldStartIdx > oldEndIdx) {
    // 下個兄弟結點/null
    const referenceNode = vnodes[endIdx + 1] == null ? null : vnodes[endIdx + 1].elm;
    
    for (let i = startIdx; i <= endIdx; i += 1) {
      const vnode = vnodes[i];
      if (vnode) {
        parentNode.insertBefore(createElm(vnode), referenceNode);
      }
    }
  } else if (startIdx > endIdx) {
    // vnodes元素個數大於oldVnodes元素個數, 即刪除了某些元素
    removeVnodes(parentNode, oldVnodes.slice(oldStartIdx, oldEndIdx + 1));
  }
}
複製代碼

咱們能夠試試:

<main id="container"></main>
  
<script> const $ = s => document.querySelector(s); const oldVnode = h('div', [ h('span', { attrs: { id: 'abc' }, style: { fontWeight: 'bold' }, on: { click: (evt) => { console.warn(evt); }, }, }, 'This is bold'), 'a', h('a', {props: {href: '/foo'}}, 'I\'ll take you places!') ]); const vnode = h('div', [ h('section', { attrs: { id: 'abc' }, style: { fontWeight: 'bold', color: 'green' }, }, 'This is bold'), 'b', h('span', {props: {href: '/foo'}}, 'I\'ll take you places!') ]); patch($('#container'), oldVnode); setTimeout(() => { // 一秒後更新視圖 patch(oldVnode, vnode); }, 1000); </script>
複製代碼

好了,以上就是本文關於Virtual DOM的所有內容了。下篇文章將暫時撇開框架源碼,探討一下Mixins到HOC/Render Props再到Hooks的組合複用模式。

相關文章
相關標籤/搜索