【译】你可能不需要派生状态
React 16.4包含了getDerivedStateFromProps的bug,这个 bug 会在 React components 复现一些已知的 bug。如果这个版本导致出现的问题,导致您在修复过之后还已知出现的话,我们非常抱歉。在这篇文章中,我们将解释一些常见的反模式与派生状态和我们的首选替代方案。
很长一段时间,生命周期componentWillReceiveProps是更新状态以响应props改变没有额外渲染的的唯一方法。在版本16.3中,我们引入了替换生命周期,getDerivedStateFromProps
以更安全的方式解决相同的情况。与此同时,我们意识到人们对如何使用这两种方法存在许多误解,并且我们发现反模式会导致细微而混乱的错误。getDerivedStateFromProps在16.4中的错误修正使得派生状态更具可预测性,因此滥用它的结果更容易被注意到
注意
本文中描述的所有反模式都适用于较旧版本
componentWillReceiveProps
和较新版本getDerivedStateFromProps
。
此博客文章将涵盖以下主题:
何时使用派生状态
getDerivedStateFromProps
的存在只有一个目的,让组件props 变化时更新state。上一个bug展示了一些实例,比如props 的offset变化时修改当前的滚动方向和根据props变化加载外部数据。
我们没有提供很多示例,应为有保守使用派生state 这个规则。大部分使用派生 state 导致的问题,不外乎两个原因:1,直接复制 props 到 state 上;2,如果 props 和 state 不一致就更新 state。下面的示例包含了这两种情况。
- 如果你只是为了缓存(memoize)基于当前 props 计算后的结果的话,你就没必要使用派生 state。。尝试一下 memoization?
- 如果只是用来保存 props 或者和当前 state 比较之后不一致后更新 state,那你的组件应该是太频繁的更新了 state。请继续阅读。
派生 state 的常见 bug
名词“受控”和“非受控”通常用来指代表单的 inputs,但是也可以用来描述数据频繁更新的组件。用 props 传入数据的话,组件可以被认为是受控(因为组件被父级传入的 props 控制)。数据只保存在组件内部的 state 的话,是非受控组件(因为外部没办法直接控制 state)。
常见的错误就是把两者混为一谈。当一个派生 state 值也被 setState
方法更新时,这个值就不是一个单一来源的值了。加载外部数据示例描述的行为和这个类似,但是有很重要的区别。在加载外部数据示例中,数据 source
和 loading
都有非常清晰并且唯一的数据来源。当 prop 改变时,loading
的状态一定会改变。相反,state 只有在 prop 改变时才会改变,除非组件内部还有其他行为改变这个状态。
上述条件如果有一个不满足,就会导致问题,最常见的就是在两个表单里修改数据。
反模式: 直接复制 props 到 state
最常见的误解就是 getDerivedStateFromProps
和 componentWillReceiveProps
只会在 props “改变”时才会调用。实际上只要父级重新渲染时,这两个生命周期函数就会重新调用,不管 props 有没有“变化”。所以,在这两个方法内直接复制(unconditionally)props 到 state 是不安全的。这样做会导致 state 后没有正确渲染。
重现一下这个问题。这个 EmailInput
组件复制 props 到 state:
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) {
// 这会覆盖所有组件内的 state 更新!
// 请不要这样做。
this.setState({ email: nextProps.email });
}
}
乍看之下还可以。 state 的初始值是 props 传来的,当在 <input>
里输入时,修改 state。但是如果父组件重新渲染,我们输入的所有东西都会丢失!(查看这个示例),即使在重置 state 前比较 nextProps.email !== this.state.email
仍然会导致更新。
这个小例子中,使用 shouldComponentUpdate
,比较 props 的 email 是不是修改再决定要不要重新渲染。但是在实践中,一个组件会接收多个 props,任何一个 prop 的改变都会导致重新渲染和不正确的状态重置。加上行内函数和对象 props,创建一个完全可靠的 shouldComponentUpdate
会变得越来越难。这个示例展示了这个情况。而且 shouldComponentUpdate
的最佳实践是用于性能提升,而不是改正不合适的派生 state。
希望以上能解释清楚为什么直接复制 prop 到 state 是一个非常糟糕的做法。在寻找解决方案之前,让我们看看一个相关的问题:假如我们只使用 props 中的 email 属性更新组件呢?
反模式: 在 props 变化后修改 state
继续上面的示例,我们可以只使用 props.email
来更新组件,这样能防止修改 state 导致的 bug:
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
也是一样
我们已经做了一个很大的改进。现在组件只会在 prop 改变时才会改变。
但是仍然有个问题。想象一下,如果这是一个密码输入组件,拥有同样 email 的两个账户进行切换时,这个输入框不会重置(用来让用户重新登录)。因为父组件传来的 prop 值没有变化!这会让用户非常惊讶,因为这看起来像是帮助一个用户分享了另外一个用户的密码,(查看这个示例)。
虽然这个设计就有问题,但是这样的错误很常见,(我就犯过这样的错误)。幸运的是,有两个方案能解决这些问题。这两者的关键在于,任何数据,都要保证只有一个数据来源,而且避免直接复制它。我们来看看这两个方案。
更好的解决方法
建议:完全可控的组件
阻止上述问题发生的一个方法是,从组件里删除 state。如果 prop 里只包含了 email,我们就没必要担心它和 state 冲突。我们甚至可以把 EmailInput
转换成一个轻量级的函数组件:
function EmailInput(props) {
return <input onChange={props.onChange} value={props.email} />;
}
这样能用最简单的方式完成我们需要的组件。但是如果我们仍然想要保存临时的值,则需要父组件手动执行保存这个动作。(点击查看这个模式的演示)。
建议:有 key 的非受控组件
另外一个选择是让组件自己存储临时的 email state。在这种情况下,组件仍然可以从 prop 接收“初始值”,但是更改之后的值就和 prop 没关系了:
在这密码管理器的例子中,为了在不同的页面切换不同的值,我们可以使用 key
这个特殊的 React 属性。当 key
变化时, React 会创建一个新的而不是更新一个既有的组件。 Keys 一般用来渲染动态列表,但是这里也可以使用。在这个示例里,当用户输入时,我们使用 user ID 当作 key 重新创建一个新的 email input 组件:
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>
每次 ID 更改,都会重新创建 EmailInput
,并将其状态重置为最新的 defaultEmail
值。(点击查看这个模式的演示) 使用此方法,不用为每次输入都添加 key
,在整个表单上添加 key
更合理。每次 key 变化,表单里的所有组件都会用新的初始值重新创建。
大部分情况下,这是处理重置 state 的最好的办法。
注意
这看起来很慢,但是这点的性能是可以忽略的。如果在组件树的更新上有很重的逻辑,这样反而会更快,因为省略了子组件 diff。
选项一:用 prop 的 ID 重置非受控组件
如果某些情况下 key
不起作用(可能是组件初始化的开销太大),一个麻烦但是可行的方案是在 getDerivedStateFromProps
观察 userID
的变化:
class EmailInput extends Component {
state = {
email: this.props.defaultEmail,
prevPropsUserID: this.props.userID
};
static getDerivedStateFromProps(props, state) {
// 只要当前 user 变化,
// 重置所有跟 user 相关的状态。
// 这个例子中,只有 email 和 user 相关。
if (props.userID !== state.prevPropsUserID) {
return {
prevPropsUserID: props.userID,
email: props.defaultEmail
};
}
return null;
}
// ...
}
如果你乐意,你也可以只重置一小部分 state (点击查看这个模式的演示)。
注意
上面的示例使用了
getDerivedStateFromProps
,用componentWillReceiveProps
也一样。
选项二:使用实例方法重置非受控组件
更少见的情况是,即使没有合适的 key
,我们也想重新创建组件。一种解决方案是给一个随机值或者递增的值当作 key
,另外一种是用实例方法强制重置内部状态:
class EmailInput extends Component {
state = {
email: this.props.defaultEmail
};
resetEmailForNewUser(newEmail) {
this.setState({ email: newEmail });
}
// ...
}
然后父级组件可以使用 ref
调用这个方法。(点击查看这个模式的演示)。
refs 在某些情况下很有用,比如这个。但通常我们建议谨慎使用。即使是做一个演示,这个命令式的方法也是非理想的,因为这会导致两次而不是一次渲染。
概括
回顾一下,设计组件时,重要的是确定组件是受控组件还是非受控组件。
不要直接复制(mirror) props 的值到 state 中,而是去实现一个受控的组件,然后在父组件里合并两个值。比如,不要在子组件里被动的接受 props.value
并跟踪一个临时的 state.value
,而要在父组件里管理 state.draftValue
和 state.committedValue
,直接控制子组件里的值。这样数据才更加明确可预测。
对于不受控的组件,当你想在 prop 变化(通常是 ID )时重置 state 的话,可以选择以下几种方式:
- 建议: 重置内部所有的初始 state,使用 key 属性
- 选项一:仅更改某些字段,观察特殊属性的变化(比如
props.userID
)。 - 选项二:使用 ref 调用实例方法。
尝试一下 memoization?
我们上面用到了————仅在输入变化时,重新计算 render
需要使用的值————这个技术叫做 memoization。
把派生 state 用作 memoization 并不是什么坏事情,但是这并不是好的方法。管理派生 state 本来就很复杂,而且这种复杂度是随着需要管理的属性变得越来越庞大。比如,如果我们想在组件 state 里添加第二个派生 state,那就需要写两份跟踪变化的逻辑。
这里有个示例,组件使用一个 prop ————一个列表————并在用户输入查询条件时显示匹配的项,我们可以使用派生 state 存储过滤后的列表:
class Example extends Component {
state = {
filterText: "",
};
// *******************************************************
// 注意:这个例子不是建议的方法。
// 下面的例子才是建议的方法。
// *******************************************************
static getDerivedStateFromProps(props, state) {
// 列表变化或者过滤文本变化时都重新过滤。
// 注意我们要存储 prevFilterText 和 prevPropsList 来检测变化。
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
,但是过于复杂。因为它必须单独追踪并检测 prop 和 state 的变化,在能及时的更新过滤后的 list。我们可以使用 PureComponent
,把过滤操作放到 render 方法里来简化这个组件:
// PureComponents 只会在 state 或者 prop 的值修改时才会再次渲染。
// 通过对 state 和 prop 的 key 做浅比较( shallow comparison )来确定有没有变化。
class Example extends PureComponent {
// state 只需要保存 filter 的值:
state = {
filterText: ""
};
handleChange = event => {
this.setState({ filterText: event.target.value });
};
render() {
// PureComponent 的 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>
);
}
}
上面的方法比派生 state 版本更加清晰明了。只有在过滤很大的列表时,这样做的效率不是很好。当有 prop 改变时 PureComponent
不会阻止再次渲染。为了解决这两个问题,我们可以添加 memoization 帮助函数来阻止非必要的过滤:
import memoize from "memoize-one";
class Example extends Component {
// state 只需要保存当前的 filter 值:
state = { filterText: "" };
// 在 list 或者 filter 变化时,重新运行 filter:
filter = memoize(
(list, filterText) => list.filter(item => item.text.includes(filterText))
);
handleChange = event => {
this.setState({ filterText: event.target.value });
};
render() {
// 计算最新的过滤后的 list。
// 如果和上次 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>
);
}
}
这样更简单,而且和派生 state 版本一样好!
在使用 memoization 时,请记住这些约束:
- 大部分情况下, 每个组件内部都要引入 memoized 方法,已免实例之间相互影响。
- 一般情况下,我们会限制 memoization 帮助函数的缓存空间,以免内存泄漏。(上面的例子中,使用
memoize-one
只缓存最后一次的参数和结果)。 - 如果每次父组件都传入新的
props.list
,那本文提到的问题都不会遇到。在大多数情况下,这种方式是可取的。
结束语
在实际应用中,组件一般都会有受控组件和非受控组件。这是正常的!不过如果每个值都有明确的来源,就可以避免上面提到的反面模式。
getDerivedStateFromProps
(以及其他派生 state)是一个高级复杂的功能,应该保守使用,这个再怎么重申也不过分。如果你的用法不属于上述这些模式,请在 GitHub 或 Twitter 与我们分享!
【作者简介:】 Mars 芦苇科技web前端开发工程师 喜欢 看电影 ,撸铁 还有学习。擅长 微信小程序开发, 系统管理后台。访问 www.talkmoney.cn 了解更多。
作者主页: