乐趣区

【译】你可能不需要派生状态

原文链接:https://reactjs.org/blog/2018…

翻译这篇文章的起因是因为在一次需求迭代中错误的使用了 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(以及通常的派生状态)是一种高级功能,应该谨慎使用。

退出移动版