信息发布→ 登录 注册 退出

Vue的diff算法原理你真的了解吗

发布时间:2026-01-11

点击量:
目录
  • 思维导图
  • 0. 从常见问题引入
  • 1. 生成虚拟dom
    • 1. h方法实现
    • 2. render方法实现
    • 3. 再次渲染
  • 2. diff算法
    • 1. 对常见的dom做优化
      • 情况1:末尾追加一个元素(头和头相同)
      • 情况2:队首添加一个节点(尾和尾)
      • 情况3:翻转类型(头和尾)
      • 情况4: 暴力比对复用
    • 对于key的探讨
      • 1. 为什么不能没有key
      • 2. 为什么key不能是index
      • 3. diff的遍历方式
  • 总结

    思维导图

    0. 从常见问题引入

    • 虚拟dom是什么?
    • 如何创建虚拟dom?
    • 虚拟dom如何渲染成真是dom?
    • 虚拟dom如何patch(patch)
    • 虚拟DOM的优势?(性能)
    • Vue中的key到底有什么用,为什么不能用index?
    • Vue中的diff算法实现
    • diff算法是深度还是广度优先遍历

    1. 生成虚拟dom

    1. h方法实现

    virtual dom ,也就是虚拟节点

    1.它通过js的Object对象模拟dom中的节点

    2.再通过特定的render方法将其渲染成真实的dom节点

    eg:

    <div id="wrapper" class="1">
        <span style="color:red">hello</span>
        world
    </div> 
    

    如果利用h方法生成虚拟dom的话:

    h('div', { id: 'wrapper', class: '1' }, h('span', { style: { color: 'red' } }, 'hello'), 'world');
    
    

    对应的js对象如下:

    let vd = {
        type: 'div',
        props: { id: 'wrapper', class: '1' },
        children: [
            {
                type: 'span',
                props: { color: 'red' },
                children: [{}]
            },
            {
                type: '',
                props: '',
                text: 'world'
            }
        ]
    }
    

    自己实现一个h方法

     function createElement(type, props = {}, ...children) {
        // 防止没有传值的话就赋值一个初始值
        let key;
        if (props.key) {
            key = props.key
            delete props.key
        }
        // 如果孩子节点有字符串类型的,也需要转化为虚拟节点
        children = children.map(child => {
            if (typeof child === 'string') {
                // 把不是节点类型的子节点包装为虚拟节点
                return vNode(undefined, undefined, undefined, undefined, child)
            } else {
                return child
            }
        })
        return vNode(type, props, key, children)
    }
    function vNode(type, props, key, children, text = undefined) {
        return {
            type,
            props,
            key,
            children,
            text
        }
    }
    

    2. render方法实现

    render的作用:把虚拟dom转化为真实dom渲染到container容器中去

    export function render(vnode, container) {
        let ele = createDomElementFrom(vnode) //通过这个方法转换真实节点
        if (ele) container.appendChild(ele)
    }
    

    把虚拟dom转化为真实dom,插入到容器中,如果虚拟dom对象包含type值,说明为元素(createElement),否则为节点类型(createTextnode),并把真实节点赋值给虚拟节点,建立起两者之间的关系

    function createDomElementFrom(vnode) {
        let { type, key, props, children, text } = vnode
        if (type) {//说明是一个标签
            // 1. 给虚拟元素加上一个domElemnt属性,建立真实和虚拟dom的联系,后面可以用来跟新真实dom
            vnode.domElement = document.createElement(type)
            // 2. 根据当前虚拟节点的属性,去跟新真实dom的值
            updateProperties(vnode)
            // 3. children中方的也是一个个的虚拟节点(就是递归把儿子追加到当前元素里)
            children.forEach(childVnode => render(childVnode, vnode.domElement))
        } else {//说明是一个文本
        }
        return vnode.domElement
    
    }
    
    function updateProperties(newVnode, oldProps = {}) {
        let domElement = newVnode.domElement //真实dom,
        let newProps = newVnode.props; //当前虚拟节点中的属性
        // 如果老的里面有,新的里面没有,说明这个属性被移出了
        for (let oldPropName in oldProps) {
            if (!newProps[oldPropName]) {
                delete domElement[oldPropName] //新的没有,为了复用这个dom,直接删除
            }
        }
        // 如果新的里面有style,老的里面也有style,style可能还不一样
        let newStyleObj = newProps.style || {}
        let oldStyleObj = oldProps.style || {}
        for (let propName in oldStyleObj) {
            if (!newStyleObj[propName]) {
                domElement.style[propName] = ''
            }
        }
        // 老的里面没有,新的里面有
        for (let newPropsName in newProps) {
            // 直接用新节点的属性覆盖老节点的属性
            if (newPropsName === 'style') {
                let styleObj = newProps.style;
                for (let s in styleObj) {
                    domElement.style[s] = styleObj[s]
                }
            } else {
                domElement[newPropsName] = newProps[newPropsName]
            }
        }
    
    }
    

    根据当前虚拟节点的属性,去更新真实dom的值
    由于还有子节点,所以还需要递归,生成子节点虚拟dom的真实节点,插入当前的真实节点里去

    3. 再次渲染

    刚刚可能会有点不解,为什么要把新的节点和老的节点属性进行比对,因为刚刚是首次渲染,现在讲一下二次渲染

    比如说现在构建了一个新节点newNode,我们需要和老节点进行对比,然而并不是简单的替换,而是需要尽可能多地进行复用

    首先判断父亲节点的类型,如果不一样就直接替换

    如果一样

    1.文本类型,直接替换文本值即可

    2.元素类型,需要根据属性来替换

    这就证明了render方法里我们的oldProps的必要性,所以这里把新节点的真实dom赋值为旧节点的真实dom,先复用一波,待会再慢慢修改

    updateProperties(newVnode, oldVNode.props)

    export function patch(oldVNode, newVnode) {
        // //判断类型是否一样,不一样直接用新虚拟节点替换老的
        if (oldVNode.type !== newVnode.type) {
            return oldVNode.domElement.parentNode.replaceChild(
                createDomElementFrom(newVnode), oldVNode.domElement
            )
        }
        // 类型相同,且是文本
        if (oldVNode.text) {
            return oldVNode.document.textContent = newVnode.text
        }
        // 类型一样,不是文本,是标签,需要根据新节点的属性更新老节点的属性
        // 1. 复用老节点的真实dom
        let domElement = newVnode.domElement = oldVNode.domElement
        // 2. 根据最新的虚拟节点来更新属性
        updateProperties(newVnode, oldVNode.props)
        // 比较儿子
        let oldChildren = oldVNode.children
        let newChildren = newVnode.children
        // 1. 老的有儿子,新的有儿子
        if (oldChildren.length > 0 && newChildren.length > 0) {
            // 对比两个儿子(很复杂)
        } else if (oldChildren.length > 0) {
            // 2. 老的有儿子,新的没儿子
            domElement.innerHTML = ''
        } else if (newChildren.length > 0) {
            // 3. 新增了儿子
            for (let i = 0; i < newChildren.length; i++) {
                // 把每个儿子加入元素里
                let ele = createDomElementFrom(newChildren[i])
                domElement.appendChild(ele)
            }
        }
    
    
    }
    

    2. diff算法

    刚刚的渲染方法里,首先是对最外层元素进行对比,对于儿子节点,分为三种情况

    1.老的有儿子,新的没儿子(那么直接把真实节点的innerHTML设置为空即可)

    2.老的没儿子,新的有儿子(那么遍历新的虚拟节点的儿子列表,把每一个都利用createElementFrom方法转化为真实dom,append到最外层真实dom即可)

    3.老的有儿子,新的有儿子,这个情况非常复杂,也就是我们要提及的diff算法

    1. 对常见的dom做优化

    • 前后追加元素
    • 正序和倒序元素
    • 中间插入元素

    以最常见的ul列表为例子

    旧的虚拟dom

    let oldNode = h('div', {},
        h('li', { style: { background: 'red' }, key: 'A' }, 'A'),
        h('li', { style: { background: 'blue' }, key: 'B' }, 'A'),
        h('li', { style: { background: 'yellow' }, key: 'C' }, 'C'),
        h('li', { style: { background: 'green' }, key: 'D' }, 'D'),
    );
    

    情况1:末尾追加一个元素(头和头相同)

    新的虚拟节点

    let newVnode = h('div', {},
        h('li', { style: { background: 'red' }, key: 'A' }, 'A'),
        h('li', { style: { background: 'blue' }, key: 'B' }, 'B'),
        h('li', { style: { background: 'yellow' }, key: 'C' }, 'C1'),
        h('li', { style: { background: 'green' }, key: 'D' }, 'D1'),
        h('li', { style: { background: 'black' }, key: 'D' }, 'E'),
    );
    

    eg:

    // 比较是否同一个节点
    function isSameVnode(oldVnode, newVnode) {
        return oldVnode.key == newVnode.key && oldVnode.type == newVnode.type
    }
    // diff
    function updateChildren(parent, oldChildren, newChildren) {
        // 1. 创建旧节点开头指针和结尾
        let oldStartIndex = 0
        let oldStartVnode = oldChildren[oldStartIndex];
        let oldEndIndex = oldChildren.length - 1
        let oldEndVnode = oldChildren[oldEndIndex];
        // 2. 创建新节点的指针
        let newStartIndex = 0
        let newStartVnode = newChildren[newStartIndex];
        let newEndIndex = newChildren.length - 1
        let newEndVnode = newChildren[newEndIndex];
        // 1. 当从后面插入节点的时候,希望判断老的孩子和新的孩子 循环的时候,谁先结束就停止循环
        while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
            // 注意:比较对象是否相等,你不能用==,因为指向的位置可能不一样,可以用type和key
            if (isSameVnode(oldStartVnode, newStartVnode)) {
                //patch比对更新
                patch(oldStartVnode, newStartVnode)
                // 移动指针
                oldStartVnode = oldChildren[++oldStartIndex]
                newStartVnode = newChildren[++newStartIndex]
            }
        }
        if (newStartIndex <= newEndIndex) {
            for (let i = newStartIndex; i <= newEndIndex; i++) {
                parent.appendChild(createDomElementFrom(newChildren[i]))
            }
        }
    }
    

    情况2:队首添加一个节点(尾和尾)

    头和头+尾和尾的处理方法:

    我们通过parent.insertBefore(createDomElementFrom(newChildren[i]), beforeElement)使得末尾添加和头部添加采用同一种处理方法

        // 如果是从前往后遍历说明末尾新增了节点,会比原来的儿子后面新增了几个
        // 也可以时从后往前遍历,说明比原来的儿子前面新增了几个
        if (newStartIndex <= newEndIndex) {
            for (let i = newStartIndex; i <= newEndIndex; i++) {
                // 取得第一个值,null代表末尾
                let beforeElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement   parent.insertBefore(createDomElementFrom(newChildren[i]), beforeElement)
            }
        }
    

    图解:

    MVVM=>数据一变,就调用patch

    情况3:翻转类型(头和尾)

    尾和头就不画图了

    else if (isSameVnode(oldStartVnode, newEndVnode)) {
                    // 头和尾巴都不一样,拿老的头和新的尾巴比较
                    patch(oldStartVnode, newEndVnode)
                    // 把旧节点的头部插入到旧节点末尾指针指向的节点之后一个
                    parent.insertBefore(oldStartVnode.domElement, oldEndVnode.domElement.nextSibling)
                    // 移动指针
                    oldStartVnode = oldChildren[++oldStartIndex]
                    newEndVnode = newChildren[--newEndIndex]
                } else if (isSameVnode(oldEndVnode, newStartVnode)) {
                    // 头和尾巴都不一样,拿老的头和新的尾巴比较
                    patch(oldEndVnode, newStartVnode)
                    // 把旧节点的头部插入到旧节点末尾指针指向的节点之后一个
                    parent.insertBefore(oldEndVnode.domElement, oldStartVnode.domElement)
                    // 移动指针
                    oldEndVnode = oldChildren[--oldEndIndex]
                    newStartVnode = newChildren[++newStartIndex]
                } else {
    

    情况4: 暴力比对复用

    else {
                    // 都不一样,就暴力比对
                    // 需要先拿到新的节点去老的节点查找是否存在相同的key,存在则复用,不存在就创建插入即可
                    // 1. 先把老的哈希
                    let index = map[newStartVnode.key]//看看新节点的key在不在这个map里
                    console.log(index);
                    if (index == null) {//没有相同的key
                        // 直接创建一个,插入到老的前面即可
                        parent.insertBefore(createDomElementFrom(newStartVnode),
                            oldStartVnode.domElement)
                    } else {//有,可以复用
                        let toMoveNode = oldChildren[index]
                        patch(toMoveNode, newStartVnode)//复用要先patch一下
                        parent.insertBefore(toMoveNode.domElement, oldStartVnode.domElement)
                        oldChildren[index] = undefined
                        // 移动指正
                    }
                    newStartVnode = newChildren[++newStartIndex]
                }
    // 写一个方法,做成一个哈希表{a:0,b:1,c:2}
    function createMapToIndex(oldChildren) {
        let map = {}
        for (let i = 0; i < oldChildren.length; i++) {
            let current = oldChildren[i]
            if (current.key) {
                map[current.key] = i
            }
        }
        return map
    }
    

    对于key的探讨

    1. 为什么不能没有key

    2. 为什么key不能是index

    3. diff的遍历方式

    采用的是深度优先,只会涉及到dom树同层的比较,先对比父节点是否相同,然后对比儿子节点是否相同,相同的话对比孙子节点是否相同

    总结

    本篇文章就到这里了,希望能够给你带来帮助,也希望您能够多多关注的更多内容!    

    在线客服
    服务热线

    服务热线

    4008888355

    微信咨询
    二维码
    返回顶部
    ×二维码

    截屏,微信识别二维码

    打开微信

    微信号已复制,请打开微信添加咨询详情!