写文章不容易,点个赞呗兄弟
专一 Vue 源码分享,文章分为白话版和 源码版,白话版助于理解工做原理,源码版助于了解内部详情,让咱们一块儿学习吧
研究基于 Vue版本 【2.5.17】
若是你以为排版难看,请点击 下面连接 或者 拉到 下面关注公众号也能够吧node
【Vue原理】Diff - 源码版 之 Diff 流程 数组
今天终于要开始探索 Vue 更新DOM 的重点了,就是 Diffapp
Diff 的内容不算多,可是若是要讲得很详细的话,就要说不少了,并且要配不少图dom
这是 Diff 的最后一篇文章,最重要也是最详细的一篇了函数
因此本篇内容不少,先提个内容概览学习
一、分析 Diff 源码比较步骤 二、我的思考为何如此比较 三、写个例子,一步步走个Diff 流程
文章很长,也很是详细,若是你对这内容有兴趣的话,也推荐边阅读源码边看,若是你对本内容暂时没有了解,能够先看不涉及源码的白话版 Diff - 白话版 测试
下面开始咱们的正文spa
在以前一篇文章 Diff - 源码版 之 重新建实例到开始diff ,咱们已经探索了 Vue 是如何重新建实例到开始diff 的 prototype
你应该还有印象,其中Diff涉及的一个重要函数就是 createPatchFunciton3d
var patch = createPatchFunction(); Vue.prototype.__patch__ = patch
那么咱们就来看下这个函数
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
处理的流程分为
一、没有旧节点 二、旧节点 和 新节点 自身同样(不包括其子节点) 三、旧节点 和 新节点自身不同
速度来看下这三个流程了
没有旧节点,说明是页面刚开始初始化的时候,此时,根本不须要比较了
直接所有都是新建,因此只调用 createElm
经过 sameVnode 判断节点是否同样,这个函数在上篇文章中说过了
旧节点 和 新节点自身同样时,直接调用 patchVnode 去处理这两个节点
patchVnode 下面会讲到这个函数
在讲 patchVnode 以前,咱们先思考这个函数的做用是什么?
当两个Vnode自身同样的时候,咱们须要作什么?
首先,自身同样,咱们能够先简单理解,是 Vnode 的两个属性 tag 和 key 同样
那么,咱们是不知道其子节点是否同样的,因此确定须要比较子节点
因此,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; } }
咱们如今就来分析这个函数
没错,正如咱们所想,这个函数的确会去比较处理子节点
总的来讲,这个函数的做用是
一、Vnode 是文本节点,则更新文本(文本节点不存在子节点)
二、Vnode 有子节点,则处理比较更新子节点
更进一步的总结就是,这个函数主要作了两种判断的处理
一、Vnode 是不是文本节点
二、Vnode 是否有子节点
下面咱们来看看这些步骤的详细分析
当 VNode 存在 text 这个属性的时候,就证实了 Vnode 是文本节点
咱们能够先来看看 文本类型的 Vnode 是什么样子
因此当 Vnode 是文本节点的时候,须要作的就是,更新文本
一样有两种处理
一、当 新Vnode.text 存在,并且和 旧 VNode.text 不同时
直接更新这个 DOM 的 文本内容
elm.textContent = vnode.text;
注:textContent 是 真实DOM 的一个属性, 保存的是 dom 的文本,因此直接更新这个属性
二、新Vnode 的 text 为空,直接把 文本DOM 赋值给空
elm.textContent = '';
当 Vnode 存在子节点的时候,由于不知道 新旧节点的子节点是否同样,因此须要比较,才能完成更新
这里有三种处理
一、新旧节点 都有子节点,并且不同
二、只有新节点
三、只有旧节点
后面两个节点,相信你们都能想通,可是咱们仍是说一下
只有新节点,不存在旧节点,那么没得比较了,全部节点都是全新的
因此直接所有新建就行了,新建是指建立出全部新DOM,而且添加进父节点的
只有旧节点而没有新节点,说明更新后的页面,旧节点所有都不见了
那么要作的,就是把全部的旧节点删除
也就是直接把DOM 删除
咦惹,又出现了一个新函数,那就是 updateChildren
预告一下,这个函数很是的重要,是 Diff 的核心模块,蕴含着 Diff 的思想
可能会有点绕,可是不用怕,相信在个人探索之下,能够稍微明白些
一样的,咱们先来思考下 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); } } }
处理的是 新子节点 和 旧子节点,循环遍历逐个比较
一、使用 while
二、新旧节点数组都配置首尾两个索引
新节点的两个索引:newStartIdx , newEndIdx
旧节点的两个索引:oldStartIdx,oldEndIdx
以两边向中间包围的形式 来进行遍历
头部的子节点比较完毕,startIdx 就加1
尾部的子节点比较完毕,endIdex 就减1
只要其中一个数组遍历完(startIdx<endIdx),则结束遍历
源码处理的流程分为两个
一、比较新旧子节点
二、比较完毕,处理剩下的节点
咱们来逐个说明这两个流程
注:这里有两个数组,一个是 新子Vnode数组,一个旧子Vnode数组
在比较过程当中,不会对两个数组进行改变(好比不会插入,不会删除其子项)
而全部比较过程当中都是直接 插入删除 真实页面DOM
找到 新旧子节点中 的 相同的子节点,尽可能以 移动 替代 新建 去更新DOM
只有在实在不一样的状况下,才会新建
首先考虑,不移动DOM
其次考虑,移动DOM
最后考虑,新建 / 删除 DOM
能不移动,尽可能不移动。不行就移动,实在不行就新建
下面开始说源码中的比较逻辑
五种比较逻辑以下
一、旧头 == 新头 二、旧尾 == 新尾 三、旧头 == 新尾 四、旧尾 == 新头 五、单个查找
来分析下这五种比较逻辑
sameVnode(oldStartVnode, newStartVnode)
当两个新旧的两个头同样的时候,并不用作什么处理
符合咱们的步骤第一条,不移动DOM完成更新
可是看到一句,patchVnode
就是为了继续处理这两个相同节点的子节点,或者更新文本
由于咱们不考虑多层DOM 结构,因此 新旧两个头同样的话,这里就算结束了
能够直接进行下一轮循环
newStartIdx ++ , oldStartIdx ++
sameVnode(oldEndVnode, newEndVnode)
和 头头 相同的处理是同样的
尾尾相同,直接跳入下个循环
newEndIdx ++ , oldEndIdx ++
sameVnode(oldStartVnode, newEndVnode)
这步不符合 不移动DOM,因此只能 移动DOM 了
源码是这样的
parentElm.insertBefore( oldStartVnode.elm, oldEndVnode.elm.nextSibling );
以 新子节点的位置 来移动的,旧头 在新子节点的 末尾
因此把 oldStartVnode 的 dom 放到 oldEndVnode 的后面
可是由于没有把dom 放到谁后面的方法,因此只能使用 insertBefore
即放在 oldEndVnode 后一个节点的前面
图示是这样的
而后更新两个索引
oldStartIdx++,newEndIdx--
sameVnode(oldEndVnode, newStartVnode)
一样不符合 不移动DOM,也只能 移动DOM 了
parentElm.insertBefore( oldEndVnode.elm, oldStartVnode.elm );
把 oldEndVnode DOM 直接放到 当前 oldStartVnode.elm 的前面
图示是这样的
而后更新两个索引
oldEndIdx--,newStartIdx++
当前面四种比较逻辑都不行的时候,这是最后一种处理方法
拿 新子节点的子项,直接去 旧子节点数组中遍历,找同样的节点出来
流程大概是
一、生成旧子节点数组以 vnode.key 为key 的 map 表
二、拿到新子节点数组中 一个子项,判断它的key是否在上面的map 中
三、不存在,则新建DOM
四、存在,继续判断是否 sameVnode
下面就详细说一下
这个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 }
拿到新子节点中的 子项Vnode,而后拿到它的 key
去匹配map 表,判断是否有相同节点
oldKeyToIdx[newStartVnode.key]
直接建立DOM,并插入oldStartVnode 前面
createElm(newStartVnode, parentElm, oldStartVnode.elm);
找到这个旧子节点,而后判断和新子节点是否 sameVnode
若是相同,直接移动到 oldStartVnode 前面
若是不一样,直接建立插入 oldStartVnode 前面
咱们上面说了比较子节点的处理的流程分为两个
一、比较新旧子节点
二、比较完毕,处理剩下的节点
比较新旧子节点上面已经说完了,下面就到了另外一个流程,比较剩余的节点,详情看下面
在updateChildren 中,比较完新旧两个数组以后,可能某个数组会剩下部分节点没有被处理过,因此这里须要统一处理
newStartIdx > newEndIdx
新子节点遍历完毕,旧子节点可能还有剩
因此咱们要对可能剩下的旧节点进行 批量删除!
就是遍历剩下的节点,逐个删除DOM
for (; oldStartIdx <= oldEndIdx; ++oldStartIdx) { oldCh[oldStartIdx] .parentNode .removeChild(el); }
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 的思想
可是我强迫本身去思考一个问题,就是
如下纯属我的意淫想法,没有权威认证,仅供参考
咱们全部的比较,都是为了找到 新子节点 和 旧子节点 同样的子节点
并且咱们的比较处理的宗旨是
一、能不移动,尽可能不移动
二、没得办法,只好移动
三、实在不行,新建或删除
首先,一开始比较,确定是按照咱们的第一宗旨 不移动 ,找到能够不移动的节点
而 头头,尾尾比较 符合咱们的第一宗旨,因此出如今最开始,嗯,这个能够想通
而后就到咱们的第二宗旨 移动,按照 updateChildren 的作法有
旧头新尾比较,旧尾新头比较,单个查找比较
我开始疑惑了,咦?头尾比较为了移动我知道,可是为何要出现这种比较?
明明我能够用 单个查找 的方式,完成全部的移动操做啊?
我思考了好久,头和尾的关系,以为多是为了不极端状况的消耗??
好比当咱们去掉头尾比较,所有使用单个查找的方式
若是出现头 和 尾 节点同样的时候,一个节点须要遍历 从头找到尾 才能找到相同节点
这样实在是太消耗了,因此这里加入了 头尾比较 就是为了排除 极端状况形成的消耗操做
固然,这只是我我的的想法,仅供参考,虽然这么说,我也的确作了个例子测试
子节点中加入了出现两个头尾比较状况的子项 b div
oldCh = ['header','span','div','b'] newCh = ['sub','b','div','strong']
使用 Vue 去更新,比较更新速度,而后更新十次,计算平均值
一、全用 单个查找,用时 0.91ms
二、加入头尾比较,用时 0.853ms
的确是快一些喔
我相信通过这么长的一篇文章,你们的脑海中尚未把全部的知识点集合起来,可能对整个流程还有点模糊
没事,咱们如今就来举一个例子,一步步走流程,完成更新
如下的节点,绿色表示未处理,灰色表示已经处理,淡绿色表示正在处理,红色表示新插入,以下
如今Vue 须要更新,存在下面两组新旧子节点,须要进行比较,来判断须要更新哪些节点
更新索引,newStartIdx++ , oldStartIdx++
开始下轮处理
更新索引,newEndIdx-- ,oldStartIdx ++
开始下轮处理
更新索引,oldEndIdx-- ,newStartIdx++
开始下轮比较
找不到同样的,直接建立插入到 oldStartVnode 前面
更新索引,newStartIdx++
此时 newStartIdx> newEndIdx ,结束循环
此时看 旧 Vnode 数组中, oldStartIdx 和 oldEndIdx 都指向同一个节点,因此只用删除 oldVnode-4 这个节点
ok,完成全部比较流程
耶,Diff 内容讲完了,谢谢你们的观看
鉴于本人能力有限,不免会有疏漏错误的地方,请你们多多包涵,若是有任何描述不当的地方,欢迎后台联系本人,有重谢