那些你用错了的 React 生命周期

前端这点事 15 0

本文重点聊一下我在一些代码中常看到使用的 componentWillReceiveProps 这个生命周期。

1.componentWillReceiveProps 误区

我们通常认为 componentWillReceiveProps 只会在父组件传递来的 props 发生变化的时候触发,因此通常我们可能会在 componentWillReceiveProps 中进行下面两种操作。 操作一:接收从父组件传来的 props,来更新子组件自身的 state。 操作二:进行一些有副作用的操作,比如发送请求。

2. 通过 props 更新 state

结论:不是好的解决方案,可能会造成 bug。

2.1 会造成什么问题?

componentWillReceiveProps 会在父组件每次 render 导致子组件 re-render 的时候执行,就算此时 props 没有发生改变也会导致该方法执行。具体可以参见这个例子,此时父组件 state 改变导致子组件重新渲染,尽管此时传递给 EmailInput 组件的 props 并没有变化,但是 EmailInput 组件在 componentWillReceiveProps 中无条件执行从 props 更新到 state 导致程序出现 bug。

另一种常见的做法是判断当前props 和 nextProps 是否相等,再决定是否执行从 props 更新到 state,例如下面:

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

  componentWillReceiveProps(nextProps) {
    // 只要 props.email 改变,就改变 state     if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      });
    }
  }

这也是最常见的写法,但是当我们遇到这种情况的时候,我们期望中的点击 reset 按钮并不会 reset 我们的输入框输入的 email 值。原因在于此时 props 并没有发生改变,因此也不会将传入的 props 更新到组件的 state 上面。

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

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

  handleChange = event => {
    this.setState({ email: event.target.value });
  };

  componentWillReceiveProps(nextProps) {
    // props 发生改变时更新 state     if (nextProps.email !== this.props.email) {
      this.setState({ email: nextProps.email });
    }
  }
}

class App extends React.Component {
  constructor() {
    super();
    this.state = { email: "test@gmail.com" };
  }

  handleReset = () => {
    this.setState({
      email: "test@gmail.com"
    });
  };

  render() {
    return (
      <Fragment>
        <EmailInput email={this.state.email} />
        <button onClick={this.handleReset}>reset</button>
      </Fragment>
    );
  }
}

通过上面两个例子证明了,使用 componentWillReceiveProps 来从 props 更新 state 并不是好的解决方案,我们将从 props 更新的 state 叫做 derived state(派生 state)。在 React 16 中,针对 derived state 的问题, React 推出了新的生命周期 getDerivedStateFromProps,代替了 componentWillReceiveProps ,但是这个生命周期也会存在上面的问题,例如下面这个例子。

使用 getDerivedStateFromProps

Class EmailInput extends React.Component {
    state = {
        email: this.props.email
    }
    static getDerivedStateFromProps (props, state) {
        if (props.email !== state.email) { // props 每次都会覆盖 state 的 email             return {
                email: props.email
            }
        }
        return null
    }
    render () {
  		...
    }
}

不论 state 如何改变都会变成传入的 email ,尽管这个时候父组件并没有像之前例子中改变 state 重新 render,原因在于不同于 componentWillReceiveProps 只在每次父组件 render 导致子组件 re-render 的时候触发, getDerivedStateFromProps 这个生命周期会在每次组件 setState 的时候也会触发,如下图所示,组件的Mounting 和Updating 的任何一个阶段都会触发这个生命周期。

同时,还有一个很麻烦的地方在于,getDerivedStateFromProps 是一个 static 方法,意味着拿不到实例的 this ,所以想要在 setState 之前比对一下 props 有没有更新,无法再使用 http://this.props.xxx ,为了解决这个问题,我们只能再使用一个 prevPropColor 变量来实现:

Class EmailInput extends React.Component {
    state = {
        email: this.props.email,
        prevPropsEmail: ''
    }
    static getDerivedStateFromProps (props, state) {
        if (props.email !== state.prevPropsEmail) {
            return {
                email: props.email,
                prevPropsEmail: props.email
            }
        }
        return null
    }
    ... 
}

并且 React 团队暂时还不想使用 preProps 变量来做这件事情,想象一下,当父组件传递的 props 足够多的时候,每次来比较其中某一个 props 是否发生变化从而更新 state 这个代码写起来会变成什么样子,可能会像下面一样麻烦且冗杂。

Class EmailNameInput extends React.Component {
    state = {
        email: this.props.email,
      	name: this.props.name,
        prevPropsEmail: this.props.email,
      	prevPropsName: this.props.name
    }
    static getDerivedStateFromProps (props, state) {
      	let hasChange = false;
      	const nextUpdateState = {};
        if (props.email !== state.prevPropsEmail) {
          	nextUpdateState.email = props.email;
          	nextUpdateState.prevPropsEmail = props.email;
          	hasChange = true;
        }
      	if (props.name !== state.prevPropsName) {
          	nextUpdateState.name = props.name;
          	nextUpdateState.prevPropName = props.name;
          	hasChange = true;
        }
      	return hasChange ? nextUpdateState : null;
    }
    ... 
}

2.2 如何解决这个问题?

造成 derived state 出现的原因是因为它的改变会有两个数据来源,一部分源自组件自身的 state 状态改变,另一部分来自于 props 中的数据改变。

转念去思考,我们为什么会需要使用 derived state,不外乎下面几种情况(被称为反模式)

  • 我们需要 无条件从 props 更新到 state
  • 我们需要通过条件判断 props 是否改变之后再更新到 state

针对两种反模式,上面的内容中已经介绍了,不论是即将废弃的 componentWillReceiveProps 还是 getDerivedStateFromProps 都会存在上述的问题。而最佳的解决方案是将数据变成单一的数据来源

单一数据源原则

单一数据源原则是指组件只拥有 state 或者 props 唯一的一个数据来源,当如果组件只拥有 props 传入的数据,则认为这个组件是受控的(被父组件控制),当组件只拥有自身的 state 数据的话,则认为这个组件是非受控的(无法被父组件控制)。组件只要符合单一数据源原则,则可以避免上面的反模式。

1. 受控组件

改变一下之前的 EmailInput 组件,让组件只拥有 props, 所有的操作行为均由父组件去完成,此时组件所有行为都受父组件的控制,组件更类似展示组件。

const EmailInput = (props) => (<input onChange={props.onChange} value={props.email} />);

2. 非受控组件

我们可以通过给 EmailInput 组件加上一个唯一的 key 属性,当 key 发生变化的时候,React 不会更新组件而是会直接创建一个新的组件,还是使用之前这个例子,使用唯一的 id 当作组件的 key,当点击 reset 按钮之后,会更新新的 key 生成一个新的组件,新的 EmailInput 组件会使用新的初始值重新创建。这种方式可能会有一些性能上面的消耗,但是在组件树的更新上有很重的逻辑,这样反而会更快,因为省略了子组件 diff。

<EmailInput key={this.state.id} defaultEmail={this.state.email} />

3. 其他方案

当有些时候没有合适的 key 或者其他别的原因无法实现这种的时候,可以退而求其次选择使用 getDerivedStateFromProps 根据某个唯一的属性(比如 id )来更新相关的所有 props。

class EmailNameInput extends Component {
  state = {
    email: this.props.email,
    name: this.props.name,
    prevPropsUserID: this.props.userID
  };

  static getDerivedStateFromProps(props, state) {
    // 重置和 userID 相关的所有属性     if (props.userID !== state.prevPropsUserID) {
      return {
        prevPropsUserID: props.userID,
        email: props.email,
        name: props.name
      };
    }
    return null;
  }
}

3.执行副作用操作

结论:不要这样做,未来难以维护。作为不安全的生命周期,componentWillReceiveProps 即将在 React 17 版本中被废弃

3.1为什么被废弃?

React 团队在 17 年发布的 16.0.0 版本中将 Fiber 正式带上去,但是至今还只发布了 experimental release 支持开启核心的 Concurrent Mode(并发模式)。

简单说一下 Concurrent Mode 的概念:

浏览器渲染进程是多线程的,其中包括我们常见的 JS 引擎线程,GUI 渲染线程,事件触发线程,定时触发器线程以及异步请求线程。为了防止渲染过程出现预期之外的效果,JS 引擎线程和 GUI 线程是互斥的,当 JS 执行时,GUI 渲染线程就会被挂起。当 React 更新一个组件时,从调用生命周期,进行 DOM Diff最后更新到真实 DOM,这是一个同步的操作,因此当我们需要更新的 React 组件非常多的时候,JS 引擎线程需要执行一段很长事件,导致此时浏览器并不会立刻响应用户的界面操作,比如用户的点击行为这类需要及时反馈给用户的操作行为。

而 React Fiber 的并发模式则是通过将一次更新过程进行分片的方式来解决这个问题的,当每个小分片的任务执行完成之后,就查看一下是否有需先处理的高优先级任务。如果存在这类任务就会先停止更新而是去执行优先级高的任务,这样保证用户操作的流畅性。但是被打断的低优先级任务则需要重新再执行。这一系列操作由 React16 引入的 Fiber reconciler 来完成。

同时新的 reconcile 过程也被分为 2 个阶段,第一个阶段是 render/reconciliation 阶段,这个阶段是可以中断的,在这个阶段主要是构造 workInProgress tree 找出需要更新的 DOM。第二个阶段是 commit 阶段, 这个阶段是不可以被中断的,主要是更新第一个阶段收集到的 DOM 到真实的 DOM 节点中。如下图,React 将生命周期也归属到下面两个阶段中,我们常见的 componentWillXXX 系列(除去 componentWillUnmount)都在第一个阶段。

 

因为第一个阶段是可以被打断的,并且并打断的低优先级任务需要重新再执行,因此当一个低优先级任务执行完 componentWillReceiveProps 生命周期之后发现自己的时间片已经用完了而此时恰好有一个高优先级任务需要执行,因此这个低优先级任务就需要作废重新再执行。此时若在 componentWillReceiveProps 生命周期中做了一些异步操作,比如发送了一个 post 请求,则可能造成不可意料的数据库重复写入,因此 componentWillReceiveProps 是不应该存在副作用行为的,同理这个也可以作用到 componentWillUpdate、componentWillMount 这两个生命周期,但是这种事情全靠自身约束,因此为了避免这种情况发生, React 干脆后面决定废弃了第一阶段的 componentWillXXX 生命周期。

3.2如何实现这种需求?

如果此时确实就是需要在 props 发生变化之后去进行一些有副作用的操作,比如发送请求等,则可以在 componentDidUpdate 中完成,因为 componentDidUpdate 在第二个阶段只会被执行一次,不会造成预期之外的问题。同时注意 componentDidUpdate 中如果有 setState 行为注意条件判断以免造成死循环。

// 错误的做法 componentWillReceiveProps(nextProps) {
    if (nextProps.id !== this.props.id) {
      this.fetchAsyncData(nextProps.id);
    }
}

// 推荐的做法 componentDidUpdate(prevProps) {
    if (this.props.id !== prevProps.id) {
    	this.fetchAsyncData(this.props.id);
 	 	}
}

本文重点讲了 componentWillReceiveProps 生命周期中常见的一些误区操作,就像文中所说,针对即将被废弃掉的 componentWillUpdate、componentWillMount 也存在一些误区操作,而这些无法被强管控的误操作有时会带来意料之外的 bug。

标签: React

发表评论 (已有0条评论)

还木有评论哦,快来抢沙发吧~