學完這篇文章,你會收穫:
我會儘量將文章寫的通俗易懂。但是,要完全理解文章內容,需要你掌握這些前置知識:
組件render的時機
Context的實現與組件的render息息相關。在講解其實現前,我們先來了解render的時機。
換句話說,組件在什麼時候render?
這個問題的答案,已經在React組件到底什麼時候render啊聊過。在這裡再概括下:
在React中,每當觸發更新(比如調用this.setState、useState),會為組件創建對應的fiber節點。
fiber節點互相連結形成一棵Fiber樹。
有2種方式創建fiber節點:
bailout,即復用前一次更新該組件對應的fiber節點作為本次更新的fiber節點。
render,經過diff算法後生成一個新fiber節點。組件的render(比如ClassComponent的render方法調用、FunctionComponent的執行)就發生在這一步。
經常有同學問:React每次更新都會重新生成一棵Fiber樹,性能不會差麼?
React性能確實不算很棒。但如你所見,Fiber樹生成過程中並不是所有組件都會render,有些滿足優化條件的組件會走bailout邏輯。
比如,對於如下Demo:
在線Demo地址[2]
點擊Parent組件的div子組件,觸發更新,但是child render!並不會列印。
這是因為Son組件會進入bailout邏輯。
bailout的條件
要進入bailout邏輯,需同時滿足4個條件:
1.oldProps === newProps
即本次更新的props全等於上次更新的props。
注意這裡是全等比較。
我們知道組件render會返回JSX,JSX是React.createElement的語法糖。
所以render的返回結果實際上是React.createElement的執行結果,即一個包含props屬性的對象。
即使本次更新與上次更新props中每一項參數都沒有變化,但是本次更新是React.createElement的執行結果,是一個全新的props引用,所以oldProps !== newProps。
2.context value沒有變化
我們知道在當前React版本中,同時存在新老兩種context,這裡指老版本context。
3.workInProgress.type === current.type
更新前後fiber.type不變,比如div沒變為p。
4.!includesSomeLane(renderLanes, updateLanes) ?
當前fiber上是否存在更新,如果存在那麼更新的優先級是否和本次整棵Fiber樹調度的優先級一致?
如果一致代表該組件上存在更新,需要走render邏輯。
bailout的優化還不止如此。如果一棵fiber子樹所有節點都沒有更新,即使所有子孫fiber都走bailout邏輯,還是有遍歷的成本。
所以,在bailout中,會檢查該fiber的所有子孫fiber是否滿足條件4(該檢查時間複雜度O(1))。
如果所有子孫fiber本次都沒有更新需要執行,則bailout會直接返回null。整棵子樹都被跳過。
不會bailout也不會render,就像不存在一樣。對應的DOM不會產生任何變化。
老Context API的實現現
在我們大體了解了render的時機。有了這個概念,就能理解ContextAPI是如何實現的,以及為什麼被重構。
我們先看被廢棄的老ContextAPI的實現。
Fiber樹的生成過程是通過遍歷實現的可中斷遞歸,所以分為遞和歸2個階段。
Context對應數據會保存在棧中。
在遞階段,Context不斷入棧。所以Concumer可以通過Context棧向上找到對應的context value。
在歸階段,Context不斷出棧。
那麼老ContextAPI為什麼被廢棄呢?因為他沒法和shouldComponentUpdate或Memo等性能優化手段配合。
shouldComponentUpdate的實現
要探究更深層的原因,我們需要了解shouldComponentUpdate的原理,後文簡稱其為SCU。
使用SCU是為了減少不必要的render,換句話說:讓本該render的組件走bailout邏輯。
剛才我們介紹了bailout需要滿足的條件。那麼SCU是作用於這4個條件的哪個呢?
顯然是第一條:oldProps === newProps
當使用shouldComponentUpdate,這個組件bailout的條件會產生變化:
-- oldProps === newProps
++ SCU === false
同理,使用PureComponenet和React.memo時,bailout的條件也會產生變化:
-- oldProps === newProps
++ 淺比較oldProps與newsProps相等
回到老ContextAPI。
當這些性能優化手段:
使組件命中bailout邏輯
同時如果組件的子樹都滿足bailout的條件4
那麼該fiber子樹不會再繼續遍歷生成。
換言之,不會再經歷Context的入棧、出棧。
這種情況下,即使context value變化,子孫組件也沒法檢測到。
新Context API的實現
知道老ContextAPI的缺陷,我們再來看新ContextAPI是如何實現的。
當通過:
創建context實例後,需要使用Provider提供value,使用Consumer或useContext訂閱value。
如:
使用:
當遍歷組件生成對應fiber時,遍歷到Ctx.Provider組件,Ctx.Provider內部會判斷context value是否變化。
如果context value變化,Ctx.Provider內部會執行一次向下深度優先遍歷子樹的操作,尋找與該Provider配套的Consumer。
在上文的例子中會最終找到useContext(Ctx)的Child組件對應的fiber,並為該fiber觸發一次更新。
注意這裡的實現非常巧妙:
一般更新是由組件調用觸發更新的方法產生。比如上文的NumProvider組件,點擊button調用add會觸發一次更新。
觸發更新的本質是為了讓組件創建對應fiber時不滿足bailout條件4:
!includesSomeLane(renderLanes, updateLanes) ?
從而進入render邏輯。
在這裡,Ctx.Provider中context value變化,Ctx.Provider向下找到消費context value的組件Child,為其fiber觸發一次更新。
則Child對應fiber就不滿足條件4。
這就解決了老ContextAPI的問題:
由於Child對應fiber不滿足條件4,所以從Ctx.Provider到Child,這棵子樹沒法滿足:
所以即使遍歷中途有組件進入bailout邏輯,也不會返回null,即不會無視這棵子樹的遍歷。
最終遍歷進行到Child,由於其不滿足條件4,會進入render邏輯,調用組件對應函數。
在函數調用中會調用useContext從Context棧中找到對應更新後的context value並返回。
總結
React性能一大關鍵在於:減少不必要的render。
從上文我們看到,本質就是讓組件滿足4個條件,從而進入bailout邏輯。
而ContextAPI本質是讓Consumer組件不滿足條件4。
我們也知道了,React雖然每次都會遍歷整棵樹,但會有bailout的優化邏輯,不是所有組件都會render。
極端情況下,甚至某些子樹會被跳過遍歷(bailout返回null)。
參考資料
[1]React技術揭秘: http://react.iamkasong.com/
[2]在線Demo地址: https://codesandbox.io/s/quirky-chaplygin-5bx67?file=/src/App.js
【編輯推薦】
【責任編輯:
姜華TEL:(010)68476606】