寫文章不容易,點個讚唄兄弟
專注 Vue 源碼分享,文章分爲白話版和 源碼版,白話版助於理解工作原理,源碼版助於瞭解內部詳情,讓我們一起學習吧
研究基於 Vue版本 【2.5.17】
如果你覺得排版難看,請點擊 下面鏈接 或者 拉到 下面關注公衆號也可以吧
今天終於要開始探索 Vue 更新DOM 的重點了,就是 Diff
Diff 的內容不算多,但是如果要講得很詳細的話,就要說很多了,而且要配很多圖
這是 Diff 的最後一篇文章,最重要也是最詳細的一篇了
所以本篇內容很多,先提個內容概覽
1、分析 Diff 源碼比較步驟
2、個人思考爲什麼如此比較
3、寫個例子,一步步走個Diff 流程
文章很長,也非常詳細,如果你對這內容有興趣的話,也推薦邊閱讀源碼邊看,如果你對本內容暫時沒有了解,可以先看不涉及源碼的白話版 Diff - 白話版
下面開始我們的正文
在之前一篇文章 Diff - 源碼版 之 從新建實例到開始diff ,我們已經探索了 Vue 是如何從新建實例到開始diff 的
你應該還有印象,其中Diff涉及的一個重要函數就是 createPatchFunciton
var patch = createPatchFunction();
Vue.prototype.__patch__ = patch
那麼我們就來看下這個函數
createPatchFunction
function createPatchFunction() {
return function patch(
oldVnode, vnode, parentElm, refElm
) {
// 沒有舊節點,直接生成新節點
if (!oldVnode) {
createElm(vnode, parentElm, refElm);
}
else {
// 且是一樣 Vnode
if (sameVnode(oldVnode, vnode)) {
// 比較存在的根節點
patchVnode(oldVnode, vnode);
}
else {
// 替換存在的元素
var oldElm = oldVnode.elm;
var _parentElm = oldElm.parentNode
// 創建新節點
createElm(vnode, _parentElm, oldElm.nextSibling);
// 銷燬舊節點
if (_parentElm) {
removeVnodes([oldVnode], 0, 0);
}
}
}
return vnode.elm
}
}
這個函數的作用就是
比較 新節點 和 舊節點 有什麼不同,然後完成更新
所以你看到接收一個 oldVnode 和 vnode
處理的流程分爲
1、沒有舊節點
2、舊節點 和 新節點 自身一樣(不包括其子節點)
3、舊節點 和 新節點自身不一樣
速度來看下這三個流程了
1 沒有舊節點
沒有舊節點,說明是頁面剛開始初始化的時候,此時,根本不需要比較了
直接全部都是新建,所以只調用 createElm
2 舊節點 和 新節點 自身一樣
通過 sameVnode 判斷節點是否一樣,這個函數在上篇文章中說過了
舊節點 和 新節點自身一樣時,直接調用 patchVnode 去處理這兩個節點
patchVnode 下面會講到這個函數
在講 patchVnode 之前,我們先思考這個函數的作用是什麼?
當兩個Vnode自身一樣的時候,我們需要做什麼?
首先,自身一樣,我們可以先簡單理解,是 Vnode 的兩個屬性 tag 和 key 一樣
那麼,我們是不知道其子節點是否一樣的,所以肯定需要比較子節點
所以,patchVnode 其中的一個作用,就是比較子節點
3 舊節點 和 新節點 自身不一樣
當兩個節點不一樣的時候,不難理解,直接創建新節點,刪除舊節點
patchVnode
在上一個函數 createPatchFunction 中,有出現一個函數 patchVnode
我們思考了這個函數的其中的一個作用是 比較兩個Vnode 的子節點
是不是我們想的呢,可以先來過一下源碼
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) return
var elm = vnode.elm = oldVnode.elm;
var oldCh = oldVnode.children;
var ch = vnode.children;
// 更新children
if (!vnode.text) {
// 存在 oldCh 和 ch 時
if (oldCh && ch) {
if (oldCh !== ch)
updateChildren(elm, oldCh, ch);
}
// 存在 newCh 時,oldCh 只能是不存在,如果存在,就跳到上面的條件了
else if (ch) {
if (oldVnode.text) elm.textContent = '';
for (var i = 0; i <= ch.length - 1; ++i) {
createElm(
ch[i],elm, null
);
}
}
else if (oldCh) {
for (var i = 0; i<= oldCh.length - 1; ++i) {
oldCh[i].parentNode.removeChild(el);
}
}
else if (oldVnode.text) {
elm.textContent = '';
}
}
else if (oldVnode.text !== vnode.text) {
elm.textContent = vnode.text;
}
}
我們現在就來分析這個函數
沒錯,正如我們所想,這個函數的確會去比較處理子節點
總的來說,這個函數的作用是
1、Vnode 是文本節點,則更新文本(文本節點不存在子節點)
2、Vnode 有子節點,則處理比較更新子節點
更進一步的總結就是,這個函數主要做了兩種判斷的處理
1、Vnode 是否是文本節點
2、Vnode 是否有子節點
下面我們來看看這些步驟的詳細分析
1 Vnode是文本節點
當 VNode 存在 text 這個屬性的時候,就證明了 Vnode 是文本節點
我們可以先來看看 文本類型的 Vnode 是什麼樣子
所以當 Vnode 是文本節點的時候,需要做的就是,更新文本
同樣有兩種處理
1、當 新Vnode.text 存在,而且和 舊 VNode.text 不一樣時
直接更新這個 DOM 的 文本內容
elm.textContent = vnode.text;
注:textContent 是 真實DOM 的一個屬性, 保存的是 dom 的文本,所以直接更新這個屬性
2、新Vnode 的 text 爲空,直接把 文本DOM 賦值給空
elm.textContent = '';
2 Vnode存在子節點
當 Vnode 存在子節點的時候,因爲不知道 新舊節點的子節點是否一樣,所以需要比較,才能完成更新
這裏有三種處理
1、新舊節點 都有子節點,而且不一樣
2、只有新節點
3、只有舊節點
後面兩個節點,相信大家都能想通,但是我們還是說一下
1 只有新節點
只有新節點,不存在舊節點,那麼沒得比較了,所有節點都是全新的
所以直接全部新建就好了,新建是指創建出所有新DOM,並且添加進父節點的
2 只有舊節點
只有舊節點而沒有新節點,說明更新後的頁面,舊節點全部都不見了
那麼要做的,就是把所有的舊節點刪除
也就是直接把DOM 刪除
3 新舊節點 都有子節點,而且不一樣
咦惹,又出現了一個新函數,那就是 updateChildren
預告一下,這個函數非常的重要,是 Diff 的核心模塊,蘊含着 Diff 的思想
可能會有點繞,但是不用怕,相信在我的探索之下,可以稍微明白些
同樣的,我們先來思考下 updateChildren 的作用
記得條件,當新節點 和 舊節點 都存在,要怎麼去比較才能知道有什麼不一樣呢?
哦沒錯,使用遍歷,新子節點和舊子節點一個個比較
如果一樣,就不更新,如果不一樣,就更新
下面就來驗證下我們的想法,來探索一下 updateChildren 的源碼
updateChildren
這個函數非常的長,但是其實不難,就是分了幾種處理流程而已,但是一開始看可能有點懵
或者可以先跳過源碼,看下分析,或者便看分析邊看源碼
function updateChildren(parentElm, oldCh, newCh) {
var oldStartIdx = 0;
var oldEndIdx = oldCh.length - 1;
var oldStartVnode = oldCh[0];
var oldEndVnode = oldCh[oldEndIdx];
var newStartIdx = 0;
var newEndIdx = newCh.length - 1;
var newStartVnode = newCh[0];
var newEndVnode = newCh[newEndIdx];
var oldKeyToIdx, idxInOld, vnodeToMove, refElm;
// 不斷地更新 OldIndex 和 OldVnode ,newIndex 和 newVnode
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);
// oldStartVnode 放到 oldEndVnode 後面,還要找到 oldEndValue 後面的節點
parentElm.insertBefore(
oldStartVnode.elm,
oldEndVnode.elm.nextSibling
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
}
// 舊尾 和新頭 比較
else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode);
// oldEndVnode 放到 oldStartVnode 前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
}
// 單個新子節點 在 舊子節點數組中 查找位置
else {
// oldKeyToIdx 是一個 把 Vnode 的 key 和 index 轉換的 map
if (!oldKeyToIdx) {
oldKeyToIdx = createKeyToOldIdx(
oldCh, oldStartIdx, oldEndIdx
);
}
// 使用 newStartVnode 去 OldMap 中尋找 相同節點,默認key存在
idxInOld = oldKeyToIdx[newStartVnode.key]
// 新孩子中,存在一個新節點,老節點中沒有,需要新建
if (!idxInOld) {
// 把 newStartVnode 插入 oldStartVnode 的前面
createElm(
newStartVnode,
parentElm,
oldStartVnode.elm
);
}
else {
// 找到 oldCh 中 和 newStartVnode 一樣的節點
vnodeToMove = oldCh[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode);
// 刪除這個 index
oldCh[idxInOld] = undefined;
// 把 vnodeToMove 移動到 oldStartVnode 前面
parentElm.insertBefore(
vnodeToMove.elm,
oldStartVnode.elm
);
}
// 只能創建一個新節點插入到 parentElm 的子節點中
else {
// same key but different element. treat as new element
createElm(
newStartVnode,
parentElm,
oldStartVnode.elm
);
}
}
// 這個新子節點更新完畢,更新 newStartIdx,開始比較下一個
newStartVnode = newCh[++newStartIdx];
}
}
// 處理剩下的節點
if (oldStartIdx > oldEndIdx) {
var newEnd = newCh[newEndIdx + 1]
refElm = newEnd ? newEnd.elm :null;
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
createElm(
newCh[newStartIdx], parentElm, refElm
);
}
}
// 說明新節點比對完了,老節點可能還有,需要刪除剩餘的老節點
else if (newStartIdx > newEndIdx) {
for (; oldStartIdx<=oldEndIdx; ++oldStartIdx) {
oldCh[oldStartIdx].parentNode.removeChild(el);
}
}
}
首先要明確這個函數處理的是什麼
處理的是 新子節點 和 舊子節點,循環遍歷逐個比較
如何 循環遍歷?
1、使用 while
2、新舊節點數組都配置首尾兩個索引
新節點的兩個索引:newStartIdx , newEndIdx
舊節點的兩個索引:oldStartIdx,oldEndIdx
以兩邊向中間包圍的形式 來進行遍歷
頭部的子節點比較完畢,startIdx 就加1
尾部的子節點比較完畢,endIdex 就減1
只要其中一個數組遍歷完(startIdx<endIdx),則結束遍歷
源碼處理的流程分爲兩個
1、比較新舊子節點
2、比較完畢,處理剩下的節點
我們來逐個說明這兩個流程
1 比較新舊子節點
注:這裏有兩個數組,一個是 新子Vnode數組,一箇舊子Vnode數組
在比較過程中,不會對兩個數組進行改變(比如不會插入,不會刪除其子項)
而所有比較過程中都是直接 插入刪除 真實頁面DOM
我們明確一點,比較的目的是什麼?
找到 新舊子節點中 的 相同的子節點,儘量以 移動 替代 新建 去更新DOM
只有在實在不同的情況下,纔會新建
比較更新計劃步驟
首先考慮,不移動DOM
其次考慮,移動DOM
最後考慮,新建 / 刪除 DOM
能不移動,儘量不移動。不行就移動,實在不行就新建
下面開始說源碼中的比較邏輯
五種比較邏輯如下
1、舊頭 == 新頭
2、舊尾 == 新尾
3、舊頭 == 新尾
4、舊尾 == 新頭
5、單個查找
來分析下這五種比較邏輯
1 舊頭 == 新頭
sameVnode(oldStartVnode, newStartVnode)
當兩個新舊的兩個頭一樣的時候,並不用做什麼處理
符合我們的步驟第一條,不移動DOM完成更新
但是看到一句,patchVnode
就是爲了繼續處理這兩個相同節點的子節點,或者更新文本
因爲我們不考慮多層DOM 結構,所以 新舊兩個頭一樣的話,這裏就算結束了
可以直接進行下一輪循環
newStartIdx ++ , oldStartIdx ++
2 舊尾 == 新尾
sameVnode(oldEndVnode, newEndVnode)
和 頭頭 相同的處理是一樣的
尾尾相同,直接跳入下個循環
newEndIdx ++ , oldEndIdx ++
3 舊頭 == 新尾
sameVnode(oldStartVnode, newEndVnode)
這步不符合 不移動DOM,所以只能 移動DOM 了
怎麼移動?
源碼是這樣的
parentElm.insertBefore(
oldStartVnode.elm,
oldEndVnode.elm.nextSibling
);
以 新子節點的位置 來移動的,舊頭 在新子節點的 末尾
所以把 oldStartVnode 的 dom 放到 oldEndVnode 的後面
但是因爲沒有把dom 放到誰後面的方法,所以只能使用 insertBefore
即放在 oldEndVnode 後一個節點的前面
圖示是這樣的
然後更新兩個索引
oldStartIdx++,newEndIdx--
4 舊尾 == 新頭
sameVnode(oldEndVnode, newStartVnode)
同樣不符合 不移動DOM,也只能 移動DOM 了
怎麼移動?
parentElm.insertBefore(
oldEndVnode.elm,
oldStartVnode.elm
);
把 oldEndVnode DOM 直接放到 當前 oldStartVnode.elm 的前面
圖示是這樣的
然後更新兩個索引
oldEndIdx--,newStartIdx++
5 單個遍歷查找
當前面四種比較邏輯都不行的時候,這是最後一種處理方法
拿 新子節點的子項,直接去 舊子節點數組中遍歷,找一樣的節點出來
流程大概是
1、生成舊子節點數組以 vnode.key 爲key 的 map 表
2、拿到新子節點數組中 一個子項,判斷它的key是否在上面的map 中
3、不存在,則新建DOM
4、存在,繼續判斷是否 sameVnode
下面就詳細說一下
1 生成map 表
這個map 表的作用,就主要是判斷存在什麼舊子節點
比如你的舊子節點數組是
[{
tag:"div", key:1
},{
tag:"strong", key:2
},{
tag:"span", key:4
}]
經過 createKeyToOldIdx 生成一個 map 表 oldKeyToIdx
{ vnodeKey: 數組Index }
屬性名是 vnode.key,屬性值是 該 vnode 在children 的位置
是這樣(具體源碼看上篇文章 Diff - 源碼版 之 相關輔助函數)
oldKeyToIdx = {
1:0,
2:1,
4:2
}
2 判斷 新子節點是否存在舊子節點數組中
拿到新子節點中的 子項Vnode,然後拿到它的 key
去匹配map 表,判斷是否有相同節點
oldKeyToIdx[newStartVnode.key]
3 不存在舊子節點數組中
直接創建DOM,並插入oldStartVnode 前面
createElm(newStartVnode, parentElm, oldStartVnode.elm);
4 存在舊子節點數組中
找到這個舊子節點,然後判斷和新子節點是否 sameVnode
如果相同,直接移動到 oldStartVnode 前面
如果不同,直接創建插入 oldStartVnode 前面
我們上面說了比較子節點的處理的流程分爲兩個
1、比較新舊子節點
2、比較完畢,處理剩下的節點
比較新舊子節點上面已經說完了,下面就到了另一個流程,比較剩餘的節點,詳情看下面
處理可能剩下的節點
在updateChildren 中,比較完新舊兩個數組之後,可能某個數組會剩下部分節點沒有被處理過,所以這裏需要統一處理
1 新子節點遍歷完了
newStartIdx > newEndIdx
新子節點遍歷完畢,舊子節點可能還有剩
所以我們要對可能剩下的舊節點進行 批量刪除!
就是遍歷剩下的節點,逐個刪除DOM
for (; oldStartIdx <= oldEndIdx; ++oldStartIdx) {
oldCh[oldStartIdx]
.parentNode
.removeChild(el);
}
2舊子節點遍歷完了
oldStartIdx > oldEndIdx
舊子節點遍歷完畢,新子節點可能有剩
所以要對剩餘的新子節點處理
很明顯,剩餘的新子節點不存在 舊子節點中,所以全部新建
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
createElm(
newCh[newStartIdx],
parentElm,
refElm
);
}
但是新建有一個問題,就是插在哪裏?
所以其中的 refElm 就成了疑點,看下源碼
var newEnd = newCh[newEndIdx + 1]
refElm = newEnd ? newEnd.elm :null;
refElm 獲取的是 newEndIdx 後一位的節點
當前沒有處理的節點是 newEndIdx
也就是說 newEndIdx+1 的節點如果存在的話,肯定被處理過了
如果 newEndIdx 沒有移動過,一直是最後一位,那麼就不存在 newCh[newEndIdx + 1]
那麼 refElm 就是空,那麼剩餘的新節點 就全部添加進 父節點孩子的末尾,相當於
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
parentElm.appendChild(
newCh[newStartIdx]
);
}
如果 newEndIdx 移動過,那麼就逐個添加在 refElm 的前面,相當於
for (; newStartIdx <= newEndIdx; ++newStartIdx) {
parentElm.insertBefore(
newCh[newStartIdx] ,
refElm
);
}
如圖
思考爲什麼這麼比較
我們已經講完了所有 Diff 的內容,大家也應該能領悟到 Diff 的思想
但是我強迫自己去思考一個問題,就是
爲什麼會這樣去比較?
以下純屬個人意淫想法,沒有權威認證,僅供參考
我們所有的比較,都是爲了找到 新子節點 和 舊子節點 一樣的子節點
而且我們的比較處理的宗旨是
1、能不移動,儘量不移動
2、沒得辦法,只好移動
3、實在不行,新建或刪除
首先,一開始比較,肯定是按照我們的第一宗旨 不移動 ,找到可以不移動的節點
而 頭頭,尾尾比較 符合我們的第一宗旨,所以出現在最開始,嗯,這個可以想通
然後就到我們的第二宗旨 移動,按照 updateChildren 的做法有
舊頭新尾比較,舊尾新頭比較,單個查找比較
我開始疑惑了,咦?頭尾比較爲了移動我知道,但是爲什麼要出現這種比較?
明明我可以用 單個查找 的方式,完成所有的移動操作啊?
我思考了很久,頭和尾的關係,覺得可能是爲了避免極端情況的消耗??
怎麼說?
比如當我們去掉頭尾比較,全部使用單個查找的方式
如果出現頭 和 尾 節點一樣的時候,一個節點需要遍歷 從頭找到尾 才能找到相同節點
這樣實在是太消耗了,所以這裏加入了 頭尾比較 就是爲了排除 極端情況造成的消耗操作
當然,這只是我個人的想法,僅供參考,雖然這麼說,我也的確做了個例子測試
子節點中加入了出現兩個頭尾比較情況的子項 b div
oldCh = ['header','span','div','b']
newCh = ['sub','b','div','strong']
使用 Vue 去更新,比較更新速度,然後更新十次,計算平均值
1、全用 單個查找,用時 0.91ms
2、加入頭尾比較,用時 0.853ms
的確是快一些喔
走流程
我相信經過這麼長的一篇文章,大家的腦海中還沒有把所有的知識點集合起來,可能對整個流程還有點模糊
沒事,我們現在就來舉一個例子,一步步走流程,完成更新
以下的節點,綠色表示未處理,灰色表示已經處理,淡綠色表示正在處理,紅色表示新插入,如下
現在Vue 需要更新,存在下面兩組新舊子節點,需要進行比較,來判斷需要更新哪些節點
1頭頭比較,節點一樣,不需移動,只用更新索引
更新索引,newStartIdx++ , oldStartIdx++
開始下輪處理
一系列判斷之後,【舊頭 2】 和 【 新尾 2】相同,直接移動到 oldEndVnode 後面
更新索引,newEndIdx-- ,oldStartIdx ++
開始下輪處理
3一系列判斷之後,【舊頭 2】 和 【 新尾 2】相同,直接移動到 oldStartVnode 前面
更新索引,oldEndIdx-- ,newStartIdx++
開始下輪比較
4只剩一個節點,走到最後一個判斷,單個查找
找不到一樣的,直接創建插入到 oldStartVnode 前面
更新索引,newStartIdx++
此時 newStartIdx> newEndIdx ,結束循環
5 批量刪除可能剩下的老節點
此時看 舊 Vnode 數組中, oldStartIdx 和 oldEndIdx 都指向同一個節點,所以只用刪除 oldVnode-4 這個節點
ok,完成所有比較流程
耶,Diff 內容講完了,謝謝大家的觀看
最後
鑑於本人能力有限,難免會有疏漏錯誤的地方,請大家多多包涵,如果有任何描述不當的地方,歡迎後臺聯繫本人,有重謝