虚拟DOM

114 阅读4分钟

基本概念和实现

src=http___img-blog.csdnimg.cn_20210103124546777.png_x-oss-process=image_watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MjIyNDA1NQ==,size_16,color_FFFFFF,t_70&refer=http___img-blo.jpg

什么是虚拟DOM(what)

用数据结构表示DOM结构,没有挂载到DOM上,被称为“虚拟DOM"。

// DOM结构
<ul id="chapterList">
    <li class="chapter">chapter1</li>
    <li class="chapter">chapter2</li>
    <li class="chapter">chapter13</li>
</ul> 
// javascript对象结构形式表示
const chapterListVirtualDom = {
    tagName: 'ul',
    attributes: {
        id: 'chapterList'
    },
    children: [
        { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter1'] }
        { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter2'] },
        { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter3'] }
    ]
}

为什么要使用虚拟DOM(why)

操作数据结构远比通过和浏览器交互操作DOM快。
操作数据结构是指改变对象(虚拟DOM),这个过程比修改真是DOM快很多。但是虚拟DOM最终也是要挂载到浏览器上。

怎么实现虚拟DOM(how)

下面我们来实现一个虚拟DOM生成类,用于生产虚拟DOM。

class Element {
    constructor (tagName, attributes = {}, children = []) {
        this.tagName = tagName
        this.attributes = attributes
        this.children = children
    }
}
function element (tagName, attributes, children) {
    return new Element(tagName, attributes, children)
}
const chapterListVirtualDom = element('ul', { id: 'list' }, [
    element('li', { class: 'chapter' }, 'chapter1'),
    element('li', { class: 'chapter' }, 'chapter2'),
    element('li', { class: 'chapter' }, 'chapter3')
])

现在我们来将虚拟DOM生成真是DOM节点。首先实现一个setAttributes方法来对DOM节点进行属性设置:

const setAttributes = (node, key, value) => {
    switch (key) {
        case 'style':
            node.style.cssText = value
            break
        case 'value':
            let tagName = node.tagName || ''
            tagName = tagName.toLowerCase()
            if (tagName === 'input' || tagName === 'textarea') {
                node.value = value
            } else { // 如果节点不是input或textarea, 则使用setAttributes设置属性
                node.setAttributes(key, value)
            }
            break
            default:
            node.setAttributes(key, value)
            break
    }
}

然后在Element类中加入render原型方法:根据虚拟DOM生成真实DOM片段。

class Element {
    constructor (tagName, attributes = {}, children = []) {
        this.tagName = tagName
        this.attributes = attributes
        this.children = children
    }
    render () {
        let element = document.createElement(this.tagName)
        let attributes = this.attributes
        for (let key in attributes) {
            setAttributes(element, key, attributes[key])
        }
        let children = this.children
        children.forEach(child => {
            let childElement = child instanceof Element
                ? child.render()
                : document.createTextNode(child)
            element.appendChild(childElement)
        })
    }
}

有了真实的DOM节点片段,我们就可以将真实的DOM节点渲染到浏览器上了,这里我们来实现一个renderDom方法。

const renderDom = (element, target) => {
    target.appendChild(element)
}

到这里,我们实现了生成DOM元素的必备方法集合。完整代码如下:

const setAttributes = (node, key, value) => {
    switch (key) {
        case 'style':
            node.style.cssText = value
            break
        case 'value':
            let tagName = node.tagName || ''
            tagName = tagName.toLowerCase()
            if (tagName === 'input' || tagName === 'textarea') {
                node.value = value
            } else {
                node.setAttributes(key, value)
            }
            break
        default:
            node.setAttributes(key, value)
            break
     }
}
class Element {
    constructor (tagName, attributes, children) {
        this.tagName = tagName
        this.attributes = attributes
        this.children = children
    }
    render () {
        let element = document.createElement(this.tagName)
        let attributes = this.attributes
        for (let key in attributes) {
            setAttributes(element, key, attributes[key])
        }
        let children = this.children
        children.forEach(child => {
            let childElement = child instanceof Element
                ? child.render()
                : document.createTextNode(child)
            element.appendChild(childElement)
        })
        return element
   }
}
function element (tagName, attributes, children) {
    return new Element(tagName, attributes, children)
}
const renderDom = (element, target) => {
    target.appendChild(element)
}
const chapterListVirtualDom = element('ul', { id: 'list' }, [
    element('li', { class: 'chapter' }, 'chapter1'),
    element('li', { class: 'chapter' }, 'chapter2'),
    element('li', { class: 'chapter' }, 'chapter3')
])
const dom = chapterListVirtualDom.render()
renderDom(dom, document.body)

虚拟DOM diff

src=http___img2020.cnblogs.com_blog_1855127_202003_1855127-20200323213525272-292080547.png&refer=http___img2020.cnblogs.jpg

用户进行特定操作后,会产出一份新的虚拟DOM,如何得出前后两份虚拟DOM的差异,这里就涉及到DOM diff的内容了。

因为虚拟DOM是个树形结构,所以我们需要对两份虚拟DOM进行递归比较,并将变化存储到patches中,代码如下:

const diff = (oldVirtualDom, newVirtualDom) => {
    let patches = {}     // 递归树,将比较后的结果存储到patches中
    walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)
    return patches
}

walkToDiff中的前两个参数是需要比较的虚拟DOM对象,第三个参数用来记录nodeIndex,在删除节点时会使用,初始值为0,第四个参数是一个闭包变量,用来记录diff的结果。

walkToDiff代码实现:

let initialIndex = 0
const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
    let diffResult = []
    // 如果newVirtualDom不存在 则说明该节点已经被移除,接着可以将type为REMOVE的对象推进diffResult,并记录index
    if (!newVirtualDom) {
        diffResult.push({
            type: 'REMOVE',
            index
        })
    } else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') { // 如果新旧节点都是文本节点
        // 比较文本中的内容是否相同,如果不同则记录新的结果
        if (oldVirtualDom !== newVirtualDom) {
            diffResult.push({
                type: 'MODIFY_TEXT',
                data: newVirtualDom,
                index
            })
        }
    } else if (oldVirtualDom.tagName === newVirtualDom.tagName) { // 如果新旧节点类型相同
        // 比较属性是否相同
        let diffAttributeResult = {}
        let oldAttributes = oldVirtualDom.attributes
        let newAttributes = newVirtualDom.attributes
        for (let key in oldAttributes) {
            if (oldAttributes[key] !== newAttributes[key]) {
                diffAttributeResult[key] = newAttributes[key]
            }
        }
        for (let key in newAttributes) {
            // 旧节点不存在的新属性
            if (!oldAttributes.hasOwnProperty(key)) {
                diffAttributeResult[key] = newAttributes[key]
            }
        }
        if (Object.keys(diffAttributeResult).length > 0) {
            diffResult.push({
                type: 'MODIFY_ATTRIBUTES',
                diffAttributeResult
            })
        }
        // 如果有子节点 则遍历子节点
        oldVirtualDom.children.forEach((child, index) => {
            walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
        })
     } else {
         // 如果节点类型不同 已经被直接替换了 则直接将新的结果放入diffResult数组中
         diffResult.push({ 
             type: 'REPLACE',
             newVirtualDom
         })
     }
     if (!oldVirtualDom) {
         diffResult.push({
             type: 'REPLACE',
             newVirtualDom
         })
     }
     if (diffResult.length) {
         patches[index] = diffResult
     }
}

我们测试下diff函数,代码如下:

const chapterListVirtualDom = element('ul', { id: 'list' }, [
    element('li', { class: 'chapter' }, ['chapter1']),
    element('li', { class: 'chapter' }, ['chapter2']),
    element('li', { class: 'chapter' }, ['chapter3'])
])
const chapterListVirtualDom1 = element('ul', { id: 'list1' }, [
    element('li', { class: 'chapter1' }, ['chapter4']),
    element('li', { class: 'chapter1' }, ['chapter5']),
    element('li', { class: 'chapter1' }, ['chapter6'])
])
diff(chapterListVirtualDom, chapterListVirtualDom1)

执行以上代码,结果如下:

屏幕截图 2022-01-06 213348.png

最小化差异应用

到这里,我们来回顾下我们已经做了哪些事情:

  • 通过Element class生成了虚拟DOM
  • 通过diff方法对任意两个虚拟DOM进行比对 那么,如何将这个差异更新到现有的DOM节点中呢?下面我们接着来实现一个patch方法:
const patch = (node, patches) => {
    let walker = { index: 0 }
    walk(node, walker, patches)
}

patch方法第一个参数是一个真实的DOM节点(需要更新的DOM节点),第二个参数是一个最小化差异集合,该集合其实就是diff方法返回的结果,其内部调用了walk函数,代码如下:

const walk = (node, walker, patches) => {
    let currentPatch = patches[walker.index]
    let childNodes = node.childNodes
    childNodes.forEach(child => {
        walker.index++
        walk(child, walker, patches)
    })
    if (currentPatch) {
        doPatch(node, currentPatch)
    }
}

walk函数会进行自身递归,对当前节点的差异调用doPatch方法进行更新:

const doPatch = (node, patches) => {
    patches.forEach(patch => {
        switch (patch.type) {
            case 'MODIFY_ATTRIBUTES':
                const attributes = patch.diffAttributeResult
                for (let key in attributes) {
                    if (node.nodeType !== 1) {
                        return
                    }
                    const value = attributes[key]
                    if (value) {
                        setAttributes(node, key, value)
                    } else {
                        node.removeAttribute(key)
                    }
                }
                break
            case 'MODIFY_TEXT':
                node.textContent = patch.data
                break
            case 'REPLACE':
                let newNode = patch.newVirtualDom instanceof Element
                    ? patch.newVirtualDom.render()
                    : document.createTextNode(patch.newVirtualDom)
                node.parentNode.replaceChild(newNode, node)
                break
            case 'REMOVE':
                node.parentNode.removeChild(node)
                break
            default:
                break
       }
   })
}

doPatch方法会对4种类型的diff进行处理,测试代码如下:

let element = chapterListVirtualDom.render()
renderDom(element, document.body)

const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)

patch(element, patches)