logo学习随笔

React 过渡

June 01, 2020

前言

笔者最近在做移动端的项目,项目经理某天给我发了一个链接panel-stack,我试用了一下感觉蛮不错的,每层(stack)切换时非常丝滑。在我们的项目中,针对路由做了一层包装,当用户点击链接时,我们会在 dom 上 append 该链接指向页面的节点,而原页面的节点不会被销毁。这样做的好处是,可以记住原页面的滚动位置、交互状态,但不好的地方是用户刷新页面时只会记录最近一个链接,这样之前页面的元素就丢失了。

简单的路由实现:

const layers = [];

const routerHandler = function(event) {
  layers.push({
    state: event.state,
    path: location.path,
    query: location.query,
  });
};

window.addEventListener('popstate', routeHandler);

const RouterWrapper = React.memo(() => {
  return layers.map((layer, index) => (
    <LayerWrapper layer={layer} key={index} />
  ));
});

根据上面的路由和 layer 的关系,我们就可以构造出 panel stack 依赖的 stack: { component: React.ComponentType, title: string }[]数据结构了。 再结合 header,那么大体上我们就满足了需求。

搬运

了解 panel stack 的用法,那么其实现是怎样的呢?

我们来看一下 PanelStack 的代码:

public render() {
  const classes = classNames(
    Classes.PANEL_STACK,
    `${Classes.PANEL_STACK}-${this.state.direction}`,
    this.props.className,
  );
  return (
    <TransitionGroup className={classes} component="div">
     <CSSTransition
        key={id}
        timeout={500}
        classNames="item"
      >
        {this.renderPanels()}
      </CSSTransition>
    </TransitionGroup>
  );
}

由 render()方法可以看到,其底层实际还是依赖了三方库:react-transition-group

虽然 react-transition-group 提供了过渡的能力,即提供了 dom 元素进入退出的多个周期节点。为了丝滑的体验,需要对每个时间节点设定样式,这样不免会写出很多的重复或相似的 css 代码。而 panel-stack 封装了scss 的过渡函数,非常的强大:

@mixin react-transition(
  $name,
  $properties,
  $duration: $pt-transition-duration,
  $easing: $pt-transition-ease,
  $delay: 0,
  $before: '',
  $after: ''
) {
  @include each-prop($properties, 2);
  @include react-transition-phase(
    $name,
    'enter',
    $properties,
    $duration,
    $easing,
    $delay,
    $before,
    $after
  );
  @include react-transition-phase(
    $name,
    'exit',
    $properties,
    $duration,
    $easing,
    $delay,
    $before,
    $after
  );
}

大概就是根据节点进入/退出,通过函数调用来生成最终 css 代码。由于我们项目使用了 less 预处理样式,所以经过几番查阅文档将 scss 转成了 less 的实现:

.each-prop(@properties, @idx) {
  each(@properties, {
    @{key}: extract(@value, @idx);
  })
}

.extract-prop(@properties) {
  each(@properties, {
    transition-property+: @key;
  })
}

.react-transition-phase(@name, @phase, @properties, @duration, @easing) {
  @start-index: if(@phase = 'enter', 1, 2);
  @end-index: if(@phase = 'enter', 2, 1);
  @class: ~".@{name}-@{phase}";
  @class-active: ~".@{name}-@{phase}-active";

  @{class} {
    .each-prop(@properties, @start-index);
  }

  @{class-active} {
    .each-prop(@properties, @end-index);
    .extract-prop(@properties);
    transition-duration: @duration;
    transition-timing-function: @easing;
  }
}

这样在使用时,可以减少许多冗余的代码:

.layer-list {
  height: 100%;
  overflow: hidden;
  position: relative;

  // 这里为节点新增时
  &.layer-list-push {
    .react-transition-phase(
      'layer-item',
      'enter',
      {transform: translateX(100%) translate(0%) ; opacity: 0 1;},
      0.4s,
      ease
    );
    .react-transition-phase(
      'layer-item',
      'exit',
      {transform: translateX(-50%) translate(0%) ; opacity: 0 1;},
      0.4s,
      ease
    );
  }

  // 这里为节点删除时
  &.layer-list-pop {
    .react-transition-phase(
      'layer-item',
      'enter',
      {transform: translateX(-50%) translate(0%) ; opacity: 0 1;},
      0.4s,
      ease
    );
    .react-transition-phase(
      'layer-item',
      'exit',
      {transform: translateX(100%) translate(0) ; opacity: 0 1;},
      0.4s,
      ease
    );
  }
}

探究

现在我们就来追根溯源,来看一下 react-transition-group 的内部实现:

Transition.js中说明, Transition 组件默认不会修改内部组件的行为,它只会记录组件的“进入”和“退出”的状态。

Transition 存在四种过渡状态:

  • 'entering'
  • 'entered'
  • 'exiting'
  • 'exited'

Transition 通过 in 这个 prop 来决定组件的方向,即当 intrue 时,组件状态会开始变为 'enter',然后会在间隔时间内变为 'entering' 状态,完成后会变为 'entered' 状态。当 infalse 时,组件会进行类似的行为,只不过从 'exiting' 过渡到 'exited'

在我们的需求中,我们需要用到 <TransitionGroup><CSSTransition> 组件,<TransitionGroup> 是用来维护一组子组件的进出状态的集合,它本身不会定义过渡效果。

<TransitionGroup> 中使用了childMapping.js 中的 getChildMapping, getInitialChildMapping, getNextChildMapping 来决定各个子组件的进出状态。

这里根据每个子组件的顺序,依次赋予 props 各个状态。这里用到了 React.cloneElement(),复制原组件并传入新的 props。

<CSSTransition> 中,定义了

onEnter = (maybeNode, maybeAppearing) => {
  const [node, appearing] = this.resolveArguments(maybeNode, maybeAppearing);
  this.removeClasses(node, 'exit');
  this.addClass(node, appearing ? 'appear' : 'enter', 'base');

  if (this.props.onEnter) {
    this.props.onEnter(maybeNode, maybeAppearing);
  }
};

<Transition
  {...props}
  onEnter={this.onEnter}
  onEntered={this.onEntered}
  onEntering={this.onEntering}
  onExit={this.onExit}
  onExiting={this.onExiting}
  onExited={this.onExited}
/>;

这里以 onEnter 为例,当处于 'enter' 状态时,在组件上增加 {prefix}-enter 的 class。

<Transition> 中,当 props 有变化时,会执行 componentDidUpdate() 周期函数:

componentDidUpdate(prevProps) {
  let nextStatus = null
  if (prevProps !== this.props) {
    const { status } = this.state

    if (this.props.in) {
      if (status !== ENTERING && status !== ENTERED) {
        nextStatus = ENTERING
      }
    } else {
      if (status === ENTERING || status === ENTERED) {
        nextStatus = EXITING
      }
    }
  }
  this.updateStatus(false, nextStatus)
}

该函数来确定当前组件应该进入的下一个状态是什么,并执行 updateStatus() 方法,该方法根据 nextStatus 来执行具体的逻辑:

updateStatus(mounting = false, nextStatus) {
  if (nextStatus !== null) {
    this.cancelNextCallback()

    if (nextStatus === ENTERING) {
      this.performEnter(mounting)
    } else {
      this.performExit()
    }
  } else if (this.props.unmountOnExit && this.state.status === EXITED) {
    this.setState({ status: UNMOUNTED })
  }
}

performEnter(mounting) {
  const { enter } = this.props
  const appearing = this.context ? this.context.isMounting : mounting
  const [maybeNode, maybeAppearing] = this.props.nodeRef
    ? [appearing]
    : [ReactDOM.findDOMNode(this), appearing]

  const timeouts = this.getTimeouts()
  const enterTimeout = appearing ? timeouts.appear : timeouts.enter

  if ((!mounting && !enter) || config.disabled) {
    this.safeSetState({ status: ENTERED }, () => {
      this.props.onEntered(maybeNode)
    })
    return
  }

  this.props.onEnter(maybeNode, maybeAppearing)

  this.safeSetState({ status: ENTERING }, () => {
    this.props.onEntering(maybeNode, maybeAppearing)

    this.onTransitionEnd(enterTimeout, () => {
      this.safeSetState({ status: ENTERED }, () => {
        this.props.onEntered(maybeNode, maybeAppearing)
      })
    })
  })
}

performEnter() 方法为例,根据状态执行我们在<CSSTransition>中传入的 props.onEntered(),或执行 props.onEnter() 并进一步执行 props.onEntering()。 由此,我们可以完整得出 transition 的全部过程。

总结

从一个小需求出发,我们可以从类似的解决方案中收获非常多的知识和技巧。有时候完成一项工作只是基本要求,更重要的是它的原理和底层实现。这样在以后遇到更复杂的需求时,也能不乱阵脚,根据以往的经验来轻松应对。