从0手写自己的虚拟DOM

2020-09-11  本文已影响0人  Benzic

所有的源码github地址:https://github.com/Benzic/Simple-VDOM-example/blob/master/index.html
喜欢请star一下哦,也请指正

做了很久的React、Vue项目,对于虚拟DOM都是耳熟能详,但是真实的虚拟DOM到底是怎么实现的呢,就想写一篇文章来记录一下自己对于虚拟DOM的理解。
虚拟DOM并不是真正的DOM,只是一个包含DOM信息的对象,例如这样:

var element = {
  tagName: 'ul', // 节点标签名
  props: { // DOM的属性,用一个对象存储键值对
    id: 'list'
  },
  children: [ // 该节点的子节点
    {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
  ]
}

对应的DOM节点:

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

虚拟DOM

先创建一个createElement方法,用于创建虚拟DOM方法:

function createElement(type, props, children) {
    return new Element(type, props, children)
}

在创建Element构造函数

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

生成虚拟DOM对象:

let VDOM = createElement("ul", {
    class: "ul-box"
}, [createElement("li", {
    class: "li-item"
}, [1]), createElement("li", {
    class: "li-item"
}, [2]), createElement("li", {
    class: "li-item"
}, [3])])
VDOM

有了虚拟DOM对象那么就要生成真实的节点并添加到页面当中,这个时候就需要先创建一个真是节点createNode:

        function createNode(node) {
            let ele = document.createElement(node.type);
            for (let key in node.props) {
                if (key === 'value') {
                    if (node.type.toUpperCase() === 'INPUT' || node.type.toUpperCase() === 'TEXTAREA') { //input和textarea特殊处理,
                        el.value = node.props[key]
                    }
                } else {
                    el.setAttribute(el, node.props[key])
                }
            }
            return ele
        }

createNode仅仅是将单个dom节点创建出来,虚拟DOM树嘛,肯定是很多dom节点,所以还需要将所有单个节点连接起来形成参天大树,这个时候就新建一个方法createDom。

        function createDom(vDom) {
            let rootNode = createNode(vDom);
            if (vDom.children && vDom.children.length) {
                vDom.children.map((item) => {
                    if (item instanceof Element) {    //如果children是节点则继续生成节点,如果不是就按文本处理
                        rootNode.appendChild(createDom(item))
                    } else {
                        rootNode.appendChild(document.createTextNode(item))
                    }
                })
            }
            return rootNode
        }
节点

现在确实获取到了真实的节点,但是页面还差一步,因为还没有把生成的节点加载到页面中去

        function renderDom(VDOM, root) {
            root.appendChild(createDom(VDOM))
        }
        renderDom(VDOM, document.querySelector('#app'))
加载到页面中

添加input甚至更复杂的内容试一下

        let VDOM = createElement("ul", {
            class: "ul-box"
        }, [createElement("li", {
            class: "li-item"
        }, [1]), createElement("li", {
            class: "li-item",
        }, [createElement("input", {
                type: "radio",
                value: "radio内容",
            }, []),
            createElement("input", {
                type: "text",
                value: "文本内容",
                placeholder: "请输入文本"
            }, [])
        ]), createElement("li", {
            class: "li-item div-box",
        }, [createElement("div", {
            class: "div-left bg-blue",
        }, ["文本内容"]), createElement("div", {
            class: "div-right bg-red",
        }, [createElement("div", {
            class: "div-right-item",
        }, ["文本内容"]), createElement("div", {
            class: "div-right-item",
        }, [createElement("a", {
                class: "a",
                href: "www.baidu.com"
            }, [
                createElement("span", {
                    class: "span"
                }, ["这是一条文本"])
            ]),
            createElement("img", {
                class: "img",
                src: "https://ss3.bdstatic.com/70cFv8Sh_Q1YnxGkpoWK1HF6hhy/it/u=2534506313,1688529724&fm=26&gp=0.jpg",
                alt: "图片",
                title: "虚拟DOM"
            }, [])
        ])])])])
更复杂的例子

这就算是一个简单的虚拟DOM例子,当然能够生成DOM树是远远不够的,虚拟DOM的diff算法才是虚拟DOM相较于真实DOM的优势所在。

Diff算法的简单实现
我们为什么还需要diff算法?

因为如果我们有一个很庞大的DOM Tree,我们要对它进行更新操作,如果我们只是更新了它很小的一部分,我们就需要更新整个DOM Tree。这也是很浪费性能和资源的。所以Diff算法的作用就是来剔除无用更新,只更新需要更新的部分。
编写的策略:

  1. 同一层级的一组节点,他们可以通过唯一的id进行区分
  2. diff只是找到差异,找到了我们需要补齐差异
  3. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构

实现过程:
先创建一个补丁对象和一个全局index索引:

let patches = {};
let index = 0;

虚拟DOM之间最主要的变化包括:
-文本变化
-属性变化
-删除节点
-替换节点

所以根据这些变化创建4个标识符:

  const TEXT = 0;  //文本  
  const ATTR = 1;  //属性
  const REMOVE = 2;  //删除
  const REPLACE = 3;  //替换

同级比较,相同颜色之间比较:


两个虚拟DOM同级比较

新旧之间的区别示例:


新旧之间的区别示例
创建diff方法比较两者差异
        function diff(oldTree, newTree) {
            walk(oldTree, newTree, index)   //遍历两个虚拟DOM树
        }

文本的比较:

        function walk(oldTree, newTree, index) {
            let patch = [];
            if (typeof oldTree === 'string' && typeof newTree === 'string') {
                if (oldTree !== newTree) {
                    patch.push({
                        type: TEXT,
                        text: newTree
                    })
                }
            }
        }

属性比较

       ...
              //必须是相同节点类型
              if (oldTree.type === newTree.type) {
                let attr = diffAttr(oldTree.props, newTree.props);
                if (JSON.stringify(attr) !== "{}") {
                  patch.push({
                      type: ATTR,
                      attr: attr
                  })
                }
                diffChildren(oldTree.children, newTree.children)
              }
       ...
  
        function diffAttr(oldProps, newProps) {
            let attr = {};//遍历找出两者之间的属性差异
            for (let key in oldProps) {
                if (oldProps[key] !== newProps[key]) {
                    attr[key] = newProps[key]
                }
            }
            for (let key in newProps) {
                if (!oldProps.hasOwnProperty(key)) {
                    attr[key] = newProps[key]
                }
            }
            return attr
        }

        function diffChildren(oldChildren, newChildren) {
            //遍历children找出区别
            oldChildren.forEach(function (child, i) {
                walk(child, newChildren[i], ++index)
            })
        }

删除节点,没有newTree就视为删除

        ...
            if (!newTree) {
                patch.push({
                    type: REMOVE,
                    index
                })
            } 
        ...

替换节点,除了以上的所有情况都视为替换

        ...
            patch.push({
                type: REPLACE,
                newTree
            })
        ...

walk的完整代码:

        function diff(oldTree, newTree) {
            let patches = {};
            let index = 0;
            const TEXT = 0; //文本  
            const ATTR = 1; //属性
            const REMOVE = 2; //删除
            const REPLACE = 3; //替换
            walk(oldTree, newTree, index)

            function diffAttr(oldProps, newProps) {
                let attr = {};
                for (let key in oldProps) {
                    if (oldProps[key] !== newProps[key]) {
                        attr[key] = newProps[key]
                    }
                }
                for (let key in newProps) {
                    if (!oldProps.hasOwnProperty(key)) {
                        attr[key] = newProps[key]
                    }
                }
                console.log(attr)
                return attr
            }

            function diffChildren(oldChildren, newChildren) {
                oldChildren.forEach(function (child, i) {
                    walk(child, newChildren[i], ++index)
                })
            }

            function walk(oldTree, newTree, index) {
                console.log(oldTree, newTree, index)
                let patch = [];
                if (!newTree) {
                    patch.push({
                        type: REMOVE,
                        index
                    })
                } else if (typeof oldTree === 'string' && typeof newTree === 'string') {
                    if (oldTree !== newTree) {
                        patch.push({
                            type: TEXT,
                            text: newTree
                        })
                    }
                } else if (oldTree.type === newTree.type) {
                    console.log(oldTree.props, newTree.props)
                    let attr = diffAttr(oldTree.props, newTree.props);
                    if (JSON.stringify(attr) !== "{}") {
                        patch.push({
                            type: ATTR,
                            attr: attr
                        })
                    }
                    diffChildren(oldTree.children, newTree.children)
                } else {
                    patch.push({
                        type: REPLACE,
                        newTree
                    })
                }
                if (patch.length > 0) {
                    patches[index] = patch;
                    console.log(patches)
                }
            }
            return patches
        }

现在我们就获得了diff比较之后的结构差异补丁对象patches,接下来要做的就是根据补丁对象,更改真实的DOM对象。

        function patch(DOM, patches) {
            let patchIndex = 0; //暂存当前处理的patchIndex
            walkPath(DOM, patches)         
        }

获取节点和子节点的,分别根据patch补丁对象比较原有DOM节点

        function walkPath(DOM, patches) {
            //获取节点的补丁
            let patch = patches[patchIndex++];
            //获取当前DOM的子节点
            let children = DOM.childNodes;
            console.log(children, patch)
            //遍历子节点,打补丁
            children && children.forEach((child) => walkPath(child, patches))
            if (patch) {
                doPath(DOM, patch)
            }
        }

根据patch类型分别处理文本、属性、删除、和新增节点的对应处理

        function doPath(node, patch) {
            patch.forEach((item) => {
                switch (item.type) {
                    case TEXT:
                        node.textContent = item.text    //替换节点文本
                        break;
                    case ATTR:
                        for (let key in item.attr) {          //遍历补丁对象根据属性替换属性值
                                let value = item.attr[key]
                                if (value) {
                                    if (key === 'value') {
                                        if (node.type.toUpperCase() === 'INPUT' || node.type.toUpperCase() ===
                                            'TEXTAREA') {
                                            node.value = value
                                        }
                                    } else {
                                        node.setAttribute(key, value)
                                    }
                                } else {
                                    node.removeAttribute(key);
                                }
                        }
                        break;
                    case REMOVE:                //删除对应节点
                        node.parentNode.removeChild(node);
                        break;
                    case REPLACE:              //替换对应节点
                        let newTree = patch.newTree
                        newTree = (newTree instanceof Element) ? createDom(newTree) : document.createTextNode(
                            newTree);
                        newTree.parentNode.replaceChild(newTree)
                        break;
                    default:
                        break;
                }
            })
        }

完整的patch方法

function patch(DOM, patches) {
            let patchIndex = 0;
            const TEXT = 0; //文本  
            const ATTR = 1; //属性
            const REMOVE = 2; //删除
            const REPLACE = 3; //替换
            walkPath(DOM, patches)

            function walkPath(DOM, patches) {
                //获取第一个节点的补丁
                let patch = patches[patchIndex++];
                //获取当前DOM的子节点
                let children = DOM.childNodes;
                console.log(children, patch)
                //遍历子节点,打补丁
                children && children.forEach((child) => walkPath(child, patches))
                if (patch) {
                    doPath(DOM, patch)
                }
            }

            function doPath(node, patch) {
                patch.forEach((item) => {
                    switch (item.type) {
                        case TEXT:
                            node.textContent = item.text
                            break;
                        case ATTR:
                            for (let key in item.attr) {
                                let value = item.attr[key]
                                if (value) {
                                    if (key === 'value') {
                                        if (node.type.toUpperCase() === 'INPUT' || node.type.toUpperCase() ===
                                            'TEXTAREA') {
                                            node.value = value
                                        }
                                    } else {
                                        node.setAttribute(key, value)
                                    }
                                } else {
                                    node.removeAttribute(key);
                                }
                            }
                            break;
                        case REMOVE:
                            node.parentNode.removeChild(node);
                            break;
                        case REPLACE:
                            let newTree = patch.newTree
                            newTree = (newTree instanceof Element) ? createDom(newTree) : document.createTextNode(
                                newTree);
                            newTree.parentNode.replaceChild(newTree)
                            break;
                        default:
                            break;
                    }
                })
            }
        }

试试效果:

        let vDom1 = createElement("div", {
            class: "div"
        }, [
            createElement("div", {
                class: "div1"
            }, ["diff之前文本"])
        ]);
        let vDom2 = createElement("div", {
            class: "div3"
        }, [
            createElement("div", {
                class: "div4"
            }, ["diff之后文本"])
        ]);
        let DOODM = createDom(vDom1)

        function renderDom(VDOM, root) {
            root.appendChild(VDOM)
        }
        renderDom(DOODM, document.querySelector('#app'))

        let patchs = diff(vDom1, vDom2)
        patch(DOODM, patchs)
执行结果
执行结果

以上就是我总结的自己实现虚拟DOM的全过程,过程较为简单,还有很多地方没有考虑到,但是也能帮助自己对于虚拟DOM的了解。

上一篇下一篇

猜你喜欢

热点阅读