美文网首页
从0手写自己的虚拟DOM

从0手写自己的虚拟DOM

作者: Benzic | 来源:发表于2020-09-11 20:58 被阅读0次

    所有的源码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的了解。

    相关文章

      网友评论

          本文标题:从0手写自己的虚拟DOM

          本文链接:https://www.haomeiwen.com/subject/lzopektx.html