React 元件生命週期 (Component Lifecycle)

一般常會用 JavaScript Class 語法來宣告 React Component:

class Greeting extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

在這一篇文章中,我們會來更深入介紹 React Component Class 有哪些屬性 (Properties) 和方法 (Methods)。

React Component Class 除了 render(),還有一系列的 Lifecycle Methods (方法),讓你可以在不同的元件事件 (也就是所謂的元件生命週期) 發生時做點你想做的事。

Lifecycle Methods 如果是 will 開頭的,表示在某個事件發生「之前」,會執行這個方法;如果是 did 開頭的,表示在某個事件發生「之後」,會執行這個方法。

Lifecycle Methods 可以分為三大類:

  1. Mounting - 裝載,當元件被加入到 DOM 中時會觸發
  2. Updating - 更新,當元件的 props 或 state 更新,重新渲染 (re-rendered) DOM 時會觸發
  3. Unmounting - 卸載,當元件要從 DOM 中被移除時會觸發
  4. Error Handling - 例外處理,當元件發生 JavaScript errors 時會觸發

Mounting Lifecycle Methods

React 提供有這些 Mounting 階段的方法:

render()

render() 是 React Component 唯一一定要實作的方法。

render() 在每次 props 或是 state 被改變時,都會被執行一次。

在 render() 中你會根據當前 this.propsthis.state 的資料狀態,來決定該元件當前的 UI 結構和顯示內容。

render() 可以返回下面這幾種資料型態其一:

  • React elements

    通常 render() 返回的就是用 JSX 建立的 React 元素。

  • String / numbers

    你也可以返回字串或是數字,這會被當作是 HTML DOM 的 text nodes 來顯示。

  • null

    返回 null 告訴 React 不顯示任何東西。

  • Booleans

    返回布林值 false 也是告訴 React 不顯示任何東西。Booleans 通常是為了這種寫法 pattern:

    render() {
      // .....
      return test && <Child />;
    }
    

    其中 test 是一個布林值,當作一個開關值 (flag) 決定是否要顯示 <Child />

實作慣例上,我們會保持 render() 是一個 pure function,不會在裡面更改 State 值,也不會在裡面寫任何會造成 side-effects 的 code (例如和瀏覽器互動)。

constructor(props)

元件 Class 的 constructor (建構子) 會在元件還沒被掛載到 DOM 之前先被執行來做初始化。

React 元件是 React.Componentsubclass,你必須在 constructor 的最前面 call super(props) 否則會發生問題,像是會沒有 this.props

constructor 是用來初始化 (initialize) state 的地方,直接將初始值指定 (assign) 給 this.state 這屬性,你不能在 constructor 中使用 setState() 喔。

用法例子:

constructor(props) {
  super(props);
  this.state = {
    color: props.initialColor
  };
}

static getDerivedStateFromProps(props, state)

getDerivedStateFromProps() 是一個 static method,會在「每一次」跑 render() 之前被呼叫執行。

getDerivedStateFromProps(props, state) 執行時會傳入當前的 props 和 state,執行後需要返回一個物件 (object) 來表示欲更新的 state 或返回 null 表示不更新。

例如:

class ExampleComponent extends React.Component {
  state = {
    isScrollingDown: false,
    lastRow: null,
  };

  static getDerivedStateFromProps(props, state) {
    if (props.currentRow !== state.lastRow) {
      // 更新 state
      return {
        isScrollingDown: props.currentRow > state.lastRow,
        lastRow: props.currentRow,
      };
    }

    // 沒任何更新
    return null;
  }
}
getDerivedStateFromProps 是從 React 16.3 提供的新方法,在之前類似的方法是 componentWillReceiveProps()

componentWillMount()

componentWillMount() 只會被執行一次,會在元件被掛載到實際的 HTML DOM 之前被呼叫執行。

componentWillMount() 會在第一次的 render() 執行之前就先被執行,所以你不能在 componentWillMount() 中做跟 DOM 有關的操作。

通常 componentWillMount() 比較少會用到,實用性不大,大部分可以在 componentWillMount() 做的事都可以也更適合在 constructor() 中完成。

由於 componentWillMount 容易被誤解誤用,從 React 17 開始被拿掉。

componentDidMount()

componentDidMount() 會在元件被掛載到 DOM 後被執行 - 也就是說元件已經實際存在在畫面中,任何需要 DOM 或會 Asynchronous 更新 state 狀態的操作都適合放在 componentDidMount() 做。

適合在 componentDidMount() 做的,像是綁定元件的 DOM 事件,或 AJAX 拉遠端資料來進一步初始化元件。

Updating Lifecycle Methods

React 提供有這些 Updating 階段的方法:

componentWillReceiveProps(nextProps)

componentWillReceiveProps() 會在每次元件接收到 props 更新時被執行,通常我們會在 componentWillReceiveProps() 中聽 props 的改變來更新元件對應的 State 值。

componentWillReceiveProps() 會傳進 nextProps 這參數,表示即將更新的 props 值,讓你可以運用 this.props 和 nextProps 來比對 props 前後值的變化,來更新相對應的 state。

有時候不一定是 props 有更新才會 call componentWillReceiveProps(),當父元件刷新子元件時也會執行 componentWillReceiveProps()。因為每一次元件更新時,componentWillReceiveProps() 有可能會被執行好幾次,所以要避免有任何 side effect 的 code 寫在裡面。

元件第一次 render() 時,React 不會 call componentWillReceiveProps()。

使用例子:

componentWillReceiveProps(nextProps) {
  if (this.props.initialX !== nextProps.initialX) {
    this.setState = ({
      // .....
    });
  }
}
由於 componentWillReceiveProps 容易被誤解誤用,從 React 17 開始被拿掉。如果你是要聽 props 變化更新 state 請改用 getDerivedStateFromProps;如果你是要聽 props 變化來做一些 side effect 操作請改用 componentDidUpdate

shouldComponentUpdate(nextProps, nextState)

shouldComponentUpdate() 是用來你想最佳化效能 (performance) 時使用,每當 Props 或 State 有更新時,React 會在 call render() 重繪畫面之前,先 call shouldComponentUpdate() 決定是否真的需要 render()。

React 雖然會很聰明地自動偵測到 Props 和 State 狀態有改變來自動更新元件 (re-render),但有些狀況下你可以幫助 React 更精確判斷元件是否真的需要更新,這就是 shouldComponentUpdate() 的功用了。

你可以利用 shouldComponentUpdate() 傳進的參數 nextProps, nextState (新的 props 和 state 值) 和當前的 this.props, this.state 來判斷元件資料狀態變化,shouldComponentUpdate() 執行後得返回一個布林值 (Boolean) 來告訴 React 是否需要更新元件,如果返回 falsecomponentWillUpdate(), render(), componentDidUpdate() 這些 Lifecycole Methods 就不會被執行。

若你沒實作 shouldComponentUpdate() 預設上返回 true

componentWillUpdate(nextProps, nextState)

componentWillUpdate() 會在元件準備更新、執行 render() 之前被執行。

每一次元件更新時,componentWillUpdate() 有可能會被執行好幾次,所以要避免有任何 side effect 的 code 寫在裡面。而在 componentWillUpdate() 中,也禁止有任何會更新到元件的動作,像是不能 call this.setState(),如果你會更新 State 請用 getDerivedStateFromProps

在大部分的情形下,其實 componentWillUpdate() 實用性並不高。

元件第一次 render() 時,React 不會 call componentWillUpdate()。
由於 componentWillUpdate 容易被誤解誤用,從 React 17 開始被拿掉。

getSnapshotBeforeUpdate(prevProps, prevState)

getSnapshotBeforeUpdate 會在畫面實際渲染 (rendered) 前一刻被呼叫執行,簡單的說觸發的時機點是在 React 進行修改前,通常是更新 DOM 前。

getSnapshotBeforeUpdate 被執行後 return 的值會被傳進 componentDidUpdate 的第三個參數。

通常 getSnapshotBeforeUpdate 可以用在紀錄畫面準備修改前的當下狀態,像是 scroll position:

class ScrollingList extends React.Component {
  constructor(props) {
    super(props);
    this.listRef = React.createRef();
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    // 如果 list 內容有變動,有新東西加到 list 前面時
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current;
      // 紀錄更新前一刻,捲軸的位置 scroll position
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // 如果有 snapshot
    if (snapshot !== null) {
      const list = this.listRef.current;
      // 調整 scroll 的位置,避免畫面才不會被新的東西推開
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return (
      <div ref={this.listRef}>{/* ...contents... */}</div>
    );
  }
}
getSnapshotBeforeUpdate 是從 React 16.3 提供的新方法。

componentDidUpdate(prevProps, prevState, snapshot)

componentDidUpdate() 會在元件更新完成、執行完 render() 重繪後被執行。而每一次元件更新時,React 確保 componentDidUpdate() 只會被執行一次。

在這邊可以執行像在 componentDidMount() 中的操作,比對 prevProps/prevState 及 this.props/this.state 狀態差異,做像是存取 DOM、重畫 Canvas、重整頁面 layout、AJAX 網路呼叫等動作。

如果你的 component 有實作 getSnapshotBeforeUpdate 方法,getSnapshotBeforeUpdate 的返回值 (return value) 會被傳進去 componentDidUpdate 當作第三個參數 snapshot,沒實作的話這參數值就會是 undefined

元件第一次 render() 時,React 不會 call componentDidUpdate()。

Unmounting Lifecycle Methods

React 提供有這些 Unmounting 階段的方法:

componentWillUnmount()

當元件將要從 DOM 中被移除之前,React 會執行 componentWillUnmount()。

你可以在 componentWillUnmount() 中做資源清理的動作,清除跟這元件有關的任何遺留物,像是清除你在 componentDidMount() 中建立的資源,例如清除 timer、取消 AJAX request、移除 event listener 等。

Error Handling Lifecycle Methods

React 提供有這些例外處理的方法:

componentDidCatch(error, info)

React 在 V16 版後新增了錯誤邊界 (Error Boundary) 的概念,新加入了 componentDidCatch() 可以用來捕捉 (catch) 從子元件 (child component tree) 中拋出的錯誤 (JavaScript errors),避免因為一個小元件發生意外錯誤就造成整個頁面掛掉,讓錯誤不會影響到邊界外層的父元件,你可以在 componentDidCatch() 決定這個例外錯誤該怎麼處理,像是 fallback UI。

想要成為一個 Error Boundary 的方式很簡單,只要在 Component 中加入 componentDidCatch() 方法就成為 Error Boundary 元件,每當子元件發生錯誤時,會被 Error Boundary 捕捉,不會讓錯誤延伸影響到錯誤邊界外層。

Error Boundary 的限制:

  • 只能捕捉子元件的錯誤,不包含 Error Boundary 元件本身
  • 只能捕捉從 constructor(), render() 和各 Lifecycle Methods 中發生的錯誤
  • 非同步 (Asynchronous) 程式中發生的錯誤無法被捕捉
  • Event Handler 發生的錯誤無法被捕捉

Error Boundary 實作範例:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // 顯示 fallback UI
    this.setState({ hasError: true });
    // 可以將 error 錯誤訊息記錄下來
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      // 顯示 fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

然後這樣使用 Error Boundary:

<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

元件生命週期事件順序

整理一下整個 Component 的生命週期發生的事件順序。

當元件第一次 render 時的順序:

  1. constructor
  2. componentWillMount, getDerivedStateFromProps
  3. render
  4. (React 實際更新 DOM / refs)
  5. componentDidMount

此後,當元件被更新 (update) 時的順序:

  1. componentWillReceiveProps, getDerivedStateFromProps
  2. shouldComponentUpdate - 如果 return false 就不會再往下走
  3. componentWillUpdate
  4. render
  5. getSnapshotBeforeUpdate
  6. (React 實際更新 DOM / refs)
  7. componentDidUpdate

其中不能執行 this.setState() 的事件:

  • render
  • componentWillMount
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • componentWillUpdate

元件的方法和屬性

之前我們介紹過的元件方法 (Method) 有 setState(),而屬性 (Properties) 有 propsstate。再來介紹幾個元件的方法和屬性:

component.forceUpdate(callback)

通常元件會根據 Props 和 State 的改變來更新元件,但有時候元件會依賴其他的資料,這時你就需要用 this.forceUpdate() 來告訴 React 你要更新元件。

執行 forceUpdate() 後 React 會跳過 shouldComponentUpdate() 直接執行 render() (但子元件的 Lifecycle Methods 都會被正常執行)。

而參數 callback 是一個 function,當元件更新完後會被執行。

一般實作上,需要盡量避免使用到 forceUpdate(),在 render() 中只用 this.props 和 this.state。

defaultProps

defaultProps 是一個 class property,用來設定當 props 是 undefined 時的預設值。

使用例子:

class CustomButton extends React.Component {
  // ...
}

CustomButton.defaultProps = {
  color: 'blue'
};

當沒有設定 props.color 時,props.color 值會是 blue:

render() {
  return <CustomButton /> ;
}

只有當 props 是 undefined 才會用 defaultProps 的值,如果值是 null 時則還是 null

render() {
  // props.color 的值會不動還是 null
  return <CustomButton color={null} />;
}

Class Fields + Arrow Function

React 內建的 Lifecycle 方法裡的 this 都會正確指向 Component 本身,但如果是你自己新增的方法,你需要在 constructor() 自己綁定 (bind) this。

例如:

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    // 要自己 bind this
    this.handleClick = this.handleClick.bind(this);
  }

  render() {
    return (
      <button onClick={this.handleClick}>
        This is a button
      </button>
    )
  }

  handleClick() {
    this.updateList();
  }
}

但更方便的方式是用 Class Fields (類別屬性) 語法和 ES6 的 Arrow function 讓 this 被自動綁定:

class MyComponent extends React.Component {

  // ...

  handleClick = () => {
    this.updateList();
  }
}
Class Fields 當前還在 ECMAScript proposal stage 3 的階段,但你還是可以使用,因為反正寫 React 本來就會用 Babel 來改寫 JSX 了,Babel 也有 Class Fields Plugin