原文地址
June 07, 2018 by Brian Vaughn
(我翻译时候使用的React和ReactDOM版本是16.7)
React 16.4修复了一个getDerivedStateFromProps的bug,使得一些现有的React组件bug可以一致地复现(复制?)。如果这次发布使你的反模式(anti-pattern)应用不能工作,很抱歉。在这篇文章中,我们将解释一些关于derived state的常用反模式以及我们推荐的替代方案。
很久以来,componentWillReceiveProps生命周期是在props发生改变时候更新state,并且不会额外引起render的唯一方案。在16.3版本,我们介绍了一个替代的生命周期getDerivedStateFromProps,解决同样的问题,更安全。同时,我们发现大家对这两个方法有很多错误的认识和用法,并且我们发现反模式导致了微妙的、令人困惑的bugs。这次16.4关于getDerivedStateFromProps的修复,让derived state更加可预见,所以更容易注意到滥用的结果。
Note
本文所有关于反模式的描述,对旧的componentWillReceiveProps和新的getDerivedStateFromProps都同样适用
本文将涵盖以下话题:
- 什么时候使用derived state
- 使用derived state时候的常见bugs
- 反模式:无条件复制props到state
- 反模式:props改变的时候改变state
- 优先的解决方案
- 什么是记忆?
什么时候使用Derived State
getDerivedStateFromProps的存在只为了一个目的。允许组件根据props变化更新内部state。我们的前一篇博客提供了一些例子,像根据offset prop记录当前的滚动位置,像根据source prop加载外部数据。
我们不提供很多例子,因为根据一般规则,derived state应该尽少使用。目前所有使用derived state引起的问题我们可以归纳为以下
- 无条件地通过props更新state
- 不管props和state是否匹配,都更新state
(下面会说更多细节)
- 如果你使用derived state来记录一些通过当前props计算出来的值,你不需要使用derived state
- 如果你更新derived state,无条件地,或者不管props和state匹配不,都更新,那你的组件改变state太频繁了。细节如下。
使用Derived State的常见bugs
术语“controlled”和“uncontrolled”通常是指表单字段,但是也可以描述为组件的数据在哪里。通过props进来的数据可以认为是controlled(因为它的父组件控制了数据)。只存在于内部state的数据可以认为是uncontrolled(因为它的父组件不能直接改变这个数据)。
最常见的derived state错误使用是混合这两种。当一个derived state值,也可以被setState改变,那么这个数据没有唯一的来源。上面提到的external data loading example 示例也许看起来很像,但是其中有几个关键点不同。在这个加载示例中,“source” prop和“loading” state都有清晰的来源。当source prop发生改变,loading state应该始终被覆盖。反过来说,state应该只有当props改变和被组件其他情况改变,才能被覆盖。
当这些约束中的任何一个发生改变时,就会出现问题。这通常有两种形式,让我们来看看。
反模式:无条件复制props到state
一个常见的误解是getDerivedStateFromProps和componentWillReceiveProps都只有在props改变时候才调用。这俩生命周期在父组件rerenders的时候始终被调用,不管props有没有发生改变。所以,在这俩生命周期中无条件地改变state是不安全的。这么做会引起state更新丢失。
我们思考一个例子来证明这个问题。这儿有一个EmailInput组件,其prop .email有一个“镜像”在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) {
// This will erase any local state updates!
// Do not do this.
this.setState({ email: nextProps.email });
}
}
首先,这个组件看起来挺好,state value值特殊初始化为props中值,并且当手动输入<input> 时候更新。但是当父组件renders,所有我们手动输入当值会丢失!(看这个例子)。即使我们在重置前比较了 nextProps.email !== this.state.email,也会这样。(老实讲我没试出来作者这句话表达的意思)
可以通过这样修复问题:在shouldComponentUpdate中添加控制只有 prop.email改变才rerender。然而在实践中,组件通常接受组合props;其他prop改变仍然会引起rerender并且不恰当地重置。Function和对象props经常内联地创建,导致通过shouldComponentUpdate去控制只有当实质变化发生才返回true变得很困难。(这儿有个例子)。所以,shouldComponentUpdate最好用来做性能优化,而不是去确定derived state的正确性(即是否更新state)。
希望大家清楚了为什么无条件复制props到state是不好的。在查看解决方案前,我们来看一个相对有问题的模式:只有当email prop改变的时候才更新state。
反模式:当props改变时候改变state
继续上面的例子,我们可以通过只有props.email改变时候才更新state,来避免意外地更新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
});
}
}
// ...
}
Note
即使上面例子用的是componentWillReceiveProps,同样的反模式也适用于getDerivedStateFromProps
我们已经做了一个很大的改进。现在我们的组件只会在props确实改变的时候才覆盖更新我们的手输值。
但是仍然有个微妙的问题。想象以下,一个密码管理软件使用上面的组件。当两个账户信息切换,但是使用同一个邮箱时候,输入框重置失败。这是因为对于两个账户来说,组件接收的props值是一样的。这对用户来说是一个惊喜,一个账户未保存的邮箱会影响另外一个账户显示同样的(看例子)。
这个设计本质上是有缺陷的,但是也很容易犯。(我自己也犯过!)幸运的是有两种替代方案更好。两种方案的关键是,对于一个数据块,你需要选择一个组件拥有它作为真实来源,并且避免在另外一个组件复制它。我们来看一下每个方案。
优先方案
建议:完全受控组件(Fully controlled component)
避免上面问题的一种方式是直接移除state。如果邮箱只作为prop存在,那么我们不用担心和state冲突。我们甚至可以把EmailInput改为轻量的函数式组件:
function EmailInput(props) {
return <input onChange={props.onChange} value={props.email} />;
}
这种方式简化了组件的实现,如果仍然想存储一些值,那么需要手动去父组件完成(点击查看这种模式例子)。
建议:带key的完全非受控组件(Fully uncontrolled component with a key)
另外一个替代方案是组件完全拥有email state。组件仍然接受一个prop(email)作为初始值,但是会忽略后来的prop变化。
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会创建一个新的组件而不是更新现有的。Keys经常在动态列表中使用,但是这种情形也适用。在我们的例子中,我们可以使用用户ID来控制选择用户时候,重新创建组件。
<EmailInput
defaultEmail={this.props.user.email}
key={this.props.user.id}
/>
每当ID改变,EmailInput会被重建,同时它的email state会被重置为defaultEmail prop值。(点击查看)。这种情况,你不需要为每个表单元添加==key==。通常情况下是为整个表单设置一个key,每当key改变,整个表单内组件会重建,state会被初始化为新值。
大多数情况下,这是处理state需要重置的最好方式。
Note
这种方式听起来会慢,性能差别通常是无关紧要的。使用key甚至会更快:如果组件更新时候,在diffing gets bypassed for that subtree之前有大量的逻辑运算.
替代方案1:
如果key因为某些情况不能使用(也许组件初始化非常耗资源),通过 ID prop重置非受控组件,一个可行的但是很繁琐的解决方式是在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;
}
// ...
}
(我不知道是不是此博客写的时候 getDerivedStateFromProps 不会在组件初始化时候被调用,所以作者在state=中也初始化了。我用react16.7,初始化时候getDerivedStateFromProps也被调用,所以上面代码逻辑会有点重复,state={}即可,)
这也提供了一种灵活改变组件内部部分state的方式(查看例子)
Note
即使上面使用的是getDerivedStateFromProps,同样也适用于componentWillReceiveProps。
替代方案2:通过实例方法重置非受控组件
更罕见的,你可能需要重置state,即使没有合适的ID作为key。一种解决是当你想重置的时候,设置一个随机数,或者自增数,作为key。另外一种可能的替代方案是写一个实例方法,通过调用重置state。
class EmailInput extends Component {
state = {
email: this.props.defaultEmail
};
resetEmailForNewUser(newEmail) {
this.setState({ email: newEmail });
}
// ...
}
父表单组件可以通过ref来调用这个方法。(查看事例)
Refs在某些情况下很有用,譬如这个例子。但是建议你少用。即使这个例子中,这个必要的方法也是非理想的,因为会出现两次render。
概要
为了概括,我们设计一个组件,决定他的数据是受控还是非受控非常重要。
与其尝试将prop“镜像”到state,不如让组件受控,并且在父组件中合并这两个发散的值。例如,让父组件管理state.draftValue和 state.committedValue同时直接控制子组件value,而不是让子组件接收一个“committed” props.value 同时跟踪“draft” state.value。这样使数据流更清晰可预见。
对于非受控组件,如果你想在部分prop(通常是ID)改变时候重置state,有几种选择:
- 建议:如果想重置所有内部state,使用key属性
- 替代方案1:只重置特定state,监听一个特殊prop改变(例如 props.userID)
- 替代方案2:也可以考虑通过refs回调一个实例方法
什么是记忆化?
我们还遇到在render中要使用的重要的值是derived state,只有在用户输入时候会被重新计算。这个技巧就是记忆化。
为了记忆化使用derived state不是绝对坏的,但是通常不是最好的解决方法。管理derived state非常复杂,并且这个复杂性随着属性增加而增加。例如,如果为组件增加第二个derived state,那么我们的代码实现得分别跟踪两个derived state。
我们看个例子,一个组件有一个prop:a list of items,并且根据用户输入renders相匹配的items。我们可以使用derived state存储filtered列表。
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变化来更新必要的filtered list。我们可以通过使用PureComponent和把过滤操作放到render中来简化。
// 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 key={item.id}>{item.text}</li>)}</ul>
</Fragment>
);
}
}
上面的方式比derived state版更简洁清晰。偶尔,这样不够好-大列表过滤可能慢,并且如果其他prop改变,PureComponent不会阻止renders。为了解决这俩关注点,可以添加一个memoization helper来避免非必要的重新过滤列表。
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 key={item.id}>{item.text}</li>)}</ul>
</Fragment>
);
}
}
这个更简单,并且执行起来和derived state版差不多好。
当使用记忆化时候,记得几个约束条件:
- 大多数情况,你得把memoized function放到一个组件实例上。这样会防止多个组件实例重新设置彼此的记忆键
- 通常,您会希望使用缓存大小有限的memoization helper,以防止久了出现内存泄漏。(在上面的例子中,我们使用memoize- one,因为它只缓存最近的参数和结果。)
- 如果props.list在每次父组件renders时候都重建,本节的所有实现都不能用,但是大多数情况下是合适的。
结束语
在真实的应用中,组件经常包含受控和非受控行为。这没问题!如果每个值有清晰的来源,你可以避免上面提到的反模式。
值得强调的是 getDerivedStateFromProps(以及通常的derived state)是个高级特性并且应当少用因为它的复杂性。如果你的使用情况超出了这些模式,请在GitHub或者Twitter上分享给我们!
网友评论