【译】React v16.4.0:你可能并不须要派生状态(Derived State)

很长一段时间,componentWillReceiveProps生命周期是在不进行额外render的前提下,响应props中的改变并更新state的惟一方式。在16.3版本中,咱们介绍了一个新的替代生命周期getDerivedStateFromProps去更安全地解决相同的问题。同时,咱们意识到人们对这两个方式都存在不少的误解,咱们发现了其中的一些反例触发了一些奇怪的bug。在本次版本中咱们修复了它,并让derived state更加可预测,因此咱们能更容易地注意到滥用的结果。html

本文的反例既包含老的componentWillReceiveProps也包含新的getDerivedStateFromProps方法react

何时去使用派生状态(Derived State)

getDerivedStateFromProps只为了一个目的存在。它使得一个组件可以响应props的变化来更新本身内部的state。好比咱们以前提到的根据变化的offset属性记录目前的滚动方向或者根据source属性加载额外的数据git

咱们提供了许多了实例,由于通常来讲,派生状态应该被谨慎地使用。咱们见过的全部关于派生状态的问题最后均可以被归为两种:(1)从props那里无条件地更新state(2)当props和state不匹配的时候更新state(咱们在下面会深刻探讨)github

  • 若是你使用派生状态来记忆基于当前props的运算结果,你并不须要它。参见下面的关于缓存记忆(memoization)
  • 若是你是第二种状况,那么你的组件可能重置得太频繁了。继续读下去得到更多内容。

使用派生状态的常见Bug

"受控""非受控"一般指表明单的输入控件,可是它还能够用于描述组件的数据所处位置。经过props传入的数据可被称为受控的(由于父组件控制这数据)。只存在内部state的数据被称做非受控的(由于父组件不能直接改变它)。数组

最多见的错误是将二者搞混了。当一个派生状态同时被setState更新的时候,数据就失去了单一的事实来源。上面提到的加载数据的例子看上去是相似的,但在一些关键的地方是有区别的。在例子中,每当source属性变化,loading状态一定会被覆盖。反过来,状态要么在props变化的时候被覆盖,要么由组件本身管理。(译注:可理解为同时只有单一的真实来源)缓存

当任何一个限制被改变的时候就会发生问题,下面举了两个典型的例子。安全

反例:无条件地复制props到state

一个常见的误解是getDerivedStateFromPropscomponentWillReceiveProps只会在props“改变”的时候调用。这些生命周期会在任何父组件发生render的时候调用,无论props是否真的改变。所以,使用这些周期去无条件地覆盖state是不安全的。这样作会使得state丢失更新app

咱们来演示一下这个问题。这是一个邮件输入组件,它“映射”了email属性到state:async

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.setState({ email: nextProps.email }); } } 复制代码

看上去这个组件好像没问题。state被props中的值初始化,而后随着<input>的输入而更新。可是若是父组件发生render,咱们在<input>中输入的东西都会消失!(参见这里的demo)即便咱们在重置前比较nextProps.email !== this.state.email也会如此。函数

在这个简单的例子中,添加shouldComponentUpdate去限制只在props中的email发生改变时才去从新render能解决这个问题。但在实际中,组件一般会接受不少的props。你没法避免其余的属性发生改变。函数和对象属性一般是内联建立的,这让咱们很难去实现判断是否发生了实质性的变化。这里有一个demo来讲明。所以,shouldComponentUpdate最好只是用来优化性能,而不是去确保派生状态的正确性。

但愿如今咱们能弄清楚为何无条件地复制props到state是坏主意。在查看可能的解决方案前,咱们先来看一个相关的例子:若是咱们只在email属性发生改变的时候更新state呢?

反例:props改变时覆盖state

继续上面的例子,咱们能够经过只在props.email改变时更新state来避免意外地覆盖已有的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
      });
    }
  }
  
  // ...
}
复制代码

尽管上面的例子中使用的是componentWillReceiveProps,但这个反例一样的也对getDerivedStateFromProps适用

咱们刚迈进了一大步。如今咱们的组件只会在props真正改变的时候覆盖掉咱们输入的东西了。

固然这里仍是有一个微妙的问题。想象一个密码管理app使用了如上的输入组件。当切换两个不一样的帐号的时候,若是这两个帐号的邮箱相同,那么咱们的重置就会失效。由于对于这两个帐户传入的email属性是同样的。(译注:比如你切换了一份数据源,可是这两份数据中的email是相等的,因而预期应该被重置的输入框没有被重置)查看demo

这个设计从根本上是有缺陷的,但倒是很容易犯的错误。(我本身也曾犯过)幸运的是有两个更好的可选方案。关键在于对于任何数据,你须要选择一个做为其真实来源的组件,而且避免在其余组件中复制它。咱们来看看下面的解决方案。

更好的方案

推荐:彻底受控组件

一个解决上述问题的方案是彻底移除咱们组件中的state。若是email仅做为属性存在,咱们就不须要担忧和state的冲突。咱们甚至能够把EmailInput做为一个更轻量级的函数组件:

function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />; } 复制代码

此举简化了咱们组件的实现,可是若是咱们仍然想保存一份输入的草稿值呢,这时候须要父组件手动来实现了,请看demo

推荐:用key标识的彻底不受控组件

另外一个可选方案是咱们的组件彻底控制eamil的“草稿”状态。在这里,咱们的组件仍然接受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} />; } } 复制代码

为了在切换不一样内容时重置输入值(像上面提到的密码管理器),咱们可使用React的key属性。当key改变,React会从新建立一个新的组件而不是更新它。Key通常用在动态列表,可是在这也是颇有用的。这里当选择一个新用户的时候,咱们用用户ID去从新建立这个email输入组件:

<EmailInput
  defaultEmail={this.props.user.email}
  key={this.props.user.id}
/>
复制代码

每当ID改变,EmailInput会被从新建立而后state会被重置为最新的defaultEmail值。(点这查看demo)其实你无需为每个输入框添加一个key。对整个表单添加一个key会显得更有用。每当key改变,表单的全部组件都会被重建且被赋上干净的初始值。

大多数状况,这是重置state最好的办法。

从新建立组件听上去会很慢,但其实对性能的影响微乎其微。若是组件具备不少更新上的逻辑,则使用key甚至能够更快,由于该子树的差别得以被绕过。

替代方案1:经过ID属性重置非受控组件

若是由于某些缘由没法使用key(好比组件初始化的代价很高),一个可行但笨重的办法是在getDerivedStateFromProps监听“userID”的改变。

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

  static getDerivedStateFromProps(props, state) {
    // Any time the current user changes,
    // Reset any parts of state that are tied to that user.
    // In this simple example, that's just the email.
    if (props.userID !== state.prevPropsUserID) {
      return {
        prevPropsUserID: props.userID,
        email: props.defaultEmail
      };
    }
    return null;
  }

  // ...
}
复制代码

若是咱们这样选择,这也提供了仅重置部件的内部状态的灵活性(这里查看demo)

上面的例子对于componentWillReceiveProps也是同样的

替代方案2:使用实例方法重置非受控组件

在极少状况,你可能须要在没有合适的ID做为key的状况下重置state。一个办法是把key设为随机值或者递增的值,在你想要重置的时候改变它。另外一个可行方案是暴露一个实例方法命令式地去改变内部的state:

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

  resetEmailForNewUser(newEmail) {
    this.setState({ email: newEmail });
  }

  // ...
}
复制代码

父表单组件就能够经过ref去调用这个方法,(点击这里查看demo

Ref这这类场景中挺有用,但咱们推荐你尽可能谨慎地去使用。即便在这个例子中这种方法也是不理想的,由于会造触发两次render。

关于缓存记忆(memoization)

咱们也见到过用派生状态来确保render中计算量较大的值仅在输入改变的时候从新计算。这个技术叫作memoization

使用它做缓存记忆不必定是很差的,但一般不是最佳解决方案。管理派生状态有必定的复杂度,这个复杂度还会随着额外的属性而增长。好比,若是咱们给组件添加了第二个派生字段,那么咱们须要分别跟踪这两个字段的变化。

咱们来看一个例子,咱们把一个列表传入这个组件,而后它须要按用户的输入筛选显示出匹配的项。咱们可使用派生状态去保存筛选后的列表。

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

  // *******************************************************
  // NOTE: 这个例子不是咱们推荐的作法
  // 推荐的方法参见下面的例子.
  // *******************************************************

  static getDerivedStateFromProps(props, state) {
    // 每当列表数组或关键字变化时筛选列表.
    // 注意到咱们须要储存prevPropsList和prevFilterText来监听变化.
    if (
      props.list !== state.prevPropsList ||
      state.prevFilterText !== state.filterText
    ) {
      return {
        prevPropsList: props.list,
        prevFilterText: state.filterText,
        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> ); } } 复制代码

这里的实现避免了filteredList没必要要的从新计算。但其实过于复杂了,由于它须要分别跟踪和监听props和state的变化来正确地更新筛选列表。这个例子中,咱们可使用PureComponent来简化操做而后把筛选操做放到render方法中去:

// PureComponents只会在至少state和props中有一个属性发生变化时渲染.
// 变化是经过引用比较来判断的.
class Example extends PureComponent {
  // State only needs to hold the current filter text value:
  state = {
    filterText: ""
  };

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

  render() {
    // 只有props.list 或 state.filterText 改变时才会调用.
    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。为了应对这种状况咱们引入了一个memoization辅助器来避免多余的筛选。

import memoize from "memoize-one";

class Example extends Component {
  // State只须要去维护目前的筛选关键字:
  state = { filterText: "" };

  // 当列表数组或关键字变化时从新筛选
  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  );

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

  render() {
    // 计算渲染列表时,若是参数同上次计算没有改变,`memoize-one`会复用上次返回的结果
    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> ); } } 复制代码

这样更简单地实现了和派生状态同样的功能!

总结

在实际应用中,组件一般既包含受控与非受控的元素。这没问题,若是每一个数据都有清晰的真实来源,你就能够避开上面提到的反例。

getDerivedStateFromProps的用法值得被从新思考,由于他是一个拥有必定复杂度的高级特性,咱们应该谨慎地使用。

原文连接:You Probably Don't Need Derived State

相关文章
相关标签/搜索