学习vue源码(14)深入学习diff

1,010 阅读27分钟

大白话简述

这一节,先对diff进行简单的描述,不会出现任何的源码,只是为了帮助大家建立一种思路,了解下 Diff 的大概内容。

1、Diff 的作用

2、Diff 的做法

3、Diff 的比较逻辑

4、简单的例子

1. Diff 作用

Diff 的出现,就会为了减少更新量,找到最小差异部分DOM,只更新差异部分DOM就好了

这样消耗就会小一些

数据变化一下,没必要把其他没有涉及的没有变化的DOM 也替换了

2. Diff 做法

Vue 只会对新旧节点中 父节点是相同节点 的 那一层子节点 进行比较

也可以说成是

只有两个新旧节点是相同节点的时候,才会去比较他们各自的子节点

最大的根节点一开始可以直接比较

这也叫做 同层级比较,并不需要递归,虽然好像降低了一些复用性,也是为了避免过度优化,是一种很高效的 Diff 算法

新旧节点是什么?

所有的 新旧节点 指的都是 Vnode 节点,Vue 只会比较 Vnode 节点,而不是比较 DOM

因为 Vnode 是 JS 对象,不受平台限制,所以以它作为比较基础,代码逻辑后期不需要改动

拿到比较结果后,根据不同平台调用相应的方法进行处理就好了

父节点是相同节点是什么意思?

比如下图出现的 四次比较(从 first 到 fouth),他们的共同特点都是有 相同的父节点

比如 蓝色方的比较,新旧子节点的父节点是相同节点 1

比如 红色方的比较,新旧子节点的父节点都是 2

所以他们才有比较的机会

而下图中,只有两次比较,就是因为在 蓝色方 比较中,并没有相同节点,所以不会再进行下级子节点比较

3. Diff 比较逻辑

Diff 比较的内核是 节点复用,所以 Diff 比较就是为了在 新旧节点中 找到 相同的节点

这个的比较逻辑是建立在上一步说过的同层比较基础之上的

所以说,节点复用,找到相同节点并不是无限制递归查找

比如下图中,的确 旧节点树 和 新节点树 中有相同节点 6,但是然并卵,旧节点6并不会被复用

就算在同一层级,然而父节点不一样,依旧然并卵

只有这种情况的节点会被复用,相同父节点 8

下面说说 Diff 的比较逻辑

1、能不移动,尽量不移动

2、没得办法,只好移动

3、实在不行,新建或删除

比较处理流程是下面这样

在新旧节点中

1、先找到 不需要移动的相同节点,消耗最小

2、再找相同但是需要移动的节点,消耗第二小

3、最后找不到,才会去新建删除节点,保底处理

比较是为了修改DOM 树

其实这里存在 三种树,一个是 页面DOM 树,一个是 旧VNode 树,一个是 新 Vnode 树

页面DOM 树 和 旧VNode 树 节点一一对应的

而 新Vnode 树则是表示更新后 页面DOM 树 该有的样子

这里把 旧Vnode 树 和 新Vnode树 进行比较的过程中

不会对这两棵Vode树进行修改,而是以比较的结果直接对 真实DOM 进行修改

比如说,在 旧 Vnode 树同一层中,找到 和 新Vnode 树 中一样但位置不一样节点

此时需要移动这个节点,但是不是移动 旧 Vnode 树 中的节点

而是 直接移动 DOM

总的来说,新旧 Vnode 树是拿来比较的,页面DOM 树是拿来根据比较结果修改的

如果你有点懵,我们就来就简单说个例子

4. Diff 简单例子

比如下图存在这两棵 需要比较的新旧节点树 和 一棵 需要修改的页面 DOM树

第一轮比较开始

因为父节点都是 1,所以开始比较他们的子节点

按照我们上面的比较逻辑,所以先找 相同 && 不需移动 的点

毫无疑问,找到 2

拿到比较结果,这里不用修改DOM,所以 DOM 保留在原地

第二轮比较开始

然后,没有 相同 && 不需移动 的节点 了

只能第二个方案,开始找相同的点

找到 节点5,相同但是位置不同,所以需要移动

拿到比较结果,页面 DOM 树需要移动DOM 了,不修改,原样移动

第三轮比较开始

继续,哦吼,相同节点也没得了,没得办法了,只能创建了

所以要根据 新Vnode 中没找到的节点去创建并且插入

然后旧Vnode 中有些节点不存在 新VNode 中,所以要删除

于是开始创建节点 6 和 9,并且删除节点 3 和 4

然后页面就完成更新啦

怎么样,有没有感兴趣,感兴趣就看源码吧哈哈

2. 从新建实例到开始diff

这一节不会对源码深入研究,而是跟着例子走一遍流程。

本节很短

先对整个流程有个把握,再仔细去探索 Diff 的思想

首先,当你新建实例的时候,比如这样

你调用一个 Vue 函数,所以来看下 Vue 函数

function Vue() {    

    ... 已省略其他

    new Watcher(function() {

        vm._update(vm._render());
    })

    ... 已省略其他

}

函数中做了两件事

1、为实例新建一个 watcher

2、为 watcher 绑定更新回调(就是 new Watcher 传入的 function )

每个实例都会有一个专属的 watcher,而绑定的回调,在页面更新时会调用

我们现在来看下简化的 Watcher 的源码

funciton Watcher(expOrFn){    

    this.getter = expOrFn;    

    this.get();

}



Watcher.prototype.get = function () {    

    this.getter()

}

watcher 会保存更新回调,并且在新建 watcher 的时候就会立刻调用一遍更新回调

现在我们继续看 更新回调的内容

vm._update(vm._render());

如果你看到之前的文章应该知道这两个函数的作用

现在就来简单说一下

vm._render

生成页面模板对应的 Vnode 树,比如

生成的 Vnode 树是( 其中num的值是111 )

{    

    tag: "div",    

    children:[{        

        tag: "span"

    },{        

        tag: undefined,        

        text: "111"

    }]
}

这一步是通过 compile 生成的,具体的话可以简单看下 学习vue源码(6)熟悉模板编译原理

vm._update

比较 旧Vnode 树 和 vm._render 生成的新 Vnode 树 进行比较

比较完后,更新页面的DOM,从而完成更新

ok,我们看下源码

Vue.prototype._update = function(vnode) {  

    var vm = this;    

    var prevEl = vm.$el;    

    var prevVnode = vm._vnode;

    vm._vnode = vnode;    



    // 不存在旧节点

    if (!prevVnode) {

        vm.$el = vm.__patch__(
            vm.$el, vnode,
            vm.$options._parentElm,
            vm.$options._refElm
        );
    }    

    else {

        vm.$el = vm.__patch__(

            prevVnode, vnode

        );

    }
};

解释其中几个点

1 vm._vnode

这个属性保存的就是当前 Vnode 树

当页面开始更新,而生成了新的 Vnode 树之后

这个属性则会替换成新的Vnode

所以保存在这里,是为了方便拿到 旧 Vnode 树

2 vm.patch

是的,没有错,你在两处地方看到这个东西

这个东西就是 Diff 的主要内容,内有乾坤,内容很多,不会在这里说,毕竟今天只探索流程

但是要看看这个东西怎么来的

var patch = createPatchFunction();

Vue.prototype.__patch__ =  patch ;

嗯,是经过一个 createPatchFunciton 生成的

然后赋值到 Vue 的原型上,所以可以 vm.patch 调用喽

我们再来说说 _update 函数中出现的那两处 patch

(1) 不存在旧节点

不需要进行比较,直接全部创建

vm.$el 保存的是 DOM 节点,如果不存在旧节点,那么 vm.$el 此时也是不存在的

而传入 vm.$el 为空的时候,patch 拿到这个值判断为空的时候,就直接创建DOM,不会去做其他操作了

(2)存在旧节点

需要把旧节点和新节点比较,尽量找到最小差异部分,然后进行更新,这部分内容就是 Diff 的重点了,需要花费不少精力的。

好了,这一节就结束了。

3.相关辅助函数

在开始我们的 Diff 主要内容之前,我们先来了解下其中会用的的一些辅助函数

本来可以放到 Diff 那里写,但是全部合起来内容又太多

而且这些函数比较具有公用性,就是抽出来用

所以打算独立一节,先预热一下,内容也不多,也挺简单,光看下也会对我们的思维有所帮助

1.节点操作函数

下面是 Diff 在比较节点时,更新DOM 会使用到的一些函数

本来会有更多,为了看得方便,我把一些合并了

下面会介绍三个函数

insert,createElm,createChildren

简单介绍

insert 作用是把节点插入到某个位置

createElm 作用是创建DOM 节点

createChildren 作用也是创建DOM 节点,但是处理的是一个数组,并且会创建 DOM 节点 和 文本节点

下面就来仔细说说这三个方法

insert

这个函数的作用就是 插入节点

但是插入也会分两种情况

1、没有参考兄弟节点,直接插入父节点的子节点末尾

2、有参考兄弟节点,则插在兄弟节点前面

可以说这个函数是 Diff 的基石哈哈

function insert(parent, elm, ref) {    

    if (parent) {        

        if (ref) {            

            if (ref.parentNode === parent) {

                parent.insertBefore(elm, ref);
            }
        } else {
            parent.appendChild(elm);
        }
    }
}

createElm

这个函数的作用跟它的名字一样,就是创建节点的意思

创建完节点之后,会调用 insert 去插入节点

你可以看一下,不难

function createElm(vnode, parentElm, refElm) {  

    var children = vnode.children;    

    var tag = vnode.tag;    

    if (tag) {

        vnode.elm = document.createElement(tag)        



        // 先把 子节点插入 vnode.elm,然后再把 vnode.elm 插入parent

        createChildren(vnode, children);

       

        //  插入DOM 节点

        insert(parentElm, vnode.elm, refElm);
    }    



    else {

        vnode.elm = document.createTextNode(vnode.text);
        
        insert(parentElm, vnode.elm, refElm);
    }
}

createElm 需要根据 Vnode 来判断需要创建什么节点

1、文本节点

当 vnode.tag 为 undefined 的时候,创建文本节点,看下 真实文本vnode

并且,文本节点是没有子节点的

2、普通节点

vnode.tag 有值,那就创建相应的 DOM

但是 该 DOM 可能存在子节点,所以子节点甚至子孙节点,也都是要创建的

所以会调用一个 createChildren 去完成所有子孙节点的创建

createChildren

这个方法处理子节点,必然是用遍历递归的方法逐个处理的

1 如果子节点是数组,则遍历执行 createElm 逐个处理

2 如果子节点的 text 属性有数据,则表示这个 vnode 是个文本节点,直接创建文本节点,然后插入到父节点中

function createChildren(vnode, children) {    
    if (Array.isArray(children)) {      

        for (var i = 0; i < children.length; ++i) {
            createElm(children[i], vnode.elm, null);
        }
    }
    else if (        

        typeof vnode.text=== 'string' ||

        typeof vnode.text=== 'number' ||
        typeof vnode.text=== 'boolean'
    ) {
        vnode.elm.appendChild(

            document.createTextNode(vnode.text)

        )

    }
}
服务Diff工具函数

下面的函数是 Vue 专门用来服务 Diff 的,介绍两个

createKeyToOldIdx,sameVnode

createKeyToOldIdx

接收一个 children 数组,生成 key 与 index 索引对应的一个 map 表

function createKeyToOldIdx(
    children, beginIdx, endIdx
) {    
    var i, key;    
    var map = {};    
    for (i = beginIdx; i <= endIdx; ++i) {
        key = children[i].key;        
        if (key) {
            map[key] = i;
        }
    }    
    return map

}

比如你的旧子节点数组是

[{    
    tag:"div",  key: "key_1"
},{  
    tag:"strong", key:"key_2"
},{  
    tag:"span",  key:"key_4"
}]

经过 createKeyToOldIdx 生成一个 map 表 oldKeyToIdx,是下面这样

{
    "key_1":0,
    "key_2":1,
    "key_4":2
}

把 vnode 的 key 作为属性名,而该 vnode 在children 的位置 作为 属性值

这个函数在 Diff 中的作用是

判断某个新 vnode 是否在 这个旧的 Vnode 数组中,并且拿到它的位置。就是拿到 新 Vnode 的 key,然后去这个 map 表中去匹配,是否有相应的节点,有的话,就返回这个节点的位置

比如

现在我有一个 新子节点数组,一个 旧子节点数组

我拿到 新子节点数组 中的某一个 newVnode,我要判断他是否 和 旧子节点数组 中某个vnode相同

要怎么判断???难道是双重遍历数组,逐个判断 newVnode.key==vnode.key ??

Vue 用了更聪明的办法,使用 旧 Vnode 数组生成一个 map 对象 obj

当 obj[ newVnode.key ] 存在的时候,说明 新旧子节点数组都存在这个节点

并且我能拿到该节点在 旧子节点数组 中的位置(属性值)

反之,则不存在

这个方法也给我们提供了在项目中相似场景的一个解决思路,以对象索引查找替代数组遍历

希望大家记住哦

sameVnode

这个函数在 Diff 中也起到非常大的作用,大家务必要记住啊

它的作用是判断两个节点是否相同

这里说的相同,并不是完全一毛一样,而是关键属性一样,可以先看下源码

function sameVnode(a, b) {    
    return (

        a.key === b.key &&
        a.tag === b.tag &&
        !!a.data === !!b.data &&
        sameInputType(a, b)
    )
}

function sameInputType(a, b) {    
   if (a.tag !== 'input') return true
    var i;    
    var types = [
        'text','number','password',
        'search','email','tel','url'
    ]    
    var typeA = (i = a.data) && (i = i.attrs) && i.type;    
    var typeB = (i = b.data) && (i = i.attrs) && i.type;    
    // input 的类型一样,或者都属于基本input类型
    return (
        typeA === typeB ||
        types.indexOf(typeA)>-1 &&

        types.indexOf(typeB)>-1

    )
}

判断的依据主要是 三点,key,tag,是否存在 data

这里判断的节点是只是相对于 节点本身,并不包括 children 在内

也就是说,就算data不一样,children 不一样,两个节点还是可能一样

比如下面这两个

有一种特殊情况,就是 input 节点

input 需要额外判断, 两个节点的 type 是否相同

或者

两个节点的类型可以不同,但是必须属于那些 input 类型

sameVnode 的内容就到这里了,但是我不禁又开始思考一个问题

为什么 sameVnode 会这么判断??

下面纯属个人意淫想法,仅供参考

sameVnode 应用在 Diff ,作用是为了判断节点是否需要新建

当两个 新旧vnode 进行 sameVnode 得到 false 的时候,说明两个vnode 不一样,会新建DOM插入

也就是两个节点从根本上不一样时才会创建

其中会比较 唯一标识符 key 和 标签名 tag,从而得到 vnode 是否一样 ,这些是毫无疑问的了

但是这里不需要判断 data 是否一样,我开始不太明白

后面想到 data 是包含有一些 dom 上的属性的,所以 data 不一样没有关系

因为就算不一样,他们还是基于同一个 DOM

因为DOM属性的值是可能是动态绑定动态更新变化的,所以变化前后的 两个 vnode,相应的 data 肯定不一样,但是其实他们是同一个 Vnode,所以 data 不在判断范畴

但是 data 在新旧节点中,必须都定义,或者都不定义

不存在 一个定义,而一个没定义, 但是会相同的 Vnode

比如,下面这个就会存在data

这个就不会存在data

他们在模板中,肯定是不属于同一个节点

总结

涉及的函数主要分为两类

一类是专门负责操作 DOM 的,insert,createElm,createChildren

这类函数比较通用,就算在我们自己的项目中也可以用得上

一类是专门特殊服务 Diff 的,createKeyToOldIdx,sameVnode

其中会包含一些项目的解决思路

大家务必先记住一下这几个函数,在下节内容的源码中会频繁出现

到时不会仔细介绍

4. Diff 流程

Diff 的内容不算多,但是如果要讲得很详细的话,就要说很多了,而且要配很多图

这是 Diff 的最后一节,最重要也是最详细的一节了

所以本节内容很多,先提个内容概览

1、分析 Diff 源码比较步骤

2、个人思考为什么如此比较

3、写个例子,一步步走个Diff 流程

文章很长,也非常详细,如果你对这内容有兴趣的话,也推荐边阅读源码边看

下面开始我们的正文

在之前,我们已经探索了 Vue 是如何从新建实例到开始diff 的

你应该还有印象,其中Diff涉及的一个重要函数就是 createPatchFunciton

var patch = createPatchFunction();

Vue.prototype.__patch__ =  patch

那么我们就来看下这个函数

createPatchFunction
function createPatchFunction() {  
    return function patch(
        oldVnode, vnode, parentElm, refElm    
    ) {      
        // 没有旧节点,直接生成新节点
        if (!oldVnode) {
            createElm(vnode, parentElm, refElm);
        } 
        else {     
            // 且是一样 Vnode
            if (sameVnode(oldVnode, vnode)) {                
                // 比较存在的根节点
                patchVnode(oldVnode, vnode);
            } 
            else {    
                // 替换存在的元素
                var oldElm = oldVnode.elm;                
                var _parentElm = oldElm.parentNode    
                // 创建新节点
                createElm(vnode, _parentElm, oldElm.nextSibling);   
                // 销毁旧节点
                if (_parentElm) {
                    removeVnodes([oldVnode], 0, 0);
                }
            }
        }        
        return vnode.elm
    }
}

这个函数的作用就是

比较 新节点 和 旧节点 有什么不同,然后完成更新

所以你看到接收一个 oldVnode 和 vnode

处理的流程分为

1、没有旧节点

2、旧节点 和 新节点 自身一样(不包括其子节点)

3、旧节点 和 新节点自身不一样

速度来看下这三个流程了

1 .没有旧节点

没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了

直接全部都是新建,所以只调用 createElm

2 .旧节点 和 新节点 自身一样

通过 sameVnode 判断节点是否一样,这个函数在上节中说过了

旧节点 和 新节点自身一样时,直接调用 patchVnode 去处理这两个节点

patchVnode 下面会讲到这个函数

在讲 patchVnode 之前,我们先思考这个函数的作用是什么?

当两个Vnode自身一样的时候,我们需要做什么?

首先,自身一样,我们可以先简单理解,是 Vnode 的两个属性 tag 和 key 一样

那么,我们是不知道其子节点是否一样的,所以肯定需要比较子节点

所以,patchVnode 其中的一个作用,就是比较子节点

3 .旧节点 和 新节点 自身不一样

当两个节点不一样的时候,不难理解,直接创建新节点,删除旧节点

patchVnode

在上一个函数 createPatchFunction 中,有出现一个函数 patchVnode

我们思考了这个函数的其中的一个作用是 比较两个Vnode 的子节点

是不是我们想的呢,可以先来过一下源码

function patchVnode(oldVnode, vnode) { 

    if (oldVnode === vnode) return

    var elm = vnode.elm = oldVnode.elm;    

    var oldCh = oldVnode.children;    

    var ch = vnode.children;   

    // 更新children

    if (!vnode.text) {   

        // 存在 oldCh 和 ch 时

        if (oldCh && ch) {            

            if (oldCh !== ch) 

                updateChildren(elm, oldCh, ch);

        }    

        // 存在 newCh 时,oldCh 只能是不存在,如果存在,就跳到上面的条件了

        else if (ch) {   

            if (oldVnode.text) elm.textContent = '';      

            for (var i = 0; i <= ch.length - 1; ++i) {

                createElm(
                  ch[i],elm, null
                );
            }

        } 

        else if (oldCh) {     

            for (var i = 0; i<= oldCh.length - 1; ++i) {

                oldCh[i].parentNode.removeChild(el);
            }

        } 

        else if (oldVnode.text) {
            elm.textContent = '';
        }
    } 

    else if (oldVnode.text !== vnode.text) {
        elm.textContent = vnode.text;
    }
}

我们现在就来分析这个函数

没错,正如我们所想,这个函数的确会去比较处理子节点

总的来说,这个函数的作用是

1、Vnode 是文本节点,则更新文本(文本节点不存在子节点)

2、Vnode 有子节点,则处理比较更新子节点

更进一步的总结就是,这个函数主要做了两种判断的处理

1、Vnode 是否是文本节点

2、Vnode 是否有子节点

下面我们来看看这些步骤的详细分析

1 Vnode是文本节点

当 VNode 存在 text 这个属性的时候,就证明了 Vnode 是文本节点

我们可以先来看看 文本类型的 Vnode 是什么样子

所以当 Vnode 是文本节点的时候,需要做的就是,更新文本

同样有两种处理

1、当 新Vnode.text 存在,而且和 旧 VNode.text 不一样时

直接更新这个 DOM 的 文本内容

elm.textContent = vnode.text;

注:textContent 是 真实DOM 的一个属性, 保存的是 dom 的文本,所以直接更新这个属性

2、新Vnode 的 text 为空,直接把 文本DOM 赋值给空

elm.textContent = '';

2 Vnode存在子节点

当 Vnode 存在子节点的时候,因为不知道 新旧节点的子节点是否一样,所以需要比较,才能完成更新

这里有三种处理

1、新旧节点 都有子节点,而且不一样

2、只有新节点

3、只有旧节点

后面两个节点,相信大家都能想通,但是我们还是说一下

1 只有新节点

只有新节点,不存在旧节点,那么没得比较了,所有节点都是全新的

所以直接全部新建就好了,新建是指创建出所有新DOM,并且添加进父节点的

2 只有旧节点

只有旧节点而没有新节点,说明更新后的页面,旧节点全部都不见了

那么要做的,就是把所有的旧节点删除

也就是直接把DOM 删除

3 新旧节点 都有子节点,而且不一样

咦惹,又出现了一个新函数,那就是 updateChildren

预告一下,这个函数非常的重要,是 Diff 的核心模块,蕴含着 Diff 的思想

可能会有点绕,但是不用怕,相信在我的探索之下,可以稍微明白些

同样的,我们先来思考下 updateChildren 的作用

记得条件,当新节点 和 旧节点 都存在,要怎么去比较才能知道有什么不一样呢?

哦没错,使用遍历,新子节点和旧子节点一个个比较

如果一样,就不更新,如果不一样,就更新

下面就来验证下我们的想法,来探索一下 updateChildren 的源码

updateChildren

这个函数非常的长,但是其实不难,就是分了几种处理流程而已,但是一开始看可能有点懵

或者可以先跳过源码,看下分析,或者便看分析边看源码

function updateChildren(parentElm, oldCh, newCh) {

    var oldStartIdx = 0;    

    var oldEndIdx = oldCh.length - 1;    

    var oldStartVnode = oldCh[0];    

    var oldEndVnode = oldCh[oldEndIdx];    

    var newStartIdx = 0;    

    var newEndIdx = newCh.length - 1;    

    var newStartVnode = newCh[0];    

    var newEndVnode = newCh[newEndIdx];    

    var oldKeyToIdx, idxInOld, vnodeToMove, refElm;



    // 不断地更新 OldIndex 和 OldVnode ,newIndex 和 newVnode

    while (

        oldStartIdx <= oldEndIdx && 

        newStartIdx <= newEndIdx

    ) {        

        if (!oldStartVnode) {

            oldStartVnode = oldCh[++oldStartIdx];
        }     

        else if (!oldEndVnode) {

            oldEndVnode = oldCh[--oldEndIdx];
        }   

        //  旧头 和新头 比较
        else if (sameVnode(oldStartVnode, newStartVnode)) {

            patchVnode(oldStartVnode, newStartVnode);

            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        }    

        //  旧尾 和新尾 比较

        else if (sameVnode(oldEndVnode, newEndVnode)) {

            patchVnode(oldEndVnode, newEndVnode);

            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        }                


        // 旧头 和 新尾 比较

        else if (sameVnode(oldStartVnode, newEndVnode)) {

            patchVnode(oldStartVnode, newEndVnode);            

            // oldStartVnode 放到 oldEndVnode 后面,还要找到 oldEndValue 后面的节点

            parentElm.insertBefore(

                oldStartVnode.elm, 

                oldEndVnode.elm.nextSibling

            );

            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        }   

        //  旧尾 和新头 比较

        else if (sameVnode(oldEndVnode, newStartVnode)) {

            patchVnode(oldEndVnode, newStartVnode);            


            // oldEndVnode 放到 oldStartVnode 前面

            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);

            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        }        


        // 单个新子节点 在 旧子节点数组中 查找位置

        else {    

            // oldKeyToIdx 是一个 把 Vnode 的 key 和 index 转换的 map

            if (!oldKeyToIdx) {
                oldKeyToIdx = createKeyToOldIdx(

                    oldCh, oldStartIdx, oldEndIdx

                );

            }     

            // 使用 newStartVnode 去 OldMap 中寻找 相同节点,默认key存在

            idxInOld = oldKeyToIdx[newStartVnode.key]        

            //  新孩子中,存在一个新节点,老节点中没有,需要新建 

            if (!idxInOld) {  

                //  把  newStartVnode 插入 oldStartVnode 的前面

                createElm(

                    newStartVnode, 

                    parentElm, 

                    oldStartVnode.elm

                );

            }            

            else {                

                //  找到 oldCh 中 和 newStartVnode 一样的节点

                vnodeToMove = oldCh[idxInOld];     
                if (sameVnode(vnodeToMove, newStartVnode)) {

                    patchVnode(vnodeToMove, newStartVnode);  

                    // 删除这个 index

                    oldCh[idxInOld] = undefined;                    
                    // 把 vnodeToMove 移动到  oldStartVnode 前面

                    parentElm.insertBefore(

                        vnodeToMove.elm, 

                        oldStartVnode.elm

                    );

                }                

                // 只能创建一个新节点插入到 parentElm 的子节点中

                else {                    

                    // same key but different element. treat as new element

                    createElm(

                        newStartVnode, 

                        parentElm, 

                        oldStartVnode.elm

                    );

                }
            }            

            // 这个新子节点更新完毕,更新 newStartIdx,开始比较下一个

            newStartVnode = newCh[++newStartIdx];
        }
    }    



    // 处理剩下的节点

    if (oldStartIdx > oldEndIdx) {  

        var newEnd = newCh[newEndIdx + 1]

        refElm = newEnd ? newEnd.elm :null;        
        for (; newStartIdx <= newEndIdx; ++newStartIdx) {

            createElm(
               newCh[newStartIdx], parentElm, refElm
            );
        }
    }    

    // 说明新节点比对完了,老节点可能还有,需要删除剩余的老节点

    else if (newStartIdx > newEndIdx) {       

        for (; oldStartIdx<=oldEndIdx; ++oldStartIdx) {

            oldCh[oldStartIdx].parentNode.removeChild(el);
        }
    }
}

首先要明确这个函数处理的是什么

处理的是 新子节点 和 旧子节点,循环遍历逐个比较

如何 循环遍历?

1、使用 while

2、新旧节点数组都配置首尾两个索引

新节点的两个索引:newStartIdx , newEndIdx

旧节点的两个索引:oldStartIdx,oldEndIdx

以两边向中间包围的形式 来进行遍历

头部的子节点比较完毕,startIdx 就加1

尾部的子节点比较完毕,endIdex 就减1

只要其中一个数组遍历完(startIdx<endIdx),则结束遍历

源码处理的流程分为两个

1、比较新旧子节点

2、比较完毕,处理剩下的节点

我们来逐个说明这两个流程

1 比较新旧子节点

注:这里有两个数组,一个是 新子Vnode数组,一个旧子Vnode数组

在比较过程中,不会对两个数组进行改变(比如不会插入,不会删除其子项)

而所有比较过程中都是直接 插入删除 真实页面DOM

我们明确一点,比较的目的是什么?

找到 新旧子节点中 的 相同的子节点,尽量以 移动 替代 新建 去更新DOM

只有在实在不同的情况下,才会新建

比较更新计划步骤:

首先考虑,不移动DOM

其次考虑,移动DOM

最后考虑,新建 / 删除 DOM

能不移动,尽量不移动。不行就移动,实在不行就新建

下面开始说源码中的比较逻辑

五种比较逻辑如下

1、旧头 == 新头

2、旧尾 == 新尾

3、旧头 == 新尾

4、旧尾 == 新头

5、单个查找

来分析下这五种比较逻辑

1 旧头 == 新头

sameVnode(oldStartVnode, newStartVnode)

当两个新旧的两个头一样的时候,并不用做什么处理

符合我们的步骤第一条,不移动DOM完成更新

但是看到一句,patchVnode

就是为了继续处理这两个相同节点的子节点,或者更新文本

因为我们不考虑多层DOM 结构,所以 新旧两个头一样的话,这里就算结束了

可以直接进行下一轮循环

newStartIdx ++ , oldStartIdx ++

2 旧尾 == 新尾

sameVnode(oldEndVnode, newEndVnode)

和 头头 相同的处理是一样的

尾尾相同,直接跳入下个循环

newEndIdx ++ , oldEndIdx ++

3 旧头 == 新尾

sameVnode(oldStartVnode, newEndVnode)

这步不符合 不移动DOM,所以只能 移动DOM 了

怎么移动?

源码是这样的

parentElm.insertBefore(
    oldStartVnode.elm, 
    oldEndVnode.elm.nextSibling
);

以 新子节点的位置 来移动的,旧头 在新子节点的 末尾

所以把 oldStartVnode 的 dom 放到 oldEndVnode 的后面

但是因为没有把dom 放到谁后面的方法,所以只能使用 insertBefore

即放在 oldEndVnode 后一个节点的前面

图示是这样的

然后更新两个索引

oldStartIdx++,newEndIdx--

4 旧尾 == 新头

sameVnode(oldEndVnode, newStartVnode)

同样不符合 不移动DOM,也只能 移动DOM 了

怎么移动?

parentElm.insertBefore(
    oldEndVnode.elm, 
    oldStartVnode.elm
);

把 oldEndVnode DOM 直接放到 当前 oldStartVnode.elm 的前面

图示是这样的

然后更新两个索引

oldEndIdx--,newStartIdx++

5 单个遍历查找

当前面四种比较逻辑都不行的时候,这是最后一种处理方法

拿 新子节点的子项,直接去 旧子节点数组中遍历,找一样的节点出来

流程大概是

1、生成旧子节点数组以 vnode.key 为key 的 map 表

2、拿到新子节点数组中 一个子项,判断它的key是否在上面的map 中

3、不存在,则新建DOM

4、存在,继续判断是否 sameVnode

下面就详细说一下

1 生成map 表

这个map 表的作用,就主要是判断存在什么旧子节点

比如你的旧子节点数组是

[{    
    tag:"div",  key:1
},{  

    tag:"strong", key:2
},{  

    tag:"span",  key:4
}]

经过 createKeyToOldIdx 生成一个 map 表 oldKeyToIdx

{ vnodeKey: 数组Index }

属性名是 vnode.key,属性值是 该 vnode 在children 的位置

是这样(具体源码看上篇文章 Diff - 源码版 之 相关辅助函数)

oldKeyToIdx = {
    1:0,
    2:1,
    4:2
}

2 判断 新子节点是否存在旧子节点数组中

拿到新子节点中的 子项Vnode,然后拿到它的 key

去匹配map 表,判断是否有相同节点

oldKeyToIdx[newStartVnode.key]

3 不存在旧子节点数组中

直接创建DOM,并插入oldStartVnode 前面

createElm(newStartVnode, parentElm, oldStartVnode.elm);

4 存在旧子节点数组中

找到这个旧子节点,然后判断和新子节点是否 sameVnode

如果相同,直接移动到 oldStartVnode 前面

如果不同,直接创建插入 oldStartVnode 前面

我们上面说了比较子节点的处理的流程分为两个

1、比较新旧子节点

2、比较完毕,处理剩下的节点

比较新旧子节点上面已经说完了,下面就到了另一个流程,比较剩余的节点,详情看下面

处理可能剩下的节点

在updateChildren 中,比较完新旧两个数组之后,可能某个数组会剩下部分节点没有被处理过,所以这里需要统一处理

1 新子节点遍历完了

newStartIdx > newEndIdx

新子节点遍历完毕,旧子节点可能还有剩

所以我们要对可能剩下的旧节点进行 批量删除!

就是遍历剩下的节点,逐个删除DOM

for (; oldStartIdx <= oldEndIdx; ++oldStartIdx) {
    oldCh[oldStartIdx]

    .parentNode

    .removeChild(el);
}

2.旧子节点遍历完了

oldStartIdx > oldEndIdx

旧子节点遍历完毕,新子节点可能有剩

所以要对剩余的新子节点处理

很明显,剩余的新子节点不存在 旧子节点中,所以全部新建

for (; newStartIdx <= newEndIdx; ++newStartIdx) {
   createElm(
      newCh[newStartIdx], 

      parentElm, 

      refElm

   );
}

但是新建有一个问题,就是插在哪里?

所以其中的 refElm 就成了疑点,看下源码

var newEnd = newCh[newEndIdx + 1]

refElm = newEnd ? newEnd.elm :null;

refElm 获取的是 newEndIdx 后一位的节点

当前没有处理的节点是 newEndIdx

也就是说 newEndIdx+1 的节点如果存在的话,肯定被处理过了

如果 newEndIdx 没有移动过,一直是最后一位,那么就不存在 newCh[newEndIdx + 1]

那么 refElm 就是空,那么剩余的新节点 就全部添加进 父节点孩子的末尾,相当于

for (; newStartIdx <= newEndIdx; ++newStartIdx) {     
    parentElm.appendChild(

        newCh[newStartIdx]

    );

}

如果 newEndIdx 移动过,那么就逐个添加在 refElm 的前面,相当于

for (; newStartIdx <= newEndIdx; ++newStartIdx) {
    parentElm.insertBefore(

        newCh[newStartIdx] ,

        refElm 

    );

}

如图

思考为什么这么比较

我们已经讲完了所有 Diff 的内容,大家也应该能领悟到 Diff 的思想

但是我强迫自己去思考一个问题,就是

为什么会这样去比较?

以下纯属个人意淫想法,没有权威认证,仅供参考

我们所有的比较,都是为了找到 新子节点 和 旧子节点 一样的子节点

而且我们的比较处理的宗旨是

1、能不移动,尽量不移动

2、没得办法,只好移动

3、实在不行,新建或删除

首先,一开始比较,肯定是按照我们的第一宗旨 不移动 ,找到可以不移动的节点

而 头头,尾尾比较 符合我们的第一宗旨,所以出现在最开始,嗯,这个可以想通

然后就到我们的第二宗旨 移动,按照 updateChildren 的做法有

旧头新尾比较,旧尾新头比较,单个查找比较

我开始疑惑了,咦?头尾比较为了移动我知道,但是为什么要出现这种比较?

明明我可以用 单个查找 的方式,完成所有的移动操作啊?

我思考了很久,头和尾的关系,觉得可能是为了避免极端情况的消耗??

怎么说?

比如当我们去掉头尾比较,全部使用单个查找的方式

如果出现头 和 尾 节点一样的时候,一个节点需要遍历 从头找到尾 才能找到相同节点

这样实在是太消耗了,所以这里加入了 头尾比较 就是为了排除 极端情况造成的消耗操作

当然,这只是我个人的想法,仅供参考,虽然这么说,我也的确做了个例子测试

子节点中加入了出现两个头尾比较情况的子项 b div

oldCh = ['header','span','div','b']
newCh = ['sub','b','div','strong']

使用 Vue 去更新,比较更新速度,然后更新十次,计算平均值

1、全用 单个查找,用时 0.91ms

2、加入头尾比较,用时 0.853ms

的确是快一些喔

本文使用 mdnice 排版