模拟Vue&React手写dom-diff

1、虚拟dom

首先,我们都知道react和vue有一个创建虚拟dom的方法createElement,用法如下:

1
2
3
4
5
    let ul1 = createElement('ul', { class: 'list' }, [
        createElement('li', { class: 'item' }, ['1']),
        createElement('li', { class: 'item' }, ['2']),
        createElement('li', { class: 'item' }, ['3'])
    ]);

createElement方法的具体实现:

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
   class Element{
       constructor(type, props, children) {
           this.type = type;
           this.props = props;
           this.children = children;
       }
   }

   //创建虚拟dom
   function createElement(type, props, children) {
       return new Element(type, props, children);
   }

   function setDomAttr(dom, propName, propVal) {
       switch (propName) {
           //这里要做特殊处理 就是因为某些元素的属性不能用setAttribute直接添加
           case 'value':
               if (dom.tagName.toUpperCase() === 'INPUT' || om.tagName.toUpperCase() === 'TEXTAREA') {
                   dom.value = propVal;
               } else {
                   dom.setAttribute(propName, propVal);
               }
               break;
           case 'style':
               dom.style.cssText = propVal;
               break;
           default:
               dom.setAttribute(propName, propVal);
               break;
       }
   }

2、虚拟dom转换成实体dom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//转换真实dom
   function renderDom(virtualDom) {
       let dom = document.createElement(virtualDom.type);
       let props = virtualDom.props;
       for (let propName in props) {//遍历属性 一一赋值 但是这里要注意setAttribute在对于input等表单元素设置value时会有偏差 因此自行封装
           setDomAttr(dom, propName, props[propName]);
       }
       if (virtualDom.children) {//当其有子节点时
           //遍历子节点 当子节点也是Element的实例时 递归 否则认为是文本节点
           virtualDom.children.forEach(item=>{
               item = (item instanceof Element) ? renderDom(item) : document.createTextNode(item);
               dom.appendChild(item);
           });
       }
       return dom;
   }

接下来我们来分析下,如果根据虚拟dom的解构去比较新旧元素的变化,要采用哪种遍历算法,以及节点的具体修改情况。

3、遍历算法

1)vue和react采用的都是深度遍历算法,同级的比较元素的变化,这样时间复杂度达到O(n)。

图片

图片

2)分析了节点的修改情况主要有以下几种:

a.当节点数量不发生更改时,且节点类型相同,节点属性发生变化时,产生一个补丁包,形如:{type: ‘ATTRS’,attrs:{class:’list-group’}}
b.当dom发生删减时 {type: ‘REMOVE’,index:xxx}
c.当节点类型不同,代表旧元素被替换 {type: ‘REPLACE’, newNode: newNode}
d.当节点的文本发生变化时,{type:’TEXT’, text: ‘xxx’}

这里暂时不考虑元素位置调换以及增加元素的情况。

下面是新旧绩点比较的代码实现:

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
    function diff(oldTree, newTree) {
        let patches = {};
        let index = 0;
        //递归树 比较后的结果放到补丁包中
        walk(oldTree, newTree, index, patches);
        return patches;
    }

    function diffAttrs(oldAttrs, newAttrs) {
        let patch = {};
        //判断新的属性和老的属性的关系
        for (let key in oldAttrs) {
            if (oldAttrs[key] !== newAttrs[key]) {
                patch[key] = newAttrs[key];//当新老属性个数不一致时,可能会存入undefined
            }
        }
        //再判断新属性中是否有新增的属性
        for (let key in newAttrs) {
            if (!oldAttrs.hasOwnProperty(key)) {//当老节点不具备新节点的某一属性时,存入补丁包
                patch[key] = newAttrs[key];
            }
        }
        return patch;
    }

    let INDEX = 0;

    function diffChildren(oldChildren, newChildren, patches) {
        oldChildren && oldChildren.forEach((child, idx) => {
            // let num = ++index;
            // console.log(num,child);
            //这里不用传参的index的原因是,形参中的数据并不会因为某次修改而保存,而是会根据上一次形参取得的数据进行操作,
            // 因此会有错误的结果产生,所以定义一个全局变量递增记录节点的修改
            //原本第二层的index分别是1、3、5,用index就会变成1、2、3
            // console.log(newChildren);
            walk(child, newChildren[idx], ++INDEX, patches);
        });
    }

    function isString (node) {
        return Object.prototype.toString.call(node) === '[object String]';
    }

    function walk(oldNode, newNode, index, patches) {
        let currentPatch = [];//当前dom层级的补丁包
        if (!newNode) {//当节点被删除时
            currentPatch.push({type: 'REMOVE', index: index});
        } else if (isString(oldNode) && isString(newNode)) {//当两者都是文本节点时
            if (oldNode !== newNode) {
                currentPatch.push({type: 'TEXT', text: newNode});
            }
        } else if (oldNode.type === newNode.type) {//节点类型相同时
            let diffAttr = diffAttrs(oldNode.props, newNode.props);
            if (Object.keys(diffAttr).length) {//当其中有更改过的属性时 放入currentPatch
                currentPatch.push({type: 'ATTRS', attrs: diffAttr});
            }
            if (oldNode.children && newNode.children) {
                diffChildren(oldNode.children, newNode.children, patches);
            } else {
                if (!newNode.children) {//兼容 节点被删除
                    currentPatch.push({type: 'REMOVE', index: index});
                }
            }
        } else {//节点类型不同时
            currentPatch.push({type: 'REPLACE', newNode: newNode});
        }
        if (currentPatch.length) {//说明当前层级的元素有修改过的补丁包
            patches[index] = currentPatch;
            console.log(patches);
        }
    }

最后映射到真实dom的代码:

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
    let allPatches;
    let Index = 0; //从第1层节点开始
    function updateDom (oldNode, patches) {//更新dom节点
        allPatches = patches;
        walkUpdate(oldNode);
    }

    function doUpdate(node, patches) {
        patches.forEach(patch => {
            switch(patch.type) {
                case 'ATTRS':
                    for (let attrName in patch.attrs) {
                        let attrVal = patch.attrs[attrName];
                        if (attrVal) {
                            setDomAttr(node, attrName, attrVal);
                        } else {
                            node.removeAttribute(attrName);
                        }
                    }
                    break;
                case 'TEXT':
                    node.textContent = patch.text;
                    break;
                case 'REPLACE':
                    let newNode = (patch.newNode instanceof Element) ? renderDom(patch.newNode) : document.createTextNode(patch.newNode);
                    node.parentNode.replaceChild(newNode, node);
                    break;
                case 'REMOVE':
                    node.parentNode.removeChild(node);
                    break;
                default:
                    break;
            }
        });
    }

    function walkUpdate(node) {
        let currentPatch = allPatches[Index++];
        console.log(num);
        let childNodes = node.childNodes;
        childNodes.forEach(child=>walkUpdate(child));
        if (currentPatch && currentPatch.length) {
            //执行打补丁操作
            doUpdate(node, currentPatch);
        }
        // console.log(node);
        return node;
    }

到这里,一个简单的dom-diff结构就算完成了,(连写好几天递归,再也不怕死循环惹:laughing:

4、知识点:

1)createTextNode:

可创建文本节点。

2)tagName:

在xml中(或其他语言,如xhtml,xul)文档中,tagName的值会保留原始的大小写; 在html文档中,tagName会返回其大写形式,对于元素节点来说,tagName属性的值和nodeName属性的值是相同的。

tagName只有在元素节点才有值,nodeName在所有节点上都有值。

因此建议可使用nodeName多一些。

3)instanceof:

用于测试构造函数的prototype属性是否存在与对象的原型链的任何位置上。

但需要注意的是:

a.构造函数的prototype不是一成不变的,发生修改后,instanceof检测后的值会发生变化。

b.实例的__proto__也会发生修改(非标准),instanceof检测后的值会发生变化。