虚拟DOM

Jan 14, 2019 00:00 · 1631 words · 4 minute read js

什么是虚拟DOM

前端的DOM操作是一个非常消耗性能的事, 因为每一个DOM元素对象拥有很多的属性

const divDom = document.createElement('div');
for (let key in divDom) {
    console.log(key);   // 大概可以打印出200+多个属性
}

在页面元素不多的情况下, 可以使用innHTML将我们想要显示的内容替换到页面上:

<body>
    <header>虚拟Dom实例:</header>
    <div id="anchor" class="container">
        <p>同学录: </p>
        <div class="list">
            <div><p>Name: Jack</p><p>Adge: 12</p></div>
            <div><p>Name: Tom</p><p>Adge: 14</p></div>
        </div>
    <div>

    <script>
        const anchorDom = document.getElementById('anchor');
        setTimeout(() => {
            anchorDom.innerHTML = `
                <p>同学录: </p>
                <div class="list">
                    <div class="active"><p>Name: Jack</p><p>Adge: 12</p></div>
                    <div><p>Name: Tom</p><p>Adge: 14</p></div>
                </div>
            `;
        }, 5000);
    </script>
</body>

这样页面上有一个id为anchor的锚点元素,里面有2个子元素, 5s后将第一个子元素的class更新为”active”, 这样做在内容较少或更新周期较长的情况下不会有什么性能问题. 但想想一下如果anchor包含上千个子元素, 这样的更新会使用户等待很长时间才能看到更新结果, 事实上前端主要的一部分工作就是维护状态并在状态变化时更新对应的视图 例如我在另一篇文章介绍的MVC. 页面的更新通常是频繁而复杂的.

虚拟DOM: 就是为了减少DOM的操作, 比如前面更新anchor的操作, 使用虚拟DOM操作后将会只更新修改部分的视图内容

<div class="active"><p>Name: Jack</p><p>Adge: 12</p></div>

它的原理也很简单, 首先将一个页面抽象成一个js Tree:

    const tree = new VElement('div', [
        new VElement('p', ['同学录: ']),
        new VElement('div', {'class': 'list'}, [
            new VElement('div', {'key': 'id_01'}, [ // key: 的作用是帮助虚拟DOM区分不同的子元素
                new VElement('p', ['Name: Jack'],
                new VElement('p', ['Adge: 12']))
            ]),
            new VElement('div', {'key': 'id_02'}, [
                new VElement('p', ['Name: Tom'],
                new VElement('p', ['Adge: 14']))
            ])
        ]),
    ]);

    const dom = tree.render();  // 生成 dom
    document.getElementById('anchor').appendChild(dom);

当页面发生变化时, 生成 newTree

    const newTree = new VElement('div', [
        new VElement('p', ['同学录: ']),
        new VElement('div', {'class': 'list'}, [
            new VElement('div', {'key': 'id_01', 'class': 'active'}, [ // 注意: key要与之前的结构对应
                new VElement('p', ['Name: Jack'],
                new VElement('p', ['Adge: 12']))
            ]),
            new VElement('div', {'key': 'id_02'}, [
                new VElement('p', ['Name: Tom'],
                new VElement('p', ['Adge: 14']))
            ])
        ]),
    ]);

比较 newTree 相对 tree js结构变化生成变化补丁:

    const patchDic = diff(tree, newTree);
    console.log(patchDic);
    // 打印结果如下
    // {
    //     4: { type: "PROPOS", props: { class: "active" }}
    // }
    // 4: 代表 深度遍历tree的第4个节点
    // type: 表示: 该节点的更新是"属性"更新,
    // props: 表示: 变更的属性是 "class" 对应的值是 "active"

最后将变化的部分,应用到tree上, 这样 tree 就与 newTree 拥有同样的DOM结构

    updatePatch(tree, patchDic);

原理:

从上面的过程不难看出 虚拟dom的4个过程:

  • 1.抽象html -> js tree结构
  • 2.抽象变化后的html -> js newTree结构
  • 3.比较两棵树的变化 生成patchDic
  • 4.在tree上应用 patchDic 使得 tree与 newTree有相同的Dom结构

步骤1,2 的代码实现:

class VElement {
  /**
   * @param {*} tagName 标签名
   * @param {*} props 属性
   * @param {*} children 子元素
   */
  constructor(tagName, props, children) {
    this.tagName = tagName;
    if (props instanceof Array) {
      children = props;
      props = {};
    }
    this.props = props || {};
    this.children = children || [];
    this.key = this.props.key;

    let count = 0;
    this.children.forEach(child => {
      if (child instanceof VElement) {
        count += child.childrenCount;
      } else {
        child = '' + child;
      }
      count ++;
    });
    this.childrenCount = count;
    this.dom = null
  }

  /**
   * 通过js tree结构 -> 真实的DOM
   */
  render() {
    const dom = document.createElement(this.tagName);
    for (const k in this.props) {
      dom.setAttribute(k, this.props[k]);
    }

    this.children.forEach(child => {
      const node = child instanceof VElement ? child.render() : document.createTextNode(child);
      dom.appendChild(node);
    });
    this.dom = dom;
    return this.dom;
  }
}

步骤3 是整个虚拟DOM的核心

/**
 * 判断两个节点是否为相同的节点, 为节点添加 key 值将有助于虚拟DOM判断节点是否是同一节点
 * @param {*} oldNode
 * @param {*} newNode
 */
function isSameNode(oldNode, newNode) {
  return oldNode.tagName === newNode.tagName && oldNode.key === newNode.key;
}

/**
 * diff
 * @param {*} oldNode
 * @param {*} newNode
 */
function diff(oldNode, newNode) {
  const patchDic = {};
  computeNodeChange(oldNode, newNode, 0, patchDic);
  return patchDic;
}

/**
 * 计算节点变化
 * @param {*} oldNode
 * @param {*} newNode
 * @param {*} oldIndex
 * @param {*} patchDic
 */
function computeNodeChange(oldNode, newNode, oldIndex, patchDic) {
  const curPatchs = [];
  // 相同节点
  if (isSameNode(oldNode, newNode)) {
    // 计算节点属性差异
    computePropsChange(oldNode, newNode, curPatchs);

    // 计算子节点差异
    computeChildrenChange(oldNode, newNode, curPatchs, oldIndex, patchDic);
  }
  if (curPatchs.length) {
    patchDic[oldIndex] = curPatchs;
  }
}

function computePropsChange(oldNode, newNode, curPatchs) {
    ...
}

function computeChildrenOrderChange(oldCh, newCh) {
    ...
}

computeChildrenOrderChange 的实现参考了 vue, 算法的复杂度接近O(n), 具体可以参考这篇文章

步骤4 的实现代码

/**
 * 更新节点变化(深度遍历)
 * @param {*} oldNode
 * @param {*} patchDic
 */
function updatePatch(oldNode, patchDic) {
  walkPatch(oldNode, patchDic, 0);
}

function walkPatch(node, patchDic, index) {
  const patchs = patchDic[index];
  if (patchs) {
    for (let i = 0; i < patchs.length; i++) {
      const patch = patchs[i];
      doPatch(node, patch);
    }
  }
  index += 1;
  if (node.children) {
    for (let i = 0; i < node.children.length; i++) {
      index = walkPatch(node.children[i], patchDic, index);

    }
  }
  return index;
}

/**
 * 执行更新操作
 * @param {*} node
 * @param {*} patch
 */
function doPatch(node, patch) {
  switch (patch.type) {
    case 'PROPOS':
      for (let key in patch.props) {
        const value = patch.props[key];
        if (value) {
          node.dom.setAttribute(key, value);
        } else {
          node.dom.removeAttribute(key);
        }
      }
      break;
    case 'CHILDREN':
      patch.steps.forEach(step => {
        const method = step.method
          , domCh = node.dom.childNodes
          , nodeCh = node.children;

        if (method === 'MOVE_TO_END') {
          node.dom.append(domCh[step.index]);
          nodeCh.push(nodeCh.splice(step.index, 1)[0]);
        } else if (method === 'MOVE_BEFORE') {
          node.dom.insertBefore(domCh[step.index], domCh[step.before]);
          nodeCh.splice(step.before, 0, nodeCh.splice(step.index, 1)[0]);
        } else if (method === 'INSERT_END') {
          const n = step.node instanceof VElement ? step.node.render() : document.createTextNode('' + step.node);
          node.dom.append(n);
          nodeCh.push(step.node);
        } else if (method === 'INSERT_BEFOR') {
          const n = step.node instanceof VElement ? step.node.render() : document.createTextNode('' + step.node);
          node.dom.insertBefore(n, domCh[step.before]);
          nodeCh.splice(step.before, 0, step.node);
        } else if (method === 'REMOVE') {
          try {
            node.dom.removeChild(domCh[step.index]);
            nodeCh.splice(step.index, 1);
          } catch (error) {
            window['node'] = node;
            window['step'] = step;
            throw error;
          }
        } else {
          console.error(`unknow method '${method}'`);
        }
      });
      break;
    default:
      break;
  }
}

直接看源码
直接看Demo