你真的瞭解 React 生命週期嗎

秋天不落葉發表於2020-01-20

前言

  • 原本我以為對 React 生命週期已經熟的不能再熟了,直到前幾天實現一個功能時,就因為沒有吃透 React 生命週期,把我坑的不要不要的,所以痛定思痛,重新學習一遍 React 生命週期

舊版生命週期

你真的瞭解 React 生命週期嗎

  • 初始化的時候不會把賦值算作更新,所以不會執行更新階段
import React, { Component } from 'react'

export default class LifeCycle extends Component {
    //// props = {age:10,name:'計數器'}
  static defaultProps = {
      name:'計數器'
  }
  constructor(props){
      //Must call super constructor in derived class before accessing 'this' or returning from derived constructor
    super();//this.props = props;
    this.state = {number:0,users:[]};//初始化預設的狀態物件
    console.log('1. constructor 初始化 props and state');
  
  }  
  //componentWillMount在渲染過程中可能會執行多次
  componentWillMount(){
    console.log('2. componentWillMount 元件將要掛載');
    //localStorage.get('userss');
  }
  //componentDidMount在渲染過程中永遠只有執行一次
  //一般是在componentDidMount執行副作用,進行非同步操作
  componentDidMount(){
    console.log('4. componentDidMount 元件掛載完成');
    fetch('https://api.github.com/users').then(res=>res.json()).then(users=>{
        console.log(users);
        this.setState({users});
    });
  }
  shouldComponentUpdate(nextProps,nextState){
    console.log('Counter',nextProps,nextState);
    console.log('5. shouldComponentUpdate 詢問元件是否需要更新');
    return true;
  }
  componentWillUpdate(nextProps, nextState){
    console.log('6. componentWillUpdate 元件將要更新');
  }
  componentDidUpdate(prevProps, prevState)){
    console.log('7. componentDidUpdate 元件更新完畢');
  }
  add = ()=>{
      this.setState({number:this.state.number});
  };
  render() {
    console.log('3.render渲染,也就是掛載')
    return (
      <div style={{border:'5px solid red',padding:'5px'}}>
        <p>{this.props.name}:{this.state.number}</p>
        <button onClick={this.add}>+</button>
        <ul>
            {
                this.state.users.map(user=>(<li>{user.login}</li>))
            }
        </ul>
        {this.state.number%2==0&&<SubCounter number={this.state.number}/>}
      </div>
    )
  }
}
class SubCounter extends Component{
    constructor(props){
        super(props);
        this.state = {number:0};
    }
    componentWillUnmount(){
        console.log('SubCounter componentWillUnmount');
    }
    //呼叫此方法的時候會把新的屬性物件和新的狀態物件傳過來
    shouldComponentUpdate(nextProps,nextState){
        console.log('SubCounter',nextProps,nextState);
        if(nextProps.number%3==0){
            return true;
        }else{
            return false;
        }
    }
    //componentWillReceiveProp 元件收到新的屬性物件
    componentWillReceiveProps(){
      console.log('SubCounter 1.componentWillReceiveProps')
    }
    render(){
        console.log('SubCounter  2.render')
        return(
            <div style={{border:'5px solid green'}}>
                <p>{this.props.number}</p>
            </div>
        )
    }
}
複製程式碼

洋蔥模型

image.png

新版生命週期

你真的瞭解 React 生命週期嗎

static getDerivedStateFromProps

  • static getDerivedStateFromProps(nextProps,prevState):接收父元件傳遞過來的 props 和元件之前的狀態,返回一個物件來更新 state 或者返回 null 來表示接收到的 props 沒有變化,不需要更新 state
  • 該生命週期鉤子的作用: 將父元件傳遞過來的 props 對映 到子元件的 state 上面,這樣元件內部就不用再通過 this.props.xxx 獲取屬性值了,統一通過 this.state.xxx 獲取。對映就相當於拷貝了一份父元件傳過來的 props ,作為子元件自己的狀態。注意:子元件通過 setState 更新自身狀態時,不會改變父元件的 props
  • 配合 componentDidUpdate,可以覆蓋 componentWillReceiveProps 的所有用法
  • 該生命週期鉤子觸發的時機:
    • 在 React 16.3.0 版本中:在元件例項化、接收到新的 props 時會被呼叫
    • 在 React 16.4.0 版本中:在元件例項化、接收到新的 props 、元件狀態更新時會被呼叫
    • 線上 demo —— 測試 16.3.0 和 16.4.0 版本中,該生命週期鉤子什麼情況下會被觸發
  • 使用: 線上 demo
    • 注意:派生狀態時,不需要把元件自身的狀態也設定進去
import React from "react";
import ReactDOM from "react-dom";
import "./styles.css";

function App() {
  return (
    <div className="App">
      <AAA />
    </div>
  );
}

class AAA extends React.Component {
  state = {
    age: 666
  };

  add = () => {
    this.setState({ age: this.state.age + 1 });
  };

  render() {
    return (
      <div>
        <ChildA onChangeParent={this.add} age={this.state.age} />
      </div>
    );
  }
}

class ChildA extends React.Component {
  state = {
    num: 888
  };
 	// 根據新的屬性物件派生狀態物件    
  // nextProps——新的屬性物件 prevState——舊的狀態物件
  static getDerivedStateFromProps(nextprops, state) {
    console.log('props',nextprops);
    // 返回一個物件來更新 state 或者返回 null 來表示接收到的 props 不需要更新 state 
    if (nextprops.age !== state.age) {
      console.log("更新吧");
      return {
        onChangeParent:nextprops.onChangeParent,
        age: nextprops.age,
        // 注意:這裡不需要把元件自身的狀態也放進來
        // num:state.num
      };
    }
    return null;
  }

  add = () => {
    this.setState({ num: this.state.num + 1 });
  };
  render() {
    const { onChangeParent } = this.state;
    console.log('state',this.state);
    return (
      <>
        <div onClick={onChangeParent}>change</div>
        <div onClick={this.add}>add</div>
      </>
    );
  }
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
複製程式碼

getSnapshotBeforeUpdate

  • getSnapshotBeforeUpdate(prevProps, prevState)接收父元件傳遞過來的 props 和元件之前的狀態,此生命週期鉤子必須有返回值,返回值將作為第三個引數傳遞給 componentDidUpdate。必須和 componentDidUpdate 一起使用,否則會報錯
  • 該生命週期鉤子觸發的時機 :被呼叫於 render 之後、更新 DOMrefs 之前
  • 該生命週期鉤子的作用: 它能讓你在元件更新 DOMrefs 之前,從 DOM 中捕獲一些資訊(例如滾動位置)
  • 配合 componentDidUpdate, 可以覆蓋 componentWillUpdate 的所有用法
  • 線上 demo:每次元件更新時,都去獲取之前的滾動位置,讓元件保持在之前的滾動位置
import React, { Component } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function App() {
  return (
    <div className="App">
      <GetSnapshotBeforeUpdate />
    </div>
  );
}

class GetSnapshotBeforeUpdate extends Component {
  constructor(props) {
    super(props);
    this.wrapper = React.createRef();
    this.state = { messages: [] };
  }
  componentDidMount() {
    setInterval(() => {
      this.setState({
        messages: ["msg:" + this.state.messages.length, ...this.state.messages]
      });
      //this.setState({messages:[...this.state.messages,this.state.messages.length]});
    }, 1000);
  }
  getSnapshotBeforeUpdate() {
    // 返回更新內容的高度 300px
    return this.wrapper.current.scrollHeight;
  }
  componentDidUpdate(prevProps, prevState, prevScrollHeight) {
    this.wrapper.current.scrollTop =
      this.wrapper.current.scrollTop +
      (this.wrapper.current.scrollHeight - prevScrollHeight);
  }
  render() {
    let style = {
      height: "100px",
      width: "200px",
      border: "1px solid red",
      overflow: "auto"
    };
    return (
      <ul style={style} ref={this.wrapper}>
        {this.state.messages.map((message, index) => (
          <li key={index}>{message}</li>
        ))}
      </ul>
    );
  }
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
複製程式碼

版本遷移

  • componentWillMountcomponentWillReceivePropscomponentWillUpdate 這三個生命週期因為經常會被誤解和濫用,所以被稱為 不安全(不是指安全性,而是表示使用這些生命週期的程式碼,有可能在未來的 React 版本中存在缺陷,可能會影響未來的非同步渲染) 的生命週期。
  • React 16.3 版本:為不安全的生命週期引入別名 UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate。(舊的生命週期名稱和新的別名都可以在此版本中使用
  • React 16.3 之後的版本:為 componentWillMountcomponentWillReceivePropscomponentWillUpdate 啟用棄用警告。(舊的生命週期名稱和新的別名都可以在此版本中使用,但舊名稱會記錄DEV模式警告
  • React 17.0 版本: 推出新的渲染方式——非同步渲染( Async Rendering),提出一種可被打斷的生命週期,而可以被打斷的階段正是實際 dom 掛載之前的虛擬 dom 構建階段,也就是要被去掉的三個生命週期 componentWillMountcomponentWillReceivePropscomponentWillUpdate。(從這個版本開始,只有新的“UNSAFE_”生命週期名稱將起作用

常見問題

當外部的 props 改變時,如何再次執行請求資料、更改狀態等操作

使用 componentWillReceiveProps

class ExampleComponent extends React.Component {
  state = {
    externalData: null,
  };

  componentDidMount() {
    this._loadAsyncData(this.props.id);
  }

  componentWillReceiveProps(nextProps) {
    // 當父元件的 props 改變時,重新請求資料
    if (nextProps.id !== this.props.id) {
      this.setState({externalData: null});
      this._loadAsyncData(nextProps.id);
    }
  }

  componentWillUnmount() {
    if (this._asyncRequest) {
      this._asyncRequest.cancel();
    }
  }

  render() {
    if (this.state.externalData === null) {
      // Render loading state ...
    } else {
      // Render real UI ...
    }
  }

  _loadAsyncData(id) {
    this._asyncRequest = asyncLoadData(id).then(
      externalData => {
        this._asyncRequest = null;
        this.setState({externalData});
      }
    );
  }
}
複製程式碼

使用 getDerivedStateFromProps + componentDidUpdate 載入資料

class ExampleComponent extends React.Component {
  state = {
    externalData: null,
  };

  static getDerivedStateFromProps(nextProps, prevState) {
    if (nextProps.id !== prevState.prevId) {
      return {
        externalData: null,
        prevId: nextProps.id,
      };
    }
    return null;
  }

  componentDidMount() {
    this._loadAsyncData(this.props.id);
  }
  
  // 藉助 componentDidUpdate
  componentDidUpdate(prevProps, prevState) {
    if (this.state.externalData === null) {
      this._loadAsyncData(this.props.id);
    }
  }

  componentWillUnmount() {
    if (this._asyncRequest) {
      this._asyncRequest.cancel();
    }
  }

  render() {
    if (this.state.externalData === null) {
      // Render loading state ...
    } else {
      // Render real UI ...
    }
  }

  _loadAsyncData(id) {
    this._asyncRequest = asyncLoadData(id).then(
      externalData => {
        this._asyncRequest = null;
        this.setState({externalData});
      }
    );
  }
}
複製程式碼

使用 getDerivedStateFromProps 更改狀態

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

import "./styles.css";

function App() {
  return (
    <div className="App">
      <AAA />
    </div>
  );
}

class AAA extends React.Component {
  state = {
    age: 66
  };

  add = () => {
    this.setState({ age: this.state.age + 1 });
  };
  render() {
    return (
      <div>
        <ChildA onChangeParent={this.add} age={this.state.age} />
      </div>
    );
  }
}

class ChildA extends React.Component {
  state = {
    num: 88
  };

  static getDerivedStateFromProps(nextProps, prevState) {
    if (nextProps.age !== prevState.age) {
      return {
        age: nextProps.age
      };
    }
    return null;
  }

  add = () => {
    this.setState({ num: this.state.num + 1 });
  };

  render() {
    const { onChangeParent } = this.props;
    console.log("render", this.state);
    return (
      <>
        <div onClick={onChangeParent}>change</div>
        <div onClick={this.add}>add</div>
      </>
    );
  }
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
複製程式碼

只用 componentDidUpdate 的寫法

  • 不一定要使用 getDerivedStateFromProps 或者 componentWillReceiveProps
  • 線上 demo
import React from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function App() {
  return (
    <div className="App">
      <AAA />
    </div>
  );
}

class AAA extends React.Component {
  state = {
    age: 66
  };

  add = () => {
    this.setState({ age: this.state.age + 1 });
  };
  render() {
    return (
      <div>
        <ChildA onChangeParent={this.add} age={this.state.age} />
      </div>
    );
  }
}

class ChildA extends React.Component {
  state = {
    num: 88,
    age: this.props.age
  };

  add = () => {
    this.setState({ num: this.state.num + 1 });
  };

  componentDidUpdate() {
    if (this.props.age !== this.state.age) {
      console.log("componentDidUpdate", this.props.age);
      this.setState({ age: this.props.age });
    }
  }

  render() {
    const { onChangeParent } = this.props;
    console.log("render", this.state);
    return (
      <>
        <div onClick={onChangeParent}>change</div>
        <div onClick={this.add}>add</div>
      </>
    );
  }
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
複製程式碼

使用 key 的寫法

  • 通過改變 key ,來重新初始化元件 線上 demo
  • 這聽起來很慢,但是這點的效能是可以忽略的。如果在元件樹的更新上有很重的邏輯,這樣反而會更快,因為省略了子元件的 diff
  • React 官方建議的模式
  • 我覺得這種寫法,非常適合:當你呼叫同事寫的業務 UI 元件時,如果他沒有考慮到元件內部狀態需要跟隨外部 props 的更改的情況(恨不得上去就給他個膝蓋重錘 ???),可以使用 key 來快速實現
class ExampleComponent extends React.Component {
  state = {
    id: '123456',
  };
  render(){
    const {id} = this.state;
    // 當 id 變化時,key 也隨之改變,那麼元件就會重新初始化
    return <ExampleComponent key={id} id={id}/>;
  }
}


class ExampleComponent extends React.Component {
  state = {
    externalData: null,
  };
  // 不需要使用 getDerivedStateFromProps 或者 componentWillReceiveProps
  // static getDerivedStateFromProps(nextProps, prevState) {
  //   if (nextProps.id !== prevState.prevId) {
  //     return {
  //       externalData: null,
  //       prevId: nextProps.id,
  //     };
  //   }
  //   return null;
  // }

  componentDidMount() {
    this._loadAsyncData(this.props.id);
  }

  componentWillUnmount() {
    if (this._asyncRequest) {
      this._asyncRequest.cancel();
    }
  }

  render() {
    if (this.state.externalData === null) {
      // Render loading state ...
    } else {
      // Render real UI ...
    }
  }

  _loadAsyncData(id) {
    this._asyncRequest = asyncLoadData(id).then(
      externalData => {
        this._asyncRequest = null;
        this.setState({externalData});
      }
    );
  }
}
複製程式碼

getDerivedStateFromProps 是一個靜態方法,而元件例項無法繼承靜態方法,所以該生命週期鉤子內部無法通過使用 this 獲取元件例項的屬性/方法。

  • 有些情況下,我們需要對父元件傳遞過來的資料進行過濾/篩選等操作,而這些操作一般都會放在一個單獨的函式中(單一原則),然後將該生命週期鉤子獲取到的 props 傳遞進這些方法中進行處理。
    • 如果選擇把這些方法放在 class 元件上,那麼這些方法得申明成靜態方法,然後在該生命週期鉤子中通過 className.xxx 呼叫這些方法。
class AAA extends React.Component {

  static getDerivedStateFromProps(nextProps, prevState) {
    if (nextProps.id !== prevState.prevId) {
      const data = AAA.filterFn(nextProps.data);
      return {
        data,
        prevId: nextProps.id,
      };
    }
    return null;
  }
  
  static filterFn(data){
  	// 過濾資料
    
    ...
    
    return newData;
  }
  
  ...
}

複製程式碼
  • 或者把這些方法放在 class 元件外面,就不用申明成靜態方法,在該生命週期鉤子中直接呼叫這些方法。
function filterFn(data){
  	// 過濾資料
    ...
    return newData;
}


class AAA extends React.Component {

  static getDerivedStateFromProps(nextProps, prevState) {
    if (nextProps.id !== prevState.prevId) {
      const data = filterFn(nextProps.data);
      return {
        data,
        prevId: nextProps.id,
      };
    }
    return null;
  }
 
  ...
}

複製程式碼
  • 在使用以上兩種方法時,我個人認為的一個缺點:如果這些方法比較複雜,內部還呼叫了別的函式,此時,要麼所有的處理函式都申明成靜態方法,要麼所有的方法都提到元件外部去,並且需要一層層的往下傳遞 props 值。無法像元件例項的方法一樣,可以在每個元件例項方法內,通過 this.props.xxx / this.state.xxx 訪問屬性,會比較麻煩。
  • 還有一種方法: 結合 componentDidUpdate 使用 線上 demo
import React from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function App() {
  return (
    <div className="App">
      <AAA />
    </div>
  );
}

class AAA extends React.Component {
  state = {
    age: 66
  };

  add = () => {
    this.setState({ age: this.state.age + 1 });
  };
  render() {
    return (
      <div>
        <ChildA onChangeParent={this.add} age={this.state.age} />
      </div>
    );
  }
}

class ChildA extends React.Component {
  state = {
    num: 88
  };
  static getDerivedStateFromProps(nextprops, state) {
    console.log("getDerivedStateFromProps", nextprops);
    if (nextprops.age !== state.age) {
      return {
        // 給一個標識
        status: false,
        // age: nextprops.age,
        onChangeParent: nextprops.onChangeParent
      };
    }
    return null;
  }

  add = () => {
    this.setState({ num: this.state.num + 1 });
  };


  processData(){
    console.log("process",this.props);
    return this.props.age;
  }

  componentDidUpdate() {
    // 根據標識來更新狀態
    if (!this.state.status) {
      this.setState({
        age: this.processData(),
        status: true
      });
      console.log("componentDidUpdate");
    }
  }
  componentDidMount() {
    this.setState({
      age: this.props.age,
      status: true
    });
  }

  render() {
    const { onChangeParent } = this.state;
    console.log("render", this.state);
    return (
      <>
        <div onClick={onChangeParent}>change</div>
        <div onClick={this.add}>add</div>
      </>
    );
  }
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

複製程式碼

使用 getDerivedStateFromProps 派生狀態時,不需要把元件自身的狀態也設定進去

class AAA extends React.Component {
  // 必須給 state 設定一個值,哪怕是一個空物件
  state = {
  	num:666
  };
  static getDerivedStateFromProps(nextProps, prevState) {
    if (nextProps.id !== prevState.prevId) {
      return {
        data:nextProps.data,
        prevId: nextProps.id,
        // 只需要對映屬性,不需要把元件自身的狀態也加進去
        // num:prevState.num
      };
    }
    return null;
  }
 
  ...
}

複製程式碼

如果 setState 更新的值不變,那麼還會觸發這些生命週期鉤子嗎?

  • 哪怕每次都設定同樣的值,還是會觸發更新
import React, {Component} from 'react'

export default class LifeCycle extends Component {
    static defaultProps = {
        name: '計數器'
    };

    constructor(props) {
        super(props);
        this.state = {number: 0};//初始化預設的狀態物件
        console.log('1. constructor 初始化 props and state');
    }
    
    componentWillMount() {
        console.log('2. componentWillMount 元件將要掛載');
    }
    
    componentDidMount() {
        console.log('4. componentDidMount 元件掛載完成');
    }

    shouldComponentUpdate(nextProps, nextState) {
        console.log('Counter', nextProps, nextState);
        console.log('5. shouldComponentUpdate 詢問元件是否需要更新');
        return true;
    }

    componentWillUpdate() {
        console.log('6. componentWillUpdate 元件將要更新');
    }

    componentDidUpdate() {
        console.log('7. componentDidUpdate 元件更新完畢');
    }

    add = () => {
        this.setState({number: this.state.number });
    };

    render() {
        console.log('3.render渲染')
        return (
            <div style={{border: '5px solid red', padding: '5px'}}>
                <p>{this.state.number}</p>
                <button onClick={this.add}>+</button>
            </div>
        )
    }
}

複製程式碼

不要在 componentWillMount 中新增事件監聽

  • componentDidMount 中新增事件監聽
  • componentWillMount 可以被打斷或呼叫多次,因此無法保證事件監聽能在 unmount 的時候被成功解除安裝,可能會引起記憶體洩露

由於 React 未來的版本中推出了非同步渲染,在 dom 被掛載之前的階段都可以被打斷重來,導致 componentWillMountcomponentWillUpdatecomponentWillReceiveProps 在一次更新中可能會被觸發多次,因此那些只希望觸發一次的副作用應該放在 componentDidMount

  • 這也就是為什麼要把非同步請求放在 componentDidMount 中,而不是放在 componentWillMount 中的原因,為了向後相容

最常見的誤解就是 getDerivedStateFromPropscomponentWillReceiveProps 只會在 props “改變”時才會呼叫。實際上只要父元件重新渲染時,這兩個生命週期函式就會重新呼叫,不管 props 有沒有“變化”

參考

Update on Async Rendering

React v16.9.0 and the Roadmap Update

你可能不需要使用派生 state

推薦閱讀

傻傻分不清之 Cookie、Session、Token、JWT

React Hooks 詳解 【近 1W 字】+ 專案實戰

React SSR 詳解【近 1W 字】+ 2個專案實戰

從 0 到 1 實現一款簡易版 Webpack

Webpack 轉譯 Typescript 現有方案

相關文章