β

从componentWillReceiveProps说起

黯羽轻扬 381 阅读

一.对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引发子组件 A render componentWillReceiveProps 被调用了,但 A 并没有发生props change

没错,只要接到了新的 props componentWillReceiveProps 就会被调用,即便新 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)

注意,这里并没有对 props diff

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

就具体实现而言,与计算 nextContext nextContext = 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有两种方式:

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

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 ,有很多种方式:

其中,第一种方法只适用于 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.

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

两种方式都保证了单一数据源(前者是 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
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} />;
  }
}

所以,在需要复制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.

五.缓存计算结果

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

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

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>
    );
  }
}

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

看起来很完美,但实际场景的 state props 一般不会这么单一,如果另一个计算无关的 props state 更新了也会引发 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说起, 感谢原作者分享。