大家好,我是阿飞,今天我在学习的是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]))
}
}
情况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算法的核心。
它的核心是,建立一个隐射表,永远用下面的一个元素,去隐射表里面查找。
- 根据Key和索引,将老的内容生成映射表
- 如果找到,就把这个元素移动到首指针的前面
- 为了防止数组塌陷,会将空的位置填充成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算法流程如下:
-
两个节点进行对比,如果节点的tag不同,直接替换
-
如果tag相同,就比较props,对属性进行替换
-
如果新的节点有儿子,老的节点没有儿子,则直接插入
-
如果老的节点有儿子,新的节点没有儿子,就直接移除
-
如果新的节点和老的节点都有儿子,会进入儿子的Diff算法
5.1 头头比较
5.2 头头比较
5.2 头尾比较
5.3 尾头比较
5.4 乱序比较(核心diff)
💡 小提示:5.1到5.4优化了向后添加、向前添加、尾巴移动到头部、头部移动到尾部、反转,真正的Diff算法核心就是5.4的乱序比价。
求赞
大佬,已经看到这儿了,点个赞再走好不好?