編者按:本文作者蘇暢,奇舞團前端開發工程師。
從v16.3.0開始如下三個生命周期鉤子被標記為UNSAFE。
究其原因,有如下兩點:
本文會從React源碼的角度剖析這兩點。
同時,通過本文的學習你可以掌握React異步狀態更新機制的原理。
被誤用的鉤子我們先來探討第一點,這裡我們以componentWillRecieveProps舉例。
我們經常在componentWillRecieveProps內處理props改變帶來的影響。有些同學認為這個鉤子會在每次props變化後觸發。
真的是這樣麼?讓我們看看源碼。
這段代碼出自updateClassInstance方法:
if (
unresolvedOldProps !== unresolvedNewProps ||
oldContext !== nextContext
) {
callComponentWillReceiveProps(
workInProgress,
instance,
newProps,
nextContext,
);
}
你可以從這裡1看到這段源碼
其中callComponentWillReceiveProps方法會調用componentWillRecieveProps。
可以看到,是否調用的關鍵是比較unresolvedOldProps與 unresolvedNewProps是否全等,以及context是否變化。
其中unresolvedOldProps為組件上次更新時的props,而unresolvedNewProps則來自ClassComponent調用this.render返回的JSX中的props參數。
可見他們的引用是不同的。所以他們全等比較為false。
基於此原因,每次父組件更新都會觸發當前組件的componentWillRecieveProps。
想想你是否也曾誤用過?
模式遷移讓我們再看第二個原因:
React從Legacy模式遷移到Concurrent模式後,這些鉤子的表現會和之前不一致。
我們先了解下什麼是模式?不同模式有什麼區別?
從Legacy到Concurrent從React15升級為React16後,源碼改動如此之大,說React被重構可能更貼切些。
正是由於變動如此之大,使得一些特性在新舊版本React中表現不一致,這裡就包括上文談到的三個生命周期鉤子。
為了讓開發者能平穩從舊版本遷移到新版本,React推出了三個模式:
legacy模式 -- 通過ReactDOM.render創建的應用會開啟該模式。這是當前React使用的方式。這個模式可能不支持一些新功能。
blocking模式 -- 通過ReactDOM.createBlockingRoot創建的應用會開啟該模式。開啟部分concurrent模式特性,作為遷移到concurrent模式的第一步。
concurrent模式 -- 通過ReactDOM.createRoot創建的應用會開啟該模式。面向未來的開發模式。
你可以從這裡2看到不同模式的特性支持情況
concurrent模式相較我們當前使用的legacy模式最主要的區別是將同步的更新機制重構為異步可中斷的更新。
接下來我們來探討React如何實現異步更新,以及為什麼異步更新情況下鉤子的表現和同步更新不同。
同步更新我們可以用代碼版本控制類比更新機制。
在沒有代碼版本控制前,我們在代碼中逐步疊加功能。一切看起來井然有序,直到我們遇到了一個緊急線上bug(紅色節點)。
為了修復這個bug,我們需要首先將之前的代碼提交。
在React中,所有通過ReactDOM.render創建的應用都是通過類似的方式更新狀態。
即所有更新同步執行,沒有優先級概念,新來的高優更新(紅色節點)也需要排在其他更新後面執行。
異步更新當有了代碼版本控制,有緊急線上bug需要修復時,我們暫存當前分支的修改,在master分支修復bug並緊急上線。
bug修復上線後通過git rebase命令和開發分支連接上。開發分支基於修復bug的版本繼續開發。
在React中,通過ReactDOM.createBlockingRoot和ReactDOM.createRoot創建的應用在任務未過期情況下會採用異步的方式更新狀態。
高優更新(紅色節點)中斷正在進行中的低優更新(藍色節點),先完成渲染流程。
待高優更新完成後,低優更新基於高優更新的部分或者完整結果重新更新。
深入源碼在React源碼中,每次發起更新都會創建一個Update對象,同一組件的多個Update(如上圖所示的A -> B -> C)會以鍊表的形式保存在updateQueue中。
首先了解下他們的數據結構。
Update有很多欄位,當前我們關注如下三個欄位:
const update: Update<*> = {
// ...省略當前不需要關注的欄位
lane,
payload: null,
next: null
};
Update由createUpdate方法返回,你可以從這裡3 看到 createUpdate 的源碼
updateQueue結構如下:
const queue: UpdateQueue<State> = {
baseState: fiber.memoizedState,
firstBaseUpdate: null,
lastBaseUpdate: null,
shared: {
pending: null,
},
// 其他參數省略...
};
UpdateQueue由initializeUpdateQueue方法返回,你可以從這裡4看到initializeUpdateQueue的源碼
baseState:更新基於哪個state開始。上圖中版本控制的例子中,高優bug修復後提交master,其他commit基於master分支繼續開發。這裡的master分支就是baseState。
firstBaseUpdate與lastBaseUpdate:更新基於哪個Update開始,由firstBaseUpdate開始到lastBaseUpdate結束形成鍊表。這些Update是在上次更新中由於優先級不夠被留下的,如圖中A B C。
shared.pending:本次更新的單或多個Update形成的鍊表。
其中baseUpdate + shared.pending會作為本次更新需要執行的Update。
例子了解了數據結構,接下來我們模擬一次異步中斷更新,來揭示本文探尋的秘密 —— componentWillXXX為什麼UNSAFE。
在某個組件updateQueue中存在四個Update,其中字母代表該Update要更新的字母,數字代表該Update的優先級,數字越小優先級越高。
baseState = '';
A1 - B2 - C1 - D2
首次渲染時,優先級1。B D優先級不夠被跳過。
為了保證更新的連貫性,第一個被跳過的Update(B)及其後面所有Update會作為第二次渲染的baseUpdate,無論他們的優先級高低,這裡為B C D。
baseState: ''
Updates: [A1, C1]
Result state: 'AC'
接著第二次渲染,優先級2。
由於B在第一次渲染時被跳過,所以在他之後的C造成的渲染結果不會體現在第二次渲染的baseState中。所以baseState為A而不是上次渲染的Result state AC。這也是為了保證更新的連貫性。
baseState: 'A'
Updates: [B2, C1, D2]
Result state: 'ABCD'
我們發現,C同時出現在兩次渲染的Updates中,他代表的狀態會被更新兩次。
如果有類似的代碼:
componentWillReceiveProps(nextProps) {
if (!this.props.includes('C') && nextProps.includes('C')) {
// ...do something
}
}
則很有可能被調用兩次,這與同步更新的React表現不一致!
基於以上原因,componentWillXXX被標記為UNSAFE。
總結由於篇幅有限,本次我們只聚焦了React源碼的冰山一角。
如果想深入學習React源碼,在此向你推薦開源、嚴謹、易懂的React源碼電子書 —— React技術揭秘5
Github地址:https://github.com/BetaSu/just-react
文內連結https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberClassComponent.new.js#L1034
https://zh-hans.reactjs.org/docs/concurrent-mode-adoption.html#why-so-many-modes
https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactUpdateQueue.old.js#L189
https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactUpdateQueue.new.js#L157
https://react.iamkasong.com/
關於奇舞周刊《奇舞周刊》是360公司專業前端團隊「奇舞團」運營的前端技術社區。關注公眾號後,直接發送連結到後臺即可給我們投稿。