使用react-transition-group引发的this.setState异步问题

@youngwind 2016-04-23 09:07:38发表于 youngwind/blog React

问题

react-css-transition-group(以下简称css-group)可以用来解决react动画问题,但是不能精准地控制,比如说在动画进入和离开的时候执行一些特定的操作。用更底层的react-transition-group(以下简称transition-group)就可以,具体使用方法可以参考官方文档。下面我主要说说transition-group与css-group的区别以及在transition-group实际应用中引发的this.setState异步问题的思考。

transition-group与css-group的不同

css-group帮我们干两件事:

  1. 维护DOM元素的进入与离开
  2. 维护类名(适时地添加example-enter-active、example-leave-active等类名)

transition-group帮我们干这两件事:

  1. 维护DOM元素的进入与离开
  2. 暴露hook,让我们在DOM元素进入和离开等特定时机做一些操作,比如数据请求,这同时也意味着我们必须自己给元素添加特殊的类名以控制动画。

如何添加类名?

ok,由于现在我要自己添加类名,我一开始的做法是这样的。

componentWillEnter:function(callback){
  this.refs.card.classList.add('demo-enter');
  this.refs.card.classList.add('demo-enter-active');
},

render:function(){
  return (
    <div className='demo' ref='card'>文字</div>
  );
}

实际执行的效果是:
div确实被添加上了'demo-enter'和'demo-enter-active'类,但是却没有触发动画。
(注意,我这里的动画是用transition写的,所以必须先后添加这两个类才能触发动画,一起添加的话是不会触发的。当然,这里也可以采用animation来写,由于animation是直接触发的,所以添加一个类就可以了。不过因为考虑到先前用css-group的时候是用transition写的,所以这里也就沿用了,这也间接导致了下面的问题。)
我觉得非常奇怪,难道这两个语句不是同步的吗?我为了验证是不是因为同时添加类名导致不能触发动画的问题,我把代码改写成下面这个样子。

componentWillEnter:function(callback){
  this.refs.card.classList.add('demo-enter');
 setTimeout( () => {
    this.refs.card.classList.add('demo-enter-active');
 }, 100);
},

咦?果然触发了动画,这说明两个类是被先后添加了。
我忽然想到这是不是react刷新频率导致的呢?
因为react的核心之一就是通过diff算法来比较DOM的不同,然后一次性操作,减少重复操作DOM,以提高性能。一般情况下react的刷新频率是60Hz,也就是说要是我把setTimeout控制在1/60s秒之内是不是就会同时添加两个类导致不能触发动画呢?所以我又把程序改成了这样。

componentWillEnter:function(callback){
  this.refs.card.classList.add('demo-enter');
 setTimeout( () => {
    this.refs.card.classList.add('demo-enter-active');
 }, 1);
},

执行结果:依然触发动画。我对于这个现象非常困惑,即使我把延时改为0ms也依然会触发动画,我意识到react背后应该是有另外一套机制控制是否执行rerender。所以我这次换了一个方向,尝试通过state控制类名,通过改变state进而渲染不同的类名来看看能不能触发动画。

this.setState居然是异步的

我又一次改了代码。

var ClassNames = require('classnames');
//这里使用了一个类名操作工具

componentWillEnter:function(callback){
  this.setState({
    willEnter:true
  });
  this.setState({
    enterActive:true
  });
},

render:function(){
   let divClassName = ClassName({
        'demo' : true,
        'demo-enter' : this.state.enter,
        'demo-enter-active' : this.state.enterActive
   });
   return (
    <div className={divClassName}>文字</div>
  );
}

执行结果:类名被同时添加,不触发动画。同样的现象,用setTimeout延时第二个setState就没问题。
官方文档中提到:

setState() does not immediately mutate this.state but creates a pending state transition. Accessing this.state after calling this method can potentially return the existing value.
There is no guarantee of synchronous operation of calls to setState and calls may be batched for performance gains.

所以说,并不是执行setState之后就会马上更新this.state的。

我参考了这篇文章,终于明白setState其实是异步的。每次setState不过是把新的state缓存到一个队列当中,至于什么时候应用这个新的state去rerender,react内部有更复杂的控制。
文章提到关键在于batchingStrategy.isBatchingUpdates,但是,并没有分析到底是谁控制了isBatchingUpdates,这也是我目前没有想明白的问题。我尝试查看react的源码来分析这个问题,但是不得不说水平还不够,没看出来。不过倒是让我发现了一个新的特性。
setState第二个参数可以接收函数作为回调,也就是说,我们可以在缓存的state队列被更新之后再执行下一次的setState

/**
 * Sets a subset of the state. Always use this to mutate
 * state. You should treat `this.state` as immutable.
 *
 * There is no guarantee that `this.state` will be immediately updated, so
 * accessing `this.state` after calling this method may return the old value.
 *
 * There is no guarantee that calls to `setState` will run synchronously,
 * as they may eventually be batched together.  You can provide an optional
 * callback that will be executed when the call to setState is actually
 * completed.
 *
 * When a function is provided to setState, it will be called at some point in
 * the future (not synchronously). It will be called with the up to date
 * component arguments (state, props, context). These values can be different
 * from this.* because your function may be called after receiveProps but before
 * shouldComponentUpdate, and this new state, props, and context will not yet be
 * assigned to this.
 *
 * @param {object|function} partialState Next partial state or function to
 *        produce next partial state to be merged with current state.
 * @param {?function} callback Called after state is updated.
 * @final
 * @protected
 */
ReactComponent.prototype.setState = function(partialState, callback) {
  ("production" !== "development" ? invariant(
    typeof partialState === 'object' ||
    typeof partialState === 'function' ||
    partialState == null,
    'setState(...): takes an object of state variables to update or a ' +
    'function which returns an object of state variables.'
  ) : invariant(typeof partialState === 'object' ||
  typeof partialState === 'function' ||
  partialState == null));
//....省略

所以最终代码可以改成这个样子。

componentWillEnter:function(callback){
  this.setState({
    willEnter:true
  }, () => {
    this.setState({
     enterActive:true
   });
  });
},

但是,这样又引申出了异步编程回调地狱的问题,有些人提出为什么setState不返回一个promise,不过这些问题都太深入了,目前还没有时间和精力研究。

另外,还有一个很不错的react动画库,有待研究。

参考资料

  1. https://facebook.github.io/react/docs/animation.html
  2. http://zhuanlan.zhihu.com/p/20328570
  3. facebook/react#2642