vue源码学习17:深入理解Vue的diff算法(熬了几个夜晚,就为了搞懂它)

502 阅读8分钟

大家好,我是阿飞,今天我在学习的是Vue2.x的Diff算法的核心

希望我写的每一篇文章,能让每一位看官能值得去花费10分钟阅读,能读有所获。

前言

Diff算法核心是一种比对算法。它利用新的虚拟Dom旧的虚拟Dom进行比对,尽可能的复用相同的Dom节点,找出变化的虚拟节点后,只更新这个变化的虚拟节点所对应的真实虚拟节点,减少Dom数的重绘与排版,从而提高效率。

举个例子:页面有一个A元素,它有两个子元素分别是B和C,当子元素B和C发生顺序变化的时候,只调整他们的位置,而不去重新生成Dom。

虚拟Dom

虚拟DOM就是为了解决浏览器性能问题而被设计出来的。

假设一个真实Dom是这样的:

<div>
    <li key="B">B</li>
    <li key="C">C</li>
    <li key="D">D</li>
    <li key="A">A</li>
</div>

它对应的虚拟Dom是这样的

它上边挂载的元素代表如下的意义:

  • children:子元素
  • el:对应的真实Dom
  • key:用来替换标识的key
  • tag:标签名
  • vm:当前组件的实例

同层对比

Vue的Diff算法在比对的时候,只会进行同层对比,不会进行跨层对比。

以上图中的例子举例,A只会和父级的A进行比对,不会和B、C的子集进行比对。

Diff比对流程

当数据发生改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher,订阅者们就会调用patch方法,更新视图。

patch方法

在patch方法中,有几种情况

第一种情况:标签名不一样

当标签名不一样的时候,直接删除老的,换成新的。

export function patch(oldVnode, vnode) {
    // ... 其他代码
    if (oldVnode.nodeType == 1) {
        // 这里是第一次渲染的逻辑,不是diff算法的逻辑,省略
    } else {
        // 此处的逻辑:如果标签名称不一样 直接删掉老的换成新的即可
        if (oldVnode.tag !== vnode.tag) {
            // 可以通过vnode.el属性。获取现在真实的dom元素
            return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el);
        }
    }
}

第二种情况:标签一样,属性不一样,patchProps方法

patchProps方法循环属性,把属性全部放到新的真实属性上。

这个方法有两种情况:

  • 第一种情况:初次渲染,直接把样式给dom赋值
  • 第二种情况:第二次及以后的更新

属性更新有几种情况:

  • 第一种情况:老的有,新的没有,就直接删除老的样式
let oldStyle = oldProps.style || {};
for (let key in oldProps) {
    if (!newProps[key]) {
        el.removeAttribute(key);
    }
}
  • 第二种情况:如果旧的style里面的属性多余新的style中的样式
let oldStyle = oldProps.style || {};
for (let key in oldStyle) {
    if (!newStyle[key]) { // 新的里面不存在这个样式
        el.style[key] = '';
    }
}

第三种情况:如果两个节点相同,儿子都是文本

if (vnode.tag == undefined) { // 新老都是文本
    if (oldVnode.text !== vnode.text) {
        el.textContent = vnode.text;
    }
    return;
}

接下来比儿子:1. 老的没有儿子,新的有儿子

如果老的没有儿子,新的有儿子,则把新的儿子生成真实Dom,插入当前的节点就可以了。

// 一方有儿子 , 一方没儿子
let oldChildren = oldVnode.children || [];
let newChildren = vnode.children || [];

if (oldChildren.length > 0 && newChildren.length > 0) {
    // ... 双方都有儿子的逻辑最后说
} else if (newChildren.length > 0) {
    // 这一阶段的逻辑:老的没儿子 但是新的有儿子
    for (let i = 0; i < newChildren.length; i++) {
        let child = createElm(newChildren[i]);
        el.appendChild(child); // 循环创建新节点
    }
} else if (oldChildren.length > 0) { // 老的有儿子 新的没儿子
   // 
}

接下来比儿子:2. 老的有儿子,新的没有儿子

如果老的有儿子,新的没有儿子,则直接删除老的儿子即可。

// 一方有儿子 , 一方没儿子
let oldChildren = oldVnode.children || [];
let newChildren = vnode.children || [];

if (oldChildren.length > 0 && newChildren.length > 0) {
    // ... 双方都有儿子的逻辑最后说
} else if (newChildren.length > 0) {
    for (let i = 0; i < newChildren.length; i++) {
        let child = createElm(newChildren[i]);
        el.appendChild(child); // 循环创建新节点
    }
} else if (oldChildren.length > 0) { 
    // 这一阶段的逻辑老的有儿子 新的没儿子
   el.innerHTML = ``; // 直接删除老节点
}

截止到这,最基本的情况已经完成了,接下来要了解的是两个都有子元素的情况,也是核心的diff算法。

子元素的比较:patchChildren

在vue中,采用了双指针的方式,来对比子元素。

patchChildren方法接受三个参数:

  • el:谁的子元素?
  • oldChildren: 旧的子元素
  • newChildren: 新的子元素
let oldStartIndex = 0;                         // 老的开始节点index
let oldStartVnode = oldChildren[0];            // 老的开始节点
let oldEndIndex = oldChildren.length - 1;      // 老的结束节点index
let oldEndVnode = oldChildren[oldEndIndex];    // 老的结束节点
let newStartIndex = 0;                         // 新的开始节点index
let newStartVnode = newChildren[0];            // 新的开始节点
let newEndIndex = newChildren.length - 1;      // 新的结束节点index
let newEndVnode = newChildren[newEndIndex];    // 新的结束节点

开始比对的情况:

比对结束的情况:

判断条件:如果有新旧子元素有一方获取不到元素了(index越界了),说明比对结束。

// 老的startIndex小于endIndex,新的startIndex 小于endIndex,说明都还没有比对结束
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 对比逻辑
}

先判断比对的虚拟Dom标签是不是一样:isSameVnode

这个方法用来判断两个标签是不是一样的,如果不一样,则直接用新的替换掉老的

如果标签名和key都一样,说明这两个节点是同一个节点

function isSameVnode(oldVnode, newVnode) {
    return (oldVnode.tag == newVnode.tag) && (oldVnode.key == newVnode.key);
}

情况1:头和头的标签一致,头和头比较

如果是相同的元素,则需要比对元素的属性和child元素,递归比对。比对完成后,索引后移

// 老的startIndex小于endIndex,新的startIndex 小于endIndex,说明都还没有比对结束
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 对比逻辑
    // 头头比较,发现标签一致
    if (isSameVnode(oldStartVnode, newStartVnode)) {
        // 递归比较
        patch(oldStartVnode, newStartVnode);
        // 索引后移
        oldStartVnode = oldChildren[++oldStartIndex];
        newStartVnode = newChildren[++newStartIndex];
    }
}

情况2:用户追加了一个元素


当指针越界的时候,多的元素会被循环,直接添加到老的节点后面。

if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
        el.appendChild(createElm(newChild[i]))
    }
}

第一种情况.gif

情况3:用户在前面添加了一个元素

当头头比较不了的时候,就会从尾部开始比较

// 同时循环新的节点和 老的节点,有一方循环完毕就结束了
if (isSameVnode(oldStartVnode, newStartVnode)) {
    ...省略头头比较代码
}else if(isSameVnode(oldEndVnode,newEndVnode)){ // 从尾部开始比较
    patch(oldEndVnode,newEndVnode);
    // 每比对一次,尾指针向前移动
    oldEndVnode = oldChildren[--oldEndIndex];
    newEndVnode = newChildren[--newEndIndex];
} 

修改添加的逻辑,不再是往尾部添加了,所有就要改成insertBefore方法

insertBefore接受两个参数:要插入的元素,插在前面.

第二个参数应该是尾指针的下一个元素

如果下一个尾指针的下一个元素不存在,则说明是插入到尾部。

📚 小提示:insertBefore(newItem,existingItem) 方法在您指定的已有子节点之前插入新的子节点。如果第二个参数是null,则添加到尾部。

if (newStartIndex <= newEndIndex) {
        //  看一下为指针的下一个元素是否存在, anchor: 参照物
        let anchor = newChildren[newEndIndex + 1] == null? null :newChildren[newEndIndex + 1].el
        el.insertBefore(createElm(newChildren[i]),anchor);
    }
}

情况4:用户删除一个元素

if(oldStartIndex <= oldEndIndex){
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
        //  如果老的多 将老节点删除 , 但是可能里面有null 的情况
        if(oldChildren[i] !== null) el.removeChild(oldChildren[i].el);
    }
}

情况5:reverse,比较方式头尾比较

在这个例子里面,可以解释为什么不能用索引index来作为key。

看下面这张图:

很容易看出,key是变化的,不能表示一个元素原来的key。所以根本无法用来标识这个元素是谁。就会导致diff算法的时候,比对失败,直接替换。

移动逻辑如下:

最后一次,头头比,一样的,结束比较。四次替换变成移动三次。

if(isSameVnode(oldStartVnode,newEndVnode)){
    patch(oldStartVnode,newEndVnode);
    // 移动老的元素,老的元素就被移动走了,不用删除
    // 注意,这里是放到D的下一个元素的前面
    el.insertBefore(oldStartVnode.el,oldEndVnode.el.nextSibling); 
    oldStartVnode = oldChildren[++oldStartIndex];
    newEndVnode = newChildren[--newEndIndex];
}

情况6:尾和头比较

尾和头比的逻辑和头尾比逻辑差不多,就不绘制比较流程图了。

if(isSameVnode(oldEndVnode,newStartVnode)){ // 尾头比较
    patch(oldEndVnode,newStartVnode);
    el.insertBefore(oldEndVnode.el,oldStartVnode.el);
    oldEndVnode = oldChildren[--oldEndIndex];
    newStartVnode = newChildren[++newStartIndex];
}

需要注意的是,如果比对成功,就把比对成功的移动到老的开始节点的前面。

核心比对:乱序比对

前面的情况都是vue做的优化处理,下面的逻辑可以处理所有的情况,才是Diff算法的核心。

它的核心是,建立一个隐射表,永远用下面的一个元素,去隐射表里面查找。

  1. 根据Key和索引,将老的内容生成映射表
  2. 如果找到,就把这个元素移动到首指针的前面
  3. 为了防止数组塌陷,会将空的位置填充成null

假设有下面这种情况:

比对第一步:当头头比,头尾比,头尾比,尾头比都是失败的时候,就拿下面的每一项去映射表查,如果查不到,就插入到前面

比对第二步: 尾头比,比对成功,移动D的位置到首指针前面

比对第三步: 头头比,比对成功,保持不动,移动指针位置

比对第四步: 头头比,头尾比,尾尾比,尾头比均失败。F在映射表未查找到,直接插入首指针的前面。

比对第五步: 逻辑和第四步一样,新的节点指针后移,越界

比对第六步: 发现指针越界,老的节点多了一个C

C多余无用,直接删除。

💡 小提示:这种乱序比对,尽量挑选能用的节点使用。

乱序比对代码如下:


// 对null进行处理,遇到null,移动指针
if(!oldStartVnode){ // 已经被移动走了
    oldStartVnode = oldChildren[++oldStartIndex];
}else if(!oldEndVnode){
    oldEndVnode = oldChildren[--oldEndIndex];
}

// 通过key和index做一个隐射表keysMap
const keysMap = makeIndexByKey(oldChildren);
const makeIndexByKey = (children)=>{
    return children.reduce((memo,current,index)=>{
        if(current.key){
            memo[current.key] = index;
        }
        return memo;
    },{})
}


// 乱序比对   核心diff
// 1.需要根据key和 对应的索引将老的内容生成程映射表
let moveIndex = keysMap[newStartVnode.key]; // 用新的去老的中查找
if(moveIndex == undefined){ // 如果不能复用直接创建新的插入到老的节点开头处
    el.insertBefore(createElm(newStartVnode),oldStartVnode.el);
}else{
    let moveNode = oldChildren[moveIndex];
    oldChildren[moveIndex] = null; // 此节点已经被移动走了
    el.insertBefore(moveNode.el,oldStartVnode.el);
    patch(moveNode,newStartVnode); // 比较两个节点的属性
}
newStartVnode = newChildren[++newStartIndex]

总结

vue的Diff算法流程如下:

  1. 两个节点进行对比,如果节点的tag不同,直接替换

  2. 如果tag相同,就比较props,对属性进行替换

  3. 如果新的节点有儿子,老的节点没有儿子,则直接插入

  4. 如果老的节点有儿子,新的节点没有儿子,就直接移除

  5. 如果新的节点和老的节点都有儿子,会进入儿子的Diff算法

    5.1 头头比较

    5.2 头头比较

    5.2 头尾比较

    5.3 尾头比较

    5.4 乱序比较(核心diff)

💡 小提示:5.1到5.4优化了向后添加、向前添加、尾巴移动到头部、头部移动到尾部、反转,真正的Diff算法核心就是5.4的乱序比价。

求赞

大佬,已经看到这儿了,点个赞再走好不好?

image.png