原文地址 March 27, 2018 by Brian Vaughn
一年多了,React小组致力于实现异步rendering。在上个月的JSConf Iceland上,Dan公布了一些关于异步rendering的令人兴奋的新可能性解锁了。
现在我们想分享在工作中使用这些特性,已经学到的一些经验和方法,以帮助你的组件为异步rendering发布做好准备。
我们学到的一个最大的经验是,一些旧的组件生命周期会变成不安全的代码实践。它们是:
- componentWillMount
- componentWillReceiveProps
- componentWillUpdate
这些生命周期方法经常被误解和巧妙地滥用;而且,这些可能的滥用在异步rendering中会更有问题。因此,我们将在最近的版本中为这些生命周期加上“UNSAFE_”前缀。(“unsafe”不是指安全性,而是说使用这些生命周期在将来的React版本中更容易有bugs,尤其是一旦启用了异步rendering)
渐进的迁移(升级)路线
React遵循semantic versioning,因此此改变是渐进的。我们目前计划如下:
- 16.3:介绍不安全生命周期的别名:UNSAFE_componentWillMount, UNSAFE_componentWillReceiveProps 和 UNSAFE_componentWillUpdate。(此版本旧名称和新别名都可以使用)
- 某个16.x版本:开启弃用警告:componentWillMount, componentWillReceiveProps 和 componentWillUpdate。(此版本旧名称和新别名可以同时使用,但是旧名称会在开发模式打印警告)
- 17.0:移除componentWillMount,componentWillReceiveProps 和 componentWillUpdate。(这以后,只有新的“UNSAFE_”生命周期名字可以使用)
注意如果你是个React应用开发者,你目前不需要为旧方法做什么。即将到来的16.3版本的主要目的是,允许开源项目维护人员借助弃用警告提前升级它们的库。这些警告直到将来某个16.x版本才会启用。
我们在Facebook维护超过50000个React组件,并且我们不打算全部立即重写。我们知道升级需要时间。我们将与React社区的成员采用渐进的升级路线。
迁移旧的生命周期
如果你想开始使用React 16.3中新的组件APIs(或者你是个维护人员,想提前升级你的库),这里有少许例子希望帮到你,开始以不同的方式思考组件。以后,我们会在文档中继续添加其他“食谱”,展示如何避免使用问题生命周期。
在进入正题之前,看下16.3版本的生命周期变动概览:
- 添加以下生命周期别名:UNSAFE_componentWillMount, UNSAFE_componentWillReceiveProps 和 UNSAFE_componentWillUpdate。(旧生命周期和新别名都可使用)
- 两个新生命周期,静态getDerivedStateFromProps和getSnapshotBeforeUpdate
新生命周期:getDerivedStateFromProps
class Example extends React.Component {
static getDerivedStateFromProps(props, state) {
// ...
}
}
新的静态生命周期getDerivedStateFromProps,在组件实例化后和每次re-rendered之前执行。它可以返回一个对象来更新state,或者返回null表示不需要更新state。
通过和componentDidUpdate一起使用,这个新生命周期应该涵盖了所有旧的componentWillReceiveProps使用情况。
Note:
旧componentWillReceiveProps和新getDerivedStateFromProps方法都增加组件复杂性。这经常引起bugs。请参考derived state简单替代方案,让组件可预见可维护。
新生命周期:getSnapshotBeforeUpdate
class Example extends React.Component {
getSnapshotBeforeUpdate(prevProps, prevState) {
// ...
}
}
新生命周期getSnapshotBeforeUpdate在制造突变之前被调用(例如DOM被更新之前)。此生命周期返回值会作为componentDidUpdate的第三个参数。(这个生命周期不经常使用,但是在某些情况很有用,譬如在rerenders时候手动保存滚动位置)
通过和componentDidUpdate一起使用,这个新生命周期应该会涵盖所有旧的componentWillUpdate使用情况。
你可以在这个要点中找到他们的类型签名。
我们来看看下面的例子中是如何使用这俩生命周期的。
例子
- Initializing state
- Fetching external data
- Adding event listeners (or subscriptions)
- Updating state based on props
- Invoking external callbacks
- Side effects on props change
- Fetching external data when props change
- Reading DOM properties before an update
Note
简单起见,下面的例子们使用了实验性质的class,但不用也是一样的迁移策略。
初始化state
这个例子显示了一个在componentWillMount中调用setState的组件
// Before
class ExampleComponent extends React.Component {
state = {};
componentWillMount() {
this.setState({
currentColor: this.props.defaultColor,
palette: 'rgb',
});
}
}
对这种组件最简单的重构是将state初始化移动到constructor或属性初始化器,例如:
// After
class ExampleComponent extends React.Component {
state = {
currentColor: this.props.defaultColor,
palette: 'rgb',
};
}
获取外部数据
这是一个组件使用componentWillMount来获取外部数据的例子:
// Before
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
componentWillMount() {
this._asyncRequest = loadMyAsyncData().then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
}
上面的例子是有问题的:对服务器rendering(外部数据不会被使用的);对即将到来的异步rendering(请求可能被发起两次)。
大多数情况下,推荐把获取数据移到componentDidMount。
// After
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
componentDidMount() {
this._asyncRequest = loadMyAsyncData().then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
}
一种常见的误解是在componentWillMount中获取可以避免第一次rendering空state。实际上这观点永远是错的,因为React一直都是在componentWillMount后直接执行render。如果在componentWillMount执行时候没有数据,第一次render将仍然显示loading state,才不会管你在哪发起的获取动作。所以大多数情况,把获取数据动作移到componentDidMount没啥影响。
Note:
一些高级用法(譬如Realy之类的库)想更快预获取异步数据。这里有一个如何实现这一点的示例。
长远来看,React组件获取数据的规范方式是基于JSConf Iceland上介绍的“suspense”API。无论是简单的数据获取,还是Apollo和Relay这样的库,都可以在底层使用它。它比上述任何一种解决方案都要简单,但是可能不会在16.3版本中实现。
如果是服务器端rendering,目前来看提供同步数据是必要的,componentWillMount经常被用于这个目的,但是也可以使用constructor。即将到来的suspense APIs将完全有可能使客户端和服务器端redndering都可以异步获取数据。
添加事件监听(或订阅)
这个组件挂载时候订阅了一个外部的事件分发
// Before
class ExampleComponent extends React.Component {
componentWillMount() {
this.setState({
subscribedValue: this.props.dataSource.value,
});
// This is not safe; it can leak!
this.props.dataSource.subscribe(
this.handleSubscriptionChange
);
}
componentWillUnmount() {
this.props.dataSource.unsubscribe(
this.handleSubscriptionChange
);
}
handleSubscriptionChange = dataSource => {
this.setState({
subscribedValue: dataSource.value,
});
};
}
很不幸的是:服务器端渲染的话,这样可能引起内存泄漏(因为componentWillUnmount永远不会被调用);而且异步rendering也会(因为rendering可能在结束前被打断,导致componentWillUnmount不会被调用)。
大家经常认为componentWillMount和componentWillUnmount是成对出现,但其实不一定。只有componentDidMount已经调用了,React才保证以后会调用componentWillUnmount,你才可以使用它来清理东西。
因此,建议使用componentDidMount生命周期添加监听/订阅。
// After
class ExampleComponent extends React.Component {
state = {
subscribedValue: this.props.dataSource.value,
};
componentDidMount() {
// Event listeners are only safe to add after mount,
// So they won't leak if mount is interrupted or errors.
this.props.dataSource.subscribe(
this.handleSubscriptionChange
);
// External values could change between render and mount,
// In some cases it may be important to handle this case.
if (
this.state.subscribedValue !==
this.props.dataSource.value
) {
this.setState({
subscribedValue: this.props.dataSource.value,
});
}
}
componentWillUnmount() {
this.props.dataSource.unsubscribe(
this.handleSubscriptionChange
);
}
handleSubscriptionChange = dataSource => {
this.setState({
subscribedValue: dataSource.value,
});
};
}
有时为了响应属性变化,更新订阅器很重要。如果你使用了类似Redux或者MobX的库,其容器组件可能帮你处理了。对于应用作者,我们创建了一个小库create-subscription来帮助你。它将和React 16.3一起发布。
我们可以通过create-subscription传递订阅值,而不是像上面例子那样传递数据源订阅属性。
import {createSubscription} from 'create-subscription';
const Subscription = createSubscription({
getCurrentValue(sourceProp) {
// Return the current value of the subscription (sourceProp).
return sourceProp.value;
},
subscribe(sourceProp, callback) {
function handleSubscriptionChange() {
callback(sourceProp.value);
}
// Subscribe (e.g. add an event listener) to the subscription (sourceProp).
// Call callback(newValue) whenever a subscription changes.
sourceProp.subscribe(handleSubscriptionChange);
// Return an unsubscribe method.
return function unsubscribe() {
sourceProp.unsubscribe(handleSubscriptionChange);
};
},
});
// Rather than passing the subscribable source to our ExampleComponent,
// We could just pass the subscribed value directly:
<Subscription source={dataSource}>
{value => <ExampleComponent subscribedValue={value} />}
</Subscription>;
注意
像Realy/Apollo之类的库,应该各自使用了和create-subscription同样的技巧在底层管理订阅(参考这里)。
基于props更新state
注意
旧的componentWillReceiveProps和新的getDerivedStateFromProps方法都会给组件带来复杂性。经常导致bugs。请考虑简单的替代方案,让组件可预见可维护。
这个组件使用了旧的componentWillReceiveProps生命周期,来基于新props值更新state。
// Before
class ExampleComponent extends React.Component {
state = {
isScrollingDown: false,
};
componentWillReceiveProps(nextProps) {
if (this.props.currentRow !== nextProps.currentRow) {
this.setState({
isScrollingDown:
nextProps.currentRow > this.props.currentRow,
});
}
}
}
即使上面的代码本身没什么问题,但componentWillReceiveProps生命周期经常被错误使用,带来问题。因此,这个方法将被弃用。
基于16.3版本,响应props变化更新state的推荐方式是使用:新静态getDerivedStateFromProps生命周期。(这个生命周期在组件创建后和接受新props时候被调用)(译者:还有更新state,forUpdate等时候)
// After
class ExampleComponent extends React.Component {
// Initialize state in constructor,
// Or with a property initializer.
state = {
isScrollingDown: false,
lastRow: null,
};
static getDerivedStateFromProps(props, state) {
if (props.currentRow !== state.lastRow) {
return {
isScrollingDown: props.currentRow > state.lastRow,
lastRow: props.currentRow,
};
}
// Return null to indicate no change to state.
return null;
}
}
你可能注意到上面例子中props.currentRow被映射到state中(state.lastRow)。这允许getDerivedStateFromProps像componentWillReceiveProps一样读取上个props值。
你可能会想为什么我们不直接把上一个props当成参数传给getDerivedStateFromProps。我们设计这个API时候考虑到了,但是最终决定反对这样,因为两个原因:
- 一个prevProps参数在第一次getDerivedStateFromProps被调用时候可能是null(实例化后),需要在prevProps存取时候添加if-not-null检查。
- 不传递上一个props给这个函数是为了将来:在未来的React版本中释放内存。(如果React不需要传递上一个props给生命周期,那么他不需要保存上一个props对象在内存中)
注意
如果你在写一个共享组件,react-lifecycles-compat polyfill 允许在旧版React中使用getDerivedStateFromProps生命周期。后面有如何使用。
执行外部回调
当内部state变化,此组件调用了一个外部函数
// Before
class ExampleComponent extends React.Component {
componentWillUpdate(nextProps, nextState) {
if (
this.state.someStatefulValue !==
nextState.someStatefulValue
) {
nextProps.onChange(nextState.someStatefulValue);
}
}
}
有时候人们把componentWillUpdate用错地方,是因为怕随着componentDidUpdate触发,更新其他组件state“太晚了”。不是这样的。React保证任何一个 componentDidMount 和 componentDidUpdate 中的setState调用,在用户看到更新后的UI前,都会被排放(flushed)。一般来说,避免这样串联升级会更好,但是有些情况是必须的(例如,如果你需要在测量rendered DOM元素后定位一个提示)。
无论哪种方式,在异步模式下用componentWillUpdate都是不安全的,因为一次更新中外部调用可能会被调用两次。相反,应该使用componentDidUpdate生命周期,因为可以保证一次更新只被调用一次。
(我没看懂这部分,在用户看到更新后的UI前,都会被排放(flushed)是什么意思?一次更新中外部调用可能会被调用两次怎么复现?)
/ After
class ExampleComponent extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (
this.state.someStatefulValue !==
prevState.someStatefulValue
) {
this.props.onChange(this.state.someStatefulValue);
}
}
}
有副作用的props改变
类似上面的例子,有时候props改变,会带来副作用。
// Before
class ExampleComponent extends React.Component {
componentWillReceiveProps(nextProps) {
if (this.props.isVisible !== nextProps.isVisible) {
logVisibleChange(nextProps.isVisible);
}
}
}
像componentWillUpdate,componentWillReceiveProps可能在一次更新中被调用多次。因此,避免在这两个方法中有副作用很重要。相反,应该使用componentDidUpdate:因为它可以保证一次更新中只被调用一次。
(同上,上面看不懂,这个肯定就不理解了)
// After
class ExampleComponent extends React.Component {
componentDidUpdate(prevProps, prevState) {
if (this.props.isVisible !== prevProps.isVisible) {
logVisibleChange(this.props.isVisible);
}
}
}
当props更新时候获取外部数据
此组件在props改变时候获取外部数据
// Before
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
componentDidMount() {
this._loadAsyncData(this.props.id);
}
componentWillReceiveProps(nextProps) {
if (nextProps.id !== this.props.id) {
this.setState({externalData: null});
this._loadAsyncData(nextProps.id);
}
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
_loadAsyncData(id) {
this._asyncRequest = loadMyAsyncData(id).then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
}
建议将数据更新移到componentDidUpdate。你也可以使用新的getDerivedStateFromProps生命周期在rendering新props之前清除旧数据。
// After
class ExampleComponent extends React.Component {
state = {
externalData: null,
};
static getDerivedStateFromProps(props, state) {
// Store prevId in state so we can compare when props change.
// Clear out previously-loaded data (so we don't render stale stuff).
if (props.id !== state.prevId) {
return {
externalData: null,
prevId: props.id,
};
}
// No state update necessary
return null;
}
componentDidMount() {
this._loadAsyncData(this.props.id);
}
componentDidUpdate(prevProps, prevState) {
if (this.state.externalData === null) {
this._loadAsyncData(this.props.id);
}
}
componentWillUnmount() {
if (this._asyncRequest) {
this._asyncRequest.cancel();
}
}
render() {
if (this.state.externalData === null) {
// Render loading state ...
} else {
// Render real UI ...
}
}
_loadAsyncData(id) {
this._asyncRequest = loadMyAsyncData(id).then(
externalData => {
this._asyncRequest = null;
this.setState({externalData});
}
);
}
}
注意
如果你使用一个支持取消动作的HTTP库,例如axios,那么在卸载时候取消一个正在进行的请求很容易。对于原生Promises,你可以使用这个方法
更新前读取DOM属性
此组件在更新前读取DOM属性,为了在列表中保持滚动位置。
class ScrollingList extends React.Component {
listRef = null;
previousScrollOffset = null;
componentWillUpdate(nextProps, nextState) {
// Are we adding new items to the list?
// Capture the scroll position so we can adjust scroll later.
if (this.props.list.length < nextProps.list.length) {
this.previousScrollOffset =
this.listRef.scrollHeight - this.listRef.scrollTop;
}
}
componentDidUpdate(prevProps, prevState) {
// If previousScrollOffset is set, we've just added new items.
// Adjust scroll so these new items don't push the old ones out of view.
if (this.previousScrollOffset !== null) {
this.listRef.scrollTop =
this.listRef.scrollHeight -
this.previousScrollOffset;
this.previousScrollOffset = null;
}
}
render() {
return (
<div ref={this.setListRef}>
{/* ...contents... */}
</div>
);
}
setListRef = ref => {
this.listRef = ref;
};
}
在上例中,componentWillUpdate用来读取DOM属性。然而异步rendering,可能在“render”阶段生命周期(例如componentWillUpdate 和 render)和“commit”阶段生命周期(例如componentDidUpdate)之间有延迟。如果用户在这期间做了一些操作例如改变窗口,componentWillUpdate中读取的scrollHeight值就过时了。
(不是很理解这部分,没遇到实际场景)
这个问题的解决方案是使用新的“commit”阶段生命周期,getSnapshotBeforeUpdate。这个方法在制造突变前(例如DOM更新前)会被直接调用。它可以返回一个值给React,作为突变后被直接调用的componentDidUpdate的一个参数。
这俩生命周期可以这样一起使用:
class ScrollingList extends React.Component {
listRef = null;
getSnapshotBeforeUpdate(prevProps, prevState) {
// Are we adding new items to the list?
// Capture the scroll position so we can adjust scroll later.
if (prevProps.list.length < this.props.list.length) {
return (
this.listRef.scrollHeight - this.listRef.scrollTop
);
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
// If we have a snapshot value, we've just added new items.
// Adjust scroll so these new items don't push the old ones out of view.
// (snapshot here is the value returned from getSnapshotBeforeUpdate)
if (snapshot !== null) {
this.listRef.scrollTop =
this.listRef.scrollHeight - snapshot;
}
}
render() {
return (
<div ref={this.setListRef}>
{/* ...contents... */}
</div>
);
}
setListRef = ref => {
this.listRef = ref;
};
}
注意
如果你在写一个共享组件,react-lifecycles-compat polyfill允许旧版React使用新的getSnapshotBeforeUpdate生命周期。后面有个例子
其他场景
我们在本文努力覆盖最常见的使用情况,我们认识到我们可能会遗漏一些。如果你用到了componentWillMount, componentWillUpdate, or componentWillReceiveProps的其他情况,并且不确定如何迁移这些旧生命周期,请file a new issue against our documentation提供你的代码和尽可能多的背景信息。当有新的替代模式时候,我们会更新这个文档。
开源项目维护人员
开源项目维护人员可能会想这些改动对共享组件意味什么。如果你实现上面的建议,有新静态getDerivedStateFromProps生命周期的组件会发生什么?你是否必须发布一个新主版并且放弃对16.2及更老版本的兼容?
幸运的是,不需要。
当React 16.3发布,我们同时发布一个新npm包,react-lifecycles-compat。这个polyfill可以让老版本React使用新 getDerivedStateFromProps 和 getSnapshotBeforeUpdate生命周期(0.14.9+)。
为了使用这个polyfill,首先添加一个依赖:
# Yarn
yarn add react-lifecycles-compat
# NPM
npm install react-lifecycles-compat --save
下一步,升级你的组件使用新的生命周期(像上面的描述)。
最后,使用polyfill兼容旧版React。
import React from 'react';
import {polyfill} from 'react-lifecycles-compat';
class ExampleComponent extends React.Component {
static getDerivedStateFromProps(props, state) {
// Your state update logic here ...
}
}
// Polyfill your component to work with older versions of React:
polyfill(ExampleComponent);
export default ExampleComponent;