从componentWillReceiveProps说起

一.对componentWillReceiveProps的误解

componentWillReceiveProps通常被认为是propsWillChange,我们确实也通过它来判断props change。但实际上,componentWillReceiveProps在每次rerender时都会调用,无论props变了没

class A extends React.Component {
  render() {
    return <div>Hello {this.props.name}</div>;
  }

  componentWillReceiveProps(nextProps) {
    console.log('Running A.componentWillReceiveProps()');
  }
}

class B extends React.Component {
  constructor() {
    super();
    this.state = { counter: 0 };
  }

  render() {
    return <A name="World" />
  }

  componentDidMount() {
    setInterval(() => {
      this.setState({
        counter: this.state.counter + 1
      });
    }, 1000)
  }
}

ReactDOM.render(<B/>, document.getElementById('container'));

上例中,父组件B的state change引发子组件ArendercomponentWillReceiveProps被调用了,但A并没有发生props change

没错,只要接到了新的propscomponentWillReceiveProps就会被调用,即便新props与旧的完全一样:

UNSAFE_componentWillReceiveProps() is invoked before a mounted component receives new props.

Note that if a parent component causes your component to re-render, this method will be called even if props have not changed.

相关实现如下:

updateComponent: function () {
  var willReceive = false;
  var nextContext;

  if (this._context !== nextUnmaskedContext) {
    nextContext = this._processContext(nextUnmaskedContext);
    willReceive = true;
  }

  // Not a simple state update but a props update
  if (prevParentElement !== nextParentElement) {
    willReceive = true;
  }

  if (willReceive && inst.componentWillReceiveProps) {
    inst.componentWillReceiveProps(nextProps, nextContext);
  }
}

(摘自典藏版ReactDOM v15.6.1)

也就是说,componentWillReceiveProps的调用时机是:

引发当前组件更新 && (context发生变化 || 父组件render结果发生变化,即当前组件需要rerender)

注意,这里并没有对propsdiff

React doesn’t make an attempt to diff props for user-defined components so it doesn’t know whether you’ve changed them.

因为props值没什么约束,难以diff

Oftentimes a prop is a complex object or function that’s hard or impossible to diff, so we call it always (and rerender always) when a parent component rerenders.

唯一能保证的是props change一定会触发componentWillReceiveProps,但反之不然

The only guarantee is that it will be called if props change.

P.S.更多相关讨论见Documentation for componentWillReceiveProps() is confusing

二.如何理解getDerivedStateFromProps

getDerivedStateFromProps是用来替代componentWillReceiveProps的,应对state需要关联props变化的场景:

getDerivedStateFromProps exists for only one purpose. It enables a component to update its internal state as the result of changes in props.

即允许props变化引发state变化(称之为derived state,即派生state),虽然多数时候并不需要把props值往state里塞,但在一些场景下是不可避免的,比如:

这些场景的特点是与props变化有关,需要取新旧props进行比较/计算,

componentWillReceiveProps类似,getDerivedStateFromProps也不只是在props change时才触发,具体而言,其触发时机为:

With React 16.4.0 the expected behavior is for getDerivedStateFromProps to fire in all cases before shouldComponentUpdate.

更新流程中,在shouldComponentUpdate之前调用。也就是说,只要走进更新流程(无论更新原因是props change还是state change),就会触发getDerivedStateFromProps

就具体实现而言,与计算nextContextnextContext = this._processContext(nextUnmaskedContext))类似,在确定是否需要更新(shouldComponentUpdate)之前,要先计算nextState

export function applyDerivedStateFromProps(
  workInProgress: Fiber,
  ctor: any,
  getDerivedStateFromProps: (props: any, state: any) => any,
  nextProps: any,
) {
  const prevState = workInProgress.memoizedState;

  const partialState = getDerivedStateFromProps(nextProps, prevState);

  // Merge the partial state and the previous state.
  const memoizedState =
    partialState === null || partialState === undefined
      ? prevState
      : Object.assign({}, prevState, partialState);
  workInProgress.memoizedState = memoizedState;

  // Once the update queue is empty, persist the derived state onto the
  // base state.
  const updateQueue = workInProgress.updateQueue;
  if (updateQueue !== null && workInProgress.expirationTime === NoWork) {
    updateQueue.baseState = memoizedState;
  }
}

(摘自react/packages/react-reconciler/src/ReactFiberClassComponent.js

getDerivedStateFromProps成了计算nextState必要环节

getDerivedStateFromProps is invoked right before calling the render method, both on the initial mount and on subsequent updates.

function mountIndeterminateComponent(
  current,
  workInProgress,
  Component,
  renderExpirationTime,
) {
  workInProgress.tag = ClassComponent;
  workInProgress.memoizedState =
    value.state !== null && value.state !== undefined ? value.state : null;

  const getDerivedStateFromProps = Component.getDerivedStateFromProps;
  if (typeof getDerivedStateFromProps === 'function') {
    applyDerivedStateFromProps(
      workInProgress,
      Component,
      getDerivedStateFromProps,
      props,
    );
  }

  adoptClassInstance(workInProgress, value);
  mountClassInstance(workInProgress, Component, props, renderExpirationTime);
  // 调用render,第一阶段结束
  return finishClassComponent(
    current,
    workInProgress,
    Component,
    true,
    hasContext,
    renderExpirationTime,
  );
}

(摘自react/packages/react-reconciler/src/ReactFiberBeginWork.js

所以在首次渲染时也会调用,这是与componentWillReceiveProps相比最大的区别

三.派生state实践原则

实现派生state有两种方式:

  • getDerivedStateFromProps:从props派生出部分state,其返回值会被merge到当前state

  • componentWillReceiveProps:在该生命周期函数里setState

实际应用中,在两种常见场景中容易出问题(被称为anti-pattern,即反模式):

  • props变化时无条件更新state

  • 更新state中缓存的props

componentWillReceiveProps时无条件更新state,会导致通过setState()手动更新的state被覆盖掉,从而出现非预期的状态丢失

When the source prop changes, the loading state should always be overridden. Conversely, the state is overridden only when the prop changes and is otherwise managed by the component.

例如(仅以componentWillReceiveProps为例,getDerivedStateFromProps同理):

class EmailInput extends 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) {
    // This will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email });
  }
}

上例中,用户在input控件中输入一串字符(相当于手动更新state),如果此时父组件更新引发该组件rerender了,用户输入的内容就被nextProps.email覆盖掉了(见在线Demo),出现状态丢失

针对这个问题,我们一般会这样解决:

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

  componentWillReceiveProps(nextProps) {
    // Any time props.email changes, update state.
    if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      });
    }
  }
}

精确限定props change到email,不再无条件重置state。似乎完美了,真的吗?

其实还存在一个尴尬的问题,有些时候需要从外部重置state(比如重置密码输入),而限定state重置条件之后,来自父组件的props.email更新不再无条件传递到input控件。所以,之前可以利用引发EmailInput组件rerender把输入内容重置为props.email,现在就不灵了

那么,需要想办法从外部把输入内容重置回props.email,有很多种方式:

  • EmailInput提供resetValue()方法,外部通过ref调用

  • 外部改变EmailInputkey,强制重新创建一个EmailInput,从而达到重置回初始状态的目的

  • key杀伤力太大(删除重建,以及组件初始化成本),或者不方便(key已经有别的作用了)的话,添个props.myKey结合componentWillReceiveProps实现局部状态重置

其中,第一种方法只适用于class形式的组件,后两种则没有这个限制,可根据具体场景灵活选择。第三种方法略绕,具体操作见Alternative 1: Reset uncontrolled component with an ID prop

类似的场景之所以容易出问题,根源在于:

when a derived state value is also updated by setState calls, there isn’t a single source of truth for the data.

一边通过props计算state,一边手动setState更新,此时该state有两个来源,违背了组件数据的单一源原则

解决这个问题的关键是保证单一数据源,杜绝不必要的拷贝

For any piece of data, you need to pick a single component that owns it as the source of truth, and avoid duplicating it in other components.

所以有两种方案(砍掉一个数据源即可):

  • 完全去掉state,这样就不存在stateprops的冲突了

  • 忽略props change,仅保留第一次传入的props作为默认值

两种方式都保证了单一数据源(前者是props,后者是state),这样的组件也可以称之为完全受控组件与完全不受控组件

四.“受控”与“不受控”

组件分为受控组件与不受控组件,同样,数据也可以这样理解

受控组件与不受控组件

针对表单输入控件(<input><textarea><select>等)提出的概念,语义上的区别在于受控组件的表单数据由React组件来处理(受React组件控制),而不受控组件的表单数据交由DOM机制来处理(不受React组件控制)

受控组件维护一份自己的状态,并根据用户输入更新这份状态:

An input form element whose value is controlled by React is called a controlled component. When a user enters data into a controlled component a change event handler is triggered and your code decides whether the input is valid (by re-rendering with the updated value). If you do not re-render then the form element will remain unchanged.

用户与受控组件交互时,用户输入反馈到UI与否,取决于change事件对应的处理函数(是否需要改变内部状态,通过rerender反馈到UI),用户输入受React组件控制,例如:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};

    this.handleChange = this.handleChange.bind(this);
  }

  handleChange(event) {
    // 在这里决定是否把输入反馈到UI
    this.setState({value: event.target.value});
  }

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

不受控组件不维护这样的状态,用户输入不受React组件控制:

An uncontrolled component works like form elements do outside of React. When a user inputs data into a form field (an input box, dropdown, etc) the updated information is reflected without React needing to do anything. However, this also means that you can’t force the field to have a certain value.

用户与不受控组件的交互不受React组件控制,输入会立即反馈到UI。例如:

class NameForm extends React.Component {
  constructor(props) {
    super(props);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.input = React.createRef();
  }

  handleSubmit(event) {
    // input的输入直接反馈到UI,仅在需要时从DOM读取
    alert('A name was submitted: ' + this.input.current.value);
    event.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Name:
          <input type="text" ref={this.input} />
        </label>
        <input type="submit" value="Submit" />
      </form>
    );
  }
}

从数据角度看受控与不受控

不受控组件把DOM当作数据源:

An uncontrolled component keeps the source of truth in the DOM.

而受控组件把自身维护的state当作数据源:

Since the value attribute is set on our form element, the displayed value will always be this.state.value, making the React state the source of truth.

让程序行为可预测的关键在于减少变因,即保证唯一数据源。那么就有数据源唯一的组件,称之为完全受控组件完全不受控组件

对应到之前派生state的场景,就有了这两种解决方案:

// (数据)完全受控的组件,不再维护输入state,value完全由外部控制
function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />;
}

// (数据)完全不受控的组件,只维护自己的state,完全不接受来自props的任何更新
class EmailInput extends Component {
  state = { email: this.props.defaultEmail };

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

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

P.S.注意,“数据受控的组件”与“受控组件”是完全不同的两个概念,按照受控组件的定义,上例两种都是受控组件

所以,在需要复制props到state的场景,要么考虑把props收进来完全作为自己的state,不再受外界影响(使数据受控):

Instead of trying to “mirror” a prop value in state, make the component controlled

要么把自己的state丢掉,完全放弃对数据的控制:

Remove state from our component entirely.

五.缓存计算结果

另一些时候,拷贝propsstate是为了缓存计算结果,避免重复计算

例如,常见的列表项按输入关键词筛选的场景:

class Example extends Component {
  state = {
    filterText: "",
  };

  static getDerivedStateFromProps(props, state) {
    if (
      props.list !== state.prevPropsList ||
      state.prevFilterText !== state.filterText
    ) {
      return {
        prevPropsList: props.list,
        prevFilterText: state.filterText,
        // 缓存props结算结果到state
        filteredList: props.list.filter(item => item.text.includes(state.filterText))
      };
    }
    return null;
  }

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

  render() {
    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

能用,但过于复杂了。通过getDerivedStateFromProps创造了另一个变因(state.filteredList),这样props change和state change都可能影响筛选结果,容易出问题

事实上,想要避免重复计算的话,并不用缓存一份结果到state,比如:

class Example extends PureComponent {
  state = {
    filterText: ""
  };

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

  render() {
    const filteredList = this.props.list.filter(
      item => item.text.includes(this.state.filterText)
    )

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

利用PureComponentrender()只在props change或state change时才会再次调用的特性,直接在render()里放心做计算

看起来很完美,但实际场景的stateprops一般不会这么单一,如果另一个计算无关的propsstate更新了也会引发rerender,产生重复计算

所以干脆抛开“不可靠”的PureComponent,这样解决:

import memoize from "memoize-one";

class Example extends Component {
  state = { filterText: "" };

  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

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

  render() {
    const filteredList = this.filter(this.props.list, this.state.filterText);

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    );
  }
}

不把计算结果放到state里,也不避免rerender,而是缓存到外部,既干净又可靠

参考资料

从componentWillReceiveProps说起》上有4条评论

    1. ayqy 文章作者

      现已更正,着重区分“数据受控的组件”与“受控组件”,感谢指出;)

      回复
  1. Alan He

    但实际上,componentWillReceiveProps在每次rerender时都会调用,无论props变了没

    这句话是错的

    回复
    1. ayqy 文章作者

      这句是没错的。见文中第一处代码示例,以及本文开头引用的官方文档

      回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code