一、什么是diff算法?
开始之前先来玩个装修游戏,如果你是装修师傅,要将下面的左图变为右图,你要怎么装修?
1)将左边的房子全部拆掉,重新装修; 2)找出两个房子的不同,只做局部改造 很明显,正常人为了减少消耗,都会选择第二种,那么这时候就要对两者进行层层比较,找出不同,再开始更新。
这就是diff算法的思想:精细化对比,最小量更新。
二、为什么要使用diff算法?
众所周知,渲染真实DOM的开销非常大,当数据进行修改时,我们如果直接渲染到真实DOM上会引起整个dom树的重绘和重排,那如果我们只是想更新我们修改的那一小块dom呢,这时候diff算法就能帮助到我们。
三、当数据发生变化时,vue是怎么更新节点的?
我们首先会根据真实的DOM生成一棵virtual DOM(虚拟DOM),当virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后Vnode和oldVnode作对比,发现有不一样的地方就直接修改在真实的DOM上,然后使oldVnode的值为Vnode。diff的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁。 注意:只有同一个虚拟节点,才能进行精细化比较,否则就是暴力删除旧的、插入新的。 那如何定义同一个虚拟节点?就是选择器相同且key相同,这也是为什么我们在写v-for循环时为什么要加key的原因。
四、virtual DOM和真实DOM的区别
virtual DOM是将真实的DOM的数据抽取出来,以对象的形式模拟树形结构。 真实DOM:
<div>
<p>123</p>
</div>
virtual DOM:
var Vnode = {
tag: 'div',
children: [
{ tag: 'p', text: '123' }
]
};
五、diff的比较方式
相信大家很多地方都看到这张图,diff算法只会进行同层的比较,不会进行跨层比较,即使是同一片虚拟节点,如果跨层比较,精细化对比就不会进行比较,只会暴力拆除,然后插入新的节点。
六、patch函数
1.patch函数被调用的过程
2.patch核心方法改写
export function patch(oldVnode, newVnode) {
const isRealElement = oldVnode.nodeType;
if (isRealElement) {
// oldVnode是真实dom元素 就代表初次渲染
} else {
// oldVnode是虚拟dom 就是更新过程 使用diff算法
//判断oldVnode和newVnode是不是同一个节点
if(oldVnode.key== newVnode.key && oldVnode.tag == newVnode.tag){
//是同一个节点,进行精细化比较
//判断oldVnode和newVnode是否是同一个对象
if(newVnode === oldVnode) return
//判断新vnode有没有text属性
if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
//vnode有text属性
if(newVnode.text != oldVnode.text){
//如果newVnode中的text和oldVnode的text不同,直接让新的text写入老的elm中,如果老得elm中是children,也会立即消失掉
oldVnode.elm.innerText = vnode.text
}
}else{
//vnode没有text属性
//判断oldVnode有没有children
if(oldVnode.children != undefined && oldVnode.children.length > 0){
//oldVnode和newVnode都有子节点
//更新子节点
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
}else{
//oldVnode没有子节点,newVnode有子节点
//清空老节点的内容
oldVnode.elm.innerHTML = '';
//遍历newVnode的子节点,创建DOM上树
for(let i = 0; i < newVnode.children.length ;i++){
let dom = createElement(newVnode.children[i]);
oldVnode.elm.appendChild(dom)
}
}
}
}
else{
//不是同一个节点暴力插入新的节点
let newNodeElm = createElement(newVnode)
if(oldVnode.elm.parentNode&& newNodeElm){
oldVnode.elm.parentNode.insetBefore(newNodeElm,oldVnode.elm)
}
//删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
}
3.diff算法子节点更新方式
首先我们要知道diff算法里面提供了四种命中查找方法:1)新前与旧前、2)新后与旧后、3)新后与旧前、4)新前与旧后; 按顺序循环命中,一个节点只要命中一个策略就再进行命中判断;如果都没有命中,就需要用循环来命中!
首先我们要理解一个概念是:无论新还是旧,前>后,那就表示这个子节点循环完毕; 1)如果旧节点先循环完毕,新节点中还有剩余节点,说明他们是要新增的节点; 2)如果新节点先循环完毕,老节点中还有剩余节点,说明他们是要被删除的节点; 3)当情况3命中时,此时要移动节点,把新前指向的节点移动到旧后之后; 4)当情况4命中时,此时要移动节点,把新前指向的节点移动到旧前之前;
updateChildren更新子节点--diff算法核心方法
// 判断是否是同一个节点
function isSameVnode(oldVnode, newVnode) {
return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key
}
export default function updateChildren(parentElm, oldCh, newCh) {
// 旧前
const oldStartIdx = 0
// 新前
const newStartIdx = 0
// 旧后
const oldEndIdx = oldCh.length - 1
// 新后
const newEndIdx = newCh.length - 1
// 旧前节点
const oldStartVnode = oldCh[0]
// 旧后节点
const oldEndVnode = oldCh[oldEndIdx]
// 新前节点
const newStartVnode = newCh[0]
// 新后节点
const newEndVnode = newCh[newEndIdx]
// 根据key来创建老的儿子的index映射表 类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
//这样就不用每次都遍历老对象了
function makeIndexByKey(children) {
const map = {}
children.forEach((item, index) => {
map[item.key] = index
})
return map
}
// 生成的映射表
const map = makeIndexByKey(oldCh)
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先判断节点是否已经被标记过
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIndex]
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIndex]
} else if (isSameVnode(oldStartIdx, newStartVnode)) {
// 新前和旧前
patch(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// 新后与旧后
patch(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (isSameVnode(newEndVnode, oldStartVnode)) {
// 新后与旧前
patch(newEndVnode, oldStartVnode)
// 当新后与旧前命中时,此时要移动节点,把新前指向的节点移动到旧后之后
parent.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
newEndVnode = newCh[--newEndIdx]
oldStartVnode = oldCh[++oldStartIdx]
} else if (isSameVnode(newStartVnode, oldEndVnode)) {
// 新前与旧后
patch(newStartVnode, oldEndVnode)
// 当新前与旧后命中时,此时要移动节点,把新前指向的节点移动到旧前之前!
parent.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else {
// 4种情况都没有命中
// 寻找当前这项在keyMap中的映射的位置序号
const idxInOld = map[newStartVnode.key]
// 如果idxInOld是undefined表示是全新的项,需要插入
if (!idxInOld) {
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
// 如果存在 表示节点需要移动
const moveVnode = oldCh[idxInOld] // 找得到就拿到老的节点
patch(moveVnode, newStartVnode)
oldCh[idxInOld] = undefined // 这个是占位操作 表示已处理完 避免数组塌陷 防止老节点移动走了之后破坏了初始的映射表位置
parent.insertBefore(moveVnode.el, oldStartVnode.el) // 把找到的节点移动到最前面
newStartVnode = newCh[++newEndIdx]
}
}
}
// 循环结束看有没有剩余节点
if (newStartIdx <= newEndIdx) {
// 新节点还有剩余节点 需要插入
const before =
newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾去,相当于applendChild方法
for (let i = newStartIdx; i <= newEndIdx; i++) {
// newCh[i]现在还没有真正的dom,需要创建
parentElm.insertBefore(createElement(newCh[i], before))
}
} else if (oldStartIdx <= oldEndIdx) {
// 旧节点还有剩余节点 需要删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
parentElm.removeChild(oldCh[i].elm)
}
}
}