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实现,其中最主要的就是子节点对比的逻辑。

相关推荐
ts码农7 分钟前
model层实现:
java·服务器·前端
修仙的人12 分钟前
【开发环境】 VSCode 快速搭建 Python 项目开发环境
前端·后端·python
泡芙牛牛15 分钟前
CSS动画:animation、transition、transform、translate的区别
前端·css
shenyi17 分钟前
openlayers实现高德地图区划+撒点+点击
前端
wwy_frontend17 分钟前
不想装 Redux?useContext + useReducer 就够了!
前端·react.js
前端老鹰27 分钟前
HTML <link rel="preload">:提前加载关键资源的性能优化利器
前端·性能优化·html
兰为鹏30 分钟前
react-quill使用服务端上传图片handlers导致中文输入问题-原理分析
前端
FanetheDivine33 分钟前
具有配置项和取消能力的防抖节流函数
前端·javascript
卸任38 分钟前
Docker打包并部署Next.js
前端·docker·next.js
行星飞行38 分钟前
使用 Figma mcp 和 Playwright mcp 提升 UI 开发与调试效率,附 rule 分享
前端