React 16.4 为 getDerivedStateFromProps
修复了一个 bug,这个 bug 会导致一些在 React 组件中存在的 bug 不断重现。如果本次更新暴露了你的应用正在使用反模式(不推荐的做法)且在本次更新后可能出现问题,我们对此感到抱歉。在这篇博文中,我们会解释一些使用 derived state 时常见的反模式和我们更推荐的替代做法。
在很长一段时间里,如果想在 props 改变时更新 state,且不造成多余的渲染,我们只能依靠生命周期 componentWillReceiveProps
。在 16.3 版本,我们介绍了一种可以替代 componentWillReceiveProps
的生命周期—— getDerivedStateFromProps
——来更安全地解决同样的问题。与此同时,我们发现大家在使用这两种生命周期时存在很多错误的观念,也发现了一些会造成微妙而令人困扰的 bug 的反模式。16.4 版本中对 getDerivedStateFromProps
的 bugfix 使 derived state 更加可预料,因而误用它所产生的结果也更加容易被注意到。
注意
本文描述的所有反模式都同时适用于旧的componentWillReceiveProps
和新的getDerivedStateFromProps
。
什么时候使用 derived state
getDerivedStateFromProps
只用于一个目的——让组件可以在 props 改变时更新内部的 state。我们之前的博文提供了一些例子,比如 基于改变中的 offset prop 记录当前的滚动方向 或者 加载代码指定的外部数据。
我们没有举很多例子,因为作为一个普遍的规则,应该尽量少而谨慎地使用 derived state。在 derived state 上出的问题,可以减少的无非两种:1. 无条件从 props 更新数据到 state;2. 不管 props 和 state 是否匹配都更新 state。(两种我们在下面都会详细讲。)
- 如果你使用 derived state 是为了记录仅基于当前 props 的计算结果,你不需要 derived state。看看下面的 试试 memoization。
- 如果你无条件更新 state,或者不管 props 和 state 是否匹配都更新 state,那你的组件恐怕太经常重置 state 了。下面会详细讲。
使用 derived state 时的常见 bug
术语“受控”和“非受控”通常指表格输入,但也可以用来描述组件的数据存在哪里。通过 props 传的数据可以被称为是“受控”的(因为父组件控制了数据),仅存在于内部 state 的数据可以被认为是“非受控”的(因为父组件不能直接改变它)。
使用 derived state 最常见的错误是把“受控”和“非受控”混在一起。在 state 同时被 derived state 和 setState
更新的时候,数据没有真实的单一来源。上面提到的 外部数据加载示例 听起来可能类似,但在一些重要的方面并不相同。在加载示例中,对于作为来源的 props 和加载中的 state 都有干净的真实来源。当作为来源的 props 改变了,加载的 state 总是都应该被重写。反过来,state 只会在 props 改变时被重写,在重写之外的其他方面才会被组件本身管理。
不遵守这些约束中的任何一个都会出现问题,问题通常以两种形式出现。
反模式:无条件把 props 复制到 state 上
一个常见的错误观念是,getDerivedStateFromProps
和 componentWillReceiveProps
只在 props 改变的时候被调用。这两个生命周期其实会在父组件渲染的时候被调用,不管 props 是否和之前不一样。因此,使用这两个生命周期无条件重写 state 是不安全的。这样做会导致 state 更新丢失。
让我们考虑一个例子来证明这个问题。以下是一个 EmailInput
组件,在 state 中“镜像”了一个 email prop:
1 | class EmailInput extends Component { |
一开始,这个组件可能看起来没问题。state 被 props 指定的值初始化,当我们在 <input>
中打字的时候,state 会更新。但如果父组件渲染,我们在 <input>
中打的字就会丢失!(demo)即使我们在重置 state 之前对比 nextProps.email !== this.state.email
也是一样的。
在这个简单的例子中,增加 shouldComponentUpdate
使只有 email prop 改变的时候才渲染可以解决这个问题。然而在实践中,组件通常接受多个 props,其他 prop 的改变也会触发渲染,导致不正确的重置。方法和对象的 props 也经常被内联创建,使我们很难使 shouldComponentUpdate
仅在真正改变的时候返回 true。(demo)。因此, shouldComponentUpdate
最好被用于性能优化,而不是确定 derived state 的正确性。
希望现在大家已经清楚为什么不能无条件把 props 复制到 state 上了。在讲可能的解决方法之前,我们先来看看另一个造成问题的同类反模式:如果我们只在 email prop 改变的时候更新 state 会怎么样呢?
反模式:当 props 改变的时候重置 state
继续上面的例子,我们可以只在 props.email
改变的时候更新 state:
1 | class EmailInput extends Component { |
注意
虽然上面的例子使用的是componentWillReceiveProps
,但道理在getDerivedStateFromProps
中是一样的。
这样就有了很大的改善。现在组件只会在 props 改变的时候才会删除我们输入的内容了。
这样仍旧有一个小问题。想象有一个使用上面这个组件的密码管理应用。当在使用同一个 email 的两个账户详情之间切换的时候,input 将不会重置。这是因为两个账户传入的 prop 是一样的。这会让用户感到很奇怪,如果有两个 email 相同的账户,一个账户详情中没有保存的内容会在另一个账户的详情中出现。(demo)
这样设计很不好,但是我们却很容易犯。(我就曾经犯过)幸运的是,有两个更好的解决方法。两种方法的关键都是,对于任何一点数据,我们都需要选择一个拥有它的组件作为真实的来源,并且避免在其他组件中复制它。让我们挨个看看这两种解决方法。
更好的解决方法
推荐:完全受控组件
要避免上面提到的问题,一种方法是把 state 从组件中移除。如果 email prop 只作为 props 存在,那么我们就不需要担心和 state 有冲突。我们甚至可以把 EmailInput
转变为一个更轻量级的函数式组件:
1 | function EmailInput(props) { |
这种方法简化了我们的组件的实践,但如果我们仍旧想要存一个草稿值,父级表单组件需要手动做这件事。(点击查看这种模式的 demo)(译者注:我觉得“草稿值”指的是用户正在操作而没有真正提交的值。)
推荐:带 key 的完全不受控组件
另一个解决方法是,让我们的组件完全拥有“草稿”的 email state。这样,组件能够始终接收一个 prop 作为初始值,但会忽视掉对于 prop 后续的更新:
1 | class EmailInput extends Component { |
为了能在其他情况下使用的时候重置值(比如我们设想的密码管理应用的情况),我们可以使用 React 的特殊属性:key
。当 key
改变的时候,React 会创建一个新的组件实例,而不是更新目前的这一个。key 通常被用于动态列表,但在这种情况下也很有用。在我们的例子中,我们可以使用 user ID 作为key,使得每次新用户被选择时都重新创建 email input。
1 | <EmailInput defaultEmail={this.props.user.email} key={this.props.user.id}> |
每次 ID 改变,EmailInput
组件都会被重新创建,它的 state 也会被重置为 defaultValue
的值。(点击查看这种模式的 demo)。用这种方法,你不需要给每一个 input 增加 key
,直接给整个 form 表单加一个 key
恐怕更合理。每次 key 改变,所有在 form 表单里的组件都会被重新创建,state 也被更新为初始值。
在大多数情况下,如果需要重置 state,这都是最好的方法。
注意
虽然这种方法听起来很慢,但是性能差距其实是微不足道的。如果组件在更新时有很重的逻辑,使用 key 甚至可以更快,因为该子树的 diff 被绕过了。
可替代方法 1:使用一个 id prop 重置非受控组件
如果 key
因为某些原因不起作用(比如组件初始化的代价非常昂贵),一个可行的笨方法是在 getDerviedStateFromProps
中观察 user id 的更改:
1 | class EmailInput extends Component { |
这样做也具有灵活性,能够只重置一部分内部的 state。(点击查看这种模式的 demo)
注意
虽然上面的例子展示的是getDerivedStateFromProps
,但同样的方法也可以被用于componentWillReceiveProps
。
可替代方法 2:使用实例方法重置非受控组件
在更加少见的情况下,你可能需要重置 state,但是没有合适的 ID 作为 key
。一个解决方案是在每次想要重置的时候,利用随机值或者自增的数字作为 key
。另一个可行的方法是,暴露一个实例方法来命令式地重置内部的 state:
1 | class EmailInput extends Component { |
接着,父组件可以使用 ref 来调用这个方法。(点击查看这种模式的 demo)
Ref 在像这样的例子里很有用,但是一般来讲,我们建议少使用 Ref。即使是在这个 demo 中,这种命令式的方法也不理想,因为会造成两次渲染,而不是不使用时的一次。
总结
总结一下,当设计一个组件的时候,很重要的是决定它的数据是受控的还是非受控的。
比起试图在 state 中“镜像” prop,不如使组件受控,在父组件的 state 中合并两个不同的值。比如,比起子组件接收“用于提交的”(committed) props.value
并追踪一个“草稿”(draft)的 state.value
,不如让父组件同时有 state.draftValue
和 state.committedValue
并直接控制子组件的值。这使得数据流更加明确而可预计。
对于非受控组件,如果你试图在一个特别的 prop(通常是 ID)改变时重置 state,你可以选择:
- 推荐:使用 key 属性重置所有内部的 state。
- 可替代 1:仅重置特定的 state 字段,监听特殊值的变动(比如
props.userID
)。 - 可替代 2:你也可以考虑退而求其次,用 refs 的命令式实例方法。
要不要试试 memoization 呢?
我们也看到 derived state 被用于确认某些用于渲染的值仅在 input 改变的时候重新计算。这种方法被称为 memoization
。
使用 derived state 做缓存(memoization)不一定不好,但通常不是最优解。管理 derived state 存在固有的复杂性,且每增加一个属性都会更加复杂。比如,如果我们给组件的 state 增加第二个 derived 字段,那么我们需要分别追踪这两者的更改。
来看一个例子,一个组件接收一个 prop——一个列表——并根据用户输入的查询字段渲染匹配的项。我们可以使用 derived state 储存过滤后的列表:
1 | class Example extends Component { |
这种做法避免了对 filteredList
不必要的重新计算。但是这种做法比正常要复杂得多。为了正确地更新过滤后的列表,需要同时分别追踪和监听 prop 和 state 中的变化。在这个例子中,我们可以用 PureComponent
简化,把过滤操作移动到 render 方法中。
1 | // 纯组件只在有 state 或者 prop 改变的时候重新渲染 |
上面的方法比 derived state 的版本更加干净简单。在个别情况下,这不够好——对很大的列表来说,过滤可能会慢,而在其他 prop 改变的时候,PureComponent
不会阻止渲染。为了解决这两个问题,我们可以添加 memoization 避免对列表不必要的重新过滤:
1 | import memoize from 'memoize-one' |
这样简单得多,且表现得和 derived state 版本一模一样。
当使用 memoization 时,记住一些限制:
- 在大多数情况下,你需要把用来做缓存的方法加到组件实例上。这防止一个组件的多个实例互相重置对方缓存的值。
- 通常,为了防止随着时间推移内存泄露,你会想要用具有缓存大小限制的助手。(在上面的例子中,我们使用
memoize-one
,因为它只缓存最近的参数和结果。) - 如果
props.list
在每次父组件渲染的时候都会重新创建,那么本节中显示的任何方法都不会起作用。但在大多数情况下,这种设置是合适的。
结语
在实际应用中,组件通常同时包含受控行为和不受控行为。这是没关系的!如果每一个值都有一个清晰的真实来源,你就可以避免上面的反模式。
值得重新考虑的是,getDerivedStateFromProps
(以及一般的派生状态)是一种高级功能,因为其复杂性,应该少而谨慎地使用。如果你的用例不在这些模式中,请在 Github 或 Twitter 中分享给我们!
译者附
原文
来自 React 官方博客,你可能不需要派生状态。
以上文章综合总结
这样说可能不太好,但我觉得这篇文章的逻辑还是有一点混乱的……
最前面,需求仿佛还是 在 prop 更新的时候,state 也需要做出反应。稍往后,作者就根据自己的业务需求,把代码上的需求更改为了“prop 只需要作为 state 的初始化数值,不需要随 prop 更新做出反应”。所以你会发现,它推荐的方法并不能真正替代反模式实现的效果,因为在 key
相同的情况下,推荐做法无法做到在 props 每次更新时都做出响应。
你可以说,作者的需求就是这样的呀。但是,提出 key
方法是根据作者自己假设的另一个需求,即存在切换用户的需求。既然作者可以假设能够切换用户,为什么就不能假设,比如,外部需要一个按钮重置所有组件的 email 值(但不影响其他值)呢?这样 key
方法不就行不通了吗?
哪怕切换用户是一开始就提出的,我也认为不妥。大家看 React 官方博客的文章,寻求的必然是通用做法,而不是完全根据一个狭窄的实际用例去区分“好方法”和“坏方法”。
因此,我觉得第二种反模式,实际上不能说是反模式。可以注意一下,第二种“反模式”,即在 props 更新时比较新旧 props,实际上与 key
推荐方法的第一种替代是同样的做法。也就是说,在 props 更新时比较新旧 props 并不是“反模式”,在可以切换用户的情况下,比较 email 值,而不是比较 user ID 的值,这才是反模式。
在我看来,后面追加的 memoization 章节,这才是真正可以用于实现“某项 prop/state 更改(而不是如切换用户等情况下的整体更改)后才做某些操作”的需求。
memoization 的核心其实就是将最终结果缓存起来(对应 memoization 的反模式:分别缓存单独的 state/prop),在组件需要更新的情况下(state/prop 改变或是父组件渲染),组件本身还是会更新,但其应该渲染的值是被缓存起来的,如果传入的参数一样,就直接返回值,不需要重新做重复的计算/请求等操作。
React 16.4+ 新的生命周期
关于如何完全取代 componentWillReceiveProps
根据 React 官方文档,有以下几点:
- 如果你需要在 props 改变后表现某种副作用(比如,请求发送或动画),使用
componentDidUpdate
生命周期代替。 - 如果你想要仅在 prop 改变的时候重新计算某些数据,使用 memoization 代替。
- 如果你想要在 prop 改变的时候重置某些 state,考虑使组件完全受控或完全不受控(并使用
key
)代替。 - 在十分少见的情况下,你可能想使用
getDerivedStateFromProps
作为最后的选择。
综合本篇文章,我认为第三条是不完全正确的。完全受控 + key 根本做不到在 props 某项改变的时候更新,只能做到在 props 整体改变,新数据与之前没有关联的时候(比如对用户的管理系统,切换了用户),整体更新。
最终总结,我认为,官方提供的在 props 改变时更新的方法有以下几点:
- 使用
componentDidUpdate
代替; - 干脆绕过去,把需要在 props 改变时更新的操作移到父组件,这样不需要在子组件比较更新,直接把最终结果下发到子组件即可;
- 如果 props 是整体全部改变,数据与之前没有关联(比如用户管理系统,切换了用户,id 都不同了),利用 id 作为
key
,将整个组件重置; - memoization;
- 万不得已再使用的
getDerivedStateFromProps
。