数据状态更新时的差异 diff 及 patch 机制
数据更新视图
之前讲到,在对 model
进行操作对时候,会触发对应 Dep
中的 Watcher
对象。Watcher
对象会调用对应的 update
来修改视图。最终是将新产生的 VNode 节点与老 VNode 进行一个 patch
的过程,比对得出「差异」,最终将这些「差异」更新到视图上。
这一章就来介绍一下这个 patch
的过程,因为 patch
过程本身比较复杂,这一章的内容会比较多,但是不要害怕,我们逐块代码去看,一定可以理解。
跨平台
因为使用了 Virtual DOM 的原因,Vue.js具有了跨平台的能力,Virtual DOM 终归只是一些 JavaScript 对象罢了,那么最终是如何调用不同平台的 API 的呢?
这就需要依赖一层适配层了,将不同平台的 API 封装在内,以同样的接口对外提供。
const nodeOps = {
setTextContent (text) {
if (platform === 'weex') {
node.parentNode.setAttr('value', text);
} else if (platform === 'web') {
node.textContent = text;
}
},
parentNode () {
//......
},
removeChild () {
//......
},
nextSibling () {
//......
},
insertBefore () {
//......
}
}
举个例子,现在我们有上述一个 nodeOps
对象做适配,根据 platform 区分不同平台来执行当前平台对应的API,而对外则是提供了一致的接口,供 Virtual DOM 来调用。
一些API
接下来我们来介绍其他的一些 API,这些API在下面 patch
的过程中会被用到,他们最终都会调用 nodeOps
中的相应函数来操作平台。
insert
用来在 parent
这个父节点下插入一个子节点,如果指定了 ref
则插入到 ref
这个子节点前面。
function insert (parent, elm, ref) {
if (parent) {
if (ref) {
if (ref.parentNode === parent) {
nodeOps.insertBefore(parent, elm, ref);
}
} else {
nodeOps.appendChild(parent, elm)
}
}
}
createElm
用来新建一个节点, tag
存在创建一个标签节点,否则创建一个文本节点。
function createElm (vnode, parentElm, refElm) {
if (vnode.tag) {
insert(parentElm, nodeOps.createElement(vnode.tag), refElm);
} else {
insert(parentElm, nodeOps.createTextNode(vnode.text), refElm);
}
}
addVnodes
用来批量调用 createElm
新建节点。
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], parentElm, refElm);
}
}
removeNode
用来移除一个节点。
function removeNode (el) {
const parent = nodeOps.parentNode(el);
if (parent) {
nodeOps.removeChild(parent, el);
}
}
removeVnodes
会批量调用 removeNode
移除节点。
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (ch) {
removeNode(ch.elm);
}
}
}
patch
首先说一下 patch
的核心 diff 算法,我们用 diff 算法可以比对出两颗树的「差异」,我们来看一下,假设我们现在有如下两颗树,它们分别是新老 VNode 节点,这时候到了 patch
的过程,我们需要将他们进行比对。
diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。
。
这张图中的相同颜色的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以十分高效。
patch
的过程相当复杂,我们先用简单的代码来看一下。
function patch (oldVnode, vnode, parentElm) {
if (!oldVnode) {
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
} else if (!vnode) {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
} else {
if (sameVnode(oldVNode, vnode)) {
patchVnode(oldVNode, vnode);
} else {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
}
}
因为 patch
的主要功能是比对两个 VNode 节点,将「差异」更新到视图上,所以入参有新老两个 VNode 以及父节点的 element 。我们来逐步捋一下逻辑, addVnodes
、 removeVnodes
等函数后面会讲。
首先在 oldVnode
(老 VNode 节点)不存在的时候,相当于新的 VNode 替代原本没有的节点,所以直接用 addVnodes
将这些节点批量添加到 parentElm
上。
if (!oldVnode) {
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
然后同理,在 vnode
(新 VNode 节点)不存在的时候,相当于要把老的节点删除,所以直接使用 removeVnodes
进行批量的节点删除即可。
else if (!vnode) {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
}
最后一种情况,当 oldVNode
与 vnode
都存在的时候,需要判断它们是否属于 sameVnode
(相同的节点)。如果是则进行patchVnode(比对 VNode )操作,否则删除老节点,增加新节点。
if (sameVnode(oldVNode, vnode)) {
patchVnode(oldVNode, vnode);
} else {
removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
sameVnode
上面这些比较好理解,下面我们来看看什么情况下两个 VNode 会属于 sameVnode
(相同的节点)呢?
function sameVnode () {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
(!!a.data) === (!!b.data) &&
sameInputType(a, b)
)
}
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = (i = a.data) && (i = i.attrs) && i.type
const typeB = (i = b.data) && (i = i.attrs) && i.type
return typeA === typeB
}
sameVnode
其实很简单,只有当 key
、 tag
、 isComment
(是否为注释节点)、 data
同时定义(或不定义),同时满足当标签类型为 input 的时候 type 相同(某些浏览器不支持动态修改<input>
类型,所以他们被视为不同类型)即可。
patchVnode
之前patch的过程还剩下 patchVnode
这个函数没有讲,这也是最复杂的一个,我们现在来看一下。因为这个函数是在符合 sameVnode
的条件下触发的,所以会进行「比对」。
function patchVnode (oldVnode, vnode) {
if (oldVnode === vnode) {
return;
}
if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
vnode.elm = oldVnode.elm;
vnode.componentInstance = oldVnode.componentInstance;
return;
}
const elm = vnode.elm = oldVnode.elm;
const oldCh = oldVnode.children;
const ch = vnode.children;
if (vnode.text) {
nodeOps.setTextContent(elm, vnode.text);
} else {
if (oldCh && ch && (oldCh !== ch)) {
updateChildren(elm, oldCh, ch);
} else if (ch) {
if (oldVnode.text) nodeOps.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (oldVnode.text) {
nodeOps.setTextContent(elm, '')
}
}
}
首先在新老 VNode 节点相同的情况下,就不需要做任何改变了,直接 return 掉。
if (oldVnode === vnode) {
return;
}
下面的这种情况也比较简单,在当新老 VNode 节点都是 isStatic
(静态的),并且 key
相同时,只要将 componentInstance
与 elm
从老 VNode 节点“拿过来”即可。这里的 isStatic
也就是前面提到过的「编译」的时候会将静态节点标记出来,这样就可以跳过比对的过程。
if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
vnode.elm = oldVnode.elm;
vnode.componentInstance = oldVnode.componentInstance;
return;
}
接下来,当新 VNode 节点是文本节点的时候,直接用 setTextContent
来设置 text,这里的 nodeOps
是一个适配层,根据不同平台提供不同的操作平台 DOM 的方法,实现跨平台。
if (vnode.text) {
nodeOps.setTextContent(elm, vnode.text);
}
当新 VNode 节点是非文本节点当时候,需要分几种情况。
oldCh
与ch
都存在且不相同时,使用updateChildren
函数来更新子节点,这个后面重点讲。- 如果只有
ch
存在的时候,如果老节点是文本节点则先将节点的文本清除,然后将ch
批量插入插入到节点elm下。 - 同理当只有
oldch
存在时,说明需要将老节点通过removeVnodes
全部清除。 - 最后一种情况是当只有老节点是文本节点的时候,清除其节点文本内容。
if (oldCh && ch && (oldCh !== ch)) {
updateChildren(elm, oldCh, ch);
} else if (ch) {
if (oldVnode.text) nodeOps.setTextContent(elm, '');
addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (oldVnode.text) {
nodeOps.setTextContent(elm, '')
}
updateChildren
接下来就要讲一下 updateChildren
函数了。
function updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, elmToMove, refElm;
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);
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
let elmToMove = oldCh[idxInOld];
if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
}
}
}
if (oldStartIdx > oldEndIdx) {
refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
看到代码那么多先不要着急,我们还是一点一点地讲解。
首先我们定义 oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
分别是新老两个 VNode 的两边的索引,同时 oldStartVnode
、newStartVnode
、oldEndVnode
以及 newEndVnode
分别指向这几个索引对应的 VNode 节点。
接下来是一个 while
循环,在这过程中,oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
会逐渐向中间靠拢。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
首先当 oldStartVnode
或者 oldEndVnode
不存在的时候,oldStartIdx
与 oldEndIdx
继续向中间靠拢,并更新对应的 oldStartVnode
与 oldEndVnode
的指向(注:下面讲到的 oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
移动都会伴随着 oldStartVnode
、newStartVnode
、oldEndVnode
以及 newEndVnode
的指向的变化,之后的部分只会讲 Idx
的移动)。
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
}
接下来这一块,是将 oldStartIdx
、newStartIdx
、oldEndIdx
以及 newEndIdx
两两比对的过程,一共会出现 2*2=4 种情况。
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);
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
首先是 oldStartVnode
与 newStartVnode
符合 sameVnode
时,说明老 VNode 节点的头部与新 VNode 节点的头部是相同的 VNode 节点,直接进行 patchVnode
,同时 oldStartIdx
与 newStartIdx
向后移动一位。
其次是 oldEndVnode
与 newEndVnode
符合 sameVnode
,也就是两个 VNode 的结尾是相同的 VNode,同样进行 patchVnode
操作并将 oldEndVnode
与 newEndVnode
向前移动一位。
接下来是两种交叉的情况。
先是 oldStartVnode
与 newEndVnode
符合 sameVnode
的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 oldStartVnode.elm
这个节点直接移动到 oldEndVnode.elm
这个节点的后面即可。然后 oldStartIdx
向后移动一位,newEndIdx
向前移动一位。
同理,oldEndVnode
与 newStartVnode
符合 sameVnode
时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 oldEndVnode.elm
插入到 oldStartVnode.elm
前面。同样的,oldEndIdx
向前移动一位,newStartIdx
向后移动一位。
最后是当以上情况都不符合的时候,这种情况怎么处理呢?
else {
let elmToMove = oldCh[idxInOld];
if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
} else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
} else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
}
}
function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
createKeyToOldIdx
的作用是产生 key
与 index
索引对应的一个 map 表。比如说:
[
{xx: xx, key: 'key0'},
{xx: xx, key: 'key1'},
{xx: xx, key: 'key2'}
]
在经过 createKeyToOldIdx
转化以后会变成:
{
key0: 0,
key1: 1,
key2: 2
}
我们可以根据某一个 key 的值,快速地从 oldKeyToIdx
(createKeyToOldIdx
的返回值)中获取相同 key 的节点的索引 idxInOld
,然后找到相同的节点。
如果没有找到相同的节点,则通过 createElm
创建一个新节点,并将 newStartIdx
向后移动一位。
if (!idxInOld) {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
否则如果找到了节点,同时它符合 sameVnode
,则将这两个节点进行 patchVnode
,将该位置的老节点赋值 undefined(之后如果还有新节点与该节点key相同可以检测出来提示已有重复的 key ),同时将 newStartVnode.elm
插入到 oldStartVnode.elm
的前面。同理,newStartIdx
往后移动一位。
else {
elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
newStartVnode = newCh[++newStartIdx];
}
}
如果不符合 sameVnode
,只能创建一个新节点插入到 parentElm
的子节点中,newStartIdx
往后移动一位。
else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
最后一步就很容易啦,当 while
循环结束以后,如果 oldStartIdx > oldEndIdx
,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes
将这些节点插入即可。
同理,如果满足 newStartIdx > newEndIdx
条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes
批量删除即可。
if (oldStartIdx > oldEndIdx) {
refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
到这里,比对的核心实现已经讲完了,这部分比较复杂,不过仔细地梳理一下比对的过程,相信一定能够理解得更加透彻的。
注:本节代码参考《数据状态更新时的差异 diff 及 patch 机制》。