看透 Vue3 重頭戲之 diff 算法
點擊上方?前端Q,關注公眾號
回復加群,加入前端Q技術交流群
終于迎來了DOM diff流程的重頭戲:diff算法,前面的流程只能算是附加項,重要的是各種節(jié)點是如何進行對比,然后進行更新。下面就對每一種節(jié)點的對比流程進行分析。

在vue3.2 初始化的時候做了什么?[1]文章的的末尾,提到了傳入effect的回調函數(shù)和響應式數(shù)據(jù)之前產(chǎn)生一個依賴關系,等同于產(chǎn)生了一個watcher。當數(shù)據(jù)發(fā)生變化的時候,會以參數(shù)二的方法執(zhí)行參數(shù)一,具體細節(jié)和調度器有關,以后再說,最終會進入componentUpdateFn函數(shù)中,我們就直接進入到更新階段的componentUpdateFn。
patch之前的處理

在開始執(zhí)行patch函數(shù)之前,會先執(zhí)行一些生命周期鉤子函數(shù),有beforeUpdate和VNode的hook:beforeUpdate。


最主要的一點,如果是父組件數(shù)據(jù)變化而導致的子組件更新,會多執(zhí)行一個東西,里面會進行更新props和slots以及換成新的VNode,做完這些之后可能會導致更新,需要在patch之前把它們執(zhí)行。(PS:更新props和slots流程可以看看我前面的文章《Vue3.2 vDOM diff 流程之一:插槽的初始化和更新》[2]和《Vue3.2 vDOM diff流程分析之一:props和attrs的初始化和更新》[3])

做完這一些,就可以產(chǎn)生新的VNode,將新舊VNode傳入patch開始進行對比,Suspense和Teleport的diff已經(jīng)在前面的文章中說明,這里就不在提及。
對比元素類型節(jié)點


對比元素進入processElement,這次是進入更新流程,執(zhí)行patchElement。n1是舊VNode,n2是新VNode

函數(shù)開頭,需要重新通過舊節(jié)點的patchFlag重新確認新節(jié)點patchFlag,因為用戶可以克隆由complie產(chǎn)生的VNode,或許可能添加一些新的props,比如cloneVNode(vnode, {class: 'cloneVNode'}),它將選擇FULL_PROPS。

緊接著執(zhí)行新的VNode自定義指令的beforeUpdate生命周期函數(shù),如果在dev模式下且HMR正在更新,則放棄優(yōu)化且把dynamicChildren清空,使用全量diff。這會影響后面diff,但是prod模式下一般都是優(yōu)化模式,使用areChildrenSVG是判斷新VNode是不是SVG。

這里分為優(yōu)化模式和非優(yōu)化模式,這里進入優(yōu)化模式的條件是dynamicChildren不為空,非優(yōu)化模式是optimized為true,但是這兩個是互斥的,一個存在另一個肯定不存在。
優(yōu)化模式下進行diff

進入到這個函數(shù),他會遍歷新VNode中dynamicChildren,并從舊的VNode的dynamicChildren取出按索引順序一致的節(jié)點進行對比。
在這之前,先要找到parent node,也就這一大坨的三元運算符,不要慌張,逐個逐個條件分析,oldVNode.el是為了在異步組件的情況下確保元素節(jié)點的真實DOM要存在。
在oldVNode.el存在的情況下,并且符合以下三個條件中的其中一個:1. oldVNode的節(jié)點類型是Fragment、2.oldVNode和newVNode不是同一種元素(key值不一樣也算)、3.oldVNode是組件,就組件而言,它可以包含任何東西。container就是oldVNode.el的parent。不然在其他的情況下,實際上沒有父容器,因此傳遞一個block元素,避免parentNode,就是傳遞fallbackContainer(是n2的真實DOM),
確認好container就和oldVNode與newVNode再次傳遞給patch,接下來就要根據(jù)newVNode的節(jié)點類型從而確定走哪個分支進行diff。

diff流程結束之后還需要做一件事,在dev模式下,如果parentComponent存在并且parentComponent啟用 HMR,需要遞歸尋找或者是定位舊的el 以便在更新節(jié)點進行引用 防止更新階段會拋出el is null。優(yōu)化模式分析完畢。
非優(yōu)化模式下進行全量diff

非優(yōu)化模式下交給patchChildren處理,在diff之前先要拿到一些東西:n1、n2的Children和n2的shapeFlag。接下來的流程分為很多種情況,一一分析。
快速diff
首先根據(jù)n2的patchFlag判斷能不能快速更新,也就是“靶向更新”,進入之后又分為兩種情況,是否鍵控(是否綁定了key),鍵控可以是完全鍵控也可以是混合鍵控(一部分帶key,一部分不帶key),分別交給patchKeyedChildren和patchUnKeyedChildren處理。
不帶有key的對比

由于帶有key的對比有點復雜,我放的后面說,這里先看沒有帶key。沒有帶key的對比簡單粗暴,因為不確保n1和n2都有children列表,沒有就默認給一個空數(shù)組。需要注意這里獲取長度,從新舊children列表兩個列表長度中取出長度的最小的作為基準,接下來的對比最多只會對比到這個位置。具體用圖解釋。

如圖所示,舊children列表長度是5,新children列表長度是3,取小的也就是3,代表在循環(huán)一對一對比中只會對比前三個,剩下會交給下面的流程。

剩下流程分為兩種情況,在循環(huán)對比后,如果是新children列表比舊children列表長度長說明有新節(jié)點,就會去掛載新節(jié)點,反之說明有不需要的舊節(jié)點,就會去卸載。流程結束。
帶key的對比
回到patchChildren中,我們看帶有key是如何對比,將會結合圖一步步分析。

這里先拿到一些東西,l2是新children列表的長度,e1是舊children列表中最后一位的索引,e2是新children列表最后一位的索引。i這里有特殊意義,代表對比的開始索引。帶有key的對比主要有五個流程,
假如有如下新舊children列表,可以準確看出只有2移動了位置,下面就看經(jīng)過五個流程是如何進行對比的。

1.流程一:對比開始位置

在這一階段會遍歷新舊children列表,只有新舊節(jié)點是用一種元素才會交給patch函數(shù)對比,每過一對新舊子節(jié)點,i就會加一,如果有一方遍歷到最后一個就會結束或者是遍歷到兩個是不同元素。例子中,前面沒有相同的節(jié)點,所以不會有任何操作

2.流程二:對比末尾位置

在這一階段一樣會遍歷新舊children列表,和階段一一樣,新舊節(jié)點是同一種元素才會交給patch函數(shù)對比,不同的是從末尾開始對比子節(jié)點,每過一對子節(jié)點,新舊最大位置索引同時會減一。例子中,從末尾的3、4、5是相同元素可以排除。

走完前面的兩個,說明新舊children列表中首尾的相同節(jié)點已經(jīng)被處理了,就剩下中間的部分,接下來的三個流程是掛載列表中的新節(jié)點和卸載不需要的舊節(jié)點以及無序對比。
但這三個流程中只會執(zhí)行其中一個或者都不執(zhí)行,總共有三種情況:1. 只需要安裝新節(jié)點、 2. 只需要卸載舊節(jié)點、 3. 無序。這和前面的講到的全量diff和像,這就要看i了,如果i大于e1并且小于或者等于e2說明有新節(jié)點,執(zhí)行流程三,如果i是大于e2說明有不需要的舊節(jié)點,執(zhí)行流程四。都不符合執(zhí)行流程五
3.流程三:掛載新節(jié)點(此流程不一定執(zhí)行)

nextPos是用來確定新增節(jié)點的位置,一般到了這一階段e2是沒有處理的新節(jié)點列表的最大索引,要加一是因為vue新增節(jié)點的方式了,vue新增元素是通過insert,實現(xiàn)原理是insertBefore,所以這里會拿到將要插入元素的位置的后一個。具體看下面的示意圖。(ps:紅色框內是被處理過的)

在這個案例中,6是新增的節(jié)點,因為經(jīng)過了流程一和二的處理,i變成了5,e2變成5,e2正好是節(jié)點6的索引,如果我們需要把它插入列表中,我們需要知道他的后一個節(jié)點是誰,以便做為瞄點,這就要加一后去新children列表中找。

但是還有第二種情況,如果新增的節(jié)點是新children列表中的最后一個,那么加一就會超出其長度,那么就會把parentAnchor作為瞄點,parentAnchor是當前列表的父容器中的最后一個節(jié)點,一般都是空字符串,(注意:這里是節(jié)點,不是元素節(jié)點)。例子中不符合,不會執(zhí)行該流程
4.流程四:卸載不需要的舊節(jié)點(此流程不一定執(zhí)行)

卸載舊節(jié)點的操作就比較簡單了,每卸載一個i就加一,通過unmount方法進行卸載,實現(xiàn)原理是通過找到要卸載的節(jié)點的父節(jié)點,調用removeChildren進行卸載。前提是i大于e2但小于等于e1。例子中不符合,不會執(zhí)行該流程
5.流程五:無序對比(此流程不一定執(zhí)行)
如果到了流程五,說明children列表中有一部分是無序的,前面的流程無法處理,需要進行無序對比。這流程五分為三部分。
這第一部分是為了產(chǎn)生index和新children列表中的key的映射圖,它會拿i作為新舊children列表的開始索引,當找到newChildren,準確來說是找到newChild身上的key,就會連同i一起保存進keyToNewIndexMap中。

這第二部分是循環(huán)舊節(jié)點列表 以匹配需要更新的節(jié)點和刪除不需要的節(jié)點,先提前創(chuàng)建一個數(shù)組(newIndexToOldIndexMap),長度是還需要進行對比(toBePatched)的數(shù)量,作為新舊索引對應的存放(默認全部都是0)
開始循環(huán)舊children列表,當patched大于toBePatched時就都是卸載節(jié)點,但是一開始patched是0并不會大于,繼續(xù)往下走,開始找newIndex,先從在前面保存的key:index的映射圖中找,沒找到就嘗試在舊children列表中定位同一種類型沒有key的節(jié)點的索引。還是沒有就只能undefined。
最后,如果newIndex是undefined,說明舊節(jié)點沒有對應的新節(jié)點直接卸載,不然,會修改newIndexToOldIndexMap中對應索引位置,如果newIndex小于新節(jié)點最大位置(maxNewIndexSoFar),說明這個節(jié)點移動了,不然maxNewIndexSoFar就賦值成newIndex。過了這么多,終于可以傳遞給patch進行對比,patched也會加一。
這最后一部分,主要是為了移動節(jié)點和新增節(jié)點,如果有需要移動節(jié)點它會先根據(jù)新舊節(jié)點索引的映射產(chǎn)生一個最長遞增子序列。而從最后開始循環(huán)也便于我們可以使用最后一個修補的節(jié)點作為瞄點,找出新節(jié)點中的最長遞增子序列,移動不在這個范圍內的節(jié)點,如果映射的oldIndex是0說明是新增節(jié)點,需要進行掛載。在例子中,就會移動1。

這流程五是最復雜的,其中不僅包含了掛載和卸載,還包含了移動節(jié)點,提高了對節(jié)點利用,到此patchKeyedChildren流程結束。
其他情況
回到patchChildren中,繼續(xù)看patchFlag不存在如何進行對比,這要根據(jù)新舊節(jié)點的情況進行更新

看起來復雜其實很簡單,先說如果新節(jié)點是TEXT_CHILDREN,如果舊節(jié)點是ARRAY_CHILDREN,會先卸載所有舊節(jié)點,再掛載新節(jié)點,舊節(jié)點也是TEXT_CHILDREN需要和新節(jié)點對比確認不同后再更新。
如果兩個都是ARRAY_CHILDREN,需要走patchKeyedChldren,但也有可能只是卸掉舊的并沒有新節(jié)點,卸載所有舊節(jié)點。
當舊節(jié)點是TEXT_CHILDREN新節(jié)點是ARRAY_CHILDREN時,會先將其變?yōu)榭兆址龠M行掛載新節(jié)點。
后面對比props的部分,在我之前的文章Vue3.2 vDOM diff流程分析之一:props和attrs的初始化和更新[4]中講過,感興趣可以去看看,到這里對比元素的流程結束。
對比組件類型節(jié)點

在patch函數(shù)中,對比組件分支執(zhí)行的是processComponent,最終會執(zhí)行updateComponent,組件更新新的會繼承舊的實例。
更新前他會執(zhí)行shouldUpdateComponent判斷是否需要更新。但是屬實是情況太多,這里就不一一列舉了,具體可以到源碼中查看`shouldUpdateComponent`[5]函數(shù)。

進入需要更新的流程,他是會優(yōu)先處理Suspense(存在asyncDep且asyncResolved不存在),不是Suspense就正常更新,把新VNode(instance.next)賦值成n2,如果當前組件已經(jīng)在更新隊列中,請將它移除,避免重復更新同一組件,然后就可以調用實例上的更新器進行更新了。

注意這里的instance.next,如果這個存在,在調用componentUpdateFn中會調用updateComponentPreRender函數(shù),這是因為組件數(shù)據(jù)變化導致其子組件更新,所以需要去更新實例中的VNode以及props和slots,順帶把更新props導致的更新執(zhí)行了。如果只是單純的數(shù)據(jù)變化,沒有影響到子組件,那next就會是原本實例上的VNode。
后面的就是正常調用生命周期函數(shù)和鉤子函數(shù),產(chǎn)生新的VNode和舊的VNode一起交給patch進行對比,后面的就要看組件里面是啥東西然后走哪個流程。
對比文本類型、注釋類型、靜態(tài)節(jié)點類型節(jié)點
文本類型

文本類型節(jié)點的更新在processText中,會先進行對比,不同才會更新文本
注釋類型

注釋節(jié)點的更新在processCommentNode中,但是因為不支持動態(tài)更新注釋,所以是直接拿以前的。
靜態(tài)節(jié)點類型

靜態(tài)節(jié)點的更新執(zhí)行的是patchStaticNode,因為vue會把靜態(tài)節(jié)點進行序列化成字符串所以可以直接進行字符串對比,相同只會賦值以前的el和anchor,不同會先循環(huán)移除舊的,連帶著anchor一起移除,再掛載新的靜態(tài)節(jié)點。
總結
本篇文章分析了vue中diff算法的處理,清楚vue中diff算法的處理流程,知道每一個節(jié)點對比如何進行,如何書寫模板可以進行最優(yōu)的對比、復用節(jié)點,從而提高性能,在列表對比中,優(yōu)化模式只會對比dynmaicChildren中的節(jié)點,也就是動態(tài)節(jié)點,非優(yōu)化模式下,雖然說是全量diff但是可以復用節(jié)點也不會損耗太多性能。
好了,到了文章的最后,還是希望各位哥哥姐姐能指導指導。有說錯或者遺漏的歡迎在評論區(qū)講解,謝謝。
關于本文
作者:咸魚是如何練成的
https://juejin.cn/post/7072321805792313357

往期推薦



最后
歡迎加我微信,拉你進技術群,長期交流學習...
歡迎關注「前端Q」,認真學前端,做個專業(yè)的技術人...


