Virtual DOM小叙

311 阅读7分钟

往期

前言

本文分为入门和进阶两部分,建议有经验的读者直接阅读进阶部分。

本文主要参考了vue和snabbdom这两个开源库,若读者阅读过它们的源码可以直接跳过本文 :)

入门

关于Node.insertBefore

首先我们需要知道Node.insertBefore这个API的用法, 其函数签名如下:

// 使用这个API需要三个元素: 新结点,引用结点以及这两个结点的共同父结点
var insertedNode = parentNode.insertBefore(newNode, referenceNode);

newNode可以是用document.createElement等API新建的DOM结点, 也可以是文档中已经存在的DOM结点。

referenceNode除了可以是文档中已经存在的DOM结点,也可以为null值。当其为null时newNode将被插入到父结点所有子结点的末尾。

由这个API我们可以轻松的做到同一父结点下的子结点位置交换, 举个例子:

<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结点中。

// 可以使用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 }

它们返回的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的组合复用模式。