理解React:Fiber架構和新舊生命週期

語言: CN / TW / HK

➣ React Fiber原理


React架構

  • 1)Virtual DOM 層,描述頁面長什麼樣
  • 2)Reconciler 層,負責呼叫元件生命週期方法,進行Diff運算等
  • 3)Renderer 層,根據不同的平臺,渲染出相應的頁面,如 ReactDOM 和 ReactNative

React15遺留問題

StackReconciler

  • 1)瀏覽器的整體渲染是多執行緒的,包括GUI渲染執行緒、JS引擎執行緒、事件觸發執行緒、定時觸發器執行緒和非同步http請求執行緒。頁面繪製和JS運算是互斥的執行緒,兩者不能同時進行。
  • 2)React15使用JS的函式呼叫棧(Stack Reconciler)遞迴渲染介面,因此在處理DOM元素過多的複雜頁面的頻繁更新時,大量同步進行的任務(樹diff和頁面render)會導致介面更新阻塞、事件響應延遲、動畫卡頓等,因此React團隊在16版本重寫了React Reconciler架構。

React16問題解決

FiberReconciler

  • 1)Fiber Reconciler架構可以允許同步阻塞的任務拆分成多個小任務,每個任務佔用一小段時間片,任務執行完成後判斷有無空閒時間,有則繼續執行下一個任務,否則將控制權交由瀏覽器以讓瀏覽器去處理更高優先順序的任務,等下次拿到時間片後,其它子任務繼續執行。整個流程類似CPU排程邏輯,底層是使用了瀏覽器APIrequestIdleCallback
  • 2)為了實現整個Diff和Render的流程可中斷和恢復,單純的VirtualDom Tree不再滿足需求,React16引入了採用單鏈表結構的Fiber樹,如下圖所示。
  • 3)FiberReconciler架構將更新流程劃分成了兩個階段:1.diff(由多個diff任務組成,任務時間片消耗完後被可被中斷,中斷後由requestIdleCallback再次喚醒) => 2.commit(diff完畢後拿到fiber tree更新結果觸發DOM渲染,不可被中斷)。左邊灰色部分的樹即為一顆fiber樹,右邊的workInProgress為中間態,它是在diff過程中自頂向下構建的樹形結構,可用於斷點恢復,所有工作單元都更新完成之後,生成的workInProgress樹會成為新的fiber tree。
  • 4)fiber tree中每個節點即一個工作單元,跟之前的VirtualDom樹類似,表示一個虛擬DOM節點。workInProgress tree的每個fiber node都儲存著diff過程中產生的effect list,它用來存放diff結果,並且底層的樹節點會依次向上層merge effect list,以收集所有diff結果。注意的是如果某些節點並未更新,workInProgress tree會直接複用原fiber tree的節點(連結串列操作),而有資料更新的節點會被打上tag標籤。
<FiberNode> : {
    stateNode,    // 節點例項
    child,        // 子節點
    sibling,      // 兄弟節點
    return,       // 父節點
}
複製程式碼

FiberTree

➣ React新舊生命週期


React16.3之前的生命週期

  1. componentWillMount()

此生命週期函式會在在元件掛載之前被呼叫,整個生命週期中只被觸發一次。開發者通常用來進行一些資料的預請求操作,以減少請求發起時間,建議的替代方案是考慮放入constructor建構函式中,或者componentDidMount後;另一種情況是在在使用了外部狀態管理庫時,如Mobx,可以用於重置Mobx Store中的的已儲存資料,替代方案是使用生命週期componentWilUnmount在元件解除安裝時自動執行資料清理。

  1. componentDidMount()

此生命週期函式在元件被掛載之後被呼叫,整個生命週期中只觸發一次。開發者同樣可以用來進行一些資料請求的操作;除此之外也可用於新增事件訂閱(需要在componentWillUnmount中取消事件訂閱);因為函式觸發時dom元素已經渲染完畢,第三種使用情況是處理一些介面更新的副作用,比如使用預設資料來初始化一個echarts元件,然後在componentDidUpdate後進行echarts元件的資料更新。

  1. componentWillReceiveProps(nextProps, nexState)

此生命週期發生在元件掛載之後的元件更新階段。最常見於在一個依賴於prop屬性進行元件內部state更新的非完全受控元件中,非完全受控元件即元件內部維護state更新,同時又在某個特殊條件下會採用外部傳入的props來更新內部state,注意不要直接將props完全複製到state,否則應該使用完全受控元件Function Component,一個例子如下:

class EmailInput extends Component {
  state = { email: this.props.email };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }

  handleChange = e => his.setState({ email: e.target.value });

  componentWillReceiveProps(nextProps) {
    if (nextProps.userID !== this.props.userID) {
      this.setState({ email: nextProps.email });
    }
  }
}
複製程式碼
  1. shouldComponentUpdate(nextProps)

此生命週期發生在元件掛載之後的元件更新階段。
值得注意的是子元件更新不一定是由於props或state改變引起的,也可能是父元件的其它部分更改導致父元件重渲染而使得當前子元件在props/state未改變的情況下重新渲染一次。
函式被呼叫時會被傳入即將更新的nextPropsnextState物件,開發者可以通過對比前後兩個props物件上與介面渲染相關的屬性是否改變,再決定是否允許這次更新(return true表示允許執行更新,否則忽略更新,預設為true)。常搭配物件深比較函式用於減少介面無用渲染次數,優化效能。在一些只需要簡單淺比較props變化的場景下,並且相同的state和props會渲染出相同的內容時,建議使用React.PureComponnet替代,在props更新時React會自動幫你進行一次淺比較,以減少不必要渲染。

class EmailInput extends Component {
  state = { email: this.props.email };

  render() {
    return <input onChange={this.handleChange} value={this.state.email} />;
  }

  handleChange = e => his.setState({ email: e.target.value });

  shouldComponentUpdate(nextProps, nextState) {
    if (
      nextProps.userID === this.props.userID &&
      nextState.email == this.state.email
    ) return false;
  }
}
複製程式碼
  1. componenetWillUpdate(newProps, newState)

此生命週期發生在元件掛載之後的更新階段。當元件收到新的props或state,並且shouldComponentUpdate返回允許更新時,會在渲染之前調此方法,不可以在此生命週期執行setState。在此生命週期中開發者可以在介面實際渲染更新之前拿到最新的nextPropsnextState,從而執行一些副作用:比如觸發一個事件、根據最新的props快取一些計算資料到元件內、平滑介面元素動畫等:

 // 需要搭配css屬性transition使用
 componentWillUpdate : function(newProps,newState){
    if(!newState.show)
      $(ReactDOM.findDOMNode(this.refs.elem)).css({'opacity':'1'});
    else
      $(ReactDOM.findDOMNode(this.refs.elem)).css({'opacity':'0'});;
  },
  componentDidUpdate : function(oldProps,oldState){
    if(this.state.show)
      $(ReactDOM.findDOMNode(this.refs.elem)).css({'opacity':'1'});
    else
      $(ReactDOM.findDOMNode(this.refs.elem)).css({'opacity':'0'});;
  }
複製程式碼
  1. componenetDidUpdate(prevProps, prevState)

此生命週期發生在元件掛載之後的更新階段,元件初次掛載不會觸發。當元件的props和state改變引起介面渲染更新後,此函式會被呼叫,不可以在此生命週期執行setState。我們使用它用來執行一些副作用:比如條件式觸發必要的網路請求來更新本地資料、使用render後的最新資料來呼叫一些外部庫的執行(例子:定時器請求介面資料動態繪製echarts折線圖):

  ...
  componentDidMount() {
    this.echartsElement = echarts.init(this.refs.echart);
    this.echartsElement.setOption(this.props.defaultData);
    ...
  }
  componentDidUpdate() {
    const { treeData } = this.props;
    const optionData = this.echartsElement.getOption();
    optionData.series[0].data = [treeData];
    this.echartsElement.setOption(optionData, true);
  }
複製程式碼
  1. componentWillUnmount()

此生命週期發生在元件解除安裝之前,元件生命週期中只會觸發一次。開發者可以在此函式中執行一些資料清理重置、取消頁面元件的事件訂閱等。

React16.3之後的生命週期

React16.3之後React的Reconciler架構被重寫(Reconciler用於處理生命週期鉤子函式和DOM DIFF),之前版本採用函式呼叫棧遞迴同步渲染機制即Stack Reconciler,dom的diff階段不能被打斷,所以不利於動畫執行和事件響應。React團隊使用Fiber Reconciler架構之後,diff階段根據虛擬DOM節點拆分成包含多個工作任務單元(FiberNode)的Fiber樹(以連結串列實現),實現了Fiber任務單元之間的任意切換和任務之間的打斷及恢復等等。Fiber架構下的非同步渲染導致了componentWillMountcomponentWillReceivePropscomponentWillUpdate三個生命週期在實際渲染之前可能會被呼叫多次,產生不可預料的呼叫結果,因此這三個不安全生命週期函式不建議被使用。取而代之的是使用全新的兩個生命週期函式:getDerivedStateFromPropsgetSnapshotBeforeUpdate

  1. getDerivedStateFromProps(nextProps, currentState)
  • 1)定義

此生命週期發生在元件初始化掛載和元件更新階段,開發者可以用它來替代之前的componentWillReceiveProps生命週期,可用於根據props變化來動態設定元件內部state。
函式為static靜態函式,因此我們無法使用this直接訪問元件例項,也無法使用this.setState直接對state進行更改,以此可以看出React團隊想通過React框架的API式約束來儘量減少開發者的API濫用。函式呼叫時會被傳入即將更新的props和當前元件的state資料作為引數,我們可以通過對比處理props然後返回一個物件來觸發的元件state更新,如果返回null則不更新任何內容。

  • 2)濫用場景一:直接複製props到state上面

這會導致父層級重新渲染時,SimpleInput元件的state都會被重置為父元件重新傳入的props,不管props是否發生了改變。如果你說使用shouldComponentUpdate搭配著避免這種情況可以嗎?程式碼層面上可以,不過可能導致後期shouldComponentUpdate函式的資料來源混亂,任何一個prop的改變都會導致重新渲染和不正確的狀態重置,維護一個可靠的shouldComponentUpdate會更難。

class SimpleInput extends Component {
  state = { attr: ''  };

  render() {
    return <input onChange={(e) => this.setState({ attr: e.target.value })} value={this.state.attr} />;
  }

  static getDerivedStateFromProps(nextProps, currentState) {
    // 這會覆蓋所有元件內的state更新!
    return { attr: nextProps.attr };
  }
}
複製程式碼
  • 3)使用場景: 在props變化後選擇性修改state
class SimpleInput extends Component {
  state = { attr: ''  };

  render() {
    return <input onChange={(e) => this.setState({ attr: e.target.value })} value={this.state.attr} />;
  }

  static getDerivedStateFromProps(nextProps, currentState) {
    if (nextProps.attr !== currentState.attr) return { attr: nextProps.attr };
    return null;
  }
}
複製程式碼

可能導致的bug:在需要重置SimpleInput元件的情況下,由於props.attr未改變,導致元件無法正確重置狀態,表現就是input輸入框元件的值還是上次遺留的輸入。

  • 4)優化的使用場景一:使用完全可控的元件

完全可控的元件即沒有內部狀態的功能元件,其狀態的改變完全受父級props控制,這種方式需要將原本位於元件內的state和改變state的邏輯方法抽離到父級。適用於一些簡單的場景,不過如果父級存在太多的子級狀態管理邏輯也會使邏輯冗餘複雜化。

function SimpleInput(props) {
  return <input onChange={props.onChange} value={props.attr} />;
}
複製程式碼
  • 5)優化的使用場景二:使用有key值的完全可控的元件

如果我們想讓元件擁有自己的狀態管理邏輯,但是在適當的條件下我們又可以控制組件以新的預設值重新初始化,這裡有幾種方法參考:

/* 
  1. 設定一個唯一值傳入作為元件重新初始化的標誌
     通過對比屬性手動讓元件重新初始化
*/
class SimpleInput extends Component {
  state = { attr: this.props.attr, id=""  }; // 初始化預設值

  render() {
    return <input onChange={(e) => this.setState({ attr: e.target.value })} value={this.state.attr} />;
  }

  static getDerivedStateFromProps(nextProps, currentState) {
    if (nextProps.id !== currentState.id)
      return { attr: nextProps.attr, id: nextProps.id };
    return null;
  }
}

/*
  2. 設定一個唯一值作為元件的key值
     key值改變後元件會以預設值重新初始化
  */
class SimpleInput extends Component {
  state = { attr: this.props.attr  }; // 初始化預設值

  render() {
    return <input onChange={(e) => this.setState({ attr: e.target.value })} value={this.state.attr} />;
  }
}

<SimpleInput
  attr={this.props.attr}
  key={this.props.id}
/>

/*
  3. 提供一個外部呼叫函式以供父級直接呼叫以重置元件狀態
     父級通過refs來訪問元件例項,拿到元件的內部方法進行呼叫
  */
class SimpleInput extends Component {
  state = { attr: this.props.attr  }; // 初始化預設值

  resetState = (value) => {
    this.setState({ attr: value });
  }

  render() {
    return <input onChange={(e) => this.setState({ attr: e.target.value })} value={this.state.attr} />;
  }
}

<SimpleInput
  attr={this.props.attr}
  ref={this.simpleInput}
/>
複製程式碼
  1. componentDidMount()

...

  1. shouldComponentUpdate(nextProps, nexState)

...

  1. getSnapshotBeforeUpdate(prevProps, prevState)

此生命週期發生在元件初始化掛載和元件更新階段,介面實際render之前。開發者可以拿到元件更新前的prevPropsprevState,同時也能獲取到dom渲染之前的狀態(比如元素寬高、滾動條長度和位置等等)。此函式的返回值會被作為componentWillUpdate周期函式的第三個引數傳入,通過搭配componentDidUpdate可以完全替代之前componentWillUpdate部分的邏輯,見以下示例。

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

  getSnapshotBeforeUpdate(prevProps, prevState) {
    // 判斷是否在list中新增新的items 
    // 捕獲滾動​​位置以便我們稍後調整滾動位置。
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // 調整滾動位置使得這些新items不會將舊的items推出檢視
    // snapshot是getSnapshotBeforeUpdate的返回值)
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return (
      <div ref={this.listRef}>{/* ...list items... */}</div>
    );
  }
}
複製程式碼
  1. componenetDidUpdate(prevProps, prevState, shot)

此生命週期新增特性:getSnapshotBeforeUpdate的返回值作為此函式執行時傳入的第三個引數。

  1. componenetWillUnmount

...