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

誠身發表於2018-04-30

一個月前,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)
複製程式碼

一個簡單的例子如下:

// before
componentWillReceiveProps(nextProps) {	
  if (nextProps.translateX !== this.props.translateX) {
    this.setState({	
      translateX: nextProps.translateX,	
    });	
  }	
}

// after
static getDerivedStateFromProps(nextProps, prevState) {
  if (nextProps.translateX !== prevState.translateX) {
    return {
      translateX: nextProps.translateX,
    };
  }
  return null;
}
複製程式碼

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

// before
componentWillReceiveProps(nextProps) {
  if (nextProps.isLogin !== this.props.isLogin) {
    this.setState({	
      isLogin: nextProps.isLogin,	
    });
  }
  if (nextProps.isLogin) {
    this.handleClose();
  }
}
複製程式碼
// after
static 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, we've just added new items.
    // Adjust scroll so these new items don't 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

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

After

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

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

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

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

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


個人 Blog 地址:AlanWei/blog

相關文章