探索 React 元件之間的生命週期

WashingtonHua發表於2019-05-01

本文首發於我的部落格

0)寫在前面

React 元件的生命週期,相信大家都非常熟悉了,無非那麼幾個函式,官方文件已經寫得非常清楚了。(那還有什麼好說的?浪費感情!合上!)

一般我們所討論的,都是單個元件的生命週期。如果是多個元件之間呢?比如父子元件?兄弟元件?各個週期又是什麼樣的?非同步路由的情況呢?前陣子新出的 Hooks 呢?有幾個人敢站出來說我全知道的?(反正我是不敢)

剛好也是最近遇到一些關於生命週期的問題,專案中涉及到大量的非同步操作,需要清楚地知道各部分的執行順序,藉此機會整理一下。

1)在你繼續之前

這篇文章並不是入門教學,如果你對 React 一點不瞭解的話,或許這篇文章並不適合你。

我假定你已經掌握 React 的基本知識,例如:元件的生命週期、Hooks 的基本概念、類元件和函式元件的區別 等,並用 React 開發過有一定複雜度的應用。

這裡我們不討論 shouldComponentUpdate()React.memo() 等優化手段,只考慮最原始的情況。

本文以瀏覽器作為目標環境,React Native 和 Electron 在基本概念上是一樣的,細節上的不同不作為本文的討論重點,

2)關於 Hooks 的生命週期

確切地說,Hooks 並不是一種新的元件型別,它只是一種程式碼複用的方式,並且總是伴隨著函式元件一起出現。

在 Hooks 之前,函式元件是沒有 state 的概念的,因而也就不存在生命週期一說,就只是一個 render 函式。Hooks 的出現,讓函式元件也可以擁有 state,相應的也就引入了生命週期的概念,具體來說也就是 useEffect()useLayoutEffect() 具體何時執行的問題。

函式元件的本質是函式,而函式本身是沒有生命週期的,Hooks 的出現也沒有改變這一點。這裡我們討論的物件是「元件」,元件是可以有生命週期的。因此當我在後面的文字中提到 Hooks 時,我其實是在表示「使用了 Hooks 的函式元件」(雖然這個說法不是很嚴謹,但是這不重要,你懂我意思就好)。

3)那麼我們就來做個實驗吧

為了一探究竟,我寫了一個 Demo 來模擬一些常見的用例:父子元件、兄弟元件、同步/非同步路由、類元件和 Hooks、元件初始化時的非同步操作(如訪問 API)等。

如果你有遇到 Demo 沒覆蓋到的使用場景,歡迎提 Issue。

3.1)TL,DR;

我知道大家的時間都很寶貴,趕時間的朋友可以直接看結論;時間寬裕的朋友,我們從下一節開始細聊:

  1. 同步路由,父元件在 render 階段建立子元件。
  2. 非同步路由,父元件在自身掛載完成之後才開始建立子元件。
  3. 掛載完成之後,在更新時,同步元件和非同步元件是一樣的。
  4. 無論是掛載還是更新,以 render 完成為界,之前父元件先執行,之後子元件先執行。
  5. 兄弟元件大體上按照在父元件中的出場順序執行。
  6. useEffect 會在掛載/更新完成之後,延遲執行。
  7. 非同步請求(如訪問 API)何時得到響應與元件的生命週期無關,即父元件中發起的非同步請求不保證在子元件掛載完成前得到響應。

3.2)掛載過程

父子元件的掛載分為三個階段。

第一階段,父元件執行到自身的 render,解析其下有哪些子元件需要渲染,並對其中同步的子元件進行建立,挨個執行各元件到 render,生成到目前為止的 Virtual DOM 樹,並 commit 到 DOM。

第二階段,此時 DOM 節點已經生成完畢,元件掛載完成,開始後續流程。先依次觸發同步子元件各自的 componentDidMount / useLayoutEffect,最後觸發父元件的。

第三階段,如果元件使用了 useEffect,則會在第二階段之後觸發 useEffect。如果父子元件都使用了 useEffect,那麼子元件先觸發,然後是父元件。

如果父元件中包含非同步子元件,則會在父元件掛載完成後被建立。

對於兄弟元件,如果是同步路由,它們的建立順序和在父元件中定義的出場順序是一致的。

對於「非同步的兄弟元件」,最終的載入順序是按照 JSX 中定義的順序,還是按照 js 檔案下載完成的順序,我暫時還不能確定。

按照我對“非同步”的理解,我更傾向於認為是按照下載完成的順序,這更符合“按需載入”的概念。

之所以會造成困擾,是因為據我目前所觀察到的情況,兩種順序是一致的,我還沒有遇到過後定義但先載入的情況。

大部分時候我們會以頁面為單位去劃分非同步元件,單個頁面需要載入多個非同步元件的場景比較少;即便在這些少數場景中,單次需要請求的檔案數量也不會很多,不至於超過瀏覽器的併發上限;即便超過,也會按照在父元件中定義的出場順序去分批發起請求。考慮到單個非同步元件的檔案尺寸通常都很小,載入速度非常快,同一批發起的請求基本上也都是同時到達,因此大部分時候下載完成的順序和定義的順序是一致的。

但沒遇到不代表不存在,該問題我會進一步驗證,已經有結果的小夥伴也可以分享一下。

如果元件的初始化過程包含非同步操作(通常在 componentDidMount()useEffect(fn, []) 中進行),這些操作何時得到響應與元件的生命週期無關,完全看非同步操作本身花了多少時間。

3.3)更新過程

React 的設計遵循單向資料流模型,兄弟節點之間的通訊也會經過父元件(Redux 和 Context 也是通過改變父元件傳遞下來的 props 實現的),因此任何兩個元件之間的通訊,本質上都可以歸結為父元件更新導致子元件更新的情況。

父子元件的更新同樣分為三個階段。

第一、三階段,和掛載過程基本一樣,無非是第一階段多了一個 Reconciliation 的過程,第三階段需要先執行 useEffect 的 Cleanup 函式。

第二階段,和掛載過程也很類似,都是子元件先於父元件,但更新比掛載涉及的函式要多一些:

  1. getSnapshotBeforeUpdate()
  2. useLayoutEffect() 的 Cleanup
  3. useLayoutEffect() / componentDidUpdate()

React 會按照上面的順序依次執行這些函式,每個函式都是各個子元件的先執行,然後才是父元件的執行。具體說來,就是先執行各個子元件的 getSnapshotBeforeUpdate(),然後是父元件的 getSnapshotBeforeUpdate(),再然後是各個子元件的 componentDidUpdate(),父元件的 componentDidUpdate(),以此類推。

這裡我們把類元件和 Hooks 的生命週期函式放在了一起,因為父子元件可以是這兩種元件型別的任意排列組合。實際渲染時不一定每一個函式都有用到,只會呼叫元件實際擁有的函式。

3.4)解除安裝過程

解除安裝過程涉及到 componentWillUnmount()useEffect() 的 Cleanup、useLayoutEffect() 的 Cleanup 這三種函式,順序固定為父元件的先執行,子元件按照在 JSX 中定義的順序依次執行各自的方法。

注意,此時的 Cleanup 函式會按照在程式碼中定義的順序先後執行,與函式本身的特性無關。

如果解除安裝舊元件的同時伴隨有新元件的建立,新元件會先被建立並執行完 render,然後解除安裝不需要的舊元件,最後新元件執行掛載完成的回撥。

4)Hooks 的特別之處

根據 React 的官方文件,useEffect()useLayoutEffect() 都是等效於 componentDidUpdate() / componentDidMount() 的存在,但實際上兩者在一些細節上還是有所不同:

4.1)先來未必先走

useLayoutEffect() 永遠比 useEffect() 先執行,即便在你的程式碼中 useEffect() 是寫在前面的。所以 useLayoutEffect() 才是事實上和 componentDidUpdate() / componentDidMount() 平起平坐的存在。

useEffect() 會在父子元件的 componentDidUpdate() / componentDidMount() 都觸發之後才被觸發。當父子元件都用到 useEffect() 時,子元件中的會比父元件中的先觸發。

4.2)不團結的 Cleanup

同樣都擁有 Cleanup 函式,useLayoutEffect() 和它的 Cleanup 未必是挨著的。

當父元件是 Hooks、子元件是 Class 時,能夠很明顯看出,useLayoutEffect() 的 Cleanup 會在 getSnapshotBeforeUpdate()componentDidUpdate() 之間被呼叫,而 useLayoutEffect() 則是和 componentDidUpdate() 同級,按照更新過程的順序被呼叫。

Hooks 作為子元件時也是這麼個過程,只是沒有了子元件,看上去不那麼明顯罷了。

useEffect() 就不一樣,它和它的 Cleanup 緊密團結在一起,每次執行都是前後腳一起的,從不分離。

5)小結

無論是類元件還是 Hooks,單拎出來大家肯定都很熟悉它們的生命週期,但當把它們混在一起,就沒那麼簡單了。撰寫這篇部落格的過程,幫助我理清了這通亂麻,但願也能夠幫到堅持看到這裡的你。

作者聯絡方式

相關文章