一次react-router + react-transition-group实现转场动画的探索

50次阅读

共计 10550 个字符,预计需要花费 27 分钟才能阅读完成。

原文地址
1. Introduction
在日常开发中,页面切换时的转场动画是比较基础的一个场景。在 react 项目当中,我们一般都会选用 react-router 来管理路由,但是 react-router 却并没有提供相应的转场动画功能,而是非常生硬的直接替换掉组件。一定程度上来说,体验并不是那么友好。
为了在 react 中实现动画效果,其实我们有很多的选择,比如:react-transition-group,react-motion,Animated 等等。但是,由于 react-transition-group 给元素添加的 enter,enter-active,exit,exit-active 这一系列勾子,简直就是为我们的页面入场离场而设计的。基于此,本文选择 react-transition-group 来实现动画效果。
接下来,本文就将结合两者提供一个实现路由转场动画的思路,权当抛砖引玉~
2. Requirements
我们先明确要完成的转场动画是什么效果。如下图所示:

3. react-router
首先,我们先简要介绍下 react-router 的基本用法(详细看官网介绍)。
这里我们会用到 react-router 提供的 BrowserRouter,Switch,Route 三个组件。

BrowserRouter:以 html5 提供的 history api 形式实现的路由(还有一种 hash 形式实现的路由)。

Switch:多个 Route 组件同时匹配时,默认都会显示,但是被 Switch 包裹起来的 Route 组件只会显示第一个被匹配上的路由。

Route:路由组件,path 指定匹配的路由,component 指定路由匹配时展示的组件。

// src/App1/index.js
export default class App1 extends React.PureComponent {
render() {
return (
<BrowserRouter>
<Switch>
<Route exact path={‘/’} component={HomePage}/>
<Route exact path={‘/about’} component={AboutPage}/>
<Route exact path={‘/list’} component={ListPage}/>
<Route exact path={‘/detail’} component={DetailPage}/>
</Switch>
</BrowserRouter>
);
}
}
如上所示,这是路由关键的实现部分。我们一共创建了首页,关于页,列表页,详情页这四个页面。跳转关系为:

首页 ↔ 关于页
首页 ↔ 列表页 ↔ 详情页

来看下目前默认的路由切换效果:

4. react-transition-group
从上面的效果图中,我们可以看到 react-router 在路由切换时完全没有过渡效果,而是直接替换的,显得非常生硬。
正所谓工欲善其事,必先利其器,在介绍实现转场动画之前,我们得先学习如何使用 react-transition-group。基于此,接下来就将对其提供的 CSSTransition 和 TransitionGroup 这两个组件展开简要介绍。
4.1 CSSTransition
CSSTransition 是 react-transition-group 提供的一个组件,这里简单介绍下其工作原理。
When the in prop is set to true, the child component will first receive the class example-enter, then the example-enter-active will be added in the next tick. CSSTransition forces a reflow between before adding the example-enter-active. This is an important trick because it allows us to transition between example-enter and example-enter-active even though they were added immediately one after another. Most notably, this is what makes it possible for us to animate appearance.
这是来自官网上的一段描述,意思是当 CSSTransition 的 in 属性置为 true 时,CSSTransition 首先会给其子组件加上 xxx-enter 的 class,然后在下个 tick 时马上加上 xxx-enter-active 的 class。所以我们可以利用这一点,通过 css 的 transition 属性,让元素在两个状态之间平滑过渡,从而得到相应的动画效果。
相反地,当 in 属性置为 false 时,CSSTransition 会给子组件加上 xxx-exit 和 xxx-exit-active 的 class。(更多详细介绍可以戳官网查看)
基于以上两点,我们是不是只要事先写好 class 对应的 css 样式即可?可以做个小 demo 试试,如下代码所示:
// src/App2/index.js
export default class App2 extends React.PureComponent {

state = {show: true};

onToggle = () => this.setState({show: !this.state.show});

render() {
const {show} = this.state;
return (
<div className={‘container’}>
<div className={‘square-wrapper’}>
<CSSTransition
in={show}
timeout={500}
classNames={‘fade’}
unmountOnExit={true}
>
<div className={‘square’} />
</CSSTransition>
</div>
<Button onClick={this.onToggle}>toggle</Button>
</div>
);
}
}
/* src/App2/index.css */
.fade-enter {
opacity: 0;
transform: translateX(100%);
}

.fade-enter-active {
opacity: 1;
transform: translateX(0);
transition: all 500ms;
}

.fade-exit {
opacity: 1;
transform: translateX(0);
}

.fade-exit-active {
opacity: 0;
transform: translateX(-100%);
transition: all 500ms;
}
来看看效果,是不是和页面的入场离场效果有点相似?
<div align=”center”> <img src=”https://upload-images.jianshu…;/></div>
4.2 TransitionGroup
用 CSSTransition 来处理动画固然很方便,但是直接用来管理多个页面的动画还是略显单薄。为此我们再来介绍 react-transition-group 提供的 TransitionGroup 这个组件。
The <TransitionGroup> component manages a set of transition components (<Transition> and <CSSTransition>) in a list. Like with the transition components, <TransitionGroup> is a state machine for managing the mounting and unmounting of components over time.
如官网介绍,TransitionGroup 组件就是用来管理一堆节点 mounting 和 unmounting 过程的组件,非常适合处理我们这里多个页面的情况。这么介绍似乎有点难懂,那就让我们来看段代码,解释下 TransitionGroup 的工作原理。
// src/App3/index.js
export default class App3 extends React.PureComponent {

state = {num: 0};

onToggle = () => this.setState({num: (this.state.num + 1) % 2});

render() {
const {num} = this.state;
return (
<div className={‘container’}>
<TransitionGroup className={‘square-wrapper’}>
<CSSTransition
key={num}
timeout={500}
classNames={‘fade’}
>
<div className={‘square’}>{num}</div>
</CSSTransition>
</TransitionGroup>
<Button onClick={this.onToggle}>toggle</Button>
</div>
);
}
}
我们先来看效果,然后再做解释:

对比 App3 和 App2 的代码,我们可以发现这次 CSSTransition 没有 in 属性了,而是用到了 key 属性。但是为什么仍然可以正常工作呢?
在回答这个问题之前,我们先来思考一个问题:
由于 react 的 dom diff 机制用到了 key 属性,如果前后两次 key 不同,react 会卸载旧节点,挂载新节点。那么在上面的代码中,由于 key 变了,旧节点难道不是应该立马消失,但是为什么我们还能看到它淡出的动画过程呢?
关键就出在 TransitionGroup 身上,因为它在感知到其 children 变化时,会先保存住即将要被移除的节点,而在其动画结束时才会真正移除该节点。
所以在上面的例子中,当我们按下 toggle 按钮时,变化的过程可以这样理解:
<TransitionGroup>
<div>0</div>
</TransitionGroup>

⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️

<TransitionGroup>
<div>0</div>
<div>1</div>
</TransitionGroup>

⬇️⬇️⬇️⬇️⬇️⬇️⬇️⬇️

<TransitionGroup>
<div>1</div>
</TransitionGroup>
如上所解释,我们完全可以巧妙地借用 key 值的变化来让 TransitionGroup 来接管我们在过渡时的页面创建和销毁工作,而仅仅需要关注如何选择合适的 key 值和需要什么样 css 样式来实现动画效果就可以了。
5. Page transition animation
基于前文对 react-router 和 react-transition-group 的介绍,我们已经掌握了基础,接下来就可以将两者结合起来做页面切换的转场动画了。
在上一小节的末尾有提到,用了 TransitionGroup 之后我们的问题变成如何选择合适的 key 值。那么在路由系统中,什么作为 key 值比较合适呢?
既然我们是在页面切换的时候触发转场动画,自然是跟路由相关的值作为 key 值合适了。而 react-router 中的 location 对象就有一个 key 属性,它会随着浏览器中的地址发生变化而变化。然而,在实际场景中似乎并不适合,因为 query 参数或者 hash 变化也会导致 location.key 发生变化,但往往这些场景下并不需要触发转场动画。
因此,个人觉得 key 值的选取还是得根据不同的项目而视。大部分情况下,还是推荐用 location.pathname 作为 key 值比较合适,因为它恰是我们不同页面的路由。
说了这么多,还是看看具体的代码是如何将 react-transition-group 应用到 react-router 上的吧:
// src/App4/index.js
const Routes = withRouter(({location}) => (
<TransitionGroup className={‘router-wrapper’}>
<CSSTransition
timeout={5000}
classNames={‘fade’}
key={location.pathname}
>
<Switch location={location}>
<Route exact path={‘/’} component={HomePage} />
<Route exact path={‘/about’} component={AboutPage} />
<Route exact path={‘/list’} component={ListPage} />
<Route exact path={‘/detail’} component={DetailPage} />
</Switch>
</CSSTransition>
</TransitionGroup>
));

export default class App4 extends React.PureComponent {
render() {
return (
<BrowserRouter>
<Routes/>
</BrowserRouter>
);
}
}
这是效果:

App4 的代码思路跟 App3 大致相同,只是将原来的 div 换成了 Switch 组件,而且还用到了 withRouter。
withRouter 是 react-router 提供的一个高阶组件,可以为你的组件提供 location,history 等对象。因为我们这里要用 location.pathname 作为 CSSTransition 的 key 值,所以用到了它。
另外,这里有一个坑,就是 Switch 的 location 属性。
A location object to be used for matching children elements instead of the current history location (usually the current browser URL).
这是官网中的描述,意思就是 Switch 组件会用这个对象来匹配其 children 中的路由,而且默认用的就是当前浏览器的 url。如果在上面的例子中我们不给它指定,那么在转场动画中会发生很奇怪的现象,就是同时有两个相同的节点在移动。。。就像下面这样:

这是因为 TransitionGroup 组件虽然会保留即将被 remove 的 Switch 节点,但是当 location 变化时,旧的 Switch 节点会用变化后的 location 去匹配其 children 中的路由。由于 location 都是最新的,所以两个 Switch 匹配出来的页面是相同的。好在我们可以改变 Switch 的 location 属性,如上述代码所示,这样它就不会总是用当前的 location 匹配了。
6. Page dynamic transition animation
虽然前文用 react-transition-group 和 react-router 实现了一个简单的转场动画,但是却存在一个严重的问题。仔细观察上一小节的示意图,不难发现我们的进入下个页面的动画效果是符合预期的,但是后退的动画效果是什么鬼。。。明明应该是上个页面从左侧淡入,当前页面从右侧淡出。但是为什么却变成当前页面从左侧淡出,下个页面从右侧淡入,跟进入下个页面的效果是一样的。其实错误的原因很简单:
首先,我们把路由改变分成 forward 和 back 两种操作。在 forward 操作时,当前页面的 exit 效果是向左淡出;在 back 操作时,当前页面的 exit 效果是向右淡出。所以我们只用 fade-exit 和 fade-exit-active 这两个 class,很显然,得到的动画效果肯定是一致的。
因此,解决方案也很简单,我们用两套 class 来分别管理 forward 和 back 操作时的动画效果就可以了。
/* src/App5/index.css */

/* 路由前进时的入场 / 离场动画 */
.forward-enter {
opacity: 0;
transform: translateX(100%);
}

.forward-enter-active {
opacity: 1;
transform: translateX(0);
transition: all 500ms;
}

.forward-exit {
opacity: 1;
transform: translateX(0);
}

.forward-exit-active {
opacity: 0;
transform: translateX(-100%);
transition: all 500ms;
}

/* 路由后退时的入场 / 离场动画 */
.back-enter {
opacity: 0;
transform: translateX(-100%);
}

.back-enter-active {
opacity: 1;
transform: translateX(0);
transition: all 500ms;
}

.back-exit {
opacity: 1;
transform: translateX(0);
}

.back-exit-active {
opacity: 0;
transform: translate(100%);
transition: all 500ms;
}
不过光有 css 的支持还不行,我们还得在不同的路由操作时加上合适的 class 才行。那么问题又来了,在 TransitionGroup 的管理下,一旦某个组件挂载后,其 exit 动画其实就已经确定了,可以看官网上的这个 issue。也就是说,就算我们动态地给 CSSTransition 添加不同的 ClassNames 属性来指定动画效果,但其实是无效的。
解决方案其实在那个 issue 的下面就给出了,我们可以借助 TransitionGroup 的 ChildFactory 属性以及 React.cloneElement 方法来强行覆盖其 className。比如:
<TransitionGroup childFactory={child => React.cloneElement(child, {
classNames: ‘your-animation-class-name’
})}>
<CSSTransition>

</CSSTransition>
</TransitionGroup>
上述几个问题都解决之后,剩下的问题就是如何选择合适的动画 class 了。而这个问题的实质在于如何判断当前路由的改变是 forward 还是 back 操作了。好在 react-router 已经贴心地给我们准备好了,其提供的 history 对象有一个 action 属性,代表当前路由改变的类型,其值是 ’PUSH’ | ‘POP’ | ‘REPLACE’。所以,我们再调整下代码:
// src/App5/index.js
const ANIMATION_MAP = {
PUSH: ‘forward’,
POP: ‘back’
}

const Routes = withRouter(({location, history}) => (
<TransitionGroup
className={‘router-wrapper’}
childFactory={child => React.cloneElement(
child,
{classNames: ANIMATION_MAP[history.action]}
)}
>
<CSSTransition
timeout={500}
key={location.pathname}
>
<Switch location={location}>
<Route exact path={‘/’} component={HomePage} />
<Route exact path={‘/about’} component={AboutPage} />
<Route exact path={‘/list’} component={ListPage} />
<Route exact path={‘/detail’} component={DetailPage} />
</Switch>
</CSSTransition>
</TransitionGroup>
));
再来看下修改之后的动画效果:

7. Optimize
其实,本节的内容算不上优化,转场动画的思路到这里基本上已经结束了,你可以脑洞大开,通过添加 css 来实现更炫酷的转场动画。不过,这里还是想再讲下如何将我们的路由写得更配置化(个人喜好,不喜勿喷)。
我们知道,react-router 在升级 v4 的时候,做了一次大改版。更加推崇动态路由,而非静态路由。不过具体问题具体分析,在一些项目中个人还是喜欢将路由集中化管理,就上面的例子而言希望能有一个 RouteConfig,就像下面这样:
// src/App6/RouteConfig.js
export const RouterConfig = [
{
path: ‘/’,
component: HomePage
},
{
path: ‘/about’,
component: AboutPage,
sceneConfig: {
enter: ‘from-bottom’,
exit: ‘to-bottom’
}
},
{
path: ‘/list’,
component: ListPage,
sceneConfig: {
enter: ‘from-right’,
exit: ‘to-right’
}
},
{
path: ‘/detail’,
component: DetailPage,
sceneConfig: {
enter: ‘from-right’,
exit: ‘to-right’
}
}
];
透过上面的 RouterConfig,我们可以清晰的知道每个页面所对应的组件是哪个,而且还可以知道其转场动画效果是什么,比如关于页面是从底部进入页面的,列表页和详情页都是从右侧进入页面的。总而言之,我们通过这个静态路由配置表可以直接获取到很多有用的信息,而不需要深入到代码中去获取信息。
那么,对于上面的这个需求,我们对应的路由代码需要如何调整呢?请看下面:
// src/App6/index.js
const DEFAULT_SCENE_CONFIG = {
enter: ‘from-right’,
exit: ‘to-exit’
};

const getSceneConfig = location => {
const matchedRoute = RouterConfig.find(config => new RegExp(`^${config.path}$`).test(location.pathname));
return (matchedRoute && matchedRoute.sceneConfig) || DEFAULT_SCENE_CONFIG;
};

let oldLocation = null;
const Routes = withRouter(({location, history}) => {

// 转场动画应该都是采用当前页面的 sceneConfig,所以:
// push 操作时,用新 location 匹配的路由 sceneConfig
// pop 操作时,用旧 location 匹配的路由 sceneConfig
let classNames = ”;
if(history.action === ‘PUSH’) {
classNames = ‘forward-‘ + getSceneConfig(location).enter;
} else if(history.action === ‘POP’ && oldLocation) {
classNames = ‘back-‘ + getSceneConfig(oldLocation).exit;
}

// 更新旧 location
oldLocation = location;

return (
<TransitionGroup
className={‘router-wrapper’}
childFactory={child => React.cloneElement(child, {classNames})}
>
<CSSTransition timeout={500} key={location.pathname}>
<Switch location={location}>
{RouterConfig.map((config, index) => (
<Route exact key={index} {…config}/>
))}
</Switch>
</CSSTransition>
</TransitionGroup>
);
});
由于 css 代码有点多,这里就不贴了,不过无非就是相应的转场动画配置,完整的代码可以看 github 上的仓库。我们来看下目前的效果:

8. Summarize
本文先简单介绍了 react-router 和 react-transition-group 的基本使用方法;其中还分析了利用 CSSTransition 和 TransitionGroup 制作动画的工作原理;接着又将 react-router 和 react-transition-group 两者结合在一起完成一次转场动画的尝试;并利用 TransitionGroup 的 childFactory 属性解决了动态转场动画的问题;最后将路由配置化,实现路由的统一管理以及动画的配置化,完成一次 react-router + react-transition-group 实现转场动画的探索。
9. Reference

A shallow dive into router v4 animated transitions
Dynamic transitions with react router and react transition group
Issue#182 of react-transition-group
StackOverflow: react-transition-group and react clone element do not send updated props

本文所有代码托管在这儿,如果觉得不错的,可以给个 star。

正文完
 0