让虚拟DOM和DOM-diff不再成为你的绊脚石

386 阅读3分钟

被问到过虚拟DOM和DOM-diff算法是如何实现的

创建虚拟DOM

  1. type: 指定元素的标签类型,如'li', 'div', 'a'等
  2. props: 表示指定元素身上的属性,如class, style, 自定义属性等
  3. children: 表示指定元素是否有子节点,参数以数组的形式传入
function createElement(type, props, children) {
    return new Element(type, props, children);
}

渲染虚拟DOM

function render(domObj) {
    // 根据type类型来创建对应的元素
    let el = document.createElement(domObj.type);
    
    // 再去遍历props属性对象,然后给创建的元素el设置属性
    for (let key in domObj.props) {
        // 设置属性的方法
        setAttr(el, key, domObj.props[key]);
    }
    
    // 遍历子节点
    // 如果是虚拟DOM,就继续递归渲染
    // 不是就代表是文本节点,直接创建
    domObj.children.forEach(child => {
        child = (child instanceof Element) ? render(child) : document.createTextNode(child);
        // 添加到对应元素内
        el.appendChild(child);
    });

    return el;
}

DOM-diff

给定任意两棵树,采用先序深度优先遍历的算法找到最少的转换步骤,DOM-diff比较两个虚拟DOM的区别,也就是在比较两个对象的区别。

作用: 根据两个虚拟对象创建出补丁,描述改变的内容,将这个补丁用来更新DOM

function diff(oldTree, newTree) {
    // 声明变量patches用来存放补丁的对象
    let patches = {};
    // 第一次比较应该是树的第0个索引
    let index = 0;
    // 递归树 比较后的结果放到补丁里
    walk(oldTree, newTree, index, patches);

    return patches;
}

比较规则

  1. 新的DOM节点不存在{type: 'REMOVE', index}
  2. 文本的变化{type: 'TEXT', text: 1}
  3. 当节点类型相同时,去看一下属性是否相同,产生一个属性的补丁包{type: 'ATTR', attr: {class: 'list-group'}}
  4. 节点类型不相同,直接采用替换模式{type: 'REPLACE', newNode}
function walk(oldNode, newNode, index, patches) {
    // 每个元素都有一个补丁
    let current = [];

    if (!newNode) { // rule1
        current.push({ type: 'REMOVE', index });
    } else if (isString(oldNode) && isString(newNode)) {
        // 判断文本是否一致
        if (oldNode !== newNode) {
            current.push({ type: 'TEXT', text: newNode });
        }

    } else if (oldNode.type === newNode.type) {
        // 比较属性是否有更改
        let attr = diffAttr(oldNode.props, newNode.props);
        if (Object.keys(attr).length > 0) {
            current.push({ type: 'ATTR', attr });
        }
        // 如果有子节点,遍历子节点
        diffChildren(oldNode.children, newNode.children, patches);
    } else {    // 说明节点被替换了
        current.push({ type: 'REPLACE', newNode});
    }
    
    // 当前元素确实有补丁存在
    if (current.length) {
        // 将元素和补丁对应起来,放到大补丁包中
        patches[index] = current;
    }
}

patch补丁更新

打补丁需要传入两个参数,一个是要打补丁的元素,另一个就是所要打的补丁了,那么直接看代码

let index = 0;  // 默认哪个需要打补丁

patch做了什么

  1. doPatch打补丁方法会根据传递的patches进行遍历

  2. 判断补丁的类型来进行不同的操作
    1.1 属性ATTR for in去遍历attrs对象,当前的key值如果存在,就直接设置属性setAttr; 如果不存在对应的key值那就直接删除这个key键的属性

    1.2 文字TEXT 直接将补丁的text赋值给node节点的textContent即可

    1.3 替换REPLACE 新节点替换老节点,需要先判断新节点是不是Element的实例,是的话调用render方法渲染新节点; 不是的话就表明新节点是个文本节点,直接创建一个文本节点就OK了。 之后再通过调用父级parentNode的replaceChild方法替换为新的节点

    1.4 删除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) {
                        setAttr(node, key, value);
                    } else {
                        node.removeAttribute(key);
                    }
                }
                break;
            case 'TEXT':
                node.textContent = patch.text;
                break;
            case 'REPLACE':
                let newNode = patch.newNode;
                newNode = (newNode instanceof Element) ? render(newNode) : document.createTextNode(newNode);
                node.parentNode.replaceChild(newNode, node);
                break;
            case 'REMOVE':
                node.parentNode.removeChild(node);
                break;
            default:
                break;
        }
    });
}

总结

整个DOM-diff的过程:

  1. 用JS对象模拟DOM(虚拟DOM)
  2. 把此虚拟DOM转成真实DOM并插入页面中(render)
  3. 如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
  4. 把差异对象应用到真正的DOM树上(patch)