虚拟DOM

# 虚拟DOM

参考教程:让虚拟DOM和DOM-diff不再成为你的绊脚石 (opens new window)

[TOC]

# 一、虚拟DOM

# 1.1 是什么

用JS模拟DOM结构,提高性能。

<ul id="list">
  <li class="item">Item 1</li>
  <li class="item">Item 2</li>
</ul>
1
2
3
4

用 JS 来模拟上面的 DOM 结构

{
  tag: 'ul',
  attrs: {
    id: 'list'
  },
  children: [
    {
      tag: 'li',
      attrs: { className: 'item' },
      children: ['Item 1']
    }, {
      tag: 'li',
      attrs: { className: 'item' },
      children: ['Item 2']
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  • 用来提高重绘性能

    • 浏览器执行DOM操作是最昂贵的。
    // 查看一个普通div的属性有多少
    var div = document.createElement('div');
    var arr = [];
    for(var key in div){arr.push(key)};
    console.log(arr);	// 241
    
    1
    2
    3
    4
    5
    • 执行js一万次都不要执行一次DOM,js运行效率高。

# 1.2 snabbdom - 实现虚拟DOM的库

// {}:存放的是属性
var vnode = h('ul#list',{},[
    h('li.item',{},'Item 1'),
    h('li.item',{},'Item 2')
]);
// 首次渲染
var container = document.getElementById('container');
patch(container, vnode);

// 模拟改变
var btnChange = document.getElementById('btn-change');
btnChange.addEventListener('click', function(){
    var newNode = h('ul#list',{},[
        h('li.item',{},'Item 31'),
        h('li.item',{},'Item 32'),
        h('li.item',{},'Item 33'),
    ]);
    // 比较后再渲染
    patch(vnode, newNode);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 二、算法实现

# 2.1 用JS对象模拟DOM树

# 2.1.1 JS记录DOM节点(标签,属性,子节点)

class Element {
    constructor(tagName, props, children) {
        this.tagName = tagName;
        this.props = props;
        this.children = children;
    }
}

function el(tagName, props, children){
    return new Element(tagName, props, children);
}

var ul = el('ul', {id: 'list'}, [
  el('li', {class: 'item'}, ['Item 1']),
  el('li', {class: 'item'}, ['Item 2']),
  el('li', {class: 'item'}, ['Item 3'])
]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 2.1.2 构建真正的DOM节点

class Element {
    render() {
        // 1.根据tagName构建
        var ele = document.createElement(this.tagName);
        var props = this.props;

        // 2.设置节点的DOM属性
        for (var propName in props) {
            var propValue = props[propName];
            ele.setAttribute(propName, propValue);
        }

        // 3.递归构建子节点
        var children = this.children || [];

        children.forEach(function (child) {
            var childEle = (child instanceof Element) 
            ?child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
            :document.createTextNode(child) // 字符串则构建文本节点
            ele.appendChild(childEle);
        });

        return ele;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 2.2 Diff算法

  • 找出需要更新的节点,只对同一个层级的元素进行对比。
let index = 0;  // 当前比较节点的索引
function diff(oldTree, newTree) {
  // 存放所有节点的补丁
  let patches = {};

  // 递归树 比较后的结果放到补丁里
  dfsWalk(oldTree, newTree, index, patches);

  return patches;
};
1
2
3
4
5
6
7
8
9
10

virtual-dom

# 2.2.1 比较规则

function dfsWalk(oldNode, newNode, index, patches) {

  // 存放当前节点的补丁
  let currentPatch = [];

  // rule1:新节点为null:{type: 'REMOVE', index}
  if (newNode === null) {
    currentPatch.push({
      type: 'REMOVE',
      index
    });
  }

  // rule2:文本节点不一致:{ type: 'TEXT', text: newNode }
  else if (typeof oldNode === 'string' && typeof newNode === 'string') {
    if (oldNode !== newNode) {
      currentPatch.push({
        type: 'TEXT',
        text: newNode
      });
    }
  }

  // rule3: 节点类型相同而属性不同:{type: 'ATTR', attr: {className: 'list-group'}}
  else if (oldNode.tagName === newNode.tagName) {
    let attrPatch = diffAttr(oldNode.props, newNode.props);
    if (Object.keys(attrPatch).length) {
      currentPatch.push({
        type: 'ATTR',
        attr: attrPatch
      });
    }
    
    // 如果有子节点,遍历子节点
    diffChildren(oldNode.children, newNode.children, patches);
  }

  // rule4: 节点类型不相同{type: 'REPLACE', newNode}
  else {
    currentPatch.push({
      type: 'REPLACE',
      newNode: newNode
    });
  }

  if (currentPatch.length) {
    patches[index] = currentPatch;
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

# 2.2.2 属性比较和子节点遍历

function diffAttr(oldAttrs, newAttrs) {

  // 存放属性补丁
  let attrPatch = {};

  // 旧节点已被改变的属性
  for (let key in oldAttrs) {
    if (oldAttrs[key] !== newAttrs[key]) {
      // 有可能是undefined,即新节点没有该属性
      attrPatch[key] = newAttrs[key]; 
    }
  }

  // 新节点添加的属性
  for (let key in newAttrs) {
    if (!oldAttrs.hasOwnProperty(key)) {
      attrPatch[key] = newAttrs[key];
    }
  }

  return attrPatch;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function diffChildren(oldChildren, newChildren, patches) {
  oldChildren.forEach((child, idx) => {
    dfsWalk(child, newChildren[idx], ++index, patches);
  });
}
1
2
3
4
5

# 2.3 把差异应用到真正的DOM树上

进行patch补丁更新。

  • # patch步骤

    1. patch方法接收两个参数(node, patches) ,内部调用dfsPatchWalk方法,给某个元素打上补丁。
    let index = 0;
    function patch(node, patches) {
        dfsPatchWalk(node, patches);
    };
    
    1
    2
    3
    4
    1. dfsPatchWalk方法里获取所有的子节点
    • 给子节点也进行先序深度优先遍历,递归dfsPatchWalk。
    • 如果当前的补丁是存在的,那么就对其打补丁(doPatch)。
    function dfsPatchWalk(node, patches) {
        let currentPatches = patches[index++];
    
        let childNodes = node.childNodes;
        // 先序深度遍历,继续遍历递归子节点
        childNodes.forEach(child => dfsPatchWalk(child, patches));
    
        if (currentPatches) {
            // 对当前节点进行补丁
            doPatch(node, currentPatches);
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    1. doPatch打补丁方法会根据传递的patches进行遍历 。
    • 属性ATTR:for in遍历attrs对象,对应的key值存在就设置属性setAttr; 不存在就删除对应属性。
    • 文字TEXT:补丁的text赋值给node节点的textContent。
    • 替换REPLACE:新节点是Element的实例,就调用render方法;否则新节点是文本节点,创建一个文本节点。通过调用父级parentNode的replaceChild方法替换为新的节点。
    • 删除REMOVE:调用父级的removeChild方法删除节点。
    function doPatch(node, patches) {
        // 遍历所有打过的补丁
        patches.forEach(patch => {
            switch (patch.type) {
                case 'ATTR':
                    for (let key in patch.attr) {
                        let value = patch.attr[key];
                        if (value) {
                            node.setAttribute(key, value);
                        } else {
                            node.removeAttribute(key);
                        }
                    }
                    break;
                case 'TEXT':
                    node.textContent = patch.text;
                    break;
                case 'REPLACE':
                    let newNode = patch.newNode;
                    newNode = (newNode instanceof Element) ? newNode.render() : document.createTextNode(newNode);
                    node.parentNode.replaceChild(newNode, node);
                    break;
                case 'REMOVE':
                    node.parentNode.removeChild(node);
                    break;
                default:
                    break;
            }
        });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
  • 测试用例

    // 1. 构建虚拟DOM
    var tree = el('div', {'id': 'container'}, [
      el('h1', {style: 'color: blue'}, ['simple virtal dom']),
      el('h2', {style: 'color: blue'}, ['Hello, virtual']),
      el('ul', {style: 'color: blue'}, [el('li',{},[''])])
    ])
    
    // 2. 通过虚拟DOM构建真正的DOM
    var root = tree.render()
    document.body.appendChild(root)
    
    // 3. 生成新的虚拟DOM
    var newTree = el('div', {'id': 'container'}, [
      el('h1', {style: 'color: red'}, ['simple virtal dom']),
      el('h2', {style: 'color: blue'}, ['Hello, virtual-dom']),
      el('ul', {style: 'color: red'}, [el('li',{},['haha'])])
    ])
    
    // 4. 比较两棵虚拟DOM树的不同
    var patches = diff(tree, newTree);
    
    // 5. 在真正的DOM元素上应用变更
    patch(root, patches);
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23