您知道一些网站和 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
组件也期望 CSSTransition
或 Transition
组件作为其直接子元素。因此,我们无法像之前那样包装它们。
我们也不能像之前那样使用相同的布尔方法切换视图,因为它由 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
事件传递之后。
现在可以看到,动画直到红色线条出现才开始。

差异不仅体现在性能分析报告中——它实际上解决了我们在真实项目中遇到的一个问题。
总结
为了起到提醒作用,我为自己创建了一份提示列表,您在深入研究项目中的视图过渡时可能会发现它很有用。
- 当动画正在发生时,不应该发生其他任何事情。在所有资源、获取和业务逻辑完成后再运行动画。
- 没有动画比糟糕的动画更好如果无法实现良好的动画,那么将其移除也是一个合理的牺牲。内容更重要,显示内容是优先级,直到到位一个良好的动画解决方案。
- 在速度较慢和较旧的设备上进行测试。它们将使您更容易发现性能较弱的地方。
- 分析并根据指标改进。不要像我一样边走边猜,而是看看是否可以发现帧丢失的地方或是否有任何看起来不正常的地方,并首先解决这些问题。
就是这样!祝您在动画视图过渡方面好运。如果这引发了任何问题,或者您在应用程序中使用了您想分享的过渡,请发表评论!
您好,我一直在关注本教程,它非常有帮助,不幸的是,我遇到了一个问题,并且难以解决,我的应用程序现在必须支持一些通过重定向处理的旧版 URL。每次发生重定向时,我都会收到一个警告“警告:您尝试重定向到当前所在的相同路由:”以下是一些示例代码
这在父组件中
嗯,我可以看到这实际上如何破坏过渡流程。这是一个难题,可能与 React Router 如何在内部处理重定向有关语句。我建议在 GitHub 上的 react-router 存储库中提出此问题,非常确定他们应该涵盖这种情况,或者至少提供一种解决方法。
是否有一种方法可以在不同视图之间为组件设置动画,就像 Sarah Drasner 使用 Vue 演示的那样?我是否需要将组件添加到两个视图中,并分别定义一个起点和一个终点,使其看起来像是同一个组件?
想通了 :)
这是一个例子
https://codesandbox.io/s/93z163xz44
欢迎反馈