在 React 中实现视图间动画

Avatar of Jeremias Menichelli
Jeremias Menichelli

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

您知道一些网站和 Web 应用在两个页面或视图之间切换时,是如何拥有那种简洁的原生体验吗?Sarah Drasner 提供了一些 很好的示例,甚至还提供了一个 Vue 库

这些动画是能够将良好的用户体验提升至卓越水平的功能类型。但在 React 架构中实现此目标,需要将应用程序中的关键部分结合起来:路由逻辑和动画工具。

让我们从动画开始。我们将使用 React 进行构建,并且有很多很棒的选项可供我们利用。值得注意的是,react-transition-group 是处理元素进入和离开 DOM 的官方包。让我们探索一些我们可以应用的相对简单的模式,即使是针对现有组件。

使用 react-transition-group 实现过渡

首先,让我们熟悉一下 react-transition-group 库,以了解如何将其用于元素进入和离开 DOM。

单个组件过渡

作为一个简单的用例示例,我们可以尝试为模态框或对话框设置动画——您知道,那种可以通过动画使其平滑进入和离开的元素类型。

对话框组件可能看起来像这样

import React from "react";

class Dialog extends React.Component {
  render() {
    const { isOpen, onClose, message } = this.props;
    return (
      isOpen && (
        <div className="dialog--overlay" onClick={onClose}>
          <div className="dialog">{message}</div>
        </div>
      )
    );
  }
}

请注意,我们使用 isOpen 属性来确定组件是否已渲染。由于 react-transition-group 模块提供的最新修改后的 API 的简单性,我们可以在不增加太多开销的情况下为此组件添加基于 CSS 的过渡。

首先,我们需要将整个组件包装在另一个 TransitionGroup 组件中。在内部,我们保留用于挂载或卸载对话框的属性,我们将它包装在 CSSTransition 中。

import React from "react";
import { TransitionGroup, CSSTransition } from "react-transition-group";

class Dialog extends React.Component {
  render() {
    const { isOpen, onClose, message } = this.props;
    return (
      <TransitionGroup component={null}>
        {isOpen && (
          <CSSTransition classNames="dialog" timeout={300}>
            <div className="dialog--overlay" onClick={onClose}>
              <div className="dialog">{message}</div>
            </div>
          </CSSTransition>
        )}
      </TransitionGroup>
    );
  }
}

每次修改 isOpen 时,对话框根元素中都会发生一系列类名更改。

如果我们将 classNames 属性设置为 "fade",则在元素挂载之前会立即添加 fade-enter,然后在过渡开始时添加 fade-enter-active。根据设置的 timeout,我们应该在过渡完成时看到 fade-enter-done。在元素即将卸载时,exit 类名组也会发生完全相同的操作。

这样,我们就可以简单地定义一组 CSS 规则来声明我们的过渡。

.dialog-enter {
  opacity: 0.01;
  transform: scale(1.1);
}

.dialog-enter-active {
  opacity: 1;
  transform: scale(1);
  transition: all 300ms;
}

.dialog-exit {
  opacity: 1;
  transform: scale(1);
}

.dialog-exit-active {
  opacity: 0.01;
  transform: scale(1.1);
  transition: all 300ms;
}

JavaScript 过渡

如果我们想使用 JavaScript 库来编排更复杂的动画,那么我们可以使用 Transition 组件。

此组件不会像 CSSTransition 那样为我们做任何事情,但它确实会在每个过渡周期中公开钩子。我们可以将方法传递给每个钩子以运行计算和动画。

<TransitionGroup component={null}>
  {isOpen && (
    <Transition
      onEnter={node => animateOnEnter(node)}
      onExit={node => animateOnExit(node)}
      timeout={300}
    >
      <div className="dialog--overlay" onClick={onClose}>
        <div className="dialog">{message}</div>
      </div>
    </Transition>
  )}
</TransitionGroup>

每个钩子都会将节点作为第一个参数传递给回调——这使我们能够控制在元素挂载或卸载时所需的任何变异。

路由

React 生态系统提供了许多路由选项。我将使用 react-router-dom,因为它是最受欢迎的选择,并且大多数 React 开发人员都熟悉其语法。

让我们从一个基本的路由定义开始

import React, { Component } from 'react'
import { BrowserRouter, Switch, Route } from 'react-router-dom'
import Home from '../views/Home'
import Author from '../views/Author'
import About from '../views/About'
import Nav from '../components/Nav'

class App extends Component {
  render() {
    return (
      <BrowserRouter>
        <div className="app">
          <Switch>
            <Route exact path="/" component={Home}/>
            <Route path="/author" component={Author} />
            <Route path="/about" component={About} />
          </Switch>
        </div>
      </BrowserRouter>
    )
  }
}

我们希望在这个应用程序中设置三个路由:首页、作者和关于。

BrowserRouter 组件处理浏览器的历史记录更新,而 Switch 根据 path 属性决定渲染哪个 Route 元素。这是没有任何过渡的版本

别担心,我们会逐步添加页面过渡。

水火不容

虽然 react-transition-group 和 react-router-dom 都是针对其预期用途的优秀且方便的包,但将它们混合在一起可能会破坏其功能。

例如,react-router-dom 中的 Switch 组件期望直接的 Route 子元素,而 react-transition-group 中的 TransitionGroup 组件也期望 CSSTransitionTransition 组件作为其直接子元素。因此,我们无法像之前那样包装它们。

我们也不能像之前那样使用相同的布尔方法切换视图,因为它由 react-router-dom 逻辑在内部处理。

React 键来救援

虽然解决方案可能不像我们之前的示例那样简洁,但可以将这些库一起使用。我们需要做的第一件事是将我们的路由声明移动到一个渲染属性中。

<BrowserRouter>
  <div className="app">
    <Route render={(location) => {
      return (
        <Switch location={location}>
          <Route exact path="/" component={Home}/>
          <Route path="/author" component={Author} />
          <Route path="/about" component={About} />
        </Switch>
      )}
    />
</BrowserRouter>

就功能而言,没有任何变化。不同之处在于,我们现在可以控制每次浏览器位置发生更改时渲染的内容。

此外,react-router-dom 在每次发生这种情况时,都会在 location 对象中提供一个唯一的 key

如果您不熟悉它们,React 用于识别虚拟 DOM 树中的元素。大多数情况下,我们不需要指定它们,因为 React 会检测 DOM 的哪个部分应该更改,然后对其进行修补。

<Route render={({ location }) => {
  const { pathname, key } = location

  return (
    <TransitionGroup component={null}>
      <Transition
        key={key}
        appear={true}
        onEnter={(node, appears) => play(pathname, node, appears)}
        timeout={{enter: 750, exit: 0}}
      >
        <Switch location={location}>
          <Route exact path="/" component={Home}/>
          <Route path="/author" component={Author} />
          <Route path="/about" component={About} />
        </Switch>
      </Transition>
    </TransitionGroup>
  )
}}/>

不断更改元素的键——即使其子元素或属性未被修改——也会强制 React 将其从 DOM 中移除并重新挂载。这有助于我们模拟之前使用的布尔切换方法,并且在这里对我们很重要,因为我们可以放置一个 Transition 元素并将其重复用于所有视图过渡,从而允许我们混合路由和过渡组件。

动画函数内部

在每次位置更改时调用过渡钩子后,我们可以运行一个方法并使用任何动画库为我们的过渡构建更复杂的场景。

export const play = (pathname, node, appears) => {
  const delay = appears ? 0 : 0.5
  let timeline

  if (pathname === '/')
    timeline = getHomeTimeline(node, delay)
  else
    timeline = getDefaultTimeline(node, delay)

  timeline.play()
}

我们的 play 函数将根据 pathname 在此处构建一个 GreenSock 时间轴,并且我们可以为每个不同的路由设置任意数量的过渡。

为当前 pathname 构建时间轴后,我们将播放它。

const getHomeTimeline = (node, delay) => {
  const timeline = new Timeline({ paused: true });
  const texts = node.querySelectorAll('h1 > div');

  timeline
    .from(node, 0, { display: 'none', autoAlpha: 0, delay })
    .staggerFrom(texts, 0.375, { autoAlpha: 0, x: -25, ease: Power1.easeOut }, 0.125);

  return timeline
}

每个时间轴方法都会深入到视图的 DOM 节点并对其进行动画处理。您可以使用 GreenSock 之外的其他动画库,但重要的细节是我们事先构建时间轴,以便我们的主要 play 方法可以决定每个路由应运行哪个时间轴。

成功!

我在许多项目中使用了这种方法,虽然它在内部导航方面没有明显的性能问题,但我确实注意到浏览器初始 DOM 树构建和第一个路由动画之间存在并发问题。这导致应用程序第一次加载时的动画出现视觉延迟。

为了确保应用程序每个阶段的动画都流畅,我们还可以做最后一件事。

分析初始加载

在 Chrome DevTools 中进行硬刷新后,对应用程序进行审计时,我发现了以下情况。

您可以看到两条线:一条蓝色,一条红色。蓝色代表load事件,红色代表DOMContentLoaded事件。两者都与初始动画的执行相交。

这些线条表明,在浏览器尚未完成构建整个 DOM 树或解析资源时,元素正在进行动画处理。动画会导致较大的性能损耗。如果我们希望其他任何操作发生,则必须在运行我们的过渡之前,等待浏览器准备好这些繁重且重要的任务。

在尝试了许多不同的方法后,最终起作用的解决方案是将动画移到这些事件之后——就这么简单。问题是我们不能依赖事件监听器。

window.addEventListener(‘DOMContentLoaded’, () => {
  timeline.play()
})

如果由于某种原因,事件在我们声明监听器之前发生,那么我们传递的回调将永远不会运行,这可能导致我们的动画永远不会发生,并且视图为空。

由于这是一个并发和异步问题,我决定依靠 Promise,但问题随之而来:Promise 和事件监听器如何一起使用?

通过创建一个在事件发生时被解析的 Promise。就是这样。

window.loadPromise = new Promise(resolve => {
  window.addEventListener(‘DOMContentLoaded’, resolve)
})

我们可以将其放在文档的head中,或加载应用程序 bundle 的脚本标签之前。这将确保事件永远不会在 Promise 创建之前发生。

此外,这样做允许我们在应用程序中的任何动画中使用全局暴露的loadPromise。假设我们不仅要为入口视图设置动画,还要为 cookie 横幅或应用程序的页眉设置动画。我们只需在 Promise 解析后使用then以及我们的过渡来调用这些动画。

window.loadPromise.then(() => timeline.play())

这种方法在整个代码库中可重用,消除了事件在动画运行之前解析时可能导致的问题。它会将动画延迟到浏览器DOMContentLoaded事件传递之后。

现在可以看到,动画直到红色线条出现才开始。

差异不仅体现在性能分析报告中——它实际上解决了我们在真实项目中遇到的一个问题。

总结

为了起到提醒作用,我为自己创建了一份提示列表,您在深入研究项目中的视图过渡时可能会发现它很有用。

  • 当动画正在发生时,不应该发生其他任何事情。在所有资源、获取和业务逻辑完成后再运行动画。
  • 没有动画比糟糕的动画更好如果无法实现良好的动画,那么将其移除也是一个合理的牺牲。内容更重要,显示内容是优先级,直到到位一个良好的动画解决方案。
  • 在速度较慢和较旧的设备上进行测试。它们将使您更容易发现性能较弱的地方。
  • 分析并根据指标改进。不要像我一样边走边猜,而是看看是否可以发现帧丢失的地方或是否有任何看起来不正常的地方,并首先解决这些问题。

就是这样!祝您在动画视图过渡方面好运。如果这引发了任何问题,或者您在应用程序中使用了您想分享的过渡,请发表评论!