getDerivedStateFromProps是React16的一个新特性,使用时一定要注意,原文:You Probably Don’t Need Derived State
新的生命周期函数getDerivedStateFromProps的使用,你也许并不需要派生状态【译】
React16.4包含一个关于getDerivedStateFromProps
的bugfix,可以让React组件中的一些现有bug以更一致的方式重现。如果这次变动导致了您的应用程序使用了反模式,并且出现在修复后没有正常工作的情况,我们对这种情况感到抱歉。在这篇文章中,我们将解释一些具有派生状态的常见反模式和我们首选的替代方案。
在很长一段时间内,componentWillReceiveProps
是在没有附加渲染的情况下更新状态的唯一方法。在版本16.3中,我们引入了一个全新的生命周期函数——getDerivedStateFromProps
——用来替换componentWillReceiveProps
,并用更安全的方式处理相同的场景。与此同时,我们意识到人们对如何使用这两种方法有很多误解,我们发现了一些反模式,这些错误导致了微妙而令人困惑的bug。在16.4中,有关getDerivedStateFromProps
的修复使得派生状态更加可预测,因此错误使用的结果更容易被注意到。
这篇文章中描述的所有反模式都适用于较老的componentWillReceiveProps和较新的getDerivedStateFromProps.
何时使用派生状态
getDerivedStateFromProps
的存在只有一个目的。它使组件能够根据changes in props的结果更新其内部状态。
根据一般规则——谨慎使用派生状态,我们没有提供很多例子。我们所看到的有关派生状态的所有问题最终都可以归结为(1)无条件地更新状态,或者(2)当props和state不匹配时更新state。(我们将在下面详细讨论这两个问题。)
- 如果你正在使用派生状态,且目的仅仅是根据当前的props对一些计算进行记忆,那么你并不需要派生状态。参考什么是memoization?。
- 如果您无条件地更新派生状态,或者在props和state不匹配时更新它,那么您的组件可能会频繁地重置它的state。
使用派生状态遇到的常见bug
“受控”和“不受控制”的术语通常指的是表单输入,但他们还可以描述任何组件数据的位置。作为props传递进组件的数据可以被认为是受控的(因为父组件控制数据)。只存在于内部状态的数据可以被认为是不受控制的(因为父类不能直接更改它)。
派生状态最常见的错误是混合这两个;当一个派生状态值也通过setState
被更新时,数据就没有单一的真实来源。
当这些约束被改变时,就会出现问题。这通常有两种形式。让我们来看看这两种情况。
反模式:无条件得使用props对state赋值
一个常见的误解是,当props“改变”时,getDerivedStateFromProps
和componentWillReceiveProps
才会被调用。事实上,只要父组件重新渲染,这些生命周期函数就会被调用,不管这些props是否与以前“不同”。正因为如此,使用任何一个去 无条件 地覆盖覆盖state都是不安全的。这样做会导致状态更新丢失。
让我们看个例子来说明这个问题。这是一个EmailInput组件,该组件通过props “email” 来映射state “email”:
class EmailInput extends Component {
state = { email: this.props.email };
render() {
return ;
}
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指定的值,并且在输入时实时更新state。但是,如果我们的组件的父类重新渲染,我们在
输入的任何东西都将丢失!(参见这个演示示例一。)即使我们在重置之前比较
nextProps.email !== this.state.email
,也一样。
示例一代码
import React, { Fragment, Component } from "react";
import { render } from "react-dom";
// This component illustrates a getDerivedStateFromProps anti-pattern.
// Don't copy this approach!
class EmailInput extends Component {
state = {
email: this.props.email
};
render() {
return ;
}
handleChange = event => {
this.setState({ email: event.target.value });
};
// This lifecycle will be re-run any time the component is rendered,
// Even if props.email has not changed.
// For this reason, it should not update state in the way shown below!
componentWillReceiveProps(nextProps) {
// This will erase any local state updates!
// Do not do this!
this.setState({ email: nextProps.email });
}
}
// This component uses a timer to simulate arbitrary re-renders.
// In a real application, this could happen for a variety of reasons:
// Event handlers that call setState, Flux updates, network responses, etc.
class Timer extends Component {
state = {
count: 0
};
componentDidMount() {
this.interval = setInterval(
() =>
this.setState(prevState => ({
count: prevState.count + 1
})),
1000
);
}
componentWillUnmount() {
clearInterval(this.interval);
}
render() {
return (
This demo illustrates a derived state anti-pattern
Type in the box below:
This component will re-render every second. Each time it renders, the
text you type will be reset. This illustrates a derived state
anti-pattern.
Read the inline comments in index.js
to learn why.
);
}
}
render( , document.getElementById("root"));
在这个简单的例子中,为了解决这个问题,必须通过添加shouldComponentUpdate
,并判断只有prop email发生改变时才重新渲染。然而在实际情况中,组件通常接受多个prop;任何一个prop发生改变都会导致重新运行和不正确的重置。而且对于Function和object类型的prop,shouldComponentUpdate
很难判断是否发生了实质性的变化。这里有一个演示二,shouldComponentUpdate
最好作为性能优化使用,而不是为了确保派生状态的正确性。
希望你现在可以清楚地知道为什么无条件得使用props对state赋值是一个坏主意。在回顾可能的解决方案之前,让我们看看一个于此相关的另外一个问题模式:如果我们只在属性email改变时更新状态会怎样?
示例二代码
import React, { Fragment, PureComponent } from "react";
import { render } from "react-dom";
// This component illustrates a getDerivedStateFromProps anti-pattern.
// Don't copy this approach!
class EmailInput extends PureComponent {
state = {
email: this.props.email
};
// This lifecycle will be re-run any time the component is rendered,
// Even if props.email has not changed.
// For this reason, it should not update state in the way shown below!
componentWillReceiveProps(nextProps) {
// This will erase any local state updates!
// Do not do this!
if (nextProps.email) {
this.setState({ email: nextProps.email });
}
}
handleChange = event => {
const email = event.target.value;
if (this.props.validate(email)) {
this.setState({ email });
} else {
// Handle error...
}
};
render() {
return ;
}
}
// This component uses a timer to simulate arbitrary re-renders.
// In a real application, this could happen for a variety of reasons:
// Event handlers that call setState, Flux updates, network responses, etc.
class Timer extends PureComponent {
state = {
count: 0
};
componentDidMount() {
this.interval = setInterval(
() =>
this.setState(prevState => ({
count: prevState.count + 1
})),
1000
);
}
componentWillUnmount() {
clearInterval(this.interval);
}
render() {
// Binding the validate function inline, as is done below,
// Causes a new function value to be passed each time we render.
// Even though EmailInput is a PureComponent,
// Its shouldComponentUpdate() will always return true because of this.
// The same would be true of inline objects (e.g. styles) or arrays.
return (
This demo illustrates a derived state anti-pattern
Type in the box below:
This component will re-render every second. Each time it renders, the
text you type will be reset.
Read the inline comments in index.js
to learn why.
);
}
validateEmail = email => {
// Validation isn't important for this example,
// So we'll skip it.
return true;
};
}
render( , document.getElementById("root"));
反模式:当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
});
}
}
// ...
}
我们刚刚取得了很大的进步。现在,只有当props真正改变的时候,组件才会擦除我们输入的内容。
现在出现了一个微妙的问题。想象一个使用上述输入组件的密码管理器应用程序。当使用相同的电子邮件在两个帐户的详细信息之间导航时,输入将无法重置。这是因为传递给组件的属性值对于两个帐户都是相同的!这对用户来说是一个惊喜,因为一个账户的未保存的变更似乎会影响到其他的帐户,这些帐户碰巧共享相同的电子邮件。(示例三)
这种设计从根本上来说是有缺陷的,但这却是一个极易犯的错误。幸运的是,有两种替代方案可以更好地工作。两种方案的关键在于——对于任何数据,您都需要确保只有一个组件作为实际的来源,并避免在其他组件中复制它。现在来看一下这两种方案。
示例三代码
import React, { Component, Fragment } from "react";
import { render } from "react-dom";
import AccountsList from "./AccountsList";
// This is fake account data.
// It mimics data that might be loaded from e.g. a server or database.
const fakeAccounts = [
{
id: 1,
name: "One",
email: "[email protected]",
password: "totally fake"
},
{
id: 2,
name: "Two",
email: "[email protected]",
password: "also fake"
},
{
id: 3,
name: "Three",
email: "[email protected]",
password: "definitely fake"
}
];
render(
,
document.getElementById("root")
);
首选方案
推荐: 完全受控组件
避免上面提到的问题的一种方法是彻底从组件中删除状态。如果"邮件地址"只是作为属性存在,那么我们就不必担心与状态的冲突。我们甚至可以把EmailInput
转换成轻量的函数组件:
function EmailInput(props) {
return ;
}
这种方法简化了组件的实现,但是如果我们仍然想要储存一个中间值(draft value),那么父表单组件现在就只能手动完成这件事。(示例四)
示例四代码
import React, { Component, Fragment } from "react";
import { render } from "react-dom";
import ControlledEmailInput from "./ControlledEmailInput";
// This mimics user data we might load from a server.
const fakeUser = {
id: 1,
name: "Brian Vaughn",
email: "[email protected]"
};
// Because ControlledEmailInput is a controlled component,
// This component is responsible for managing both the "commited"
// and "draft" email values.
class App extends Component {
state = {
draftEmail: this.props.user.email
};
handleEmailChange = event => {
this.setState({ draftEmail: event.target.value });
};
resetForm = () => {
this.setState({
draftEmail: this.props.user.email
});
};
render() {
return (
This demo illustrates a fully controlled component
Make some changes to the field below:
Then click the reset button:
Read the inline comments in ControlledEmailInput.js
and{" "}
index.js
to learn more.
);
}
}
render( , document.getElementById("root"));
推荐: 有"key"的完全非受控组件
另一种方案是,让组件完全拥有中间的email状态(draft email state)。在这个示例中,我们的组件仍然接收一个属性用来设置email的初始值,但是却无法接收这个属性之后的变化:
class EmailInput extends Component {
state = { email: this.props.defaultEmail };
handleChange = event => {
this.setState({ email: event.target.value });
};
render() {
return ;
}
}
为了在移动到另一项(如密码管理器场景)时可以重新赋值,我们可以使用“key”这个React的特殊属性。当一个“key”发生变化时,React将创建一个新的组件实例,而不是更新当前的一个实例。“key”通常用于动态列表,但在这里也很有用。在我们的例子中,我们可以使用用户ID在新用户被选中时重新创建"EmailInput":
每当ID改变时,EmailInput
将被重新创建,它的状态将被重置为最新的defaultEmail
值(示例五)。使用这种方法,您不需要向每个输入项添加key
。把key
放在整个表单上可能更有意义。每次改变时,表单中的所有组件都将用一个新初始化的状态重新创建。
在大多数情况下,这是处理有重置要求的状态的最好方法。
这看起来似乎会变慢,不过这点性能差异通常情况是无关紧要的(原文:While this may sound slow, the performance difference is usually insignificant)。相反,如果组件具有在更新上运行的重逻辑,则使用“key”甚至可以更快,因为该子树的diff运算被省略了。
示例五代码
import React, { Component, Fragment } from "react";
import { render } from "react-dom";
import AccountsList from "./AccountsList";
// This is fake account data.
// It mimics data that might be loaded from e.g. a server or database.
const fakeAccounts = [
{
id: 1,
name: "One",
email: "[email protected]"
},
{
id: 2,
name: "Two",
email: "[email protected]"
}
];
render(
,
document.getElementById("root")
);
备选 1: 通过ID属性重置非受控组件
如果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;
}
// ...
}
这也提供了另一种灵活的处理方案,我们可以有选择的,只重置组件内部的某些状态。(示例六)
虽然上边的示例使用的是getDerivedStateFromProps,对于componentWillReceiveProps也同样有效
示例六代码
import React, { Component, Fragment } from "react";
import { render } from "react-dom";
import AccountsList from "./AccountsList";
// This is fake account data.
// It mimics data that might be loaded from e.g. a server or database.
const fakeAccounts = [
{
id: 1,
name: "One",
email: "[email protected]"
},
{
id: 2,
name: "Two",
email: "[email protected]"
}
];
render(
,
document.getElementById("root")
);
备选 2: 通过实例方法重置非受控组件
比较不常见的情况是,您需要重新设置状态,却没有合适的ID作为key。一种解决方案是在每次想要重置的时候,将“key”重置为一个随机值或自动递增的数字。另一种可行的替代方法是公开实例方法,以强制重置内部状态:
class EmailInput extends Component {
state = {
email: this.props.defaultEmail
};
resetEmailForNewUser(newEmail) {
this.setState({ email: newEmail });
}
// ...
}
父级表单组件可以通过ref调用该方法(示例七)
在某些情况下,Refs可能会很有用,但一般来说,我们建议您谨慎地使用它们。即使在示例中,这种命令式方法也是不理想的,因为要发生两次渲染。
回顾
回顾一下,在设计组件时,最重要的是决定它的数据是否需要被控制。
与其尝试在状态中镜像一个属性值,不如让组件被控制,并在某些父组件的状态中合并两个不同的值。例如,与其让子组件既接收一个“committed”属性又要维护一个“draft”的状态,不如让父级组件同时管理两个状态——state.committedValue
和state.draftValue
——直接控制子组件的值。这使得数据流更加清晰和可预测。
对于非受控的组件,如果您试图在某特定的属性(通常是ID)更改时重置状态,那么您有几个选项:
- 推荐:如果要重置全部内部状态,使用key特性
- 备选 1:只重置某些特定的状态字段,关注特定属性的更改(例如props.userID)。
- 备选 2:您还可以考虑使用refs调用一个命令式实例方法。
什么是memoization?
派生状态可用于确保执行render
时使用的值仅在输入发生变化时才会重新计算。这种技术被称为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 (
{this.state.filteredList.map(item => - {item.text}
)}
);
}
}
这个实现避免了不必要的重新计算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 (
{filteredList.map(item => - {item.text}
)}
);
}
}
上面的方法比派生状态版本更清洁和简单。 但是有时,这样并不好——对于大型列表,过滤可能会很慢,如果另一个属性改变,“PureComponent”不会阻止重新渲染。 为了解决这两个问题,我们可以添加一个记忆帮助器,以避免不必要地重新过滤我们的列表:
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 (
{filteredList.map(item => - {item.text}
)}
);
}
}
这非常简单,并且与派生状态版本一样好!
在使用memoization时,需要注意一些约束:
- 在大多数情况下,您需要将memoized函数附加到组件实例。这可以防止组件的多个实例重置彼此的memoized key。
- 通常情况下,您需要使用具有可控缓存大小的记忆辅助程序,以防止随着时间的推移内存泄漏。 (在上面的例子中,我们使用了memoize-one,因为它只缓存最近的参数和结果。)
- 如果父组件每次渲染时都重新创建了“props.list”,本节中显示的任何实现都不起作用。但在大多数情况下,这种设置是合适的。
最后
在现实世界的应用程序中,组件通常包含受控和非受控行为的混合。这没关系!如果每个值都有明确的真相来源,则可以避免上述的反模式。
值得重新思考的是getDerivedStateFromProps
(和通常的派生状态)是一个高级特性,也因为这种复杂性,使用时务必谨慎。