React-实现仿原生App的转场动画

React大法好

一、前言

最近这两个星期,已经从jQuery的泥潭里抽出身来,开始学习React框架。React其实是一个UI框架,其功能性比较单一,需要依赖各种插件进行开发。这些React插件集合起来,就是鼎鼎大名的React全家桶。

目前我了解并使用过的有如下几个插件:

  • react-router-dom React路由跳转插件
  • react-loadable 一个动态导入加载UI的高阶组件
  • redux, react-redux-dom react函数响应式编程框架,类似于iOS中的ReactiveCocoa数据驱动视图的思想。刚开始学的时候,配上ES6、7、8的语法糖,能绕的你不要不要的
  • prop-types 为弱语法的JS提供强类型操作
  • axios React界可以与ajax相抗衡的网络库
  • react-motion React中的弹性动画库
  • react-transition-group React中个人目前感觉最好用的动画库
    待了解的插件及知识点:
  • react-saga、react-thunk、immutable、webpack打包

二、需求产出

本篇文章是我在React学习过程中,打算将用过的知识点串起来时遇到的一个需求问题。因为还是iOS出身,所以梳理知识点的时候,还是想按照移动端那种形式去整理,如下图所示。很屌丝,终归还是一时难忘iOS。

React学习Demo调试图

此Demo现在还在编写完善当中,暂不公开。
Demo中的路由跳转肯定就是用的react-router-dom这个插件了。但是这个插件进行路由切换时,效果很僵硬,没有过渡效果。这对于追求用户体验的iOS开发者来说肯定是不能接受的!!!所以我要做的,就是对react路由切换加上仿原生的转场动画。

三、react-router-dom

简单实现一下纯router插件的路由跳转,效果及代码如下:
要说的都在代码注释中!!!先不要去管CSS样式

import React from 'react';
import {
    //以html5提供的history api形式实现的路由
    //一般其作为项目的原始组件进行包裹
    BrowserRouter as Router,
    //路由组件,path指定匹配的路由,component指定路由匹配时展示的组件
    Route,
    //Route组件包裹器
    Switch,
    //一个高阶组件,为组件提供location,history等对象
    withRouter
} from 'react-router-dom';
//自定义HomePage组件
import HomePage from '../page/home';
//自定义SecondPage组件
import SecondPage from '../page/second';

const RouteModule = function (props) {
    return (
        <Switch history={props.history}>
            <Route exact path={'/'} component={HomePage} name={'首页'} />
            <Route path={'/second'} component={SecondPage} name={'第二页'} />
        </Switch>
    );
};

export default class DemoApp5 extends React.Component {
    render() {
        const Routes = withRouter(RouteModule);
        return (
            <Router>
                <Routes />
            </Router>
        );
    }
}

默认的路由切换效果如下:


僵硬的react-router-dom路由切换效果.gif

四、react-transition-group

上面也略有介绍,react-transition-group是react中的一个很不错的动画库。为什么我会想到用它去实现转场动画?因为我了解的react动画库就两个,还有一个react-motion是弹性动画库,显然不合适。
众所周知,JS实现动画最方便的还要属jQuery。其提供了一系列动画函数,好用且方便。但是操控的都是真实dom,这显然与React的虚拟dom思想相违背,所以没有考虑jQuery去实现需求。

1、 CSSTransition实现单一组件动画

CSSTransition单独使用时,有两个比较重要的属性。in和classNames。

  • classNames属性: CSSTransition子组件动态类选择器名前缀。
  • in属性:
    intrue时,CSSTransition的子组件会先添加${classNames}-enter类,下一个tick会添加${classNames}-enter-active类。
    infalse时,CSSTransition的子组件会先添加${classNames}-exit类,下一个tick会添加${classNames}-exit-active类。

基于上面的知识点,我们先出一个react-transition-group的简单小Demo。

import React from 'react';
import {CSSTransition} from 'react-transition-group';
import './style.css';
/*
 * 知识点
 * CSSTransition属性in为true时,其子组件会加上`${classNames}-enter`的类
 * 然后再下一个tick时,马上加上`${classNames}-enter-active`的类
 * 当in为false时,其组件会加上`${classNames}-exit`和`${classNames}-exit-active`类
 *
 * unmountOnExit为false的时候,其子组件exit后不会卸载,并添加`${classNames}-exit-done`类
 * 为true,子组件exit后会卸载
 * 这里我们直接卸载,省的其写done样式
 * */
export default class DemoApp1 extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
           show: true
        };
        this.handleSwitch = this.handleSwitch.bind(this);
    }
    handleSwitch() {
        this.setState({
            show: !this.state.show
        });
    }
    render() {
        return (
            <div className='app1-container'>
                <CSSTransition
                    in={this.state.show}
                    classNames='app1'
                    timeout={500}
                    unmountOnExit={true}
                >
                    <div className='app1-square' />
                </CSSTransition>
                <button
                    onClick={this.handleSwitch}
                    className='app1-btn'
                >切换
                </button>
            </div>
        );
    }
}

CSS样式如下:

.app1-enter {
    /*初始透明度为0*/
    opacity: 0;
    /*初始偏移量为100%*/
    transform: translateX(100%);
}

.app1-enter-active {
    /*随后透明度为1*/
    opacity: 1;
    /*随后偏移量回归原位*/
    transform: translateX(0);
    /*这个移动过程我们做个动画,动画持续时长500毫秒*/
    transition: all 500ms ease;
}

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

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

效果符合预期:


单一组件动画效果
2、TransitionGroup实现多组件协调动画

我们的路由跳转,实际上是有两个组件同时动画的。即第一个页面组件和第二个页面组件。所以单纯的CSSTransition组件不能满足需求。
以代码为例进行讲解:

export default class DemoApp2 extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            number: 0
        };
        this.handleSwitch = this.handleSwitch.bind(this);
    }
    handleSwitch(event) {
        this.setState({
            number: this.state.number === 0?1:0
        });
    }
    render() {
        return (
            <div className='app2-container'>
                <TransitionGroup>
                    <CSSTransition
                        key={this.state.number}
                        timeout={500}
                        classNames='app2'
                        unmountOnExit={true}
                    >
                        <div className='app2-square'>{this.state.number}</div>
                    </CSSTransition>
                </TransitionGroup>
                <button className='app2-btn' onClick={this.handleSwitch}>切换</button>
            </div>
        );
    }
}

CSS代码同上
我们和纯CSSTransition用法进行比较,发现有以下几点不同:

  1. CSSTransition组件上层嵌了一层TransitionGroup组件
  2. 没有使用in属性作为控制组件添加enterexit类的手段,而是使用了key属性。
    我们先来看一下效果:
    TransitionGroup组件使用效果

    将动画速度调低,来看一下子节点类选择器的变化:
    子节点类选择器变化过程

    现象:我们可以看到在点击切换节点内容的时候,会新增了一个新的dom。此时新老dom并存。老dom新增了-exit和-exit-active两个选择器。新dom新增了-enter和-enter-active两个选择器。这样的情况确实会出现我们看到的效果。
    原因:刚开始学习react的时候,我们就听过了react的虚拟dom渲染优化机制。它是有一个diff算法,比较出存在变化的地方,然后针对性地进行重新渲染。diff机制其实用到的就是key,我们两次key不一样,react就会卸载旧key对应的节点,装载新key对应的节点。但是为什么会有动画效果,而不是立马卸载装载呢?这就是TransitionGroup组件的特别之处,它会保存住即将被卸载的children,并在动画执行完毕将其进行移除。

五、路由转场动画

针对上面对TransitionGroupCSSTransition组件的运用,想象一下,其实我们把案例中的子组件div换成对应的Route路由组件,讲道理就能实现转场动画了。
但是diff算法需要的key,用什么来表示呢?你应该一下子就能想起来了,每个Route路由的pathname路径都不一样,用它来简直完美。

注意: 新旧两个节点,一定要在同一位置才符合我们enter、exit选择器规定的transform属性,并作出X轴方向上平移动画。
所以我将父节点TransitionGroup的position设为releative,子节点HomePage和SecondPage设为绝对定位,且top和left都为0

代码如下:

const RouteModule = function (props) {
    return (
        <TransitionGroup style={{position: 'releative'}}>
            <CSSTransition
                //key为路由路径,因为使用高阶组件withRouter
                //所以会有location和history属性
                key={props.location.pathname}
                timeout={1000}
                classNames={'app3'}
            >
                //这里注意一点,Switch组件是根据location属性中的url进行匹配子组件的
                //如果这个地方不对应设置location,那么旧的Switch组件
                //就会使用新的location去匹配子组件。这样会造成新旧组件为同一个的bug
                <Switch location={props.location}>
                    <Route exact path={'/'} component={HomePage} name={'首页'} />
                    <Route path={'/second'} component={SecondPage} name={'第二页'} />
                </Switch>
            </CSSTransition>
        </TransitionGroup>
    );
};

export default class DemoApp3 extends React.Component {
    render() {
        const Routes = withRouter(RouteModule);
        return (
            <Router>
                <Routes />
            </Router>
        );
    }
}

来看一下效果:


转场效果图

应该算是符合预期了,但是push与pop时的效果是一样的,因为我们并没有进行区分。

六、实现Push、Pop效果分离

上面我们其实已经做出了Push效果,但是Pop的效果其实是没有处理的,因为选择器都是同一个。
Pop的时候,enter与exit选择器的效果应该和push时的正好相反才对,所以我们先对CSS样式进行处理。

/*push*/
.app4-push-enter {
    opacity: 0;
    transform: translateX(100%);
}

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

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

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

/*pop*/
.app4-pop-enter {
    opacity: 0;
    transform: translateX(-100%);
}

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

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

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

CSS的类选择器完成后,下一步就是在适当的时机,对TransitionGroup子组件添加这些对应的选择器。
一开始我的做法是通过路由中的location.action去判断是否为PUSH还是POP操作,并对应设置CSSTransition组件的选择器前缀classNames属性。

<CSSTransition
                key={props.location.pathname}
                timeout={500}
                classNames={props.history.action === 'PUSH'?'app4-push':'app4-pop'}
            >

但是效果貌似出了些问题~~~

667.gif

确定CSS中的逻辑是没有问题的情况下。将动画速度调低,我们看一下子组件类选择器的变化情况:
子组件类选择器添加过程

我们来分析一下:
当点击Push的时候。按照我们的思路,secondPage组件应该是添加push-enter选择器,home组件应该添加push-exit选择器。
点击Pop的时候,home组件应该添加pop-enter选择器,secondPage组件应该添加pop-exit选择器。
但是现象却是点击push时,home组件添加了pop-exit选择器。
点击pop时,second组件添加了push-exit选择器。
为什么会这样???
静下心来继续分析:debugger调试发现,其实组件的location.action值默认是pop。当第一次push操作时,新组件的action变为PUSH,而旧组件action默认为POP,所以自然会添加pop-exit选择器。第二次pop操作时,因为旧组件此时的action为PUSH,所以添加了push.exit。

action:pop     push操作         action:push
旧组件   -------------------->   新组件
新组件  <--------------------    旧组件
               pop操作

那么如何让push操作的时候,新旧子组件分别添加push-enter、enter-exit。pop操作的时候,新旧子组件分别添加pop-enter、pop-exit选择器呢?

经过查找资料,发现TransitionGroup组件有个childFactory属性可以强行覆盖子组件的类选择器名称。

<TransitionGroup
            style={{position: 'releative'}}
            //childFactory属性为一个function
            //这个function的第一个参数child实际上就是TransitionGroup子组件
            //通过React.cloneElement方法重新克隆子组件,并根据当前的操作类型去设置类选择器前缀
            //这样,当操作为push时,子组件的类选择器前缀并不是根据其本身的location.action去分别命名。
            //而是根据最新的action类型设置
            childFactory={child => React.cloneElement(
                  child,
                  {classNames: props.history.action === 'PUSH'?'app4-push':'app4-pop'}
            )}
>

最终效果如下:


react转场实战的最终效果

七、总结

本篇文章是针对React知识点的第一篇文章。围绕这个需求,可以提升对React开发时遇到问题如何适当调试的技能,加深对react-router-domreact-transition-group两个插件的理解和运用。文中所涉及的代码全部可在这里下载查看,如果对您有所帮助,希望给个Star
本人是初入前端,水平有限。如若您发现问题,望及时指出,谢谢~🙂

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342