深入React的生命週期(上):出生階段(Mount)

李熠發表於2017-11-05

前言

本文是對開源圖書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分支有沒有新的合併事件發生,如果你的服務收到了該事件的訊息,那麼你就可以例子執行部署工作。

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

出生

  • constructor
  • getDefaultProps() (React.createClass) orMyComponent.defaultProps (ES6 class)
  • getInitialState() (React.createClass) or this.state = ... (ES6 constructor)
  • componentWillMount()
  • render()
  • componentDidMount()

首先我們要引入一個概念:元件(Component)。元件非常好理解,就是可以複用的模板。例如通過按鈕元件(模板)我們可以例項化出多個相似的按鈕出來。這和程式碼中類(Class)的概念是相同的。並且在ES6程式碼中定義元件時也是通過類來實現的:

import React from 'react';

class MyButton extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    return (
      <button>My Button</button>
    )
  }
}複製程式碼

也可以通過ES2015的語法介面React.createClass來定義元件:

const MyButton = React.createClass({
  render: function() {
    return (
      <button>My Button</button>      
    );
  }
});複製程式碼

如果你的babel配置檔案.babelrcpresets指定了es2015,那麼在編譯之後的檔案中,你會發現class MyButton extends React.Component語句編譯之後的結果就是React.createClass

注意到當我們在使用class定義元件時,繼承(extends)了React.Component類。但實際上這並不是必須的。比如你完全可以寫成純函式的形式:

const MyButton = () => {
  return <h1>My Button</h1>
}複製程式碼

這就是無狀態(stateless)元件,顧名思義它是沒有自己獨立狀態的,這個概念被用於React的設計模式:High Order Component和Container Component中。具體可以參考我的另一篇文章面試系列之三:你真的瞭解React嗎(中)元件間的通訊以及React優化

它的侷限也很明顯,因為沒有繼承React.Component的緣故,你無法獲得各種生命週期函式,也無法訪問狀態(state),但是仍然能夠訪問傳入的屬性(props),它們是作為函式的引數傳入的。

定義元件時並不會觸發任何的生命週期函式,元件自己也並不會存在生命週期這一說,真正的生命週期開始於元件被渲染至頁面中。

讓我們看一段最簡單的程式碼:

import React from 'react';
import ReactDOM from 'react-dom';

class MyComponent extends React.Component {
  render() {
    return <div>Hello World!</div>;
  }
};

ReactDOM.render(<MyComponent />, document.getElementById('mount-point'));複製程式碼

在這段程式碼中,MyComponnet元件通過ReactDOM.render函式被渲染至頁面中。如果你在MyComponent元件的各個生命週期函式中新增日誌的話,會看到日誌依次在控制檯輸出。

為了說明一些問題,我們嘗試對程式碼做一些修改:

import MyButton from './Button';
class MyComponent extends React.Component {
  render() {
    const button = <MyButton />
    return <div>Hello World!</div>;
  }
};複製程式碼

在元件的render函式中,我們使用到了另一個元件MyButton,但是它並沒有出現在最終返回的DOM結構中。問題來了,當MyComponnet元件渲染至頁面上時,Mybutton元件的生命週期函式會開始呼叫嗎?<MyButton />究竟代表了什麼?

我們先回答第二個問題。<MyButton />看上去確實有些奇怪,但是別忘了它是JSX語法。如果你去看babel編譯之後的程式碼就會發現,其實它把<MyButton />轉化為函式呼叫:React.createElement(MyButton, null)。也就是說<XXX />語法,實際上返回的是一個XXX型別的React元素(Element)。React元素說白了就是一個純粹的object物件,基本由key(id), props(屬性), ref, type(元素型別)四個屬性組成(children屬性包含在props中)。為什麼要用“純粹”這個形容詞,是因為雖然它和元件有關,但是它並不包含元件的方法,此時此刻,它僅僅是一個包含若干屬性的物件。如果你覺得這一切看上去都無比熟悉的話,那麼你猜對了,元素代表的其實是虛擬DOM(Virtual DOM)上的節點,是對你在頁面上看到的每一個DOM節點的描述。

那麼我們可以回答第一個問題了,僅僅是生成一個React元素是不會觸發生命週期函式呼叫的。

當我們把React元素傳遞給ReactDOM.render方法,並且告訴它具體在頁面上渲染元素的位置之後,它會給我們返回元件的例項(Instance)。在JS語法中,我們通過new關鍵字初始化一個類的例項,而在React中,我們通過ReactDOM.render方法來初始化一個元件的例項。但一般情況下我們不會用到這個例項,不過你也可以保留它的引用賦值給一個變數,當測試元件的時候可以派上用場

Default Porps & Default State

如果被問起constructor之後的下一個生命週期函式是什麼,絕大部分人會回答componentWillMount。準確來說應該是getDefaultPropsgetInitialState

而為什麼大部分人對這兩個函式陌生,是因為這兩個函式只是在ES2015語法中建立元件時暴露出來,在ES6語法中我們通過兩個賦值語句實現了同樣的效果。

比如新增預設屬性的getDefaultProps函式在ES6中是通過給元件類新增靜態欄位defaultProps實現的:

class MyComponent extends React.Component() {
  //...
}
MyComponent.defaultProps = { age: 'unknown' }複製程式碼

在實際計算屬性的過程中,將傳入屬性與預設屬性進行合併成為最終使用的屬性,用虛擬碼寫的意思就是

this.props = Object.assign(defaultProps, passedProps);複製程式碼

注意知識點要來了,看下面這個元件:

class App extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    return <div>{this.props.name}</div>
  }
}
App.defaultProps = { name: 'default' };複製程式碼

我給這個元件設定了一個預設屬性name,值為default。那麼在

  1. <App name={null} />
  2. <App name={undefined} />
    這兩種情況下,this.props.name值會是什麼?也就是最終輸出會是什麼?

正確答案是如果給name傳入的值是null,那麼最終頁面上的輸出是空,也就是null會生效;如果傳入的是undefined,那麼React認為這個值是undefined貨真價實的未定義,則會使用預設值,最終頁面上的輸出是default

而獲取預設狀態的函式getInitialState在ES6中是通過給this.state賦值實現的

class Person extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
  }
  //...
}複製程式碼

componentWillMount()

componentWillMount函式在第一次render之前被呼叫,並且只會被呼叫一次。當元件進入到這個生命週期中時,所有的stateprops已經配置完畢,我們可以通過this.propsthis.state訪問它們,也可以通過setState重新設定狀態。總之推薦在這個生命週期函式裡進行狀態初始化的處理,為下一步render做準備

render()

當一切配置都就緒之後,就能夠正式開始渲染元件了。render函式和其他的鉤子函式不同,它會同時在出生和更新階段被呼叫。在出生階段被呼叫一次,但是在更新階段會被呼叫多次。

無論是編寫哪個階段的render函式,請牢記一點:保證它的“純粹”(pure)。怎樣才算純粹?最基本的一點是不要嘗試在render裡改變元件的狀態。因為通過setState引發的狀態改變會導致再一次呼叫render函式進行渲染,而又繼續改變狀態又繼續渲染,導致無限迴圈下去。如果你這麼做了你會在開發模式下收到警告:

Warning: Cannot update during an existing state transition (such as within render or another component's constructor). Render methods should be a pure function of props and state; constructor side-effects are an anti-pattern, but can be moved to componentWillMount.

另一個需要注意的地方是,你也不應該在render中通過ReactDOM.findDOMNode方法訪問原生的DOM元素(原生相對於虛擬DOM而言)。因為這麼做存在兩個風險:

  1. 此時虛擬元素還沒有被渲染到頁面上,所以你訪問的元素並不存在
  2. 因為當前的render即將執行完畢返回新的DOM結構,你訪問到的可能是舊的資料。

並且如果你真的這麼做了,那麼你會得到警告:

Warning: App is accessing findDOMNode inside its render(). render() should be a pure function of props and state. It should never access something that requires stale data from the previous render, such as refs. Move this logic to componentDidMount and componentDidUpdate instead.

componentDidMount()

當這個函式被呼叫時,就意味著可以訪問元件的原生DOM了。如果你有經驗的話,此時不僅僅能夠訪問當前元件的DOM,還能夠訪問當前元件孩子元件的原生DOM元素。

你可能會覺得所有這一切應當。

在之前講解每個周期函式時,都只考慮單個元件的情況。但是當元件包含孩子元件時,孩子元件的鉤子函式的呼叫順序就需要留意了。

比如有下面這樣的樹狀結構的元件

react element tree
react element tree

在出生階段時componentWillMountrender的呼叫順序是

A -> A.0 -> A.0.0 -> A.0.1 -> A.1 -> A.2.複製程式碼

這很容易理解,因為當你想渲染父元件時,務必也要立即開始渲染子元件。所以子元件的生命週期開始於父元件之後。

componentDidMount的呼叫順序是

 A.2 -> A.1 -> A.0.1 -> A.0.0 -> A.0 -> A複製程式碼

componentDidMount的呼叫順序正好是render的反向。這其實也很好理解。如果父元件想要渲染完畢,那麼首先它的子元件需要提前渲染完畢,也所以子元件的componentDidMount在父元件之前呼叫。

正因為我們能在這個函式中訪問原生DOM,所以在這個函式中通常會做一些第三方類庫初始化的工具,包括非同步載入資料。比如說對c3.js的初始化

import React from 'react';
import ReactDOM from 'react-dom';
import c3 from 'c3';

export default class Chart extends React.Component {

  componentDidMount() {
    this.chart = c3.generate({
      bindto: ReactDOM.findDOMNode(this.refs.chart),
      data: {
        columns: [
          ['data1', 30, 200, 100, 400, 150, 250],
          ['data2', 50, 20, 10, 40, 15, 25]
        ]
      }
    });
  }

  render() {
    return (
      <div ref="chart"></div>
    );
  }
}複製程式碼

因為能夠訪問原生DOM的緣故,你可能會在componentDidMount函式中重新對元素的樣式進行計算,調整然後生效。因此立即需要對DOM進行重新渲染,此時會使用到forceUpdate方法

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

參考

相關文章