## 數據更新視圖
之前講到,在對 `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 機制》](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)。