## 数据更新视图
之前讲到,在对 `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` 的过程,我们需要将他们进行比对。
![](https://user-gold-cdn.xitu.io/2017/12/28/1609be691ed64525?w=706&h=295&f=jpeg&s=18300)
diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。
![](https://img.kancloud.cn/78/ed/78ed0f7a71736796f0d4c31f5a9d1b9c_628x214.gif)
这张图中的相同颜色的方块中的节点会进行比对,比对得到「**差异**」后将这些「**差异**」更新到视图上。因为只进行同层级的比对,所以十分高效。
`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 节点。
![](https://img.kancloud.cn/9d/4a/9d4a83c7a917571940a8edd29396c345_885x397.gif)
接下来是一个 `while` 循环,在这过程中,`oldStartIdx`、`newStartIdx`、`oldEndIdx` 以及 `newEndIdx` 会逐渐向中间靠拢。
```
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
```
![](https://img.kancloud.cn/9b/fa/9bfa3d79db0aa810974ce4000e01ffd2_864x428.gif)
首先当 `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` 向后移动一位。
![](https://user-gold-cdn.xitu.io/2018/1/2/160b71f5a48631f4?w=618&h=251&f=png&s=19993)
其次是 `oldEndVnode` 与 `newEndVnode` 符合 `sameVnode`,也就是两个 VNode 的结尾是相同的 VNode,同样进行 `patchVnode` 操作并将 `oldEndVnode` 与 `newEndVnode` 向前移动一位。
![](https://user-gold-cdn.xitu.io/2018/1/2/160b7228b9ecb23a?w=753&h=235&f=png&s=20727)
接下来是两种交叉的情况。
先是 `oldStartVnode` 与 `newEndVnode` 符合 `sameVnode` 的时候,也就是老 VNode 节点的头部与新 VNode 节点的尾部是同一节点的时候,将 `oldStartVnode.elm` 这个节点直接移动到 `oldEndVnode.elm` 这个节点的后面即可。然后 `oldStartIdx` 向后移动一位,`newEndIdx` 向前移动一位。
![](https://user-gold-cdn.xitu.io/2018/1/2/160b723af0fd706a?w=1540&h=776&f=png&s=105982)
同理,`oldEndVnode` 与 `newStartVnode` 符合 `sameVnode` 时,也就是老 VNode 节点的尾部与新 VNode 节点的头部是同一节点的时候,将 `oldEndVnode.elm` 插入到 `oldStartVnode.elm` 前面。同样的,`oldEndIdx` 向前移动一位,`newStartIdx` 向后移动一位。
![](https://img.kancloud.cn/aa/8d/aa8d34c20a47b212378b1da678355af9_810x432.gif)
最后是当以上情况都不符合的时候,这种情况怎么处理呢?
```
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` 往后移动一位。
![](https://img.kancloud.cn/e9/fa/e9fa09c72cbca2763a9fd891f8894d3c_750x373.gif)
```
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` 往后移动一位。
![](https://img.kancloud.cn/bc/0d/bc0dc37b80d324472e77d4eb418edf67_927x462.gif)
```
else {
createElm(newStartVnode, parentElm);
newStartVnode = newCh[++newStartIdx];
}
```
最后一步就很容易啦,当 `while` 循环结束以后,如果 `oldStartIdx > oldEndIdx`,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 `addVnodes` 将这些节点插入即可。
![](https://img.kancloud.cn/9b/17/9b172cca47e698754c47653c10bc815f_784x373.gif)
同理,如果满足 `newStartIdx > newEndIdx` 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 `removeVnodes` 批量删除即可。
![](https://img.kancloud.cn/37/65/3765205d0b78788b7cdddcffb70a495f_836x367.gif)
```
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 机制》](https://github.com/answershuto/VueDemo/blob/master/%E3%80%8A%E6%95%B0%E6%8D%AE%E7%8A%B6%E6%80%81%E6%9B%B4%E6%96%B0%E6%97%B6%E7%9A%84%E5%B7%AE%E5%BC%82%20diff%20%E5%8F%8A%20patch%20%E6%9C%BA%E5%88%B6%E3%80%8B.js)。