(扩展:什么是反模式 )
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; } // ... }
如果我们这样选择,这也提供了仅重置部件的内部状态的灵活性。 (点击这里查看该模式的演示。(https://codesandbox.io/s/rjyvp7l3rq))
注意
尽管上面的示例显示了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。
使用派生状态进行memoization并不一定是不好的,但它通常不是最好的解决方案。管理派生状态存在固有的复杂性,并且这种复杂性随着每个附加属性而增加。例如,如果我们向组件状态添加第二个派生字段,那么我们的实现将需要分别跟踪两者的更改。
我们来看一个组件的例子,它带有一个prop - 一个项目列表 - 并呈现与用户输入的搜索查询匹配的项目。我们可以使用派生状态来存储过滤的列表:
class Example extends Component { state = { filterText: "", }; // ******************************************************* // NOTE: this example is NOT the recommended approach. // See the examples below for our recommendations instead. // ******************************************************* static getDerivedStateFromProps(props, state) { // Re-run the filter whenever the list array or filter text change. // Note we need to store prevPropsList and prevFilterText to detect changes. 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>{item.text}</li>)}</ul> </Fragment> ); } }
这个实现避免了重新计算filteredList的次数。但它比它需要更复杂,因为它必须分别跟踪和检测道具和状态的变化,以便正确更新过滤列表。在这个例子中,我们可以通过使用PureComponent并将滤镜操作移动到渲染方法来简化:
// PureComponents only rerender if at least one state or prop value changes. // Change is determined by doing a shallow comparison of state and prop keys. class Example extends PureComponent { // State only needs to hold the current filter text value: state = { filterText: "" }; handleChange = event => { this.setState({ filterText: event.target.value }); }; render() { // The render method on this PureComponent is called only if // props.list or state.filterText has changed. 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>{item.text}</li>)}</ul> </Fragment> ); } }
上面的方法比派生的状态版本更清洁和简单。偶尔,这将不够好 - 对于大型列表来说,过滤可能会很慢,并且如果另一个props发生变化,PureComponent不会阻止退回。为了解决这两个问题,我们可以添加一个memoize帮助器,以避免不必要地重新过滤我们的列表:
import memoize from "memoize-one";class Example extends Component { // State only needs to hold the current filter text value: state = { filterText: "" }; // Re-run the filter whenever the list array or filter text changes: filter = memoize( (list, filterText) => list.filter(item => item.text.includes(filterText)) ); handleChange = event => { this.setState({ filterText: event.target.value }); }; render() { // Calculate the latest filtered list. If these arguments haven't changed // since the last render, memoize-one will reuse the last return value. 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>{item.text}</li>)}</ul> </Fragment> ); } }
这非常简单,并且与派生状态版本一样好!
在使用memoization时,记住一些约束:
在大多数情况下,您需要将memoized函数附加到组件实例。这可以防止组件的多个实例重置彼此的memoized密钥。
通常情况下,您需要使用具有有限缓存大小的memoization助手,以防止随着时间的推移内存泄漏。 (在上面的例子中,我们使用了memoize-one,因为它只缓存最近的参数和结果。)
如果每次父组件呈现时重新创建props.list,本节中显示的实现都不会起作用。但在大多数情况下,这种设置是合适的。
最后
在现实世界的应用程序中,组件通常包含受控和非受控行为的混合。这没关系!如果每个值都有明确的真相来源,则可以避免上述的反模式。
值得重新思考的是,getDerivedStateFromProps(以及通常的派生状态)是一种高级功能,应该谨慎使用,因为这种复杂性。如果您的用例超出这些模式,请在GitHub 或 Twitter!上与我们分享!
共同学习,写下你的评论
评论加载中...
作者其他优质文章