React 16.4 包含了一个 getDerivedStateFromProps 的 bugfix,这个 bug 导致一些 React 组件潜在的 bug 稳定复现。这个版本暴露了个案例,当你的应用正在使用反模式构建,则将会在此次修复后可能无法工作,我们对这个改动感到抱歉。在本文中,我们将阐述一些通常的使用 Derived State 的反模式以及相应的解决替代方案。

在很长一段时间,在 props 改变时响应 state 的更新,无需额外的渲染,唯一的途径就是 componentWillReceiveProps 这个生命周期方法。在 16.3 版本下,我们介绍了一个替代的生命周期 getDerivedStateFromProps 更加安全的方式来解决相同的用例。同时,我们意识到人们有很多关于使用这两个方法的错误解读,我们发现了这些反模式导致一些微妙且令人困惑的 bug。 getDerivedStateFromProps 在 16.4 中做了修复,使得 derived state 更加可预测,因此滥用的后果更加容易被留意到。 Note 所有关于旧的 > componentWillReceiveProps 和新 > getDerivedStateFromProps 的反模式都会在本文中阐述。 这篇文章涵盖以下话题:

  • 什么时候使用 derived state

  • 使用 derived state 时通常的 bug

    • 反模式:无条件地将 prop 复制给 state

    • 反模式:当 props 改变时清除 state

  • 推荐的方案

  • 什么是 memoization ?


什么时候使用 Derived State

getDerivedStateFromProps 存在只为了一个目的。它让组件在 props 发生改变时更新它自身的内部 state。我们之前的文章提供一些例子,例如:基于 offset prop 的改变记录当前滚动位置或者 通过源 prop 加载外部数据

我们没有提供更多的例子,因为这有一个常规的准则,应该保守地使用 derived state。所有我们看到关于 derived state 的问题从根本上可以归结成两类:(1) 无条件的以 props 更新 state 或者 (2) 每当 props 和 state 不同时就更新 state。(我们将在下面谈到更多细节。)

  • 当你使用 derived state 来暂存一些仅基于当前 props 的计算结果时,你不需要 derived state。查看 什么是 memoization ?

  • 当你无条件更新 derived state 抑或是每当 props 与 state 不同时更新 state,你的组件可能会频繁重置它的 state。


使用 Derived State 时的常见 bug

“受控的” 和 “不受控的” 这两个术语经常涉及到 form 的 input,然而他们也能描述组件数据存在的位置。当数据作为 props 传递时,则数据可以被认为是受控的(因为父组件控制了这些数据)。仅存在于内部 state 的数据可以被认为是不受控的(因为父组件不能直接改变它)。

derived state 的最常见错误就是混合了“受控”和“不受控”两种情况;当一个 derived state 值也使用 setState 来更新时,那它数据来源就不是唯一的。上文提到的“外部数据加载的例子”看上去好像就是这样,但其实有本质上差别。在数据加载例子中,’source’ prop 和 ‘loading’ state 都有明确的数据来源。当 ‘source’ prop 改变时, ‘loading’ state 总会被覆盖。相反地,state 当且仅当 prop 改变时才会被覆盖,否则只能被 state 所在的组件所管理。

当这些约束被改变时问题就浮现了。这会产生两个经典形式。让我们看一看他们。

反模式:无条件地将 prop 复制给 state

一个常见关于 getDerivedStateFromPropscomponentWillReceiveProps 的错误理解就是他们只会在 props “变化”时调用。无论是组件重新渲染还是 props 和之前“不同”,这些生命周期方法都会被调用。基于此,这两个生命周期方法总是被用于不安全地无条件地覆盖 state。这样做将导致 state 的更新发生丢失。

让我们思考一个例子来说明这个问题。这里有一个 EmailInput 组件“映射”了一个 email 属性在 state 中:

  1. class EmailInput extends Component {
  2. state = { email: this.props.email };
  3. render() {
  4. return <input onChange={this.handleChange} value={this.state.email} />;
  5. }
  6. handleChange = event => {
  7. this.setState({ email: event.target.value });
  8. };
  9. componentWillReceiveProps(nextProps) {
  10. // This will erase any local state updates!
  11. // Do not do this.
  12. this.setState({ email: nextProps.email });
  13. }
  14. }

首先,这个组件看上去没什么问题。State 被 props 传递进来的值所初始化,并在我们键入 <input> 的时候被更新。但是如果我们的父组件重新渲染的时候,我们输入到 input 的内容就会丢失(看这个例子)!即使我们在重置前进行比较 nextProps.email !== this.state.email也会这样。

在这个简单的例子中,只有当 email 属性被改变时加入 shouldComponentUpdate 来解决重渲染。然而在实践中,组件总是接受多个 props;另一个 prop 改变时依然会导致重渲染和不当重置。在函数和对象属性在内部被创建,在一个实质性的变化发生时,实现 shouldComponentUpdate 可靠地只返回 true 值变得困难。这里有个 demo 展示发生的情况。因此, shouldComponentUpdate 作为性能优化的最好方式被使用,而不用在 derived state 中保证正确性。

至此,为何无条件地将 props 复制给 state 是一个坏想法显而易见。在 review 可能的解决方案,让我们来看看一个有关的问题模式:在email 属性改变时,如果我们只更新 state ?

反模式:当 props 改变时清除 state

继续上述的例子,当 props.email 改变时,我们可以通过只更新来避免意外地清除 state:

  1. class EmailInput extends Component {
  2. state = {
  3. email: this.props.email
  4. };
  5. componentWillReceiveProps(nextProps) {
  6. // Any time props.email changes, update state.
  7. if (nextProps.email !== this.props.email) {
  8. this.setState({
  9. email: nextProps.email
  10. });
  11. }
  12. }
  13. // ...
  14. }

Note 不仅在以上例子中 > componentWillReceiveProps ,一个的反模式也被用于 > getDerivedStateFromProps 中。 我们做了很大的改进。现在我们的组件在 props 实质变化时才会清楚我们输入的内容。

但依旧存在一个微妙的问题。想象一下一个密码管理应用使用上述输入组件。当在两个相同 email 的账户下切换时,输入组件重置会失败。这是因为两个账户传递给组件的 prop 值是相同的!这使得用户感到诧异,一个账户没有保存的变更会影响另一个共享同一 email 的账号上。(这里看 demo)

这种设计是有本质缺陷的,但它是最容易犯的。(我就犯过!)幸运的是,以下有两个更好的替代方案。而关键就是对每一片数据,你需要选一个控制数据并以其作为真实源的简单组件,并避免副本数据存在于其他组件。让我们来看一下这些替代方案。


优选方案

推荐:完全受控组件

一个避免上述涉及问题的途径就是完全地移除我们组件中的 state。如果 email 地址只存在于 prop,那我们没必要担心 state 的冲突。我们甚至可以把 EmailInput 缓存一个更加轻量的函数式的组件:

  1. function EmailInput(props) {
  2. return <input onChange={props.onChange} value={props.email} />;
  3. }

这个途径简化了我们组件的实现,但是我们如果想存储草稿的时候,父组件还是需要手工完成这件事。(点这看这种模式的例子)

推荐:带有 key 的完全不受控组件

另一个替代方案就是我们的组件完全的控制自己的 email state “草稿”。在此例子中,我们的组件依然可以接收一个来自于初始值,但它将会忽略后面 prop 的改动:

  1. class EmailInput extends Component {
  2. state = { email: this.props.defaultEmail };
  3. handleChange = event => {
  4. this.setState({ email: event.target.value });
  5. };
  6. render() {
  7. return <input onChange={this.handleChange} value={this.state.email} />;
  8. }
  9. }

为了能在不同的情境下重置值(如密码管理方案),我们使用特殊的 React 属性 key 。当 key 改变时,React 将创建一个新的组件实例而不是更新现有的这个。Keys 经常被用于动态 list,但在这里依然管用。在我们的案例中,我们能根据 user ID 在新用户被选中时重新创建 email 输入组件:

  1. <EmailInput
  2. defaultEmail={this.props.user.email}
  3. key={this.props.user.id}
  4. />

每当 ID 改变时, EmailInput 将会被重新创建,它的 state 将会用最新的 defaultEmail 值重置。(点这里看这种模式的例子)使用这种途径,你不需要为每一个输入组件加 key 。也许在 from 中加一个 key 会来得更好。每当 key 改变时,所有在 from 里的组件都会用一个新的 initialized state 来重新创建。

更多的案例中,这是一个处理需要被重置的 state 的最佳方式。 Note 虽然这貌似会很慢,在性能差异无关紧要的时候。当组件有很重的更新逻辑时候,使用一个 key ,忽略子树 diffing 甚至会更快。

替代方案1:使用 ID prop 重置不受控组件

如果 key 由于某些原因不能被使用(也许组件有昂贵的初始化代价),一个可行但笨重的方案就是在 getDerivedStateFromProps 中监听 “userID” 的改变:

  1. class EmailInput extends Component {
  2. state = {
  3. email: this.props.defaultEmail,
  4. prevPropsUserID: this.props.userID
  5. };
  6. static getDerivedStateFromProps(props, state) {
  7. // Any time the current user changes,
  8. // Reset any parts of state that are tied to that user.
  9. // In this simple example, that's just the email.
  10. if (props.userID !== state.prevPropsUserID) {
  11. return {
  12. prevPropsUserID: props.userID,
  13. email: props.defaultEmail
  14. };
  15. }
  16. return null;
  17. }
  18. // ...
  19. }

这也提供了灵活性——重置部分被我们选中的组件内部 state。(点这里看此模式的 demo) Note 及时以上例子展示了 > getDerivedStateFromProps ,同样的技术手段也可以被用在 > componentWillReceiveProps

替代方式2:在一个实例方法中重置不受控组件

更罕见地,你可能需要重置 state 即使没有适当的 ID 可用为 key 。一个解决方案就是每次你想重置时用一个随机数或者自增数字重置 key。另一个可行的方案是暴露一个实例方法命令式的重置内部 state:

  1. class EmailInput extends Component {
  2. state = {
  3. email: this.props.defaultEmail
  4. };
  5. resetEmailForNewUser(newEmail) {
  6. this.setState({ email: newEmail });
  7. }
  8. // ...
  9. }

父组件能用 ref 来调用这个方法。(点击这看这个模式例子)

Refs 在这个确定的例子中是有用的,但通常上我们建议你保守使用。甚至在这个 demo 中,这个必要的方法是不理想的,因为本来一次的渲染会变成两次。


扼要重述

重述一下,当设计一个组件的时候,决定数据是否受控或不受控是至关重要的。

让组件变得受控,而不是试图在 state 中复制一个 prop ,在一些父组件的 state 中联合两个分散的值。举个例子,与其子组件接收一个“已提交的” props.value 并跟踪一个“草稿” state.value ,不如在父组件中管理 state.draftValuestate.committedValue ,并控制直接控制子组件的值。这让数据流更加明确和可预测。

不受控组件,如果你在一个特殊的 prop (通常是 ID)改变时试图重置 state,你有一些选择:

  • 推荐:重置所有内部 state,使用 key 属性

  • 替代方案1:仅重置确定的 state 字段,监听特定属性的变化(例如: props.userID)。

  • 替代方案2:你也可以考虑使用 refs 调用一个命令式实例方法。


什么是 memoization ?

我们也看到,仅当输入变化的时候,derived state 被用于确保关键值被用于 render 中会重新计算。这个技巧被称之为 memoization

使用 derived state 来完成 memoization 并不一定是坏事,但这经常不是最佳方案。管理 derived state 具有内在复杂度,这个复杂度随着属性的增加而提升。例如,如果我们想要加入第二个 derived feild 到我们的组件 state,那么我们的实现将需要分别跟踪两者的变化。

让我们来看一个例子——组件携带一个属性(一个 item list),并渲染匹配用户输入的搜索查询的 item。我们使用 derived state 存储过滤的 list:

  1. class Example extends Component {
  2. state = {
  3. filterText: "",
  4. };
  5. // *******************************************************
  6. // NOTE: this example is NOT the recommended approach.
  7. // See the examples below for our recommendations instead.
  8. // *******************************************************
  9. static getDerivedStateFromProps(props, state) {
  10. // Re-run the filter whenever the list array or filter text change.
  11. // Note we need to store prevPropsList and prevFilterText to detect changes.
  12. if (
  13. props.list !== state.prevPropsList ||
  14. state.prevFilterText !== state.filterText
  15. ) {
  16. return {
  17. prevPropsList: props.list,
  18. prevFilterText: state.filterText,
  19. filteredList: props.list.filter(item => item.text.includes(state.filterText))
  20. };
  21. }
  22. return null;
  23. }
  24. handleChange = event => {
  25. this.setState({ filterText: event.target.value });
  26. };
  27. render() {
  28. return (
  29. <Fragment>
  30. <input onChange={this.handleChange} value={this.state.filterText} />
  31. <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
  32. </Fragment>
  33. );
  34. }
  35. }

这个实现避免了不必要 filteredList 的重新计算。但这比原来的更复杂,因为他必须分开跟踪和检测 props 和 state 的变化,才能正确更新过滤后的列表。在这个例子中,我们能使用 PureComponent 简化工作,移动更新操作到 render 方法中:

  1. // PureComponents only rerender if at least one state or prop value changes.
  2. // Change is determined by doing a shallow comparison of state and prop keys.
  3. class Example extends PureComponent {
  4. // State only needs to hold the current filter text value:
  5. state = {
  6. filterText: ""
  7. };
  8. handleChange = event => {
  9. this.setState({ filterText: event.target.value });
  10. };
  11. render() {
  12. // The render method on this PureComponent is called only if
  13. // props.list or state.filterText has changed.
  14. const filteredList = this.props.list.filter(
  15. item => item.text.includes(this.state.filterText)
  16. )
  17. return (
  18. <Fragment>
  19. <input onChange={this.handleChange} value={this.state.filterText} />
  20. <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
  21. </Fragment>
  22. );
  23. }
  24. }

这种途径比用 derived state 更加清晰且简单。偶尔地,这不够好——在大列表中过滤会变得慢,如果其他 prop 变化时 PureComponent 不会阻止重渲染。为了解决这些问题,我们可以加入一个 memoization helper 来避免对 list 的不必要过滤:

  1. import memoize from "memoize-one";
  2. class Example extends Component {
  3. // State only needs to hold the current filter text value:
  4. state = { filterText: "" };
  5. // Re-run the filter whenever the list array or filter text changes:
  6. filter = memoize(
  7. (list, filterText) => list.filter(item => item.text.includes(filterText))
  8. );
  9. handleChange = event => {
  10. this.setState({ filterText: event.target.value });
  11. };
  12. render() {
  13. // Calculate the latest filtered list. If these arguments haven't changed
  14. // since the last render, `memoize-one` will reuse the last return value.
  15. const filteredList = this.filter(this.props.list, this.state.filterText);
  16. return (
  17. <Fragment>
  18. <input onChange={this.handleChange} value={this.state.filterText} />
  19. <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
  20. </Fragment>
  21. );
  22. }
  23. }

这更加简单,而且性能和 derived state 版本的一样好!

当使用 memoization 时,记住一些约束条件:

  1. 在大多数案例中,你会想把 memoized 函数附加到组件实例上。这防止一个组件的多个实例重置彼此的 memoized key。

  2. 通常地,你会想使用一个具有缓存大小限制的 memoization helper 来避免内存泄露问题。(在以上的例子中,我们用了 memoize-one 因为它仅缓存最近的参数和结果。)

  3. 如果 props.list 在每次父组件渲染时被重新创建,本节中展示的实现手段是无法工作的。但在多数案例中,这种设置是适当的。


结语

在实际的应用中,组件经常包含受控和不受控行为的混合。这是没问题的!如果每一个值都有清晰的真实源,你可以避免上面提及的反模式。

同样值得重申的是, getDerivedStateFromProps (一般的 derived state) 是一个高级特性,由于其复杂度,应该保守的使用它。如果你使用的案例超出这些模式,请在 GithubTwitter 上与我们分享!