這是 聊diff 的第三篇文章,聊聊vue3的diff思路.思路主要來自 vue-design 項目
【第一篇】和麪試官聊聊Diff___React
【第二篇】和麪試官聊聊Diff___vue2
【第三篇】和麪試官聊聊Diff___Vue3(本文)html
爲了更好的閱讀體驗,建議從第一篇看起前端
我是一名前端的小學生。行文中對某些設計原理理解有誤十分歡迎你們討論指正😁😁😁,謝謝啦!固然有好的建議也謝謝提出來
(玩笑)vue
Let's startnode
本文注重的是patch過程,具體的細節和邊界就沒有考慮。react
==另 外 注 意==git
- 三篇文章 diff 的講解,爲了方便展現 節點複用, 用了
children
保存內容,實際上這是不合理的,由於children不一樣還會遞歸補丁(patch)- diff也不是vue optimize的所有,只是其中一部分,例如compile時肯定節點類型,不一樣類型 不一樣的
mount/patch
處理方式等等。
Vue2.x的 diff 相對於 react 更好一些,避免了一些沒必要要的比對。
我先假設有以下節點, key
是 Vnode
的 key
, children
表明該節點的內容github
// 之前的節點 const preNodes = [ {key: "k-1", children: "<span>old1</span>"}, {key: "k-2", children: "<span>old2</span>"}, {key: "k-3", children: "<span>old3</span>"}, {key: "k-4", children: "<span>old4</span>"}, {key: "k-5", children: "<span>old5</span>"}, {key: "k-6", children: "<span>old6</span>"}, ] // 新節點,最後更新的結果 const nextNodes = [ {key: "k-11", children: "<span>11</span>"}, {key: "k-0", children: "<span>0</span>"}, {key: "k-5", children: "<span>5</span>"}, {key: "k-13", children: "<span>13</span>"}, {key: "k-1", children: "<span>1</span>"}, {key: "k-7", children: "<span>7</span>"}, {key: "k-16", children: "<span>16</span>"}, {key: "k-3", children: "<span>3</span>"}, {key: "k-15", children: "<span>15</span>"}, {key: "k-17", children: "<span>7</span>"}, {key: "k-4", children: "<span>4</span>"}, {key: "k-6", children: "<span>6</span>"} ]
diff 是基於新舊的 diff, 先要明確這個大前提,若是剛剛開始沒有節點,則會先 mount 而不會 patch。
最後指望的結果(老節點都獲得了複用)
面試
另外新產生節點(newNodes)是基於老節點的,因而算法
// 最終的節點數據,由於最終的節點是基於老節點的,這裏作個模擬 let newNodes = JSON.parse(JSON.stringify(preNodes));
preNodes
: 老節點;nextNodes
: 新節點;newNodes
: 新產生節點,最後用於渲染爲真實dom.其實vue2早期就是先徹底產生新節點,最後再渲染爲真實dom.後面版本變爲一次(patch)優化遍歷時就更新相應的Dom.segmentfault中心思想提煉: 從老節點中找到與新節點中key相同的節點,進行復用。
詳細思路:
1. 先找兩端相同的節點(key相同),找到即再往中間找。
如圖,J
從開頭找, preEndIndex
和 nextEndIndex
分別對應老節點和新節點的末尾索引。找到就增長 J 或者減小 preEndIndex
和 nextEndIndex
。
代碼以下
let j = 0; let preEndIndex = preNodes.length - 1; let nextEndIndex = nextNodes.length - 1; let preVNode = preNodes[j]; let nextVNode = nextNodes[j]; while(preVNode.key === nextVNode.key){ j++; preVNode = preNodes[j]; nextVNode = nextNodes[j]; } preVNode = preNodes[preEndIndex]; nextVNode = nextNodes[nextEndIndex]; while(preVNode.key === nextVNode.key){ preVNode = preNodes[--preEndIndex]; nextVNode = nextNodes[--nextEndIndex]; }
考慮到一種狀況
上面的狀況會出現老節點比對完了,新節點還存在,那麼最後會形成 J > preEndIndex
[狀況1],同理,老節點未比對完,新節點已經比對完,那麼會出現 J > nextEndIdnex
[狀況2].
針對這兩種狀況,
因而把原來代碼改一下:
// .... outer: { while(preVNode.key === nextVNode.key){ j++; if(j> preEndIndex || j > newEndIndex) { break outer; } preVNode = preNodes[j]; nextVNode = nextNodes[j]; } preVNode = preNodes[preEndIndex]; nextVNode = nextNodes[nextEndIndex]; while(preVNode.key === nextVNode.key){ if(j> preEndIndex || j > nextEndIndex) { break outer; } preVNode = preNodes[--preEndIndex]; nextVNode = nextNodes[--nextEndIndex]; } } if(j > preEndIndex) { // 老節點遍歷完了,新節點還存在,將新節點放入。 for(let i = j; i< nextEndIndex; i++){ const addedNode = nextNodes[i]; // 注意: 框架內部是利用appendchild 更新dom. newNodes.splice(i,0,addedNode); } } else if(j > nextEndIndex){ // 新節點遍歷完了,老節點還存在,將老節點刪除。 const deleteLen = preEndIndex - j; // 注意: 框架內部是重寫removeChild 更新dom. newNodes.splice(i,deleteLen); }else { //均還有不一樣節點時 }
大多數狀況就像剛開始的例子同樣: 中間都還存在不一樣的節點,須要移動和新增。這個狀況是patch
主要處理的地方,代碼寫在上面的 else
裏面。
具體思路是怎樣呢,
2. 產生一個老節點可複用節點的映射數組
先生成一個 每項爲 -1
的數組noPatchedIndex
,長度爲 新節點未遍歷的節點長度。
遍歷未比對的 老節點和新節點(這裏有個優化細節: 新節點不用遍歷,由於結構的特殊性,直接生成key
對應 index
的對象 keyInIndex
例如{k-1: 0, k-2: 1, ...}
,後面直接取)。
未比對老節點中若是存在未比對新節點相同的節點那麼在 noPatchedIndex
相應位置保存起來它在老節點中的索引。
另外判斷時,新節點中不存在還需刪除老節點,而且得出是否須要移動元素(索引數組 noPatchedIndex
中存在非遞增排序,即數組中當前項不能大於以後的項)
大概這個意思
新生成節點。k-2在新節點(nextNodes
)中不存在,因此被刪除。
3. 處理須要移動的狀況(複用節點處理)
大概有以下步驟:
noPatchedIndex
最大遞增子序列 lisArr
索引數組針對①,這裏有一個函數 lis
,
lis([3,1,5,4,2]) //[1, 4] | 1,2 爲最大遞增子序列 lis([1,2,3]) //[0, 1, 2] | 1,2,3 爲最大遞增子序列 lis([0,-1,8,6,10,7]) //[1, 3, 5] | -1,6,7 爲最大遞增子序列
lis
具體實現請參考個人另外一篇文章 [算法篇---尋找最大遞增子序列]()
因而有
針對②,爲何要找最大遞增子序列 lisArr
呢,由於對於 lisArr
裏面的項順序是不用動的,新節點的未比對節點只須要在這些項先後插入便可。
具體實現就是遍歷noPatchedIndex
與 lisArr
:
noPatchedIndex
項等於 -1
,表示,老節點中不存在的項,須要新增noPatchedIndex
索引 與 lisArr
不相等時,須要移動老節點到響應的位置noPatchedIndex
索引 與 lisArr
相等時,不作操做。須要注意:遍歷都是從後向前遍歷,目的是防止數組長度變換影響索引值進而影響節點取值,插入,刪除。
爲了直觀的理解,下面來一波操做圖:i
與 j
爲 noPatchedIndex
與 lisArr
刪除的爲紅色,新增的爲綠色,複用的節點爲灰色;
節點複用插入時調用的是DOM API [insertBefore]()這個是先回添加該節點若是存在重複是會刪除原有節點的;
插入位置 爲 節點nextNodes[nopatchedIndex[lisArr[j]]]
對應在oldNodes
的位置
1)i =10, j = 2
; 節點 k-4
複用
2)i=9, j=1
3)i=8, j=1
. 新增
4)i=7,j=1.
k-3
複用
5)i=6, j=0
6)i=5, j=0
7)i=4, j=0
.插入(涉及到增長和刪除),這裏由於插入的是老節點,而原節點(2)在插入位置(0)後面,因此新增以後刪除的索引位置要減一(代碼中會有體現)
8)i=3,j=0
新增
9)i=2, j=0
.插入(涉及到增長和刪除),這裏由於插入的是老節點,而原節點(9)在插入位置(0)後面,因此新增以後刪除的索引位置要減一(代碼中會有體現)
10)i=1,j=0
11)i=0, j=0
,新增
12) i= -1
, 循環結束。
最後結果來看,k-5
, k-1
, k-2
, k-4
, k-6
獲得了複用, 那麼k-2
到哪去了呢,新節點nextNodes不含該節點天然在移動(move)前就刪除了!前面提到了。
至此,diff過程結束了相信其實看圖也能夠看明白
哎,畫圖太累了🤣,如今真心對那麼文章配有圖解說的博主 瑞思拜🙏🙏🙏(respect!!!)。absolute!!!
最後奉上所有代碼。
// 老節點 const preNodes = [ {key: "k-1", children: "<span>old1</span>"}, {key: "k-2", children: "<span>old2</span>"}, {key: "k-3", children: "<span>old3</span>"}, {key: "k-4", children: "<span>old4</span>"}, {key: "k-5", children: "<span>old5</span>"}, {key: "k-6", children: "<span>old6</span>"}, ] // 新節點 const nextNodes = [ {key: "k-11", children: "<span>11</span>"}, {key: "k-0", children: "<span>0</span>"}, {key: "k-5", children: "<span>5</span>"}, {key: "k-13", children: "<span>13</span>"}, {key: "k-1", children: "<span>1</span>"}, {key: "k-7", children: "<span>7</span>"}, {key: "k-6", children: "<span>6</span>"}, {key: "k-3", children: "<span>3</span>"}, {key: "k-15", children: "<span>15</span>"}, {key: "k-17", children: "<span>7</span>"}, {key: "k-4", children: "<span>4</span>"}, {key: "k-6", children: "<span>6</span>"} ] // 最終的節點數據,由於最終的節點是基於老節點的,這裏作個模擬 let newNodes = JSON.parse(JSON.stringify(preNodes)); //兩個都從左邊開始比對的索引 let j = 0; let preEndIndex = preNodes.length - 1; let nextEndIndex = nextNodes.length - 1; let preVNode = preNodes[j]; let nextVNode = nextNodes[j]; outer: { while(preVNode.key === nextVNode.key){ j++; if(j> preEndIndex || j > newEndIndex) { break outer; } preVNode = preNodes[j]; nextVNode = nextNodes[j]; } preVNode = preNodes[preEndIndex]; nextVNode = nextNodes[nextEndIndex]; while(preVNode.key === nextVNode.key){ if(j> preEndIndex || j > nextEndIndex) { break outer; } preVNode = preNodes[--preEndIndex]; nextVNode = nextNodes[--nextEndIndex]; } } if(j > preEndIndex) { // 老節點遍歷完了,新節點還存在,將新節點放入。 for(let i = j; i< nextEndIndex; i++){ const addedNode = nextNodes[i]; // 注意: 框架內部是利用appendchild 更新dom. newNodes.splice(i,0,addedNode); } } else if(j > nextEndIndex){ // 新節點遍歷完了,老節點還存在,將老節點刪除。 const deleteLen = preEndIndex - j; // 注意: 框架內部是重寫removeChild 更新dom. newNodes.splice(i,deleteLen); } else { //保存是否須要移動 let moved = false; const preStart = j; const nextStart = j; let pos = 0; //保存新節點key-index 的map, 避免屢次循環 const keyInIndex = {}; //{ k-1: 1, k-2: 2, k-3:3,... } //新節點未比對的節點長度 const newLength = nextEndIndex - nextStart + 1; for(let i = nextStart; i< newLength; i++) { keyInIndex[nextNodes[i].key] = i } const oldLength = preEndIndex - preStart + 1; //防止老節點比新節點多時,刪除已找到的重複的節點 let patched = 0; // 產生新節點能複用的老節點的索引數組 const noPatchedIndex = Array(newLength).fill(-1); //-1狀態保存 for(let i = preStart; i< oldLength; i++) { const preNode = preNodes[i]; if(patched <= nextEndIndex) { //保存老節點key在新節點中對應的index const k = keyInIndex[preNode.key]; if(typeof k !== 'undefined'){ let idx = k - preStart; noPatchedIndex[idx] = i; patched++; // 篩選出須要往前調換的元素 if(k < pos) moved = true; else pos = k; }else { // 動態查找刪除項索引 const deleteIndex = newNodes.findIndex(node => node.key === preNodes[i].key) newNodes.splice(deleteIndex,1); } }else { newNodes.splice(i,1) } } //處理須要移動的狀況 if(moved){ const newNodesCopy = JSON.parse(JSON.stringify(newNodes)) //最大遞增子序列索引 const lisArr = lis(noPatchedIndex); let j = lisArr.length - 1; //遍歷新節點中未比對的節點,從後面遍歷,防止更新過程index非預期變化。 for(let i=newLength - 1; i>=0; i--){ const current = noPatchedIndex[i]; // 更新的實際位置 const pos = i+nextStart; let insertPos = newNodes.findIndex(node => node.key === nextNodes[nextStart+lisArr[j]].key); if(current === -1){// -1即爲新增的狀況 // 注意 [1,2,3].splice(0,4) => [4,1,2,3] newNodes.splice(insertPos+1, 0, nextNodes[pos]); continue; }else if(lisArr[j] !== i) {//能夠複用非遞增節點的狀況 /* insertBefore 做用: 若是給定的子節點是對文檔中現有節點的引用,insertBefore() 會將其從當前位置移動到新位置 如下的操做就是實現 insertBefore 方法。 */ //移動元素在新節點的位置 let oldPos = newNodes.findIndex(node => node.key === nextNodes[pos].key); //須要刪除插入的節點對應原來的節點 //新節點中插入老節點對應的位置 newNodes.splice(insertPos+1, 0, newNodes[oldPos]); // 判斷插入節點的位置在被插入位置的前面仍是後面,若是是後面就加1 oldPos = insertPos > oldPos ? oldPos : oldPos+1; newNodes.splice(oldPos, 1) }else { j--; } } } } console.log('newNodes: ', newNodes); // 尋找最大遞增子序列 索引 // https://en.wikipedia.org/wiki/Longest_increasing_subsequence /* [3,1,5,4,2] => [1,2] */ function lis(arr) { const p = arr.slice(); const result = [0]; // 索引數組 let i; let j; let u; let v; let c; const len = arr.length; for (i = 0; i < len; i++) { const arrI = arr[i]; if (arrI !== 0) { // 取最後一個元素 j = result[result.length - 1]; if (arr[j] < arrI) { p[i] = j; result.push(i); continue; } u = 0; v = result.length - 1; //result長度大於1時 while (u < v) { // 取中位數 c = ((u + v) / 2) | 0; if (arr[result[c]] < arrI) { u = c + 1; } else { v = c; //result中位數大於等於 當前項。v取中位數 } } if (arrI < arr[result[u]]) { if (u > 0) { p[i] = result[u - 1]; } result[u] = i; } } } u = result.length; v = result[u - 1]; while (u-- > 0) { result[u] = v; v = p[v]; } return result; }
本文中例子只是爲了更好理解diff思路, patch過程與真實狀況還有些差別
insertbefore
、 delete
、add
。這些方法均是單獨封裝不能採用相對應的 Dom Api,由於 vue 不止用在瀏覽器環境。
Vue@3.2
⇲ 已經出來了,React@18
也快了,哎,框架學不完。仍是多看看不變的東西吧(js, 設計模式, 數據結構,算法...)哎哎哎,,同志,看完怎麼不點贊,別看別人就說你呢,你幾個意思?
站在別人肩膀能看的更遠。
【推薦】vue-design
【掘金小冊】剖析Vue.js內部運行機制
【Vue patch源碼地址】vue-next ⇲
另外,大佬們正在翻譯 vue3的 英文文檔 docs-next-zh-cn
以上。