原文链接:
https://reactjs.org/blog/2018…React 16.4包含了一个
getDerivedStateFromProps
的 bug 修复:曾带来一些 React 组件频繁复现的 已有bug。如果你的应用曾经采用某种反模式写法,但是在这次修复之后没有被覆盖到你的情况,我们对于该 bug 深感抱歉。在下文,我们会阐述一些常见的,
derived state
相关的反模式,还有我们的建议写法。
很长一段时间,componentWillReceiveProps
是响应props 改变,不会带来额外重新渲染,更新 state 的唯一方式。在16.3版本中,我们引入了一个生命周期方法getDerivedStateFromProps
,为的是以一种更安全的方式来解决同样的问题。同时,我们意识到人们对于这两个钩子函数的使用有许多误解,也发现了一些造成这些晦涩 bug 的反模式。getDerivedStateFromProps
的16.4版本修复使得 derived state
更稳定,滥用情况会减少一些。
注意事项
本文提及的所有反模式案例面向旧钩子函数
componentWillReceiveProps
和新钩子函数
getDerivedStateFromProps
。
本文会涵盖下面讨论:
- 什么时候去使用 derived state
一些 derived state 的常见 bug
- 反模式:无条件地拷贝props 到state
- 反模式:当 props 改变的时候清除 state
- 建议解决方案
- 内存化
什么时候去使用Derived State
getDerivedStateFromProps
存在的唯一目的是使得组件在 props 改变时能都更新好内在state。我们之前的博文有过一些例子,比如基于一个变化着的偏移 prop 来记录当前滚动方向或者根据一个来源 prop 来加载外部数据。
我们没有给出许多例子,因为总体原则上来讲,derived state 应该用少点。我们见过的所有derived state 的问题大多数可以归结为,要么没有任何前提条件的从 props 更新state,要么 props,state 不匹配的任何时候去更新 state。(我们将在下面谈及更多细节)
- 如果你正在使用 derived state 来进行一些基于当前 props 的内存化计算,那么你不需要 derived state。memoization 小节会细细道来。
- 如果你在无条件地更新 derived state或者 props,state 不匹配的时候去更新它,你的组件很可能太频繁地重置 state,继续阅读可见分晓。
derived state 的常见 bug
受控,不受控概念通常针对表单输入,但是也可以用来描述组件的数据活动。props 传递进来的数据可以看成受控的(因为父组件控制了数据源)。组件内部状态的数据可以看成不受控的(因为组件能直接改变他)。
最常见的derived state错误 就是混淆两者(受控,不受控数据);当一个 state 的变更字段也可以通过 setState 调用来更新的时候,就没有一个单一的(真相)数据源。上面谈及的加载外部数据的例子可能听起来情况类似,但是一些重要方面还是不一样的。在加载例子中,source 属性和 loading 状态有着一个清晰数据源。当source prop改变的时候,loading 状态总是被重写。相反,loading 状态只会在 prop 改变的时候被重写,其他情况下就是被组件管控着。
问题就是在这些约束变化的时候出现的。最典型的两种形式如下,我们来瞧瞧:
反模式: 无条件的从 props 拷贝至 state
一个常见的误解就是以为getDerivedStateFromProps
和componentWillReceivedProps
会只在props 改变的时候被调用。实际上这两个钩子函数可能在父组件渲染的任何时候被调用,不管 props 是不是和以前不同。因此,用这两个钩子函数来无条件消除 state 是不安全的。这样做会使得 state 更新丢失。
我们看看一个范例,这是一个邮箱输入组件,镜像了一个 email prop 到 state:
class EmailInput extends Component {
state = { email: this.props.email }
render () {
return <input onChange={this.handleChange} value={this.state.email} />
}
handleChange = e => {
this.setState({ email: e.target.value })
}
componentWillReceiveProps(nextProps) {
// This will erase any local state updates!
// Do not do this.
this.setState({ email: nextProps.email })
}
}
刚开始,该组件可能看起来 Okay。State 依靠 props 来进行值初始化,我们输入的时候也会更新 State。但是如果父组件重新渲染的时候,我们敲入的任何字符都会被忽略。就算我们在 钩子函数setState 之前进行了nextProps.email !== this.state.email
的比较,也无济于事。
在这个简单例子中,我们可以通过增加shouldComponentUpdate
,使得只在 email prop改变的时候重新渲染。但是实践表明,组件通常会有多个 prop,另一个 prop的改变仍旧可能造成重新渲染还是有不正确的重置。函数和对象类型的 prop 经常行内生成。使得shouldComponentUpdate
只允许在一种情形发生时返回 true很难实现。这儿有个直观例子。所以,shouldComponentUpdate
是性能优化的最佳手段,不要想着确保 derived state 的正确使用。
希望现在的你明白了为什么无条件拷贝 props 到 state 是个坏主意。在总结解决方案之前,我们来看看相关反模式:如果我们指向在 email prop 改变的时候去更新 state 呢
反模式: props 改变的时候擦除 state
接着上面例子继续,我们可以避免在 props.email
改变的时候故意擦除 state:
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
})
}
}
}
注意事项
即使上面的例子中只谈到
componentWillReceiveProps
, 但是也同样适用于
getDerivedStateFromProps
。
我们已经改善许多,现在组件会只在props 改变的时候清除我们输入过的旧字符。
但是还有一个残留问题。想象一下一个密码控件在使用上述输入框组件,当涉及到拥有同一邮箱的两个帐号的细节式,输入框无法重置。因为 传递给组件的prop值,对于两个帐号而言是一样的。这会困扰到用户,因为一个账号还没保存的变更将会影响到共享同一邮箱的其他帐号。这有demo。
这是个根本性的设计失误,但是也很容易犯错,比如我。幸运的是有两个更好的方案。关键在于,对于任何片段数据,需要用一个单独组件来保存数据,并且要避免在其他组件重复。我们来看看这两个方案:
解决方案
推荐方案一:全受控组件
避免上面问题的一个办法,就是从组件当中完全移除 state。如果我们的邮箱地址只是作为一个 prop 存在,那么我们不用担心和 state 的冲突。甚至可以把EmailInput
转换成一个更轻量的函数组件:
function EmailInput(props) {
return <input onChange={props.onChange} value={props.email} />
}
这个办法简化了组件的实现,如果我们仍然想要保存草稿值的话,父表单组件将需要手动处理。这有一个这种模式的demo。
推荐方案二: 带有 key 属性的全不受控组件
另一个方案就是我们的组件需要完全控制 draft 邮箱状态值。这样的话,组件仍然可以接受一个prop初始值,但是会忽略该prop 的连续变化:
class EmailInput extends Component {
state = { email: this.props.defaultEmail }
handleChange = e => {
this.setState({ email: e.target.value })
}
render () {
return <input onChange={this.handleChange} value={this.state.email} />
}
}
在聚焦到另一个表单项的时候为了重置邮箱值(比如密码控件场景),我们可以使用React 的 key 属性。当 key 变化时,React 会创建一个新组件实例,而不是更新当前组件。Keys 通常对于动态列表很有用,不过在这里也很有用。在一个新用户选中时,我们用 user ID 来重新创建一个表单输入框:
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>
每次 ID 改变的时候,EmailInput
输入框都会重新生成,它的 state 也就会重置到最新的 defaultEmail
值。栗子不能少,这个方案下,没有必要把 key 值添加到每个输入框。在整个form表单上 添加一个 key 属性或许会更合理。每次 key 变化时,表单内的所有组件都会重新生成,同时初始化 state。
在大多数情况,这是处理需要重置的state的最佳办法。
注意事项
这个办法可能听起来性能慢,但是实际表现上可能微不足道。如果一个组件有复杂更新逻辑的话使用key属性可能会更快,因为diffing算法走了弯路
- 方案一:通过 ID 属性重置 uncontrolled 组件
如果 key 由于某个原因不生效(有可能是组件初始化成本高),那么一个可用但是笨拙的办法就是在getDerivedStateFromProps
里监听userID 的变化。
class EmailInput extends Component {
state = {
email: this.props.defaulEmail,
pervPropsUserID: this.props.userID,
}
static getDerivedFromProps(nextProps, prevState) {
// 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 (nextProps.userID !== prevState.prevPropsUserID) {
return {
prevPropsUserID: nextProps.userID,
email: nextProps.defaultEmail,
}
}
return null
}
// ...
}
如果这么做的话,也给只重置组件部分内在状态带来了灵活性,举个例子。
注意事项
即使上面的例子中只谈到
getDerivedStateFromProps
, 但是也同样适用于
componentWillReceiveProps
。
- 方案二:用实例方法来重置非受控组件
极少情况下,即使没有用作 key 的合适 ID,你还是想重置 state。一个办法是把 key重置成随机值或者每次你想重置的时候会自动纠正。另一个选择就是用一个实例方法用来命令式地重置内部状态。
class EmailInput extends Component {
state = {
email: this.props.defaultEmail,
}
resetEmailForNewUser (newEmail) {
this.setState({ email: newEmail })
}
// ...
}
父表单组件就可以使用一个 ref 属性来调用这个方法
,这里有 Demo.
总结
总结一下,设计一个组件的时候,重要的是确定数据是受控还是不受控。
不要把 prop 值“镜像”到 state,而是要让组件受控,并且合并在一些父组件中的两个分叉值。比如说,不是要让子组件接收一个props.value
,并且跟踪一个草稿字段state.value
,而是要让父组件管理 state.draftValue
还有state.committedValue
,直接控制子组件的值。会使得数据流更明显,更稳定。
对于不受控组件,如果你想要在一个 ID 这样的特殊 prop 变化的时候重置 state,你会有以下选项:
- 推荐:为了重置所有内部state,使用 key 属性
- 方案一:为了重置某些字段值,监听一个
props.userID
这种特殊字段的变化 - 方案二:也可以会退到使用 refs 属性的命令式实例方法
内存化
我们已经看到 derived state 为了确保一个用在 render
的字段而在输入框变化时被重新计算。这项技术叫做内存化。
使用 derived state 去达到内存化并没有那么糟糕,但是也不是最佳方案。管理 derived state 本身比较复杂,属性变多时变得更复杂了。比如说,如果我们增加第二个 derived 字段到我们的组件 state,那么我们需要针对两个值的变化来做追踪。
看看一个组件例子,它有一个列表 prop,组件渲染出匹配用户查询输入字符的列表选项。我们应该使用 derived state 来存储过滤好的列表。
class Example extends Component {
state = {
filterText: '',
}
// ********************
// NOTE: this example is NOT the recommended approach.
// See the examples below for our recommendations instead.
// ********************
staitic getDerivedStateFromProps(nextProps, prevState) {
// Re-run the filter whenever the list array or filter text change.
// Note we need to store prePropsList and prevFilterText to detect change.
if ( nextProps.list !== prevState.prevPropsList || prevState.prevFilterList !== prevState.filterText) {
return {
prevPropsList: nextProps.list,
prevFilterText: prevState.filterText,
filteredList: nextProps.list.filter(item => item.text.includes(prevState.filterText))
}
}
return null
}
handleChange = e => {
this.setState({ filterText: e.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 的变化,为的是适当的更新过滤好的列表。这里,我们可以使用PureCompoennt
来做简化,把过滤操作放到 render 方法里去:
// PureCompoents only rerender if at least one stae or prop value changes.
// Change is determined by doing a shallow comparison of stae and prop keys.
class Example Extends PureComponent {
// State only needs to hold the current filter text value:
state = {
filterText: '',
}
handleChange = e => {
htis.setState({ filterText: e.target.value })
}
render () {
// The render method on this PureComponent is called only if
// props.list or state.filterList has changed.
const filteredList = this.props.list.filter(
item => item.text.includes(this.stae.filterText)
)
return (
<Fragment>
<input onChange={this.handleChange} value={this.state.filterText} />
<ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
</Fragment>
)
}
}
上面代码要干净多了而且比 derived state 版本要更简单。只是偶尔不够好:对于大列表的过滤有点慢,而且如果另一个 prop 要变化的话PureComponent
不会防止重新渲染。基于这样的考虑,我们增加了memoization helper
来避免非必要的列表重新过滤:
import memoize from 'memoize-one'
class Example extends Component {
// State only need to hold the current filter text value:
state = { filterText: '' }
filter = memoize(
(list, filterText) => list.filter(item => item.text.includes(filterText))
)
handleChange = e => {
this.setState({ filterText: e.target.value })
}
render () {
// Calculate the latest filtered list. If these arguments havent changed
// since the last render, `'memoize-one` will reuse the last return value.
const filteredList = this.filter(this.props.list, this.sate.filterText)
return (
<Fragment>
<input onChange={this.handleChange} value={this.state.filterText} />
<ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
</Fragment>
)
}
}
这要简单多了,而且和 derived state 版本一样好。
当使用memoization
的时候,需要满足一些条件:
- 在大多数情况下,你会把内存化函数添加到一个组件实例上。这会防止该组件的多个实例重置每一个内存化属性。
- 通常你使用一个带有有限缓存大小的内存化工具,为的是防止时间累计下来的内存泄露。(在上述例子中,我们使用
memoize-one
因为它仅仅会缓存最近的参数和结果)。 - 这一节里,如果每次父组件渲染的时候
props.list
重新生成的话,上述实现会失效。但是在多数情况下,上述实现是合适的。
结束语
在实际应用中,组件经常混合着受控和不受控的行为。理所应当。如果每个值都有明确源,你就可以避免上面的反模式。
重申一下,由于比较复杂,getDerivedStateFromProps
(还有 derived state)是一项高级特性,而且应该用少点。如果你使用的时候遇到麻烦,请在 GitHub 或者 Twitter 上联系我们。