从零开始的Vue世界-06

1,011 阅读5分钟

本章主要解释Vue中diff算法

为什么要做diff算法

在之前的更新中每次更新,都会产生新的虚拟节点,通过新的虚拟节点生成真实节点,生成后替换掉老的节点。这种方法确实可以做到每次更新页面重新渲染,但是会操作很多的dom,如果可以复用之前的元素,就可以将性能进行优化。diff算法其实就是如何来复用元素。

抛出一个问题

<body>
    <div id="app">
        <div>
            <li v-for="(a,index) in arr" >
                {{a}} <input type="checkbox">
            </li>
        </div>
        <button @click="append">追加</button>
    </div>
    <!-- <script src="vue.js"></script> -->
    <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js"></script>
    <script>
        let vm = new Vue({
            el: '#app',
            data() {
                return { arr: ['香蕉', '苹果', '橘子'] }
            },
            methods:{
                append(){
                    this.arr.unshift('桃子');
                }
            }
        })
    </script>
</body>

image.png 我选中香蕉后,点击追加,新增的桃子有选中状态吗?

diff实现

前面几节中说过,渲染watcher会将渲染函数传入回调函数

Vue.prototype._update = function(vnode){ // 将vnode转化成真实dom
        const vm = this;
        const el = vm.$el;

        // patch既有初始化的功能  又有更新 
        vm.$el = patch(el,vnode);
    }
  const updateComponent = ()=>{
        vm._update(vm._render()); // vm.$options.render() 虚拟节点
    }

    const watcher = new Watcher(vm,updateComponent,true); // true用于标识是一个渲染watcher

下面看patch方法

export function patch(oldVNode, vnode) {
    // 写的是初渲染流程 
    const isRealElement = oldVNode.nodeType;
    if (isRealElement) {
        const elm = oldVNode; // 获取真实元素
        const parentElm = elm.parentNode; // 拿到父元素
        let newElm = createElm(vnode);
        parentElm.insertBefore(newElm, elm.nextSibling);
        parentElm.removeChild(elm); // 删除老节点

        return newElm
    } else {
        // 1.两个节点不是同一个节点  直接删除老的换上新的  (没有比对了)
        // 2.两个节点是同一个节点 (判断节点的tag和 节点的key)  比较两个节点的属性是否有差异 (复用老的节点,将差异的属性更新)
        // 3.节点比较完毕后就需要比较两人的儿子
        return patchVnode(oldVNode, vnode);
    }
}

这里会判断是否是第一次渲染,第一次oldvNode是真实dom,只需要生成新dom,插入,删除老dom即可 如果不是的话,就要进行diff算法了,走到patchVnode里,会比较两个节点是不是同一个节点,不是的话直接删除老的,换上新的,tag都不一样就不需要对比了;如果两个节点是同一个(tag和key一致),会比较节点属性差异复用老的节点,将差异的属性更新;当节点比较玩后就比较两个的儿子

function patchVnode(oldVNode, vnode) {
    if (!isSameVnode(oldVNode, vnode)) { // tag == tag key === key
        // 用老节点的父亲 进行替换
        let el = createElm(vnode);
        oldVNode.el.parentNode.replaceChild(el, oldVNode.el)
        return el;
    }

    // 文本的情况  文本我们期望比较一下文本的内容
    let el = vnode.el = oldVNode.el; // 复用老节点的元素
    if (!oldVNode.tag) { // 是文本
        if (oldVNode.text !== vnode.text) {
            el.textContent = vnode.text; // 用新的文本覆盖掉老的
        }
    }
    // 是标签   是标签我们需要比对标签的属性
    patchProps(el, oldVNode.data, vnode.data);

    // 比较儿子节点 比较的时候 一方有儿子 一方没儿子 
    //                       两方都有儿子


    let oldChildren = oldVNode.children || [];
    let newChildren = vnode.children || [];


    if (oldChildren.length > 0 && newChildren.length > 0) {
        // 完整的diff算法 需要比较两个人的儿子
        updateChildren(el, oldChildren, newChildren);

    } else if (newChildren.length > 0) { // 没有老的,有新的
        mountChildren(el, newChildren);
    } else if (oldChildren.length > 0) { // 新的没有  老的有 要删除
        el.innerHTML = ''; // 可以循环删除
    }
    return el;
}
function mountChildren(el, newChildren) {
    for (let i = 0; i < newChildren.length; i++) {
        let child = newChildren[i];
        el.appendChild(createElm(child))
    }
}

属性更新方法

export function patchProps(el, oldProps = {}, props = {}) {
    // 老的属性中有,新的没有  要删除老的
    let oldStyles = oldProps.style || {};
    let newStyles = props.style || {};
    for (let key in oldStyles) { // 老的样式中有 新的吗,没有则删除
        if (!newStyles[key]) {
            el.style[key] = ''
        }
    }

    for (let key in oldProps) { // 老的属性中有
        if (!props[key]) { // 新的没有删除属性
            el.removeAttribute(key);
        }
    }
    for (let key in props) { // 用新的覆盖老的
        if (key === 'style') { // style{color:'red'}
            for (let styleName in props.style) {
                el.style[styleName] = props.style[styleName];
            }
        } else {
            el.setAttribute(key, props[key]);
        }
    }

}

判断是否是同一个节点

export function isSameVnode(vnode1,vnode2){
    return vnode1.tag === vnode2.tag && vnode1.key === vnode2.key;
}

核心代码

function updateChildren(el, oldChildren, newChildren) {
    // 我们操作列表 经常会是有  push shift pop unshift reverse sort这些方法  (针对这些情况做一个优化)
    // vue2中采用双指针的方式 比较两个节点
    let oldStartIndex = 0;
    let newStartIndex = 0;
    let oldEndIndex = oldChildren.length - 1;
    let newEndIndex = newChildren.length - 1;

    let oldStartVnode = oldChildren[0];
    let newStartVnode = newChildren[0];

    let oldEndVnode = oldChildren[oldEndIndex];
    let newEndVnode = newChildren[newEndIndex];


    function makeIndexByKey(children) {
        let map = {

        }
        children.forEach((child, index) => {
            map[child.key] = index;
        });
        return map;
    }

    let map = makeIndexByKey(oldChildren);




    // 循环的时候为什么要+key
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 有任何一个不满足则停止  || 有一个为true 就继续走
        // 双方有一方头指针,大于尾部指针则停止循环
        if (!oldStartVnode) {
            oldStartVnode = oldChildren[++oldStartIndex]
        } else if (!oldEndVnode) {
            oldEndVnode = oldChildren[--oldEndIndex]
        } else if (isSameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode); // 如果是相同节点 则递归比较子节点
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex];
            // 比较开头节点
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode); // 如果是相同节点 则递归比较子节点
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex];
            // 比较开头节点
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode);
            // insertBefore 具备移动性 会将原来的元素移动走
            el.insertBefore(oldEndVnode.el, oldStartVnode.el); // 将老的尾巴移动到老的前面去
            oldEndVnode = oldChildren[--oldEndIndex];
            newStartVnode = newChildren[++newStartIndex];
        }
        else if (isSameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode);
            // insertBefore 具备移动性 会将原来的元素移动走
            el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); // 将老的尾巴移动到老的前面去
            oldStartVnode = oldChildren[++oldStartIndex];
            newEndVnode = newChildren[--newEndIndex];
        } else {
            // 在给动态列表添加key的时候 要尽量避免用索引,因为索引前后都是从0 开始 , 可能会发生错误复用 
            // 乱序比对
            // 根据老的列表做一个映射关系 ,用新的去找,找到则移动,找不到则添加,最后多余的就删除
            let moveIndex = map[newStartVnode.key]; // 如果拿到则说明是我要移动的索引
            if (moveIndex !== undefined) {
                let moveVnode = oldChildren[moveIndex]; // 找到对应的虚拟节点 复用
                el.insertBefore(moveVnode.el, oldStartVnode.el);
                oldChildren[moveIndex] = undefined; // 表示这个节点已经移动走了
                patchVnode(moveVnode, newStartVnode); // 比对属性和子节点
            } else {
                el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
            }
            newStartVnode = newChildren[++newStartIndex];
        }

    }
    if (newStartIndex <= newEndIndex) { // 新的多了 多余的就插入进去
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            let childEl = createElm(newChildren[i])
            // 这里可能是像后追加 ,还有可能是向前追加
            let anchor = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1].el : null; // 获取下一个元素
            // el.appendChild(childEl);
            el.insertBefore(childEl, anchor); // anchor 为null的时候则会认为是appendChild
        }
    }

    if (oldStartIndex <= oldEndIndex) { // 老的对了,需要删除老的
        for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            if (oldChildren[i]) {
                let childEl = oldChildren[i].el
                el.removeChild(childEl);
            }
        }
    }

    // 我们为了 比较两个儿子的时候 ,增高性能 我们会有一些优化手段
}

image.png diff算法首先利用双指针进行了“头头,尾尾,尾头,头尾”的比较,如果相同就可以复用元素,然后进行了乱序比对,把老的做映射,用新的去找,如果找到则移动老的元素,找不到就添加,最后删除多余的元素。 最后将多余老的删除,多余新的插入,这样就实现了diff算法

回顾问题

点击追加后,桃子是选中状态的

image.png 在点击追加后,在进行diff比较多时候,tag一样,key都是undefined,这样会被认为是同一个节点进行复用。