实现一个简单的虚拟dom

原文地址:https://github.com/livoras/blog/issues/13

从0手写自己的虚拟DOM - 简书

目录

一、用JS对象模拟DOM树

二、比较两棵虚拟DOM树的差异

2.1 深度优先遍历,记录差异

2.2 差异类型

2.3 列表对比算法

三、把差异应用到真正的DOM树上

四、试试效果


一、用JS对象模拟DOM树

function Element(tagName, props, children) {
    this.tagName = tagName
    this.props = props
    this.children = children
}
function createElement(tagName, props, children){
    return new Element(tagName, props, children)
}

上面的DOM结构可以简单的表示:

let ul = createElement('ul', { id: 'list' }, [
    createElement('li', { class: 'item' }, ['Item 1']),
    createElement('li', { class: 'item' }, ['Item 2']),
    createElement('li', { class: 'item' }, ['Item 3']),
])

查看ul结构

实现一个简单的虚拟dom_第1张图片

现在ul只是一个 JavaScript 对象表示的 DOM 结构,页面上并没有这个结构。我们可以根据这个ul构建真正的

    Element.prototype.render = function () { // 渲染
        let el = document.createElement(this.tagName)
        let props = this.props
        // console.log('props', props) 
        for (let propsName in props) { // 设置节点的DOM属性
            el.setAttribute(propsName, props[propsName])
        }
        let children = this.children || []
        children.forEach(child => {
            let childEl = child instanceof Element
                ? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
                : document.createTextNode(child) // 如果字符串,只构建文本节点
            el.appendChild(childEl)
        })
        return el
    }

    挂在到界面上

    
    let ulRoot = ul.render()
    document.body.appendChild(ulRoot)

    二、比较两棵虚拟DOM树的差异

    正如你所预料的,比较两棵DOM树的差异是 Virtual DOM 算法最核心的部分,这也是所谓的 Virtual DOM 的 diff 算法。两个树的完全的 diff 算法是一个时间复杂度为 O(n^3) 的问题。但是在前端当中,你很少会跨越层级地移动DOM元素。所以 Virtual DOM 只会对同一个层级的元素进行对比:

    实现一个简单的虚拟dom_第2张图片

    上面的div只会和同一层级的div对比,第二层级的只会跟第二层级对比。这样算法复杂度就可以达到 O(n)。

    2.1 深度优先遍历,记录差异

    在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:

    在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。

    实现一个简单的虚拟dom_第3张图片

    在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个对象里面。

    function diff(oldTree, newTree) {
        let patches = {}
        let walks ={
            index: 0
        }
        dfsWalk(oldTree, newTree, walks, patches)
        return patches
    }
    
    function dfsWalk(oldTree, newTree, walks, patches) {
        let patch = []
        let index = walks.index
        if (typeof oldTree === 'string' && typeof newTree === 'string') {
            if (oldTree !== newTree) {
                patch.push({
                    type: TEXT,
                    text: newTree
                })
            }
        } else if (!newTree) {
            patch.push({
                type: REMOVE,
                node: newTree
            })
        } else if (oldTree.tagName === newTree.tagName) {//决定props的同时,还需要处理children
            let props = diffProps(oldTree, newTree)
            if (JSON.stringify(props) !== '{}') {
                patch.push({
                    type: PROPS,
                    props
                })
            }
            diffChildren(oldTree.children, newTree.children, walks, patches)
        } else {
            patch.push({
                type: REPLACE,
                node: newTree
            })
        }
        if (patch.length > 0) {
            patches[index] = patch
        }
    }
    
    function diffChildren(oldTree, newTree, walks, patches) {
        
        if (oldTree && oldTree.length > 0) {
            oldTree.forEach((oldchild, i) => {
                walks.index++
                let newChild = newTree[i]
                dfsWalk(oldchild, newChild, walks, patches)
            })
        }
    }

    2.2 差异类型

    上面说的节点的差异指的是什么呢?对 DOM 操作可能会:

    • 替换掉原来的节点,例如把上面的div换成了section
    • 移动、删除、新增子节点,例如上面div的子节点,把pul顺序互换
    • 修改了节点的属性
    • 对于文本节点,文本内容可能会改变。例如修改上面的文本节点2内容为Virtual DOM 2

    所以我们定义了几种差异类型:

    const TEXT = 0 // 文本节点
    const PROPS = 1 // 属性节点
    const REMOVE = 2 // 删除节点
    const REPLACE = 3 // 替换节点

    2.3 列表对比算法

    假设现在可以英文字母唯一地标识每一个子节点:

    旧的节点顺序:

    a b c d e f g h i
    

    现在对节点进行了删除、插入、移动的操作。新增j节点,删除e节点,移动h节点:

    新的节点顺序:

    a b c h d f g i j
    

    现在知道了新旧的顺序,求最小的插入、删除操作(移动可以看成是删除和插入操作的结合)。这个问题抽象出来其实是字符串的最小编辑距离问题(Edition Distance),最常见的解决算法是 Levenshtein Distance,通过动态规划求解,时间复杂度为 O(M * N)。但是我们并不需要真的达到最小的操作,我们只需要优化一些比较常见的移动情况,牺牲一定DOM操作,让算法时间复杂度达到线性的(O(max(M, N))。具体算法细节比较多,这里不累述,有兴趣可以参考代码。

    我们能够获取到某个父节点的子节点的操作,就可以记录下来:

    patches[0] = [{
      type: REORDER,
      moves: [{remove or insert}, {remove or insert}, ...]
    }]

     但是要注意的是,因为tagName是可重复的,不能用这个来进行对比。所以需要给子节点加上唯一标识key,列表对比的时候,使用key进行对比,这样才能复用老的 DOM 树上的节点。

    这样,我们就可以通过深度优先遍历两棵树,每层的节点进行对比,记录下每个节点的差异了。完整 diff 算法代码可见 diff.js。

    三、把差异应用到真正的DOM树上

    因为步骤一所构建的 JavaScript 对象树和render出来真正的DOM树的信息、结构是一样的。所以我们可以对那棵DOM树也进行深度优先的遍历,遍历的时候从步骤二生成的patches对象中找出当前遍历的节点差异,然后进行 DOM 操作。

    function diffProps(oldProps, newProps) {
        let props = {}
        for (let key in oldProps) {
            if (oldProps[key] !== newProps[key]) {
                props[key] = newProps
            }
        }
        for (let key in newProps) {
            if (!oldProps.hasOwnProperty(key)) {
                props[key] = newProps
            }
        }
        return props
    }
    

    applyPatches,根据不同类型的差异对当前节点进行 DOM 操作:

    function applyPatches(node, currentPatches) {
        currentPatches.forEach(currentPatch => {
            // console.log('currentPatch', currentPatch)
            // console.log('node', node)
            switch (currentPatch.type) {
                case REPLACE:
                    node.parentNode.replaceChild(currentPatch.newTree.render(), node)
                    break
                case REMOVE:
                    node.parentNode.removeChild( node)
                    break 
                case PROPS:
                    for(let key in currentPatch.props){
                        let propsVal = currentPatch.props[key][key]
                        if(propsVal){
                            if(key === 'value'){
                                if( node.tagName.toUpperCase() === 'INPUT'
                                || node.tagName.toUpperCase() === 'TEXTAREA' ){
                                    node.value = propsVal
                                }
                            } else {
                                node.setAttribute(key, propsVal)
                            }
                        }
                    }
                
                    break
                case TEXT:
                    node.textContent = currentPatch.text
                    break
                default:
                    throw new Error('Unknow patch type' + currentPatch.type)
            }
        })
    }
    

    四、试试效果

    let elRoot = tree.render()
    document.body.appendChild(elRoot)
    
    let newTree = createElement('div', {id: 'container'}, [
        createElement('h1', {style:'color: red'}, ['s1imple virtal dom']),
        // createElement('p',{style:'color: blue'}, ['hello virtual-dom']),
        createElement('ul', {style:'color: blue'}, [createElement('li', {}, ['name'])]),
    ])
    let patches = diff(tree, newTree)
    patch(elRoot, patches)

你可能感兴趣的:(vue,javascript,vue.js,node.js)