首发于pure render
React 源码剖析系列 - 玩转 React Transition

React 源码剖析系列 - 玩转 React Transition

过去一年,React 给整个前端界带来了一种新的开发方式,我们抛弃了无所不能的 DOM 操作。对于 React 实现动画这个命题,DOM 操作已经是一条死路,而 CSS3 动画又只能实现一些最简单的功能。这时候 ReactCSSTransitionGroup Addon,无疑是一枚强心剂,能够帮助我们以最低的成本实现例如节点初次渲染、节点被删除时添加动效的需求。本文将会深入实现原理来玩转 ReactCSSTransitionGroup。

初窥 ReactCSSTransitionGroup

在介绍 ReactCSSTransitionGroup 的用法前,先来实现一个常规 transition 动画,要实现的是删除某个节点的时候,让该节点的透明度不断的变大。

handleRemove(item) {
  const { items } = this.state;
  const len = items.length;

  this.setState({
    items: items.reduce((result, entry) => {
      return entry.id === item.id ? [...result, { ...item, isRemoving: true }] : [...result, item];
    }, [])
  }, () => {
    setTimeout(() => {
      this.setState({
        items: items.reduce((result, entry) => {
          return entry.id === item.id ? result : [...result, item];
        }, [])
      });
    }, 500);
  });
},

render() {
  const items = this.state.items.map((item, i) => {
    return (
      <div
        key={item.id} onClick={this.handleRemove.bind(this, item)}
        className={item.isRemoving ? 'removing-item' : ''}>
        {item.name}
      </div>
    );
  });

  return (
    <div>
      <button onClick={this.handleAdd}>Add Item</button>
      <div>
        {items}
      </div>
    </div>
  );
}

同时我们在 CSS 中需要提供如下的样式

.removing-item {
  opacity: 0.01;
  transition: opacity .5s ease-in;
}

相同的需求,使用 ReactCSSTransitionGroup 创建动画会是怎么的呢?

handleRemove(i) {
  const { items } = this.state;
  const len = items.length;

  this.setState({
    items: [...items.slice(0, i),  ...item.slice(i + 1, len - 1)]
  });
},

render() {
  const items = this.state.items.map((item, i) => {
    return (
      <div key={item} onClick={this.handleRemove.bind(this, i)}>
        {item}
      </div>
    );
  });

  return (
    <div>
      <button onClick={this.handleAdd}>Add Item</button>
      <ReactCSSTransitionGroup transitionName="example">
        {items}
      </ReactCSSTransitionGroup>
    </div>
  );
}

在这个例子中,当新的节点从 ReactCSSTransitionGroup 中删除时,这个节点会被加上 example-leave 的 class,在下一帧中这个节点还会被加上 example-leave-active 的 class,通过添加以下 CSS 代码,被删除的节点就会有动画的效果。

.example-leave {
  opacity: 1;
  transition: opacity .5s ease-in;
}

.example-leave.example-leave-active {
  opacity: 0.01;
}

从这个例子,我们可以看到 ReactCSSTransition 可以把开发者从一大堆动画相关的 state 中解放出来,只需要关心数据的变化,以及 CSS 的 transition 动画逻辑。

后面将会仔细分析 ReactCSSTransitionGroup 的源码实现。在看代码之前,大家可以先看 官网的文档,对 ReactCSSTransitionGroup 的用法进一步了解。看完之中,可以想想两个问题:

  • appear 动画和 enter 动画有什么区别?

  • ReactCSSTransitionGroup 子元素的生命周期是怎样的?

ReactCSSTransitionGroup 模块关系

ReactCSSTransitionGroup 的源码分为5个模块,我们先看看这5个模块之间的关系:

我们来整理一下这几个模块的分工与职责:

  • ReactTransitionEvents 提供了对各种前缀的 transitionend、animationend 事件的绑定和解绑工具

  • ReactTransitionChildMapping 提供了对 ReactTransitionGroup 这个 component 的 children 进行格式化的工具

  • ReactCSSTransitionGroup 会调用 ReactCSSTransitionGroupChild 对 children 中的每个元素进行包装,然后将包装后的 children 作为 ReactTransitionGroup 的 children 。

从这个关系图里面可以看到,ReactTransitionGroupReactCSSTransitionGroupChild 才是实现动画的关键部分,因此,本文会从 ReactTransitionGroup 开始解读,然后从 ReactCSSTransitionGroupChild 中解读怎么实现具体的动画逻辑。

ReactTransitionGroup 源码解读

下面我们按照 React 生命周期来解读 ReactTransitionGroup

初次 Mount

  • 在初始化 state 的时候,将 this.props.children 转化为对象,其中对象的 key 就是 component key,这个 key 与 children 中的元素一一对应,然后将该对象设置为 this.state.children;

  • 在初次 render 的时候,将 this.state.children 中每一个普通的 child component 通过指定的 childFactory包裹成一个新的 component,并渲染成指定类型的 component 的子元素。在下面的源码中也可以看到,我们在创建过程中给每个 child 设置的 key 也会作为 ref,方便后续索引。

render: function() {
  var childrenToRender = [];
  for (var key in this.state.children) {
    var child = this.state.children[key];

    if (child) {
      childrenToRender.push(React.cloneElement(
        this.props.childFactory(child),
        {ref: key, key: key}
      ));
    }
  }
  return React.createElement(
    this.props.component,
    this.props,
    childrenToRender
  );
}
  • 初次 mount 后,遍历 this.state.children 中的每个元素,依次执行 appear 动画的逻辑。

更新 component

当接收到新的 props 后,先将 nextProps.children 和 this.props.children 合并,然后转化为对象,并更新到this.state.children。计算在 nextProps 中即将 leave 的 child,如果该元素当前没有正在运行的动画,将该元素的 key 保存在 keysToLeave。

对于 nextProps 中新的 child,如果该元素没有正在运行的动画的话(也许会疑惑,一个刚进入的元素怎么会有动画正在运行呢?下文将会解释),将该元素的 key 保存在 keysToEnter。从这里也能看出来,本来在 nextProps 中即将 leave 的 child 会被保留下来以达到动画效果,等动画效果结束后才会被 remove。

component 更新完成后,对 keysToEnter 中的每个元素执行 enter 动画的逻辑,对 keysToLeave 中的每个元素执行 leave 动画的逻辑。由于 enter 动画的逻辑和 appear 动画的逻辑几乎一模一样,无非是变成执行 child 的componentWillEnter 和 componentDidEnter 方法。

leave 动画稍有不同,看下面源码可以看到,在 leave 动画结束后,如果发现该元素重新 enter,这里会再次执行 enter 动画,否则的话通过更新 state 中的 children 来删除相应的节点。这里也可以回答,为什么对刚 enter 的元素,也要判断该元素是否正在进行动画,因为如果该元素上一次 leave 的动画还没有结束,那么这个节点还一直保留在页面中运行动画。

另外,大家有没有注意到一个问题,如果 leave 动画的回调函数没有被调用,那么这个节点将永远不会被移除。

if (currentChildMapping && currentChildMapping.hasOwnProperty(key)) {
  // This entered again before it fully left. Add it again.
  this.performEnter(key);
} else {
  this.setState(function(state) {
    var newChildren = assign({}, state.children);
    delete newChildren[key];
    return {children: newChildren};
  });
}

至此,我们看到 ReactTransitionGroup 没有实现任何具体的动画逻辑。

ReactCSSTransitionGroup

搞清楚 ReactTransitionGroup 的原理以后,ReactCSSTransitionGroup 做的事情就很简单了。简单地说, ReactCSSTransitionGroup 调用了 ReactTransitionGroup ,提供了自己的 childFactory 方法,而这个 childFactory 则是调用了 ReactCSSTRansitionGroupChild 。

_wrapChild: function(child) {
  // We need to provide this childFactory so that
  // ReactCSSTransitionGroupChild can receive updates to name, enter, and
  // leave while it is leaving.
  return React.createElement(
    ReactCSSTransitionGroupChild,
    {
      name: this.props.transitionName,
      appear: this.props.transitionAppear,
      enter: this.props.transitionEnter,
      leave: this.props.transitionLeave,
      appearTimeout: this.props.transitionAppearTimeout,
      enterTimeout: this.props.transitionEnterTimeout,
      leaveTimeout: this.props.transitionLeaveTimeout,
    },
    child
  );
}

下面来看 ReactCSSTransitionGroupChild 是怎么实现节点的动画的。以 appear 动画为例,在child.componentWillAppear 被调用的时候,给该节点加上 xxx-appear 的 className ,并且在一帧(React 里是写死的17s)后,给该节点加上 xxx-appear-active 的 className ,最后在动画结束后删除 xxx-appear 以及 xxx-appear-active的 className。

enter、leave 动画的实现类似。到这里源码就解读完了,其中,还有一些细节要去注意的。

隐藏在 key 里的秘密

在源码解读的过程中,我们发现 ReactTransitionGroup 会将 children 转化为对象,然后通过 for...in... 遍历。对于这一过程,会不会感到有所疑虑,ReactTransitionGroup 怎么保证子节点渲染的顺序。

对于这个问题,React 的处理过程可以简化为下面的代码,测试结果显示,当 key 为字符串类型时,for...in... 遍历的顺序和 children 的顺序能够保持一致;但是当 key 为数值类型时,for...in... 遍历的顺序和 children 的顺序就不一定能够保持一致,大家可以用下面这段简单的代码测试一下。

function test (o) {
  var result = {};
  for (var i = 0, len = o.length; i < len; i++) {
    result[o[i].key] = o[i];
  }
  for (var key in result) {
    if (result[key]) {
      console.log(key, result[key]);
    }
  }
}

因此,我们知道 ReactCSSTransitionGroup 所有子 component 的 key 千万不要设置成纯数字,一定要是字符串类型的。

transitionend 之殇

在 React 0.14 版本中,React 已经表示将在未来的版本中废弃监听 transitionend、 animationend 事件,而是通过设置动画的 timeout 来达到结束动画的目的,有没有想过 React 为什么要放弃原生事件,而改用 setTimeout。

事实上,原因很简单,transitontend 事件在某些情况是不会被触发。在 transitionend 的 MDN文档 中有这么几行文字:

In the case where a transition is removed before completion, such as if the transition-property is removed, then the event will not fire. The event will also not fire if the animated element becomes display: none before the transition fully completes.

  • 当动画元素的 transition 属性在动画完成前被移除了,transitionend 事件不会被触发

  • 当动画元素在动画完成前,display 样式被设置成 "none",这种情况 transitionend 事件不会被触发

  • 当动画还没完成,当前浏览器标签页失焦很长的时间(大于动画时间),transitionend 事件不会被触发,直到该标签页重新聚焦后 transitionend 事件才会触发

正是由于 transitionend 不会触发,会导致隐形 bug,可以看其中一个 bug

总结

  • appear 动画是 ReactCSSTransitionGroup 组件初次 mount 后,才会被添加到 ReactCSSTransitionGroup 的所有子元素上。

  • enter 动画是 ReactCSSTransitionGroup 组件更新后,被添加到新增的子元素上。

  • ReactCSSTransitionGroup 提供创建 CSS 动画最简单的方法,对于更加个性化的动画,大家可以通过调用 ReactTransitionGroup 自定义动画。

参考资料

编辑于 2015-12-15

文章被以下专栏收录