深入React的生命周期(下):更新(Update)

前言

本文是对开源图书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分支有没有新的合并事件发生,如果你的服务收到了该事件的消息,那么你就可以例子执行部署工作。

我们按照阶段的时间顺序对每一个钩子函数进行讲解。

有关出生阶段请参考上一篇《深入React的生命周期(上):出生阶段(Mount)》

更新阶段

  • componentWillReceiveProps()
  • shouldComponentUpdate()
  • componentWillUpdate()
  • render()
  • componentDidUpdate()

更新阶段会在三种情况下触发:

  • 更改props:一个组件并不能主动更改它拥有的props属性,它的props属性是由它的父组件传递给它的。强制对props进行重新赋值会导致程序报错。

  • 更改statestate的更改是通过setState接口实现的。同时设计state是需要技巧的,哪些状态可以放在里面,哪些不可以;什么样的组件可以有state,哪些不可以有;这些都需要遵循一定原则的。这个话题有机会可以单独拎出来说

  • 调用forceUpdate方法:这个我们在上一阶段已经提到了,强制组件进行更新。

setState是异步的

组件的更新原因很大一部分是因为调用setState接口更新state所致,我们常常以同步的方式调用setState,但实际上setState方法是异步的。比如下面的这段代码:

onClick() {
  this.setState({
    count: 1,
  });
  console.log(this.state.count)
}

在一个组件的点击事件处理函数中,我们更新了state中的count,然后立即尝试去读取最新的count。事实是你读取的结果不是1,二应该是之前的值。

更致命的错误是类似这样在同一个块级中连续调用setState的代码

this.setState({ ...this.state, foo: 42 });
this.setState({ ...this.state, isBar: true });

在这种情况下,第一次设置的foo值会被第二次的设置覆盖而还原

componentWillReceiveProps(nextProps)

当传递给组件的props发生改变时,组件的componentWillReceiveProps即会被触发调用,方法传递的参数的是发更更改的之后的props值(通常我们命名为nextProps)。在这个方法里,你可以通过this.props访问当前的属性值,可以通过nextProps访问即将更新的属性值,或者将它们进行对比,或者将它们进行计算,最终确定你需要更新的状态(state)并最终调用setState方法对状态进行更新。在这个钩子函数中调用setState方法并不会触发再一次渲染。

非常有意思的是,虽然props的更改会引起componentWillReceiveProps的调用;但componentWillReceiveProps的调用并不意味着props真的发生了变化。这可不是我说的,Facebook官方花了一整篇文章说这件事:(A => B) !=> (B => A)。比如看下面这个组件:

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      number: 1,
    }
    this.onClick = this.onClick.bind(this);
  }
  onClick() {
    this.setState({
      number: 1,
    })
  }
  render() {
    return (
      <MyButton onClick={this.onClick} data-number={this.state.number} />
    );
  }
}

每一次点击事件都会重新使用setState接口对state进行更新,但每次更新的值都是相同的,即number:1。并且把当前组件的状态以属性的形式传递给<MyButton />。问题来了,那么当我每次点击按钮时,按钮MyButtoncomponentWillReceiveProps都会被调用吗?

会,即使每次更新的值都是一样的。

之所以出现这样的情况原因其实非常简单,因为React并不知道传入的属性是否发生了更改。而为什么React不尝试去做一个是否相等的判断呢?

因为办不到,新传入的属性和旧属性可能引用的是同一块内存区域(引用类型),所以单纯的用===判断是否相等并不准确。可行的解决办法之一就是对数据进行深度拷贝然后进行比较,但是这对大型数据结构来说性能太差,还能会碰上循环引用的问题。

所以React将这个变化通过钩子函数暴露出来,千万不要以为当componentWillReceiveProps被调用就意味着props发生了更改,如果需要在变化时做一些事情,务必要手动的进行比较。

shouldComponentUpdate()

shouldComponentUpdate很重要,它可以决定是否继续当前的生命周期。默认情况该函数返回true即继续当前的生命周期;也可以返回false终止当前的生命周期,阻止进一步的render与接下来的步骤。

我们上面刚刚说过,React并不会对props进行深度比较,这对state也同样适用。所以即使propsstate并未发生了更改,shouldComponentUpdate也会被再次调用,包括接下来的步骤componentWillUpdaterendercomponentDidUpdate也都会再次运行一次。这很明显会给性能造成不小的伤害。

传递给shouldComponentUpdate的参数包括即将改变的propsstate,形参的名称是nextPropsnextState,在这个函数里你同时又能通过this关键字访问到当前的stateprops,所以你在这里你是“全知”的,可以完全按照你自己的业务逻辑判断是否stateprops是否发生了更改,并且决定是否要继续接下来的步骤。shouldComponentUpdate也就通常我们在优化React性能时的第一步。这一步的优化不仅仅是优化组件自身的流程,同时也能节省去子组件的重新渲染的代价 。

当然如果你对判断props是否发生改变的检测逻辑要求比较简单的话,比如只是浅度(shallow)的判断(即判断对象的引用是否发生了更改)对象是否发生了更改,那么可以利用PureRenderMixin

import PureRenderMixin from 'react-addons-pure-render-mixin'; // ES6
const createReactClass = require('create-react-class');

createReactClass({
  mixins: [PureRenderMixin],

  render: function() {
    return <div className={this.props.className}>foo</div>;
  }
});

minins是React支持的一种允许多个组件共用代码的一种机制。PureRenderMixin插件的工作非常简单,它为你重写了shouldComponentUpdate函数,并对对象进行了浅度对比,具体代码可以从这里和这里找到。

在ES6中你也可以通过直接继承React.PureComponent而不是React.Component来实现这个功能。用React官方的原话说就是

React.PureComponent is exactly like React.Component, but implements shouldComponentUpdate() with a shallow prop and state comparison.

Pure

我们再次强调,PureComponent为你实现的只是对引用是否发生了更改的判断,甚至可以说它只是简单的用===进行的判断,所以这也是我们称之为pure的原因。为了具体说明问题,我们举一个实际的例子

/* MyButton.js: */
import React from 'react';

class MyButton extends React.PureComponent {
  constructor(props) {
    super(props);
  }
  render() {
    console.log('render');
    return <button onClick={this.props.onClick}>My Button</button>
  }
}
export default MyButton;

/* App.js: */
import React from 'react';
import MyButton from './Button.js';

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      arr: [1],
    }
    this.onClick = this.onClick.bind(this);
  }
  onClick() {
    this.setState({
      arr: [...this.state.arr, 2],
    });
  }
  render() {
    return (
      <MyButton onClick={this.onClick} data-arr={this.state.arr} />
    );
  }
}

export default App;

在上面的这个例子中,每一次点击都会修改state中的arr变量,arr变量的引用和值都发生了更改。重点是MyButton组件继承的是React.PureComponent。那么每一次点击时,MyButton中的log信息都会被打印出来,即每次都会重新出发render

如果我们把onClick方法做一些修改:

onClick() {
  const arr = this.state.arr;
  arr.push(2);
  this.setState({
    arr: arr,
  })
}

这个方法同样使得arr变量发生了变化,但是仅仅是值而不是引用,此时当再一次点击按钮(MyButton)时,MyButton都不会再次进行渲染了。也就是说PureComponent提前为我们进行了shallow comparison.

使用这种只修改引用,不修改数据内容的immutable data也常常作为优化React的一个手段之一。immutable.js就能为我们实现这个需求,每一次修改数据时你得到的其实是新的数据引用,而不会修改到原有的数据。同时Redux中的reducer想达到的效果其实也相似,reducer的重点是它的纯洁性(pure),在执行时不会造成副作用,即避免对传入数据引用的修改,同时也方便比较出组件状态的更新。

componentWillUpdate()

componentWillUpdate方法和componentWillMount方法很相似,都是在即将发生渲染前触发,在这里你能够拿到nextPropsnextState,同时也能访问到当前即将过期的propsstate。如果有需要的话你可以把它们暂存起来便于以后使用。

componentWillMount不同的是,在这个方法中你不可以使用setState,否则会立即触发另一轮的渲染并且又再一次调用componentWillUpdate,陷入无限循环中。

componentDidUpdate()

和Mount阶段类似,当组件进入componentDidUpdate阶段时意味着最新的原生DOM已经渲染完成并且可以通过refs进行访问。该函数会传入两个参数,分别是prevPropsprevState,顾名思义是之前的状态。你仍然可以通过this关键字访问当前的状态,因为可以访问原生DOM的关系,在这里也适用于做一些第三方需要操纵类库的操作。

update阶段各个钩子函数的调用顺序也与mount阶段相似,尤其是componentDidUpdate,子组件的该钩子函数优先于父组件调用

因为可以访问DOM的缘故,我们有可能需要在这个钩子函数里获取实际的元素样式,并且写入state中,比如你的代码可能会长这样:

componentDidUpdate(prevProps, prevState) {
// BAD: DO NOT DO THIS!!!
  let height = ReactDOM.findDOMNode(this).offsetHeight;
  this.setState({ internalHeight: height });
}

如果默认情况下你的shouldComponentUpdate()函数总是返回true的话,那么这样在componentDidUpdate里更新state的代码又会把我们带入无限render的循环中。如果你必须要这么做,那么至少应该把上一次的结果缓存起来,有条件的更新state:

componentDidUpdate(prevProps, prevState) {
  // One possible fix...
  let height = ReactDOM.findDOMNode(this).offsetHeight;
  if (this.state.height !== height ) {
    this.setState({ internalHeight: height });
  }
}

死亡阶段

componentWillUnmount()

当组件需要从DOM中移除时,即会触发这个钩子函数。这里没有太多需要注意的地方,在这个函数中通常会做一些“清洁”相关的工作

  1. 将已经发送的网络请求都取消掉
  2. 移除组件上DOM的Event Listener

总结

最后再次强调,本文是开源图书React In-depth: An exploration of UI development的归纳。基本上想了解生命周期看这一本书就够了,看完也无敌了。希望这篇中文简约版也会对你有帮助。

本文同时也发布在我的知乎专栏,欢迎大家关注

参考

  • do not extend React.Component
  • React Elements vs React Components vs Component Backing Instances
  • React.createClass versus extends React.Component
  • (A => B) !=> (B => A)
  • Beware: React setState is asynchronous!

相关推荐