美文网首页vue
手写Vue2核心(五):节点差异与diff算法

手写Vue2核心(五):节点差异与diff算法

作者: 羽晞yose | 来源:发表于2021-03-05 18:23 被阅读0次

    网上找的图,懒得自己画,毕竟本人PS一般(程序员程度的一般,对比设计师为未毕业渣渣级)

    diff算法

    在这里也多说一句,节点对比不属于diff算法,diff算法仅对于父节点一致,并且都有子节点的时候才需要用到,其他的就是简单的逻辑判断一直去if...else if...而已
    开发准备工作,创建两个vnode,前面方法已经实现了,直接调用即可(开发完删除即可)。

    // index.js
    import { compileToFunctions } from './compiler/index.js'
    import { createdElm, patch } from './vdom/patch.js'
    
    // 构建两个虚拟Dom
    let vm1 = new Vue({
        data () {
            return {
                name: 'yose'
            }
        }
    })
    
    let render1 = compileToFunctions(`<div id="a" a="1" style="color: red">{{name}}</div>`) // 将模板变为render函数
    let oldVnode = render1.call(vm1) // 老的虚拟节点
    let el = createdElm(oldVnode) // 创建真实节点
    document.body.appendChild(el)
    
    let vm2 = new Vue({
        data () {
            return {
                name: 'Catherine'
            }
        }
    })
    
    let render2 = compileToFunctions(`<div id="b" b="1" style="background: blue">{{name}}</div>`) // 将模板变为render函数
    let newVnode = render2.call(vm2) // 老的虚拟节点
    
    setTimeout(() => {
        patch(oldVnode, newVnode)
    }, 2000)
    

    父元素对比

    patch负责两件事:渲染成真实Dom(初渲染)及diff算法,之前diff已经预留过,非真实节点!isRealElement走diff算法,现在就是来完善diff

    父元素对比情况列举:

    1. 对比父节点标签,如果不一致,直接替换
    2. 标签一致,但是为文本标签(tagundefined),替换文本内容
    3. 标签一致,非文本标签,但是属性不同,复用老节点并且更新属性
    // vdom/patch.js
    export function patch(oldVnode, vnode) {
        if (isRealElement) {
            // code...
        } else {
    +       // 1. 如果两个虚拟节点的标签不一致,就直接替换掉
    +       if (oldVnode.tag !== vnode.tag) {
    +           return oldVnode.el.parentNode.replaceChild(createdElm(vnode), oldVnode.el)
    +       }
    
    +       // 2. 标签一样,但是是两个文本元素(tag: undefined)
    +       if (!oldVnode.tag) {
    +           if (oldVnode.text !== vnode.text) {
    +               return oldVnode.el.textContent = vnode.text
    +           }
    +       }
    
    +       // 3. 元素相同,属性不同,复用老节点并且更新属性
    +       let el = vnode.el = oldVnode.el
    +       // 用老的属性和新的虚拟节点进行比对
    +       updateProperties(vnode, oldVnode.data)
    
    +       // 4. 更新子元素
    +       let oldChildren = oldVnode.children || []
    +       let newChildren = vnode.children || []
    
    +       if (oldChildren.length > 0 && newChildren.length > 0) { // 新的老的都有子元素,需要使用diff算法
    
    +       } else if (oldChildren.length > 0) { // 1. 老的有子元素,新的没有子元素,删除老的子元素
    +           el.innerHTML = '' // 清空所有子节点
    +       } else if (newChildren.length > 0) { // 2. 新的有子元素,老的没有子元素,在老节点增加子元素即可
    +           newChildren.forEach(child => el.appendChild(createElm(child)))
    +       }
        }
    }
    
    // 更新属性,注意这里class与style无法处理表达式,因为从前面解析的时候就没处理,还是那句,重点不在完全实现,而是学习核心思路
    function updateProperties (vnode, oldProps = {}) {
        const newProps = vnode.data || {}
        const el = vnode.el
    
    +   // 1. 老的属性,新的没有,删除属性
    +   // 前面提到过一次,以前vue1需要考虑重绘,现在新版浏览器已经会做合并,所以不用再去考虑使用documentFlagment来优化了
    +   for (let key in oldProps) {
    +       if (!newProps[key]) {
    +           el.removeAttribute(key)
    +       }
    +   }
    
    +   let newStyle = newProps.style || {}
    +   let oldStyle = oldProps.style || {}
    +   for (let key in oldStyle) { // 新老样式先进行比对,删除新vnode中没有的样式
    +       if (!newStyle[key]) {
    +           el.style[key] = ''
    +       }
    +   }
    
        // 2. 新的属性,老的没有,直接用新的覆盖,不用考虑有没有
        // 原本code...
    }
    

    diff算法

    diff算法是当父元素一致,并且都有子节点的情况下使用的
    diff算法是借鉴于snabbdom.js的,有兴趣的可自行拓展了解
    diff算法的核心思路是去操作vnode,通过vnode来排查是否需要重新创建节点,而不是直接去访问真实节点(减少过桥费)
    对到节点,能移动的采用移动,能复用的节点则复用,不能移动或复用的才创建插入(减少节点的销毁创建,因为Dom操作是具备移动性的,会移动节点,Dom映射)
    还要注意一点,对比使用的vnode,移动真实Dom(这句在下面代码最后一个情景里自行体会,比如后面比对可复用,会将节点置为null,置null的是虚拟节点,真实节点是直接移动)

    // vdom\index.js
    // 是否为相同虚拟节点
    + export function isSameVnode (oldVnode, newVnode) {
    +     return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
    + }
    

    diff算法主要实现逻辑代码

    export function patch(oldVnode, vnode) {
        // code...
    
        if (isRealElement) {
            // code...
    +   } else {
    +       // 1. 如果两个虚拟节点的标签不一致,就直接替换掉
    +       if (oldVnode.tag !== vnode.tag) {
    +           return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
    +       }
    +
    +       // 2. 标签一样,但是是两个文本元素(tag: undefined)
    +       if (!oldVnode.tag) {
    +           if (oldVnode.text !== vnode.text) {
    +               return oldVnode.el.textContent = vnode.text
    +           }
    +       }
    +
    +       // 3. 元素相同,属性不同,复用老节点并且更新属性
    +       let el = vnode.el = oldVnode.el
    +       // 用老的属性和新的虚拟节点进行比对
    +       updateProperties(vnode, oldVnode.data)
    +
    +       // 4. 更新子元素
    +       let oldChildren = oldVnode.children || []
    +       let newChildren = vnode.children || []
    +
    +       if (oldChildren.length > 0 && newChildren.length > 0) { // 新的老的都有子元素,需要使用diff算法
    +           updateChildren(el, oldChildren, newChildren)
    +       } else if (oldChildren.length > 0) { // 1. 老的有子元素,新的没有子元素,删除老的子元素
    +           el.innerHTML = '' // 清空所有子节点
    +       } else if (newChildren.length > 0) { // 2. 新的有子元素,老的没有子元素,在老节点增加子元素即可
    +           newChildren.forEach(child => el.appendChild(createElm(child)))
    +       }
    +   }
    }
    
    + // diff算法主要逻辑
    + function updateChildren (parent, oldChildren, newChildren) {
    +     let oldStartIndex = 0 // 老的父元素起始指针
    +     let oldEndIndex = oldChildren.length - 1 // 老的父元素终止指针
    +     let oldStartVnode = oldChildren[0] // 老的开始节点
    +     let oldEndVnode = oldChildren[oldEndIndex] // 老的结束节点
    + 
    +     let newStartIndex = 0 // 新的父元素起始指针
    +     let newEndIndex = newChildren.length - 1 // 新的父元素终止指针
    +     let newStartVnode = newChildren[0] // 新的开始节点
    +     let newEndVnode = newChildren[newEndIndex] // 新的结束节点
    + 
    +     // 创建字典表,用于乱序
    +     function makeIndexByKey (oldChildren) {
    +         let map = {}
    +         oldChildren.forEach((item, index) => {
    +             map[item.key] = index
    +         })
    +         return map
    +     }
    + 
    +     let map = makeIndexByKey(oldChildren)
    + 
    +     // 1. 前端中比较常见的操作有:尾部插入 头部插入 头部移动到尾部 尾部移动到头部 正序和反序
    +     while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    +         if (!oldStartVnode) { // 乱序diff算法中处理过的虚拟节点
    +             oldStartVnode = oldChildren[++oldStartIndex]
    +         } else if (!oldEndVnode) { // 乱序diff算法中处理过的虚拟节点
    +             oldEndVnode = oldChildren[--oldEndIndex]
    +         } else if (isSameVnode(oldStartVnode, newStartVnode)) { // 向后插入操作,开始的虚拟节点一致
    +             patch(oldStartVnode, newStartVnode) // 递归比对节点
    +             oldStartVnode = oldChildren[++oldStartIndex]
    +             newStartVnode = newChildren[++newStartIndex]
    +         } else if (isSameVnode(oldEndVnode, newEndVnode)) { // 向前插入,开始的虚拟节点不一致,结束的虚拟节点一致
    +             patch(oldEndVnode, newEndVnode)
    +             oldEndVnode = oldChildren[--oldEndIndex]
    +             newEndVnode = newChildren[--newEndIndex]
    +         } else if (isSameVnode(oldStartVnode, newEndVnode)) { // 开始结束都不一致,旧的开始与新的结尾一致(头部插入尾部)
    +             patch(oldStartVnode, newEndVnode)
    +             parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)
    +             oldStartVnode = oldChildren[++oldStartIndex]
    +             newEndVnode = newChildren[--newEndIndex]
    +         } else if (isSameVnode(oldEndVnode, newStartVnode)) { // 开始结束都不一致,旧的结尾与新的起始一致(尾部插入头部)
    +             patch(oldEndVnode, newStartVnode)
    +             parent.insertBefore(oldEndVnode.el, oldStartVnode.el)
    +             oldEndVnode = oldChildren[--oldEndIndex]
    +             newStartVnode = newChildren[++newStartIndex]
    +         } else { // 乱序diff算法,检测是否有可复用的key值,有则将原本节点移动,老的位置置为null,否则将新的节点插入进老的节点中来
    +             // 1. 需要先查找当前索引 老节点索引和key的关系
    +             // 移动的时候通过新的 key 去找对应的老节点索引 => 获取老节点,可以移动老节点
    +             let moveIndex = map[newStartVnode.key]
    +             if (moveIndex === undefined) { // 不在字典中存在,是个新节点,直接插入
    +                 parent.insertBefore(createElm(newStartVnode), oldStartVnode.el)
    +             } else {
    +                 let moveVnode = oldChildren[moveIndex]
    +                 oldChildren[moveIndex] = undefined // 表示该虚拟节点已经处理过,后续递归时可直接跳过
    +                 patch(moveVnode, newStartVnode) // 如果找到了,需要两个虚拟节点对比
    +                 parent.insertBefore(moveVnode.el, oldStartVnode.el)
    +             }
    +             newStartVnode = newChildren[++newStartIndex]
    +         }
    +     }
    + 
    +     // 新的比老的多,插入新节点
    +     if (newStartIndex <= newEndIndex) {
    +         // 将多出来的节点一个个插入进去
    +         for (let i = newStartIndex; i <= newEndIndex; i++) {
    +             // 排查下一个节点是否存在,如果存在证明指针是从后往前(insertBefore),反之指针是从头往后(appendChild)
    +             let nextEle = newChildren[newEndIndex + 1] === undefined ? null : newChildren[newEndIndex + 1].el
    +             // 这里不需要分情况使用 appendChild 或 insertBefore
    +             // 如果 insertBefore 传入 null,等价于 appendChild
    +             parent.insertBefore(createElm(newChildren[i]), nextEle)
    +         }
    +     }
    + 
    +     // 老的比新的多,删除老节点
    +     if (oldStartIndex <= oldEndIndex) {
    +         for (let i = oldStartIndex; i <= oldEndIndex; i++) {
    +             let child = oldChildren[i]
    +             if (child !== undefined) { // 有可能是遍历到已经被使用过的虚拟节点,需要排除掉
    +                 parent.removeChild(child.el)
    +             }
    +         }
    +     }
    + }
    
    // 更新属性,注意这里class与style无法处理表达式,因为从前面解析的时候就没处理,还是那句,重点不在完全实现,而是学习核心思路
    function updateProperties (vnode, oldProps = {}) {
        const newProps = vnode.data || {}
        const el = vnode.el
    
    +   // 1. 老的属性,新的没有,删除属性
    +   // 前面提到过一次,以前vue1需要考虑重绘,现在新版浏览器已经会做合并,所以不用再去考虑使用documentFlagment来优化了
    +   for (let key in oldProps) {
    +       if (!newProps[key]) {
    +           el.removeAttribute(key)
    +       }
    +   }
    
    +   let newStyle = newProps.style || {}
    +   let oldStyle = oldProps.style || {}
    +   for (let key in oldStyle) { // 新老样式先进行比对,删除新vnode中没有的样式
    +       if (!newStyle[key]) {
    +           el.style[key] = ''
    +       }
    +   }
    
    +   // 2. 新的属性,老的没有,直接用新的覆盖,不用考虑有没有
        for (let key in newProps) {
            if (key === 'style') {
                for (let styleName in newProps.style) {
                    el.style[styleName] = newProps.style[styleName]
                }
            } else if (typeof tag === 'class') { // 静态的class可以没有这段,但还是写上,假装如果是class可以处理简单的表达式
                vnode.className = newProps.class
            } else {
                el.setAttribute(key, newProps[key])
            }
        }
    }
    

    vue更新时引入diff算法

    diff算法是在渲染更新时才需要使用的,前面实现时,_update使用了一个虚拟节点,所以现在要在实例上再挂一个_vnode,用于保存上一次的虚拟节点(patch需要老虚拟节点与新虚拟节点做对比)

    // lifecycle.js
    export function lifecycleMixin (Vue) {
        // 视图更新方法,用于渲染真实DOM
        Vue.prototype._update = function (vnode) {
            const vm = this
    
    +       const preVnode = vm._vnode // 初始化时必然为undefind
    +       vm._vnode = vnode
    +
    +       if (!preVnode) { // 初渲染
    +           // 首次渲染,需要用虚拟节点,来更新真实的dom元素(vm._render())
    +           // 第一次渲染完毕后 拿到新的节点,下次再次渲染时替换上次渲染的结果
                vm.$el = patch(vm.$el, vnode) // 组件调用patch方法后会产生$el属性
    +       } else { // 视图更新渲染
    +           vm.$el = patch(preVnode, vnode)
    +       }
        }
    }
    

    相关文章

      网友评论

        本文标题:手写Vue2核心(五):节点差异与diff算法

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