深入React的生命週期(下):更新(Update)

李熠發表於2019-03-04

前言

本文是對開源圖書React In-depth: An exploration of UI development的歸納和增強。同時也融入了自己在開發中的一些心得。

你或許會問,閱讀完這篇文章之後,對工作中開發React相關的專案有幫助嗎?實話實說幫助不會太大。這篇文章不會教你使用一項新技術,不會幫助你提高程式設計技巧,而是完善你的React知識體系,例如區分某些概念,明白一些最佳實踐是怎麼來的等等。如果硬是要從功利的角度來考慮這些知識帶來的價值,那麼會是對你的面試非常有幫助,這篇文章裡知識點在面試時常常會被問到,為什麼我知道,因為我吃過它們的虧。

React元件的生命週期劃分為出生(mount),更新(update)和死亡(unmount),然而我們怎麼知道元件進入到了哪個階段?只能通過React元件暴露給我們的鉤子(hook)函式來知曉。什麼是鉤子函式,就是在特定階段執行的函式,比如constructor只會在元件出生階段被呼叫一次,這就算是一個“鉤子”。反過來說,當某個鉤子函式被呼叫時,也就意味著它進入了某個生命階段,所以你可以在鉤子函式裡新增一些程式碼邏輯在用於在特定的階段執行。當然這不是絕對的,比如render函式既會在出生階段執行,也會在更新階段執行。順便多說一句,“鉤子”在程式設計中也算是一類設計模式,比如github的Webhooks。顧名思義它也是鉤子,你能夠通過Webhook訂閱github上的事件,當事件發生時,github就會像你的服務傳送POST請求。利用這個特性,你可以監聽master分支有沒有新的合併事件發生,如果你的服務收到了該事件的訊息,那麼你就可以例子執行部署工作。

我們按照階段的時間順序對每一個鉤子函式進行講解。

有關出生階段請參考上一篇《深入React的生命週期(上):出生階段(Mount)》

更新階段

  • componentWillReceiveProps()
  • shouldComponentUpdate()
  • componentWillUpdate()
  • render()
  • componentDidUpdate()

更新階段會在三種情況下觸發:

  • 更改props:一個元件並不能主動更改它擁有的props屬性,它的props屬性是由它的父元件傳遞給它的。強制對props進行重新賦值會導致程式報錯。

  • 更改statestate的更改是通過setState介面實現的。同時設計state是需要技巧的,哪些狀態可以放在裡面,哪些不可以;什麼樣的元件可以有state,哪些不可以有;這些都需要遵循一定原則的。這個話題有機會可以單獨拎出來說

  • 呼叫forceUpdate方法:這個我們在上一階段已經提到了,強制元件進行更新。

setState是非同步的

元件的更新原因很大一部分是因為呼叫setState介面更新state所致,我們常常以同步的方式呼叫setState,但實際上setState方法是非同步的。比如下面的這段程式碼:

onClick() {
  this.setState({
    count: 1,
  });
  console.log(this.state.count)
}複製程式碼

在一個元件的點選事件處理函式中,我們更新了state中的count,然後立即嘗試去讀取最新的count。事實是你讀取的結果不是1,二應該是之前的值。

更致命的錯誤是類似這樣在同一個塊級中連續呼叫setState的程式碼

this.setState({ ...this.state, foo: 42 });
this.setState({ ...this.state, isBar: true });複製程式碼

在這種情況下,第一次設定的foo值會被第二次的設定覆蓋而還原

componentWillReceiveProps(nextProps)

當傳遞給元件的props發生改變時,元件的componentWillReceiveProps即會被觸發呼叫,方法傳遞的引數的是發更更改的之後的props值(通常我們命名為nextProps)。在這個方法裡,你可以通過this.props訪問當前的屬性值,可以通過nextProps訪問即將更新的屬性值,或者將它們進行對比,或者將它們進行計算,最終確定你需要更新的狀態(state)並最終呼叫setState方法對狀態進行更新。在這個鉤子函式中呼叫setState方法並不會觸發再一次渲染。

非常有意思的是,雖然props的更改會引起componentWillReceiveProps的呼叫;但componentWillReceiveProps的呼叫並不意味著props真的發生了變化。這可不是我說的,Facebook官方花了一整篇文章說這件事:(A => B) !=> (B => A)。比如看下面這個元件:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      number: 1,
    }
    this.onClick = this.onClick.bind(this);
  }
  onClick() {
    this.setState({
      number: 1,
    })
  }
  render() {
    return (
      <MyButton onClick={this.onClick} data-number={this.state.number} />
    );
  }
}複製程式碼

每一次點選事件都會重新使用setState介面對state進行更新,但每次更新的值都是相同的,即number:1。並且把當前元件的狀態以屬性的形式傳遞給<MyButton />。問題來了,那麼當我每次點選按鈕時,按鈕MyButtoncomponentWillReceiveProps都會被呼叫嗎?

會,即使每次更新的值都是一樣的。

之所以出現這樣的情況原因其實非常簡單,因為React並不知道傳入的屬性是否發生了更改。而為什麼React不嘗試去做一個是否相等的判斷呢?

因為辦不到,新傳入的屬性和舊屬性可能引用的是同一塊記憶體區域(引用型別),所以單純的用===判斷是否相等並不準確。可行的解決辦法之一就是對資料進行深度拷貝然後進行比較,但是這對大型資料結構來說效能太差,還能會碰上迴圈引用的問題。

所以React將這個變化通過鉤子函式暴露出來,千萬不要以為當componentWillReceiveProps被呼叫就意味著props發生了更改,如果需要在變化時做一些事情,務必要手動的進行比較。

shouldComponentUpdate()

shouldComponentUpdate很重要,它可以決定是否繼續當前的生命週期。預設情況該函式返回true即繼續當前的生命週期;也可以返回false終止當前的生命週期,阻止進一步的render與接下來的步驟。

我們上面剛剛說過,React並不會對props進行深度比較,這對state也同樣適用。所以即使propsstate並未發生了更改,shouldComponentUpdate也會被再次呼叫,包括接下來的步驟componentWillUpdaterendercomponentDidUpdate也都會再次執行一次。這很明顯會給效能造成不小的傷害。

傳遞給shouldComponentUpdate的引數包括即將改變的propsstate,形參的名稱是nextPropsnextState,在這個函式裡你同時又能通過this關鍵字訪問到當前的stateprops,所以你在這裡你是“全知”的,可以完全按照你自己的業務邏輯判斷是否stateprops是否發生了更改,並且決定是否要繼續接下來的步驟。shouldComponentUpdate也就通常我們在優化React效能時的第一步。這一步的優化不僅僅是優化元件自身的流程,同時也能節省去子元件的重新渲染的代價 。

當然如果你對判斷props是否發生改變的檢測邏輯要求比較簡單的話,比如只是淺度(shallow)的判斷(即判斷物件的引用是否發生了更改)物件是否發生了更改,那麼可以利用PureRenderMixin

import PureRenderMixin from `react-addons-pure-render-mixin`; // ES6
const createReactClass = require(`create-react-class`);

createReactClass({
  mixins: [PureRenderMixin],

  render: function() {
    return <div className={this.props.className}>foo</div>;
  }
});複製程式碼

minins是React支援的一種允許多個元件共用程式碼的一種機制。PureRenderMixin外掛的工作非常簡單,它為你重寫了shouldComponentUpdate函式,並對物件進行了淺度對比,具體程式碼可以從這裡這裡找到。

在ES6中你也可以通過直接繼承React.PureComponent而不是React.Component來實現這個功能。用React官方的原話說就是

React.PureComponent is exactly like React.Component, but implements shouldComponentUpdate() with a shallow prop and state comparison.

Pure

我們再次強調,PureComponent為你實現的只是對引用是否發生了更改的判斷,甚至可以說它只是簡單的用===進行的判斷,所以這也是我們稱之為pure的原因。為了具體說明問題,我們舉一個實際的例子

/* MyButton.js: */
import React from `react`;

class MyButton extends React.PureComponent {
  constructor(props) {
    super(props);
  }
  render() {
    console.log(`render`);
    return <button onClick={this.props.onClick}>My Button</button>
  }
}
export default MyButton;

/* App.js: */
import React from `react`;
import MyButton from `./Button.js`;

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      arr: [1],
    }
    this.onClick = this.onClick.bind(this);
  }
  onClick() {
    this.setState({
      arr: [...this.state.arr, 2],
    });
  }
  render() {
    return (
      <MyButton onClick={this.onClick} data-arr={this.state.arr} />
    );
  }
}

export default App;複製程式碼

在上面的這個例子中,每一次點選都會修改state中的arr變數,arr變數的引用和值都發生了更改。重點是MyButton元件繼承的是React.PureComponent。那麼每一次點選時,MyButton中的log資訊都會被列印出來,即每次都會重新出發render

如果我們把onClick方法做一些修改:

onClick() {
  const arr = this.state.arr;
  arr.push(2);
  this.setState({
    arr: arr,
  })
}複製程式碼

這個方法同樣使得arr變數發生了變化,但是僅僅是值而不是引用,此時當再一次點選按鈕(MyButton)時,MyButton都不會再次進行渲染了。也就是說PureComponent提前為我們進行了shallow comparison.

使用這種只修改引用,不修改資料內容的immutable data也常常作為優化React的一個手段之一。immutable.js就能為我們實現這個需求,每一次修改資料時你得到的其實是新的資料引用,而不會修改到原有的資料。同時Redux中的reducer想達到的效果其實也相似,reducer的重點是它的純潔性(pure),在執行時不會造成副作用,即避免對傳入資料引用的修改,同時也方便比較出元件狀態的更新。

componentWillUpdate()

componentWillUpdate方法和componentWillMount方法很相似,都是在即將發生渲染前觸發,在這裡你能夠拿到nextPropsnextState,同時也能訪問到當前即將過期的propsstate。如果有需要的話你可以把它們暫存起來便於以後使用。

componentWillMount不同的是,在這個方法中你不可以使用setState,否則會立即觸發另一輪的渲染並且又再一次呼叫componentWillUpdate,陷入無限迴圈中。

componentDidUpdate()

和Mount階段類似,當元件進入componentDidUpdate階段時意味著最新的原生DOM已經渲染完成並且可以通過refs進行訪問。該函式會傳入兩個引數,分別是prevPropsprevState,顧名思義是之前的狀態。你仍然可以通過this關鍵字訪問當前的狀態,因為可以訪問原生DOM的關係,在這裡也適用於做一些第三方需要操縱類庫的操作。

update階段各個鉤子函式的呼叫順序也與mount階段相似,尤其是componentDidUpdate,子元件的該鉤子函式優先於父元件呼叫

因為可以訪問DOM的緣故,我們有可能需要在這個鉤子函式裡獲取實際的元素樣式,並且寫入state中,比如你的程式碼可能會長這樣:

componentDidUpdate(prevProps, prevState) {
// BAD: DO NOT DO THIS!!!
  let height = ReactDOM.findDOMNode(this).offsetHeight;
  this.setState({ internalHeight: height });
}複製程式碼

如果預設情況下你的shouldComponentUpdate()函式總是返回true的話,那麼這樣在componentDidUpdate裡更新state的程式碼又會把我們帶入無限render的迴圈中。如果你必須要這麼做,那麼至少應該把上一次的結果快取起來,有條件的更新state:

componentDidUpdate(prevProps, prevState) {
  // One possible fix...
  let height = ReactDOM.findDOMNode(this).offsetHeight;
  if (this.state.height !== height ) {
    this.setState({ internalHeight: height });
  }
}複製程式碼

死亡階段

componentWillUnmount()

當元件需要從DOM中移除時,即會觸發這個鉤子函式。這裡沒有太多需要注意的地方,在這個函式中通常會做一些“清潔”相關的工作

  1. 將已經傳送的網路請求都取消掉
  2. 移除元件上DOM的Event Listener

總結

最後再次強調,本文是開源圖書React In-depth: An exploration of UI development的歸納。基本上想了解生命週期看這一本書就夠了,看完也無敵了。希望這篇中文簡約版也會對你有幫助。

本文同時也釋出在我的知乎專欄,歡迎大家關注

參考

相關文章