一张图读懂React组件生命周期,及组件更新的注意点

看了网上很多资料,大部分文章在谈React的组件生命周期时,都把所有的组件周期函数给讲一遍,但却很难把握。要读懂react的生命周期,必须要靠我下面提供的这张图。我们在理解生命周期时,不应该把重点放在那些生命周期函数上,这些函数什么时候调用,甚至掉不掉用,完全取决于他们出现在生命周期的哪一个节点上。因此,我们应该从组件真正的生命周期重新去理解这些函数,而非从函数反过来理解周期。

React组件生命周期函数调用顺序示意图

那些资料除了错误的讲解思路之外,还有一个不足就是把生命周期仅限于单个组件,而非父子组件系统。所以,当遇到父组件更新,子组件的周期如何的问题时,就很难理解。这张示意图表达了父子组件系统里面,组件生命周期中对周期函数的调用顺序。

生命周期三阶段

react的组件生命存在三个阶段:创建期、存活期、清理期。事后来看,创建期和清理期很容易搞懂。真正难懂的,是存活期内,对试图的更新问题。即当你在代码中使用this.setState时,界面的更新带来的一系列周期函数是否调用、调用顺序的问题。

创建期

即一个ES6类的实例化过程。当然,react的组件有简写的方法,但我们这里普遍讨论的是,用ES6 class语法撰写的组件的代码流程。我们把目光聚焦到“子组件”上,现在我们来看一个组件的创建过程。

我们使用一个组件,往往是把它引入之后,使用directive的方式,用jsx语法直接在render函数中使用,如下:

import MyComponent from './my-component'

export default class ParentComponent extends React.Component {
  render() {
    return (
      <div>
        <MyComponent />
      </div>
    )
  }
}

当父组件被实例化时,会按照jsx语法,实例化MyComponent作为当前父组件的子组件实例。所谓“实例”是指一个抽象类的具体表现,也就是说同一个类可以有多个实例,甚至在一个父组件里面也可以有多个实例。既然子组件在父组件中是以实例的方式存在,那么就必然有实例化的过程。因此,当父组件实例化的时候,子组件也被实例化出来。

我们回到图中,当父组件的生命周期进入到实例化的render函数之后,react会以内部的机制去实例化子组件。而子组件和孙组件之间的关系,和父组件与子组件的关系是一摸一样的,因此,图中并没有描画孙组件的生命周期调用情况。但是可以肯定的是,当真正的DOM渲染之后,子组件的componentDidMount函数会比父组件的更早执行。当然,组件是一个相对封闭的单位,因此周期函数不应该对上下级组件的did函数有任何影响。

存活期

而实际上,componentDidMount函数的半只脚已经在组件的存活期内了。一旦通过render函数之后,react会把Virtual DOM渲染为真实的DOM,这时就已经是存活期了。而componentDidMount函数是在DOM被渲染出来之后执行的,所以说它已经半只脚在存活期内了。

而一旦组件进入存活期,就可能发生更新的情况,而且更新次数是无限制的。有三种情况下,组件视图会被更新,一种是组件自己调用this.setState,一种是组件自己调用this.forceUpdate,还有一种是父组件更新(父组件调用this.setState或this.forceUpdate或祖组件更新带来的props更改)时导致自己的props被更改。

让我们把目光集中在上图中的子组件的存活期。

react组件存活期的函数调用顺序示意图

创建期过去之后,创建期函数不会再被调用。但componentDidMount中的某些操作将常驻内存,比如绑定了事件,还有一些操作可以带来界面的更新,即在componentDidMount中调用this.setState。

上面这幅小图展示了组件更新的三种方式,最常发生的是父组件传递的props发生变化(父组件的render函数中),此时子组件会首先调用componentWillReceiveProps,这个函数的作用主要是在组件发生变化之前,对当前的state(甚至传递来的props,虽然不提倡)进行修改,即在这个函数内调用this.setState。但是非常重要的一点是,在componentWillReceiveProps中调用setState不会引起界面的重绘,无论父组件传递的props是否发生变化都会经过componentWillReceiveProps函数,只要父组件重新执行了render函数。

另一种是调用setState,即在子组件自己内部调用this.setState,这时不会调用componentWillReceiveProps函数。因此非常明显的一点,当componentWillReceiveProps函数被调用时,明显是来自父组件的变化。因此,可以在componentWillReceiveProps函数内做一些记录,以此来判定组件的变化是否来自父组件。

以上两种情况的更新,都会紧接着执行shouldComponentUpdate函数,这个函数非常重要,它决定了更新动作是否继续往下走。如果返回false,那么就不会在执行下面的那些函数,当然,界面也不会被更新,因此,它是优化性能的关键。由于state和props的变化都会经过这里,所以它有两个参数:nextProps和nextState,它们各自对应不同的更新方式。

还有一种是在组件内调用this.forceUpdate,不需要经过shouldComponentUpdate,forceUpdate直接忽略shouldComponentUpdate,一定会更新组件。

如果有机会进入componentWillUpdate函数,那么当前组件的this.props和this.state都会是新的,你现在可以使用this.props或this.state得到新的值了。它们会被用到render函数中。

经过render函数之后,componentDidUpdate和componentDidMount很像,也是在渲染完(即使界面没有发生变化)之后被调用。但是不同的是,componentDidMount中可以调用this.setState,而componentDidUpdate中几乎不可以。从上面的图中你可以看出setState之后componentDidUpdate函数会被调用,如果你在componentDidUpdate中再调用setState,那么就会造成死循环。当然,从逻辑上讲,如果shouldComponentUpdate返回为false,那么不会进入componentDidUpdate,因此在绝对保证没有问题的情况下,也是可以使用setState的,只不过你要极其极其小心。

销毁期

在销毁期,开发者经常做的事是将componentWillUnmount中设置的监听或异步动作取消掉,已防止监听回调被调用时,发现当期组件已经被销毁了,这样会报错。

其实问题在于,组件什么时候会被销毁?我们开发的时候,几乎很少自己去销毁一个组件。组件的销毁隐匿于父组件的render函数中。当父组件执行render函数时,某些情况下,Virtual DOM中的某些节点经过diff,发现已经不存在了,因此会被销毁,而这个节点正好是一个组件的话,那么组件就会被销毁,而组件的componentWillUnmount方法会被调用。我们来看下例子:

class M extends Component {
  render() {
    return (
      <div>
        {this.props.has ? <MyComponent /> : null}
      </div>
    )
  }
}

当父组件发生更新,而父组件的this.props.has为false时,MyComponent这个组件就会被销毁,它的componentWillUnmount就会被调用。

组件更新的注意点

经过上面的讲解,相信你已经从糊里糊涂变得更加清晰了,但是,在实际开发中,你可能还会遇到一些问题,特别是在涉及到主动更新界面的时候。我们不妨来一一探讨一下。

setState是异步的

这已经被无数人强调过了。当你要进行一项操作,更改state之后想要获取它的值,一定要知道,你不可能马上得到更新后的值:

let count = this.state.count // 9
this.setState({ count: count + 1 })
console.log(this.state.count) // 9

这种不可靠的操作让我们在一些开发过程中非常苦恼。因为异步性,所以react提供了callback方法,让你在state被更新之后可以获得新的state:

let count = this.state.count // 9
this.setState({ count: count + 1 }, () => {
  console.log(this.state.count) // 10
})

但是这样的操作显得很傻。因此,遇到这种情况,你需要非常小心,不一定非得使用setState,你可以有自己的数据管理器,比如自己在组件上挂载一个this.$data来进行数据管理。从这一点上来讲,setState对我来说,更像是update方法,也就是界面的更新触发函数。

父组件中异步加载数据

重点是“父组件中”,异步加载数据是一个非常麻烦的事,因为react的DOM是在第一次实例化的时候被渲染的,而异步加载数据只能放在componentDidMount中,或者你可以有更好的办法把异步加载操作放到componentWillMount中,但是无论如何,你不可能在创建期实现把数据渲染出来,只能在componentDidMount中使用setState把数据塞回去,通过更新界面来展示数据。

我在这篇文章里面提到,组件分为两种,一种是业务组件,一种是纯UI组件。而数据加载一定是业务组件的事,但展示数据往往又是UI组件实现的。那么这里对UI组件就有一个要求:

UI组件输入相同的props,一定能得到相同的界面,而且是同步阻塞的

要求UI组件同步阻塞,就必须要求UI组件的render中使用this.props.data作为数据源,而不能使用this.state.data进行内部可变的操作。这一点很难理解,我们反其道而行,看下如果一个UI组件使用state.data的案例:

export default MyUI extends React.Component {
  construtor(props) {
    super(props)
    this.state = {
      data: []
    }
  }
  componentDidMount() {
    let data = this.props.data
    let newData = []
    data.forEach(item => {
      newData.push({ name: item.n, age: item.a })
    })
    this.setState({ data: newData })
  }
  render() {
    return <Table data={this.state.data} /> // 假设我们有一个Table组件
  }
}

我们在父组件中这样使用它:

export default MyData extends React.Component {
  constructor(props) {
    super(props)
    this.state = { data: [] }
  }
  componentDidMount() {
    fetch(...).then(res => res.json).then(data => this.setState({ data })) // 异步获取数据
  }
  render() {
    return <MyUI data={this.state.data} />
  }
}

父组件作为业务组件,这样是完全没有问题的。我们来看子组件,当子组件实例化之后,componentDidMount被执行,但是实例化的时候,父组件数据根本没有回来,所以子组件的state.data是空的。而当父组件的数据回来之后,本来想通过setState来触发子组件的重新渲染,结果子组件没有任何变化。这是因为,子组件中使用state.data作为数据源,componentDidMount函数被执行之后,数据源就没有发生过任何变化。我们可以通过componentWillReceiveProps来进行修复,前面已经讲过了,可以使用setState:

export default MyUI extends React.Component {
  construtor(props) {
    super(props)
    this.state = {
      data: []
    }
  }
  componentWillReceiveProps(nextProps) {
    this.setState({ data: nextProps.data }) // 不会触发更新视图,但state已经发生改变
  }
  componentDidMount() {
    let data = this.props.data
    let newData = []
    data.forEach(item => {
      newData.push({ name: item.n, age: item.a })
    })
    this.setState({ data: newData })
  }
  render() {
    return <Table data={this.state.data} /> // 假设我们有一个Table组件
  }
}

上面的修改看似合理,但是又出现了新的问题,我们希望对新传过来的数据进行处理之后才给state.data,因此,又必须把数据处理的部分单独成一个独立的方法:

export default MyUI extends React.Component {
  construtor(props) {
    super(props)
    this.state = {
      data: []
    }
  }
  dealWithData(data) {
    let newData = []
    data.forEach(item => {
      newData.push({ name: item.n, age: item.a })
    })
    return newData
  }
  componentWillReceiveProps(nextProps) {
    this.setState({ data: this.dealWithData(nextProps.data) })
  }
  componentDidMount() {
    this.setState({ data: this.dealWithData(this.props.data)})
  }
  render() {
    return <Table data={this.state.data} /> // 假设我们有一个Table组件
  }
}

这一下就改了一大堆代码。而如果我们把这一大堆事情都交给父组件去做,UI组件在渲染的时候直接使用this.props.data作为数据源,而非经过处理的this.state.data,那么事情会变得非常简单。

如果你的界面上,还使用了一个loading效果,那么最好把数据的异步加载放到loading组件的同级组件中,这样就可能出现数据的获取,和数据的展示,两个之间差了好几层组件,这时就可以考虑使用redux了。

2017-10-04 |