当vue数据新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组节点,用于比较的算法就叫做Diff算法,操作DOM的性能开销比较大,Diff算法就是为了解决这个问题而诞生的
DOM复用与key的作用
相比于不断卸载并挂载子节点,通过DOM移动来完成更新性能更优,前提是新旧两组节点中存在可复用的节点,应如何确定新的子节点是否出现在旧的一组子节点当中呢,需要引入额外的key来作为vnode的标识,如下所示:
旧节点
[
{type: 'p', children: '1', key: 1},
{type: 'p', children: '2', key: 2},
{type: 'p', children: '3', key: 3}
]
新节点
[
{type: 'p', children: '3', key: 3},
{type: 'p', children: '1', key: 1},
{type: 'p', children: '2', key: 2}
]
key属性就像虚拟节点的‘身份证’号, 只要两个虚拟节点的type属性值和key属性值相同,则认为他们是相同的,可以进行DOM复用
如何移动元素
移动节点指的是,移动一个虚拟节点所对应的真实DOM节点,并不是移动虚拟节点本身,既然移动的是真实DOM节点,就需要取得对它的引用,而当虚拟节点被挂载后,其对应的真实DOM节点会存储在它的vnode.el中
因此在代码中,可以通过旧子节点的vnode.el属性取的它对应的真实DOM节点,当更新操作发生时,渲染器会调用patchElement函数在新旧虚拟节点之间进行打补丁(patchElement函数属于渲染器内容,不过多解释)
function patchElement(n1, n2) {
//新的vnode也引用了真实DOM元素
const el = n1.el = n2.el
//省略部分代码
}
更新步骤
- 取新的一组子节点中的第一个节点p-3,它的key为3,尝试在旧的一组子节点中找到具有相同key值得可复用节点,发现能够找到,并且该节点在旧的一组子节点中的索引为2,此时变量lastIndex的值为0,索引2大于0,所以p-3对应的真实DOM不需要移动,但需要更新变量lastIndex的值为2
- 取新的一组子节点中第二个节点p-1,它的key为1,能在旧节点中找到具有相同key值的可复用节点,并且再旧子节点中索引为0,此时变量lastIndex值为2,索引0小于2,所以节点p-1对应的真实DOM需要移动
添加新元素
在新的一组子节点中,多了节点p-4,key值为4,该节点在旧的子节点中不存在,应将其视为新增节点,更新时应正确挂载
定义find变量,代表渲染器能否在旧的一组子节点中找到可复用的节点,初始值定为false,一旦找到可复用的节点,则将变量find的值设为true,若内层循环结束后,变量find的值仍为false,代表当前newVNode是一个全新的节点,需要挂载它,为了将节点挂载正确,先获取锚点元素,找到newVNode的前一个虚拟节点,即prevVNode,若存在,则使用它对应的真实DOM的下一个兄弟节点作为锚点元素,若不存在,说明挂载的newVNode是容器元素的第一个子节点,此时使用容器元素的container.firstChild作为锚点元素
移除不存在元素
当基本的更新结束后,遍历旧的一组子节点,然后去新的一组子节点中寻找具有相同key值的节点,若找不到,说明应该删除该节点
具体实现
//n1 旧节点, n2 新节点, container 父容器
function patchChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
//用来存储寻找过程中遇到的最大索引值
let lastIndex = 0
for (let i=0; i < newChildren.length; i++) {
const newNode = newChildren[i]
let j = 0
//代表是否在旧的一组节点中找到可复用的节点
let find = false
for (j; j < oldChildren.length; j++){
const oldNode = oldChildren[j]
if (newNode.key === oldNode.key) {
find = true
//更新节点内容,patch方法属于编译器内容,不再赘述
patch(oldNode, newNode, container)
if (j < lastIndex) {
//说明newVNode对应的真实DOM需要移动
//获取newVNode的前一个vnode,即prevVNode
const prevVNode = newChildren[i-1]
//若prevVNode不存在,说明当前newVNode是第一个节点,不需要移动
if (prevVNode) {
//newVNode对应的真实DOM移动到prevVNode所对应的真实DOM后面
//获取prevVNode所对应的真实DOM的下一个兄弟节点,并将其作为锚点
const anchor = prevVNode.el.nextSibling
//调用insert方法插入元素
insert(newNode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
//当前newNode在旧的子节点中找不到,属于新增节点,需要挂载
if (!find) {
const prevVNode = newChildren[i - 1]
let anchor = null
if (prevVNode) {
anchor = prevVNode.el.nextSibling
}else {
//若没有前一个节点,使用容器的firstChild作为锚点
anchor = container.firstChild
}
patch(null, newNode, container, anchor)
}
}
//更新操作完成后,遍历旧的子节点
for (let i = 0; i < oldChildren.length; i++) {
const oldNode = oldChildren[i]
const has = newChildren.find(vnode => vnode.key === oldNode.key)
if (!has) {
//找不到具有相同key的节点,说明需要删除该节点
unmount(oldNode)
}
}
}
插入节点方法
//anchor被插入哪个元素之前, null 代表插入最后
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor)
}
移除节点
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}