React v16.3 版本新生命周期函數淺析及升級方案

一個月前,React 官方正式發布了 v16.3 版本。在這次的更新中,除了前段時間被熱烈討論的新 Context API 之外,新引入的兩個生命周期函數 getDerivedStateFromPropsgetSnapshotBeforeUpdate 以及在未來 v17.0 版本中即將被移除的三個生命周期函數 componentWillMountcomponentWillReceivePropscomponentWillUpdate 也非常值得我們花點時間去探究一下其背後的原因以及在具體項目中的升級方案。

componentWillMount

首屏無數據導致白屏

在 React 應用中,許多開發者為了避免第一次渲染時頁面因為沒有獲取到非同步數據導致的白屏,而將數據請求部分的代碼放在了 componentWillMount 中,希望可以避免白屏並提早非同步請求的發送時間。但事實上在 componentWillMount 執行後,第一次渲染就已經開始了,所以如果在 componentWillMount 執行時還沒有獲取到非同步數據的話,頁面首次渲染時也仍然會處於沒有非同步數據的狀態。換句話說,組件在首次渲染時總是會處於沒有非同步數據的狀態,所以不論在哪裡發送數據請求,都無法直接解決這一問題。而關於提早發送數據請求,官方也鼓勵將數據請求部分的代碼放在組件的 constructor 中,而不是 componentWillMount

另一個常見的 componentWillMount 的用例是在服務端渲染時獲取數據,因為在服務端渲染時 componentDidMount 是不會被調用的。針對這個問題,筆者這裡提供兩種解法。第一個簡單的解法是將所有的數據請求都放在 componentDidMount 中,即只在客戶端請求非同步數據。這樣做可以避免在服務端和客戶端分別請求兩次相同的數據(componentWillMount 在客戶端渲染時同樣會被調用到),但很明顯的缺點就是無法在服務端渲染時獲取到頁面渲染所需的所有數據,所以如果我們需要保證服務端返回的 HTML 就是用戶最終看到的 HTML 的話,我們可以將每個頁面的數據獲取邏輯單獨抽離出來,然後一一對應到相應的頁面,在服務端根據當前頁面的路由找到相應的數據請求,利用鏈式的 Promise 在渲染最終的頁面前就將數據塞入 redux store 或其他數據管理工具中,這樣服務端返回的 HTML 就是包含非同步數據的結果了。

事件訂閱

另一個常見的用例是在 componentWillMount 中訂閱事件,並在 componentWillUnmount 中取消掉相應的事件訂閱。但事實上 React 並不能夠保證在 componentWillMount 被調用後,同一組件的 componentWillUnmount 也一定會被調用。一個當前版本的例子如服務端渲染時,componentWillUnmount 是不會在服務端被調用的,所以在 componentWillMount 中訂閱事件就會直接導致服務端的內存泄漏。另一方面,在未來 React 開啟非同步渲染模式後,在 componentWillMount 被調用之後,組件的渲染也很有可能會被其他的事務所打斷,導致 componentWillUnmount 不會被調用。而 componentDidMount 就不存在這個問題,在 componentDidMount 被調用後,componentWillUnmount 一定會隨後被調用到,並根據具體代碼清除掉組件中存在的事件訂閱。

升級方案

將現有 componentWillMount 中的代碼遷移至 componentDidMount 即可。

componentWillReceiveProps

更新由 props 決定的 state 及處理特定情況下的回調

在老版本的 React 中,如果組件自身的某個 state 跟其 props 密切相關的話,一直都沒有一種很優雅的處理方式去更新 state,而是需要在 componentWillReceiveProps 中判斷前後兩個 props 是否相同,如果不同再將新的 props 更新到相應的 state 上去。這樣做一來會破壞 state 數據的單一數據源,導致組件狀態變得不可預測,另一方面也會增加組件的重繪次數。類似的業務需求也有很多,如一個可以橫向滑動的列表,當前高亮的 Tab 顯然隸屬於列表自身的狀態,但很多情況下,業務需求會要求從外部跳轉至列表時,根據傳入的某個值,直接定位到某個 Tab。

在新版本中,React 官方提供了一個更為簡潔的生命周期函數:

static getDerivedStateFromProps(nextProps, prevState)

一個簡單的例子如下:

// beforecomponentWillReceiveProps(nextProps) { if (nextProps.translateX !== this.props.translateX) { this.setState({ translateX: nextProps.translateX, }); } }// afterstatic getDerivedStateFromProps(nextProps, prevState) { if (nextProps.translateX !== prevState.translateX) { return { translateX: nextProps.translateX, }; } return null;}

乍看下來這二者好像並沒有什麼本質上的區別,但這卻是筆者認為非常能夠體現 React 團隊對於軟體工程深刻理解的一個改動,即 React 團隊試圖通過框架級別的 API 來約束或者說幫助開發者寫出可維護性更佳的 JavaScript 代碼。為了解釋這點,我們再來看一段代碼:

// beforecomponentWillReceiveProps(nextProps) { if (nextProps.isLogin !== this.props.isLogin) { this.setState({ isLogin: nextProps.isLogin, }); } if (nextProps.isLogin) { this.handleClose(); }}// afterstatic getDerivedStateFromProps(nextProps, prevState) { if (nextProps.isLogin !== prevState.isLogin) { return { isLogin: nextProps.isLogin, }; } return null;}componentDidUpdate(prevProps, prevState) { if (!prevState.isLogin && this.props.isLogin) { this.handleClose(); }}

通常來講,在 componentWillReceiveProps 中,我們一般會做以下兩件事,一是根據 props 來更新 state,二是觸發一些回調,如動畫或頁面跳轉等。在老版本的 React 中,這兩件事我們都需要在 componentWillReceiveProps 中去做。而在新版本中,官方將更新 state 與觸發回調重新分配到了 getDerivedStateFromPropscomponentDidUpdate 中,使得組件整體的更新邏輯更為清晰。而且在 getDerivedStateFromProps 中還禁止了組件去訪問 this.props,強制讓開發者去比較 nextProps 與 prevState 中的值,以確保當開發者用到 getDerivedStateFromProps 這個生命周期函數時,就是在根據當前的 props 來更新組件的 state,而不是去做其他一些讓組件自身狀態變得更加不可預測的事情。

升級方案

將現有 componentWillReceiveProps 中的代碼根據更新 state 或回調,分別在 getDerivedStateFromPropscomponentDidUpdate 中進行相應的重寫即可,注意新老生命周期函數中 prevPropsthis.propsnextPropsprevStatethis.state 的不同。

componentWillUpdate

處理因為 props 改變而帶來的副作用

componentWillReceiveProps 類似,許多開發者也會在 componentWillUpdate 中根據 props 的變化去觸發一些回調。但不論是 componentWillReceiveProps 還是 componentWillUpdate,都有可能在一次更新中被調用多次,也就是說寫在這裡的回調函數也有可能會被調用多次,這顯然是不可取的。與 componentDidMount 類似,componentDidUpdate 也不存在這樣的問題,一次更新中 componentDidUpdate 只會被調用一次,所以將原先寫在 componentWillUpdate 中的回調遷移至 componentDidUpdate 就可以解決這個問題。

在組件更新前讀取 DOM 元素狀態

另一個常見的 componentWillUpdate 的用例是在組件更新前,讀取當前某個 DOM 元素的狀態,並在 componentDidUpdate 中進行相應的處理。但在 React 開啟非同步渲染模式後,render 階段和 commit 階段之間並不是無縫銜接的,也就是說在 render 階段讀取到的 DOM 元素狀態並不總是和 commit 階段相同,這就導致在

componentDidUpdate 中使用 componentWillUpdate 中讀取到的 DOM 元素狀態是不安全的,因為這時的值很有可能已經失效了。

為了解決上面提到的這個問題,React 提供了一個新的生命周期函數:

getSnapshotBeforeUpdate(prevProps, prevState)

componentWillUpdate 不同,getSnapshotBeforeUpdate 會在最終的 render 之前被調用,也就是說在 getSnapshotBeforeUpdate 中讀取到的 DOM 元素狀態是可以保證與 componentDidUpdate 中一致的。雖然 getSnapshotBeforeUpdate 不是一個靜態方法,但我們也應該盡量使用它去返回一個值。這個值會隨後被傳入到 componentDidUpdate 中,然後我們就可以在 componentDidUpdate 中去更新組件的狀態,而不是在 getSnapshotBeforeUpdate 中直接更新組件狀態。

官方提供的一個例子如下:

class ScrollingList extends React.Component { listRef = null; getSnapshotBeforeUpdate(prevProps, prevState) { // Are we adding new items to the list? // Capture the scroll position so we can adjust scroll later. if (prevProps.list.length < this.props.list.length) { return ( this.listRef.scrollHeight - this.listRef.scrollTop ); } return null; } componentDidUpdate(prevProps, prevState, snapshot) { // If we have a snapshot value, weve just added new items. // Adjust scroll so these new items dont push the old ones out of view. // (snapshot here is the value returned from getSnapshotBeforeUpdate) if (snapshot !== null) { this.listRef.scrollTop = this.listRef.scrollHeight - snapshot; } } render() { return ( <div ref={this.setListRef}> {/* ...contents... */} </div> ); } setListRef = ref => { this.listRef = ref; };}

升級方案

將現有的 componentWillUpdate 中的回調函數遷移至 componentDidUpdate。如果觸發某些回調函數時需要用到 DOM 元素的狀態,則將對比或計算的過程遷移至 getSnapshotBeforeUpdate,然後在 componentDidUpdate 中統一觸發回調或更新狀態。

小結

最後,讓我們從整體的角度再來看一下 React 這次生命周期函數調整前後的異同:

Before

After

在第一張圖中被紅框圈起來的三個生命周期函數就是在新版本中即將被移除的。通過上述的兩張圖,我們可以清楚地看到將要被移除的三個生命周期函數都是在 render 之前會被調用到的。而根據原來的設計,在這三個生命周期函數中都可以去做一些諸如發送請求,setState 等包含副作用的事情。在老版本的 React 中,這樣做也許只會帶來一些性能上的損耗,但在 React 開啟非同步渲染模式之後,就無法再接受這樣的副作用產生了。舉一個 Git 的例子就是在開發者 commit 了 10 個文件更新後,又對當前或其他的文件做了另外的更新,但在 push 時卻仍然只 push 了剛才 commit 的 10 個文件更新。這樣就會導致提交記錄與實際更新不符,如果想要避免這個問題,就需要保證每一次的文件更新都要經過 commit 階段,再被提交到遠端,而這也就是 React 在開啟非同步渲染模式之後要做到的。

另一方面,為了驗證個人的理解及測試新版本的穩定性,筆者已經將個人負責的幾個項目全部都升級到了 React 16.3 並根據上述提到的升級方案替換了所有即將被移除的生命周期函數。目前,所有項目在生產環境中都運行良好,沒有收到任何不良的用戶反饋。

當然,以上的這些生命周期函數的改動,一直要到 React 17.0 中才會實裝,這給廣大的 React 開發者們預留了充足的時間去適應這次改動。但如果你是 React 開源項目(尤其是組件庫)的維護者的話,不妨花點時間去詳細了解一下這次生命周期函數的改動。因為這不僅僅可以幫助你將開源項目更好地升級到 React 的最新版本,更重要的是可以幫助你提前理解即將到來的非同步渲染模式。

同時,筆者也相信在 React 正式開啟非同步渲染模式之後,許多常用組件的性能將很有可能迎來一次整體的提升。進一步來說,配合非同步渲染,許多現在的複雜組件都可以被處理得更加優雅,在代碼層面得到更精細粒度上的控制,並最終為用戶帶來更加直觀的使用體驗。


推薦閱讀:

redux單向數據流的好處是什麼?
Web Component 和類 React、Angular、Vue 組件化技術誰會成為未來?
從零搭建webpack4+React工程(一)
Elixir&React架構踩坑記錄.一

TAG:React | 前端開發 | 前端框架 |