vuejs设计与实现-简单diff算法

71 阅读1分钟

减少DOM操作的性能开销

核心diff只关心新旧虚拟节点都存在一组子节点的情况. 首先遍历长度较短的一组, 尽可能调用patch函数进行更新. 如果新子节点更长, 说明需要挂载新子节点; 如果旧节点更长, 则有旧子节点需要卸载.

function patchChildren(n1, n2, container){
    if(typeof n2.children === 'string'){
        // ...
    } else if (Array.isArray(n2.children)){
        const oldC = n1.children, newC = n2.children,
        oldL = oldC.length, newL = newC.length;
        
        const len = Math.min(oldL, newL) 
        // 首先比较公共长度的部分
        for(let i = 0; i < len; i++) {
            patch(oldC[i], newC[i], container)
        }
        
        if(newL > oldL){
            // 新子节点更长, 需要挂载多出的部分
            for(let i = len; i < newL; i++) {
                patch(null, newC[i], container)
            }
        } else if (newL < oldL){
            // 旧子节点更长, 需要卸载多出的部分
            for(let i = len; i < oldL; i++) {
                unmount(oldC[i])
            }
        } 
    } else {
        // ...
    }
} 

dom复用与key的作用

前面通过减少dom操作的次数(比较新旧子节点数, 进行patch, 然后根据情况删除或插入其余的子节点. 而不是将旧子节点全部卸载, 插入新的子节点), 减少性能开销.

但是对于相同的子节点, 可以通过dom的移动完成子节点的更新. 避免卸载和挂载的浪费. key属性就像虚拟节点的“身份证”号, 只要两个虚拟节点的type属性和key属性都相同, 就认为它们是相同的, 可以进行dom的复用.

// 可复用不代表不需要更新, 仍需要进行打补丁, 因为其子节点内容已发生变化
const oldVnode = { type: 'p', key: 1, children: 'text 1' }  
const newVnode = { type: 'p', key: 1, children: 'text 2' } 

// 对子节点进行patch
function patchChildren(n1, n2, container) {
     if(typeof n2.children === 'string'){
        // ...
    } else if (Array.isArray(n2.children)){
         const oldC = n1.children, newC = n2.children
         // 遍历新旧节点, 
         for(let i = 0; i < newC.length; i++) {
            const newVnode = newC[i]
            for(let j = 0; j < oldC.length; j++) {
                const oldVnode = oldC[i]
                // 两个子节点可复用, 但仍需要调用patch函数更新
                if(newVnode.key === oldVnode.key) {
                    patch(oldVnode, newVnode, container)
                    break
                }
            }
            
        }
    }
}

找到旧子节点中所有可复用的节点, 调用patch函数进行更新. 保证可复用的节点都更新完毕.

找到要移动的元素

首先, 新旧子节点的节点顺序不变时, 就不需要额外的操作.

记录在旧节点中寻找具有相同key值节点的过程中, 遇到的最大索引值. 在后续寻找过程中, 如果存在索引值比当前遇到的最大索引值还小的节点, 则意味着该节点需要移动.

// newVnode   oldVnode
// p-3          p-1 
// p-1          p-2
// p-2          p-3

// 1. p-3在旧节点中相同节点的索引为 2.
// 2. p-1在旧节点索引为 0, 则 p-1需要移动
// 3. p-2在旧节点索引为 1, 也需要移动
function patchChildren(n1, n2, container){
    if(typeof n2.children === 'string'){
        // ...
    } else if (Array.isArray(n2.children)){
        const oldC = n1.children, newC = n2.children;
        // 存储寻找过程遇到的最大索引
        let lastIndex = 0
        for(let i < 0; i < newC.length; i++){
            const newVnode = newC[i]
            for(let j = 0; j < oldC.length; j++) {
                const oldVnode = oldC[i]
                if(newVnode.key === oldVnode.key) {
                    patch(oldVnode, newVnode, container)
                    if(j < lastIndex) {
                        // 当前节点在旧children中的索引小于最大索引值, 则该节点需要移动
                    } else {
                        // 如果过程中当前节点在旧children中的索引不小于最大索引值, 则更新 lastIndex
                        lastIndex = j
                    }
                    break
                }
            }
        }
    } else {
        // ...
    }
} 

如何移动元素

移动节点指的是移动一个虚拟节点对应的真实dom节点, 而不是其本身.

当更新操作发生时, 渲染器会调用patchElement函数在新旧虚拟节点间打补丁. 在复用了元素之后, 新节点也将持有对真实dom的引用.

将当前节点对应的真实dom移动到前一个节点对应的真实dom后面完成移动.

// 打补丁操作
function patchElement(n1, n2){
    // 新的vnode也引用了dom元素(dom元素的复用)
    const el = n2.el = n1.el
    // ...
}

function patchChildren(n1, n2, container){
    if(typeof n2.children === 'string'){
        // ...
    } else if (Array.isArray(n2.children)){
        const oldC = n1.children, newC = n2.children;
        
        let lastIndex = 0
        for(let i < 0; i < newC.length; i++){
            const newVnode = newC[i]
            let j = 0;
            for(j; j < oldC.length; j++) {
                const oldVnode = oldC[i]
                if(newVnode.key === oldVnode.key) {
                    patch(oldVnode, newVnode, container)
                    // newVnode对应的真实dom需要移动
                    if(j < lastIndex) {
                        const prevVnode = newC[i - 1]
                        // 将新节点对应的真实dom插入到prevVnode对应的真实dom的后面
                        if(prevVnode){
                            const anchor = prevVnode.el.nextSibling
                            insert(newVnode.el, container, anchor)
                        }
                    } else {
                        lastIndex = j
                    }
                    break
                }
            }
        }
    } else {
        // ...
    }
} 

添加新元素

当新子节点中的节点, 在旧子节点中不存在时, 就是新增节点.

function patchChildren(){
     if(typeof n2.children === 'string'){
        // ...
    } else if (Array.isArray(n2.children)){
        const oldC = n1.children, newC = n2.children;
        
        let lastIndex = 0
        // 遍历新子节点, 定义find变量用于标识是否在旧子节点中找到此节点
        for(let i = 0; i < newC.length; i++){
            const newVonde = newC[i]
            let j = 0, find = false
            // 遍历旧子节点, 与新子节点中当前节点逐个比较
            for(j; j < oldC.length; j++) {
                const oldVnode = oldC[i]
                // 节点可复用, 则找到了对应节点
                if(newVonde.key === oldVnode.key){
                    find = true
                    patch(oldVnode, newVonde, container)
                    if(j < lastIndex){
                        const prevVnode = newC[i - 1]
                        if(prevVnode){
                            insert(newVonde.el, container, anchor)
                        }
                    } else {
                        lastIndex = j
                    }
                    break
                }
            }
            // 没有找到可复用的节点, 则当前节点是新增节点
            if(!find){
                // 将新增节点挂载至合适位置, 需要先找到锚点元素
                const prevVnode = newC[i - 1]
                let anchor
                // 前一个节点存在, 则锚点元素是它的下一个兄弟节点
                if(prevVnode) {
                    anchor = prevVnode.el.nextSibling
                // 前一个节点不存在, 则锚点元素是第一个子节点
                } else {
                    anchor = container.firstChild
                }
                // 挂载newVonde
                patch(null, newVonde, container, anchor)
            }
            
        }
    }
}


// 新增入参 锚点元素
function patch(n1, n2, container, anchor){
    // ...
    
    if(typeof type === 'string') {
        if(!n1) {
            mountElement(n2, container, anchor)
        } else {
            patchElement(n1, n2)
        }
    } else if(typeof type === Text) {
        // ...
    } else if(typeof type === Fragment) {
        // ...
    }
}

// 锚点元素  记录插入位置
function mountElement(vnode, container, anchor){
    // ...
    insert(el, container, anchor)
}

移除不存在的元素

当更新结束时, 需要遍历旧的一组子节点, 然后去新的一组子节点中寻找具有相同key的节点. 如果找不到则删除该节点.

function patchChildren(n1, n2, container){
    if(typeof n2.children === 'string'){
        // ...
    } else if (Array.isArray(n2.children)){
        const oldC = n1.children, newC = n2.children;
        
        let lastIndex = 0
        for(let i < 0; i < newC.length; i++){
            // ...
        }
        
        // 上一步的更新操作完成后, 遍历旧的子节点
        for(let i < 0; i < oldC.length; i++){
            const oldVnode = oldC[i]
            const has = newC.find(vnode => vnode.key === oldVnode.key)
            // 如果新子节地中不存在, 则卸载当前子节点
            if(!has) {
                unmount(oldVnode)
            }
        }
        
    } else {
        // ...
    }
} 

总结

  • Diff算法用于计算两组子节点的差异, 并试图最大程度地复用dom元素.
  • key属性是虚拟节点的“身份证号”. 在更新时渲染器通过key属性找到可复用的节点, 然后在更新时尽可能地通过dom移动操作来完成更新, 避免过多地对dom元素进行销毁和重建.
  • 简单diff算法的核心逻辑是, 拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点. 如果找到了, 则记录该节点的索引(最大索引). 在整个更新过程中, 如果一个节点的索引小于最大索引, 则说明该节点对应的dom元素需要移动.