【译】你可能不需要派生状态
翻译这篇文章的起因是因为在一次需求迭代中错误的使用了getDerivedStateFromProps这个生命周期导致子组件的state被循环重置,于是翻到了这篇文章,然后就开启的翻译之旅。
在很长一段时间,生命周期componentWillReceiveProps
是用来响应props更新来改变state并不需要额外渲染的唯一方法。在16.3版本中,我们提供了getDerivedStateFromProps
这个更安全生命周期来解决相同的用例。同时,我们发现人们对于如何使用这两种方式有很多误解,并且我们发现了一些造成微妙和令人混淆的反模式。在16.4中的getDerivedStateFromProps
的bug修复使得派生状态更加可预测,且更容易让人注意到错误使用它的结果。
什么时候去使用派生状态
getDerivedStateFromProps的存在只有一个目的。它可以使组件根据props的改变来更新内部的state。我们之间的博客提供了一些例子:通过改变offset的prop来改变当前的滚动方向和加载通过source props所指定的外部数据。
我们没有提供更多的例子,因为作为一个基本的规则,派生状态应该被谨慎的使用。所有派生状态导致的问题无异于两种:(1)无条件的根据props来更新state(2)无论props和state是否匹配来更新state。
- 如果仅用派生状态来记录一些基于当前props的计算,则不需要派生状态;
- 如果你无条件的更新派生状态,或者无论props和state是否匹配来更新state,你的组件将会过于频繁的去重置状态;
使用派生状态的常见问题
“受控的”和“不受控的”通常用来指表单的输入,但它也同样可以表示任何组件数据所在的位置。数据通过props传来被认为是“受控的”(因为父组件在控制着这个数据)。数据仅存在其内部的state中被认为是“不受控的”(因为其父组件不能直接的改变这它)。
派生状态最常见的错误就是将这两者混和在一起。当一个派生状态的值同样通过setState的调用来更新时,这就无法保证数据有单一的真实来源。这也许和上面提到的外部数据加载的例子很相似,但他们在一些重要的方面上是不同的。在加载的例子中,”source“的props和”loading“的state都有一个明确的真实来源。当source props改变的时候,应该总是覆盖loading state。相反,只有props改变且由组件管理的时候,才去重写state。
当这些约束中的任何一个被改变时将会出现问题。通常有两种形式,让我们接下来看一下这两种形式。
反模式:无条件的从prop复制状态到state
一个常见的误解是getDerivedStateFromProps
和componentWillReceiveProps
只有在props改变的时候会被调用。这两个生命周期将会在父组件重新渲染的任何时间被调用,而不管props是否与之前不同。因此,在使用这两个生命周期时,无条件的覆盖state总是不安全的,将会导致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由prop传来的数据初始化,且当我们改变input的值时state被更新。但是当我们的父组件重新渲染的时候,任何我们在input中输入的状态都将丢失,即使我们去比较nextProps.email !== this.state.email
也是如此。
在这个例子中,只有当email的prop的改变的时候添加shouldComponentUpdate
来重新渲染可以解决这个问题,但是实际上,组件通常会接收多个prop,另一个prop的改变任然会造成组件的重新渲染和不正确的重置。此外函数和对象的prop通常也会是内联创建的,这也会使shouldComponentUpdate
正确的返回true变得困难。这里有一个例子。因此shouldComponentUpdate
通常被用于性能优化而不是来判断派生状态的正确性。
希望到现在大家清楚为什么不要无条件的复制props到state。在我们找到可能的解决方案之前,让我们去看一个与之相关的问题:如果只在props.email改变的时候去更新state会怎样?
反模式:props改变的时候清除state
继续上面的例子,我们可以避免在props.email更改时意外的清除state:
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 }); } } // ... }
我们取得了很大的进步,现在我们的组件只有在props真正改变的时候才会清除state。
还有一个微妙的问题,想象一下使用以上的组件来构建密码管理应用。当使用同一个email在两个账户的详情页导航时,input将会无法重置,这是因为传递给组件的props相对于两个账号来说时相同的。这对用户来说将会是一个惊喜,因为对一个账户的未保存更改会错误的影响到另一个账户。查看演示。
这种设计从本质上来说是错误的,但却是一个很容易犯的错误,幸运的是,有两种更好的选择,这两者的关键在于,对于任何数据片段,你都需要选择一个将它作为数据源的组件,而避免在其它组件重复使用。
首选方案
推荐:完全受控组件
避免上述问题的一个方案是完全移除组建中的state,如果email仅作为props存在,那我们将不必担心它和state冲突,我们甚至可以讲EmailInput组件变为更轻量级的function组件:
function EmailInput(props) { return <input onChange={props.onChange} value={props.email} />; }
这种方法简化了组件的实现,但如果你仍需要存储一个草稿值,那么父表单组件现在需要手动执行该操作。查看演示。
推荐:带有key的完全不受控组件
另一个方法是让我们的组件完全拥有“草稿”email的state,这时我们的组件仍然可以接收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的特殊属性key
。当一个key改变的时候,React会创建一个新的组件实例而不是更新当前的组件,key
通常被用在动态的list但是同样可以在这里使用。
<EmailInput defaultEmail={this.props.user.email} key={this.props.user.id} />
每当id改变的时候,EmailInput组件将会被重新创建,它的state将会被重置为最后一次的defaultEmail的值。查看演示。使用此方法,你讲不用在每一个input上添加key
,把一个key
放在整个form上更有意义,每当key
改变的时候,表单中的input都会重置到其初始状态。
替代方案1:通过ID prop来重置不受控组件
如果key
在某些场合不适用(也许初始化对于组件来说是昂贵的),一个可行但繁琐的方式是在getDerivedStateFromProps
中去监测userID:
class EmailInput extends Component { state = { email: this.props.defaultEmail, prevPropsUserID: this.props.userID }; static getDerivedStateFromProps(props, state) { if (props.userID !== state.prevPropsUserID) { return { prevPropsUserID: props.userID, email: props.defaultEmail }; } return null; } // ... }
这也提供了灵活性,如果我们选择,只重置组件内的部分state。查看演示。
替代方案2:通过实例方法来重置不受控组件
如果没有合适的id来作为key
但是又要重置状态,一种解决方案是为组件生成一个随机数或者自动递增值来作为key
,另一种方案是通过实例的方法来强制重置组件的state。
class EmailInput extends Component { state = { email: this.props.defaultEmail }; resetEmailForNewUser(newEmail) { this.setState({ email: newEmail }); } // ... }
父组件将通过ref
拿到组件的实例从而调用该方法。查看演示。
在某些场景下ref
会很有用,但是我们建议你谨慎的使用它,即使在demo中,这个方法也是最不理想的,因为将会造成两次渲染而不是一个。
总结
总而言之,当设计一个组件时,一个重要的方面是它的数据是可控的还是不可控的。
尽量避免在state中去“镜像”一个props值,使这个组件成为受控组件,在父组件的state中去合并这两个state。例如,与其在组件中去接受一个committed的props并且跟踪一个draft的state,不如让父组件去同时管理这个state.draftValue和state.committedValue并直接控制子组件,这将使组件更加的明确和可预测。
对于一个不受控组件,如果你想根据一个props的改变来重置state,你需要遵循以下几点:
- 首选:要重置全部内部state,使用
key
属性; - 备选1:如果只重置部分state,监测props中属性的变化;
- 备选2:还可以考虑通过
ref
调用实力的方法;
memoization怎样?
我们还看到了派生状态用于确保渲染中使用的昂贵值仅在输入发生变化时才会重新计算,这种技术叫做memoization
使用派生状态来做memoization不一定是坏事,但通常不是最好的解决办法。派生状态的管理存在一定的复杂性,并且这种复杂性随着属性的增加而增加。例如,如果我们向组件的state添加第二个派生字段,那么我们的实现将需要分别跟踪对两个字段的更改。
让我们看一个组件的示例,该组件使用一个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 key={item.id}>{item.text}</li>)}</ul> </Fragment> ); } }
这种方式避免了重新计算filteredList。但是他比我们需要的更加的复杂,因为它需要分别的跟踪和检查我们的props和state以便能够正确的更新列表。在下面这个例子中,我们通过PureComponent
并将filter操作放到render中来简化操作:
// PureComponents只有在至少一个state或者prop改变的时候才会重新渲染 // 通过对state和props的keys的浅比较来确认改变。 class Example extends PureComponent { state = { filterText: "" }; handleChange = event => { this.setState({ filterText: event.target.value }); }; render() { // 只有props.list 或 state.filterText 改变的时候PureComponent的render才会调用 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> ); } }
上述例子比派生状态的版本更加的干净和简洁,但是有些时候这可能还不够好,例如对于大型列表来说,过滤可能很慢,且如果有其他的props改变PureComponent
也不会阻止其重新渲染。为了解决这两个问题,我们可以添加一个memoization,以避免不必要地重新过滤我们的列表:
import memoize from "memoize-one"; class Example extends Component { state = { filterText: "" }; filter = memoize( (list, filterText) => list.filter(item => item.text.includes(filterText)) ); handleChange = event => { this.setState({ filterText: event.target.value }); }; render() { 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> ); } }
当使用memoization时,有以下约束:
- 在大多数情况下,您需要将memoized函数附加到组件实例。这可以防止组件的多个实例重置彼此的memoized key。
- 通常情况下,您需要使用具有有限缓存大小的memoization,以防止内存泄漏。(在上面的例子中,我们使用了memoize-one,因为它只缓存最近的参数和结果。)
- 如果每次父组件呈现时重新创建props.list,本节中显示的实现都不会起作用。但在大多数情况下,这种设置是合适的。
最后
在实际应用中,组件通常包含受控和不受控制行为混合。没关系,如果每个值都有明确的来源,则可以避免上面提到的反模式。
值得重新思考的是,getDerivedStateFromProps
(以及通常的派生状态)是一种高级功能,应该谨慎使用。