Vue2源码笔记(7)运行时-diff

前言

在上一篇中我们整理了Vue2中页面渲染的主流程代码,并完成了页面渲染、依赖收集、页面更新的逻辑整理。但在其中我们对patch只做了简略整理,但实际上它时Vue2的又一个核心。

在上一篇中我们的页面更新逻辑其实是粗暴地添加新节点,去除子节点。对于一个Vue2这样的优秀项目来说显然是不会如此简单的,在实际的实现中,这里应该经过diff算法优化:复用节点,以提升渲染性能。

这部分功能在patch中实现,它的功能应该包含两块:

  1. 首次渲染,根据VNode生成真实元素
  2. 更新渲染,新老VNode对比再渲染
ini 复制代码
function patch(oldVnode, vnode) {
    const isRealElement = oldVnode.nodeType;
    if (isRealElement) {
        const elm = createElm(vnode);
        const parentNode = oldVnode.parentNode;;
        parentNode.insertBefore(elm, oldVnode.nextSibling); 
        parentNode.removeChild(oldVnode);
        return elm;
    } else {
        if(!isSameVnode(oldVnode, vnode)){ // 不是相同节点,直接替换
            return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
        }
        // 子节点比较
        let oldChildren = oldVnode.children || {};
        let newChildren = vnode.children || {};
    }
}

diff的核心就在于子节点的对比,所以对于相同节点的比较、仅文本内容更新、属性更新的部分在这里先省略了。先专注于子节点比较这个核心需求:

老有新无

即老节点有子节点,新节点没有子节点的情况

ini 复制代码
function patch(oldVnode, vnode) {
    const isRealElement = oldVnode.nodeType;
    if (isRealElement) {
        const elm = createElm(vnode);
        const parentNode = oldVnode.parentNode;;
        parentNode.insertBefore(elm, oldVnode.nextSibling); 
        parentNode.removeChild(oldVnode);
        return elm;
    } else {
        if(!isSameVnode(oldVnode, vnode)){
            return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
        }
        // 子节点比较
        let oldChildren = oldVnode.children || {};
        let newChildren = vnode.children || {};
        if (oldChildren.length > 0 && newChildren.length == 0) {
            el.innerHTML = '';
        }
    }
}

老无新有

即老节点没有子节点,新节点有子节点的情况

ini 复制代码
function patch(oldVnode, vnode) {
    const isRealElement = oldVnode.nodeType;
    if (isRealElement) {
        const elm = createElm(vnode);
        const parentNode = oldVnode.parentNode;;
        parentNode.insertBefore(elm, oldVnode.nextSibling); 
        parentNode.removeChild(oldVnode);
        return elm;
    } else {
        if(!isSameVnode(oldVnode, vnode)){
            return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
        }
        // 子节点比较
        let oldChildren = oldVnode.children || {};
        let newChildren = vnode.children || {};
        if (oldChildren.length > 0 && newChildren.length == 0) {
            el.innerHTML = '';
        } else if (oldChildren.length == 0 && newChildren.length > 0) {
            newChildren.forEach((child)=>{
                let childElm = createElm(child);
                el.appendChild(childElm);
            })
        }
    }
}

老有新有

以上两个情况都相对简单,所以难点其实是落在了新老节点都有子节点的情况中:

ini 复制代码
function patch(oldVnode, vnode) {
    const isRealElement = oldVnode.nodeType;
    if (isRealElement) {
        const elm = createElm(vnode);
        const parentNode = oldVnode.parentNode;;
        parentNode.insertBefore(elm, oldVnode.nextSibling); 
        parentNode.removeChild(oldVnode);
        return elm;
    } else {
        if(!isSameVnode(oldVnode, vnode)){
            return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
        }
        // 子节点比较
        let oldChildren = oldVnode.children || {};
        let newChildren = vnode.children || {};
        if (oldChildren.length > 0 && newChildren.length == 0) {
            el.innerHTML = '';
        } else if (oldChildren.length == 0 && newChildren.length > 0) {
            newChildren.forEach((child)=>{
                let childElm = createElm(child);
                el.appendChild(childElm);
            })
        } else {
            updateChildren(el, oldChildren, newChildren); 
        }
    }
}

updateChildren的逻辑可以分成两大块:有序、无序,有序是指将新老子节点按照头对头、尾对尾、头对尾、尾对头几种方式来进行比较,无序则使用乱序比较。在做比较之前我们需要做一些铺垫工作:为新老子节点建立头尾指针。

ini 复制代码
function updateChildren(el, oldChildren, newChildren) {
    let oldStartIndex = 0
    let oldStartVnode = oldChildren[oldStartIndex]
    let oldEndIndex = oldChildren.length - 1
    let oldEndVnode = oldChildren[oldEndIndex]
​
    let newStartIndex = 0
    let newStartVnode = newChildren[0]
    let newEndIndex = newChildren.length - 1
    let newEndVnode = newChildren[newEndIndex]
}

有序

头对头

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (isSameVnode(oldStartVnode, newStartVnode)) { // 先进行头对头比较
            patch(oldStartVnode, newStartVnode); // 调用patch递归处理节点,会判断进行当前文本内容更新、属性更新、子节点diff
            oldStartVnode = oldChildren[++oldStartIndex]; // 更新 老子节点的头节点、头指针
            newStartVnode = newChildren[++newStartIndex]; // 更新 新子节点的头节点、头指针
        }
    }
}

在这次比较中命中了"头对头"这一情况,所以移动的是新老子节点的头指针。

尾对尾

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            patch(oldEndVnode, newEndVnode);
            oldEndVnode = oldChildren[--oldEndIndex]; // 更新 老子节点的尾节点、尾指针
            newEndVnode = newChildren[--newEndIndex]; // 更新 新子节点的尾节点、尾指针
        }
    }
}

在这次比较中命中了"尾对尾"这一情况,所以移动的是新老子节点的尾指针。

头对尾

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            patch(oldStartVnode, newEndVnode);
            oldStartVnode = oldChildren[++oldStartIndex]; // 更新 老子节点的头节点、头指针
            newEndVnode = newChildren[--newEndIndex]; // 更新 新子节点的尾节点、尾指针
        }
    }
}

在这次比较中命中了"头对尾"这一情况,所以移动的是老子节点的头指针、新子节点的尾指针。

尾对头

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            patch(oldEndVnode, newStartVnode);
            oldEndVnode = oldChildren[--oldEndIndex]; // 更新 老子节点的尾节点、尾指针
            newStartVnode = newChildren[++newStartIndex]; // 更新 新子节点的头节点、头指针
        }
    }
}

在这次比较中命中了"头对尾"这一情况,所以移动的是老子节点的尾指针、新子节点的头指针。

无序

但并不是每次比较都能命中有序情形的,所以Vue中在有序比较之外使用了乱序比较

对于无序情况,则需要使用一些额外的铺垫:

为老节点建立映射关系

javascript 复制代码
function updateChildren (el, oldChildren, newChildren) {
    // ...
    function makeKeyByIndex(children) {
        let map = {};
        children.forEach((item, index) => {
            map[item.key] = index;
        })
        return map;
    }
    
    let mapping = makeKeyByIndex(oldChildren); // 为老节点建立一个映射
}

有了这个映射后,我们就可以在乱序比较时,查询新子节点是否在老子节点中存在,这个判断通过比较key实现

新有老无

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // ...
        } else {
            let moveIndex = mapping[newStartVnode.key];
            if (moveIndex == undefined) { // 新有老无
                el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
            }
            newStartVnode = newChildren[++newStartIndex]; // 乱序比较完后移动新节点指针
        }
    }
}

如果新子节点在老子节点的映射中查询不到,那么说明这个节点就是新增的。因为我们比较的是新子节点的头节点,所以此时需要把它添加到老子节点的头节点前。

新有老有

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // ...
        } else {
            let moveIndex = mapping[newStartVnode.key];
            if (moveIndex == undefined) {
                el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
            } else { // 新有老有
                let moveVnode = oldChildren[moveIndex];
                el.insertBefore(moveVnode.el, oldStartVnode.el); // 复用节点但是移动到到相应位置,即头节点前
                patch(moveVnode, oldStartVnode); // 还需要调用patch递归处理节点,会判断进行当前文本内容更新、属性更新、子节点diff
                oldChildren[moveIndex] = undefined; // 移动后需要将原子节点位置上的内容清除
            }
            newStartVnode = newChildren[++newStartIndex]; // 乱序比较完后移动新节点指针
        }
    }
}

边界情况

此时会出现一些边界情况需要处理,即乱序比较会置空节点,匹配到时需要跳过它:

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (!oldStartVnode) { // 乱序比较置空了头节点 的情况
            oldStartVnode = oldChildren[++oldStartIndex];
        } else if (!oldEndVnode) { // 乱序比较置空了尾节点 的情况
            oldEndVnode = oldChildren[--oldEndIndex];
        } else if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // ...
        } else {
            // ...
        }
    }
}

对比完成

遍历完后,我们需要作出实际的操作来更新页面,这也是传入参数el的原因:

scss 复制代码
function updateChildren(el, oldChildren, newChildren) {
    // ...
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) { // 无论是采用哪种比较方式,新老子节点之一的头尾指针交错都说明对比结束
        if (!oldStartVnode) { // 乱序比较置空了头节点 的情况
            oldStartVnode = oldChildren[++oldStartIndex];
        } else if (!oldEndVnode) { // 乱序比较置空了尾节点 的情况
            oldEndVnode = oldChildren[--oldEndIndex];
        } else if (isSameVnode(oldStartVnode, newStartVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            // ...
        } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // ...
        } else {
            // ...
        }
    }
    // 遍历结束后处理
    if (newStartIndex <= newEndIndex) {
        // newChildren有余会被添加
        for (let i = 0; i <= newEndIndex; i++) {
            // 通过对anchor进行判断取值,可以确定是往前插入元素还是往后插入
            let anchor = newChildren[newEndIndex + 1] === null ? null : newChildren[newEndIndex + 1].el
            // 当anchor为null时,语句相当于el.appendChild(createElm(newChildren[i]))
            el.insertBefore(createElm(newChildren[i]), anchor)
        }
    }
​
    if (oldStartIndex <= oldEndIndex) {
        // oldChildren有余会被删除
        for (let i = 0; i <= oldEndIndex; i++) {
            let child = oldChildren[i]
            el.removeChild(child.el)
        }
    }
}

小结

一言以蔽之:子节点比较的过程就是一个根据不同情形使用指针进行比较,然后相应地更新子节点、指针直到完成比较的过程。

总结

在这篇中我们整理了Vue的diff实现,其中最主要的就是子节点对比的逻辑。

相关推荐
li35742 小时前
将已有 Vue 项目通过 Electron 打包为桌面客户端的完整步骤
前端·vue.js·electron
Icoolkj2 小时前
VuePress 与 VitePress 深度对比:特性、差异与选型指南
前端·javascript·vue.js
excel2 小时前
CNN 分层详解:卷积、池化到全连接的作用与原理
前端
excel2 小时前
CNN 多层设计详解:从边缘到高级特征的逐层学习
前端
西陵4 小时前
Nx带来极致的前端开发体验——任务编排
前端·javascript·架构
大前端helloworld4 小时前
从初中级如何迈入中高级-其实技术只是“入门卷”
前端·面试
东风西巷5 小时前
Balabolka:免费高效的文字转语音软件
前端·人工智能·学习·语音识别·软件需求
萌萌哒草头将军6 小时前
10个 ES2025 新特性速览!🚀🚀🚀
前端·javascript·vue.js
半夏陌离6 小时前
SQL 入门指南:排序与分页查询(ORDER BY 多字段排序、LIMIT 分页实战)
java·前端·数据库