正文
(扩展:
什么是反模式
)
React 16.4包含一个针对
getDerivedStateFromProps的错误修正
,这会导致React组件中的一些现有错误更加得到重现。如果此版本暴露了您的应用程序使用反模式并且在修复后无法正常工作的情况,我们对此表示抱歉。在这篇文章中,我们将解释一些常见的反模式以及派生状态和我们的首选备选方案。
很长一段时间,生命周期componentWillReceiveProps是更新状态以响应没有额外渲染
props
更改的唯一方法。在版本16.3中,我们引入了替代生命周期
getDerivedStateFromProps
,以更安全的方式解决相同的用例。同时,我们意识到人们对于如何使用这两种方法存在很多误解,并且我们发现了反模式,导致了一些微妙而混乱的错误。在16.4中的
getDerivedStateFromProps
错误修正使派生状态更具可预测性,因此滥用它的结果更容易被注意到。
注意
本文中描述的所有反模式都适用于较早的componentWillReceiveProps和较新的getDerivedStateFromProps。 This blog post will cover the following topics:
何时使用派生状态
getDerivedStateFromProps仅用于一个目的。由于道具的变化,它使组件能够更新其内部状态。我们之前的博客文章提供了一些示例,例如
基于变化的偏移props记录当前滚动方向
或
加载源props指定的外部数据
.。
我们没有提供很多例子,因为作为一般规则,谨慎地使用派生状态。我们所看到的派生状态的所有问题都可以最终归结为(1)无条件地从porops更新state,或者(2)当props和state不匹配时更新状态。 (我们将在下面更详细地讨论)。
-
如果您使用派生状态来仅根据当前道具记忆某些计算,则不需要派生状态。请
参阅memoization
。
-
如果您无条件地更新派生状态,或者每当props和state不匹配时更新它,您的组件可能会过于频繁地重置其状态。请阅读以获得更多详情。
使用派生状态时的常见错误
术语
“controlled”
和
“uncontrolled”
通常指形式输入,但它们也可以描述任何组件的数据所在的位置。作为道具传入的数据可以认为是
controlled
的(因为父组件控制着这些数据)。只存在于内部状态的数据可以被认为是
uncontrolled
的(因为父母不能直接改变它)。
导出状态最常见的错误是混合这两个;当派生状态值也通过setState调用进行更新时,数据没有单一来源。
上面提到的外部数据加载示例
可能听起来很相似,但在几个重要方面它有所不同。在加载示例中,“源”道具和“加载”状态都有明确的事实来源。当源道具改变时,加载状态应该总是被覆盖。相反,只有在道具发生变化并由组件管理时才会覆盖该状态。
当这些约束条件发生变化时,就会出现问题。这通常有两种形式。我们来看看两者。
反模式:无条件复制props到state
一个常见的误解是getDerivedStateFromProps和componentWillReceiveProps仅在props“更改”时调用。无论props是否与之前“不同”,这些生命周期都会在父组件再生时随时调用。因此,使用这些生命周期中的任何一个都无条件地覆盖状态一直是不安全的。这样做会导致状态更新丢失。
我们来考虑一个例子来演示这个问题。以下是一个EmailInput组件,它可以在状态中“镜像”电子邮件道具:
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 will erase any local state updates!
// Do not do this.
this.setState({ email: nextProps.email });
}
}
起初,这个组件可能看起来不错。 State被初始化为由props指定的值,并在我们输入时更新。但是如果我们的组件的父节点退出,我们输入的任何东西都会丢失! (请参阅本演示
示例
。)即使我们要在重置之前比较nextProps.email!== this.state.email,也是如此。
在这个简单的例子中,只有在电子邮件道具发生变化时才能修复shouldComponentUpdate以重新渲染。然而在实践中,组件通常接受多个道具;另一个改变道具仍然会导致重新投入和不适当的重置。函数和对象的道具通常也是内联创建的,这使得很难实现只有在发生重大变化时才可靠地返回true的shouldComponentUpdate。
这是一个演示
,显示发生了什么情况。因此,shouldComponentUpdate最好用作性能优化,而不是确保派生状态的正确性。
希望现在很清楚,为什么无条件复制道具来陈述它是一个坏主意。在审查可能的解决方案之前,让我们看看一个相关的问题模式:如果我们只在电子邮件道具更改时更新状态,该怎么办?
反模式:props改变时擦除state
继续上面的例子,我们可以避免在props.email更改时仅通过更新来意外擦除状态:
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实际改变时才会清除我们输入的内容。
仍然存在一个微妙的问题。想象一下使用上述输入组件的密码管理器应用程序。当使用同一封电子邮件在两个帐户的详细信息之间导航时,输入将无法重置。这是因为传递给组件的道具值对于两个帐户都是相同的!这对用户来说是一个惊喜,因为对一个帐户的未保存更改似乎会影响发生共享相同电子邮件的其他帐户。 (
请参阅此处的演示。
)
这种设计从根本上说是有缺陷的,但它也是一个容易犯的错误。 (
我自己做的!
)幸运的是,有两种方法可以更好地工作。两者的关键在于,对于任何一块数据,您都需要选择一个拥有它作为真相源的组件,并避免将其复制到其他组件中。让我们来看看每个选项。
首选方案
建议:完全控制组件
避免上述问题的一种方法是完全从组件中删除状态。如果电子邮件地址仅作为props存在,那么我们不必担心与state的冲突。我们甚至可以将EmailInput转换为更轻量级的功能组件:
function EmailInput(props) {
return <input onChange={props.onChange} value={props.email} />;
}
这种方法简化了我们组件的实现,但是如果我们仍然想要存储草稿值,则父表单组件现在需要手动完成。 (
点击这里查看该模式的演示。
)
建议:完全不受控制的组件
另一种选择是我们的组件完全拥有“草稿”电子邮件状态。在这种情况下,我们的组件仍然可以接受初始值的props,但它会忽略对该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属性。当一个键改变时,React将
创建一个新的组件实例
,而不是更新当前的组件实例。密钥通常用于动态列表,但在这里也很有用。在我们的例子中,我们可以使用用户标识在任何时候选择新用户时重新创建电子邮件输入:
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>
每次ID更改时,EmailInput都将被重新创建,其状态将重置为最新的defaultEmail值。 (
点击这里查看该模式的演示。
)使用这种方法,您不必为每个输入添加密钥。相反,在整个表单上放置一个key可能更有意义。每次key更改时,表单中的所有组件都将以新的初始化状态重新创建。
在大多数情况下,这是处理需要重置的状态的最佳方式。
注意
虽然这听起来很慢,但性能差异通常不显着。如果组件具有在更新上运行的重逻辑,则使用密钥甚至可以更快,因为该子树的差异被绕过。
备选方案1:使用IDprops重置非受控组件
如果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;
}
// ...
}
如果我们这样选择,这也提供了仅重置部件的内部状态的灵活性。 (点击这里查看该模式的演示。(
codesandbox.io/s/rjyvp7l3r…
注意
尽管上面的示例显示了getDerivedStateFromProps,但可以使用与componentWillReceiveProps相同的技术。
备选方案2:使用实例方法重置非受控组件
更为罕见的是,即使没有适当的ID用作密钥,您也可能需要重置状态。一种解决方法是每次重置时将密钥重置为随机值或自动增量编号。另一个可行的选择是公开一个实例方法来强制重置内部状态:
class EmailInput extends Component {
state = {
email: this.props.defaultEmail
};
resetEmailForNewUser(newEmail) {
this.setState({ email: newEmail });
}
// ...
}
父表单组件然后可以
使用ref来调用此方法。
(
点击这里查看该模式的演示。
)
在这种情况下,Refs可能会有用,但通常我们建议您谨慎使用它们。即使在演示中,这个必要的方法也是非理想的,因为两个渲染将会发生,而不是一个。
概括
总而言之,在设计组件时,决定其数据是受控制的还是不受控制的是非常重要的。
而不是试图“镜像”状态下的道具值,控制组件,并合并父组件状态中的两个发散值。例如,不是接受“已提交”props.value并跟踪“草稿”状态值的孩子,而是让父组件管理state.draftValue和state.committedValue,并直接控制子组件的值。这使得数据流更加明确和可预测。
对于不受控制的组件,如果您尝试在特定的道具(通常是ID)发生变化时重置状态,则可以选择以下几种方式:
-
推荐:要重置所有内部状态,请使用键属性。
-
备选方案1:要只重置某些状态字段,请注意特殊属性(例如props.userID)的更改。.
-
备选方案2:您也可以考虑使用参考回退到命令式实例方法。
memoization怎么样?
我们还看到了派生状态,用于确保渲染中使用的昂贵值仅在输入发生变化时才会重新计算。这种技术被称为
memoization
。