Vue实现内部组件轮播切换效果

对于那些不须要路由的内部组件,在切换的时候但愿增长一个轮播过渡的效果,效果以下:javascript


咱们能够引入一个轮播组件,可是有个问题,一般轮播组件都会把全部的slide都渲染出来再进行切换,这样就致使全部的资源都会触发加载,这可能不是咱们所期待的,毕竟若是slide比较多的状况须要一次性加载的图片等资源太多了。因此咱们能够手动简单地写一个,知足需求便可。css

如今一步步来实现这个功能,先写一个实现基本切换的demo.html

1. 实现切换

先用vue-cli搭建一个工程脚手架,使用如下命令:前端

npm install -g vue-cli
vue init webpack slide-demo # 运行后router等都选择no复制代码

这样就搭了一个webpack + vue的工程,进入slide-demo目录,查看src/App.vue,这个文件是初始化工具提供的,是整个页面的组件。还有一个src/components目录,这个是放子组件的目录。vue

在这个目录里面新建3个组件:task-1.vue、task-2.vue、task-3.vue,而后在App.vue里面import进来,以下App.vue所示:java

<script> // import HelloWorld from './components/HelloWorld' import Task1 from "./components/task-1"; import Task2 from "./components/task-2"; import Task3 from "./components/task-3"; export default { name: 'App', components: { Task1, Task2, Task3 } } </script>复制代码

咱们的数据格式questions是这样的:webpack

[{index: 1, type: 1, content: ''}, {index: 2, type: 1, content: ''}, 
 {index: 3, type: 2, content: ''}, {index: 4, type: 3, content: ''}]复制代码

它是一个数组,数组里的每一个元素表明每道题,每道题都有一个类型,如选择题、填空题、判断题等,分别对应上面的task-一、task-二、task-3,咱们用一个currentIndex变量表示当前是在哪道题,初始化为0,以下代码所示(添加到App.vue里面):git

data() {
        return {
            currentIndex: 0
        };
    },
    created() {
        // 请求question数据
        this.questions = [
            {index: 1, type: 1, question: ''}, /*...*/];
    },复制代码

经过改变currentIndex的值,从而切到一下题即下一个组件,要怎么实现这个切换的效果呢?github

可使用Vue自定义的一个全局组件component,给合它的is属性,达到动态改变组件的目的,以下代码所示:web

<template> <div id="app"> <div class="task-container"> <component :is="'task-' + questions[currentIndex].type" </component> </div> </div> </template>复制代码

当currentIndex增长时,就会改变:is里面的值,依次从task-1变到task-二、task-3等,这样component就会换成相应的task组件。

接着,再添加一个切换到下一题的按钮,在这个按钮的响应函数里面改变currentIndex的值。同时把question的数据传给component:

<template> <div id="app"> <div class="task-container"> <component :is="'task-' + questions[currentIndex].type" :question="questions[currentIndex]"></component> <button class="next-question" @click="nextQuestion">下一题</button> </div> </div> </template>复制代码

响应函数nextQuestion实现以下:

methods: {
    nextQuestion() {
        this.currentIndex = (this.currentIndex + 1) 
               % this.questions.length;
    }
},复制代码

具体每一个task的实现参考如task-1.vue示例:

<template>
<section>
    <h2>{{question.index}}. 选择题</h2>
    <div>{{content}}</div>
</section>
</template>
<script>
export default {
    props: ["question"]
}
</script>复制代码

最后的效果以下(加上题目内容):


2. 添加轮播切换效果

轮播切换一般是把全部的slide都拼起来,拼成一张长长的横图,而后改变这个横图在显示容器里面的位置,如老牌jQuery插件flipsnap.js,它是把全部的slide都float: left,造成一张长图,而后改变这张长图的translate值,达到切换的目的。这个插件的缺点是没有办法从最后一张切回第一张,解决这个问题的方法之一是不断地移动DOM:每次切的时候都把第一张移到最后一张的后面,这样就实现了最后一张点下一张的时候回到第一张的目的,可是这样移来移去地对性能消耗比较大,不是很优雅。另一个轮播插件jssor slider,它也是把全部的slide都渲染出来,而后每次切换的时候都动态地计算每张slide的translate的值,而不是总体长图的位置,这样就不用移动DOM节点,相对较为优雅。还有不少Vue的轮播插件的实现也是相似上面提到的方式。

无论怎么样,上面的轮播模式都不太适用于咱们的场景,其中一个是这种答题的场景不须要切回上一题,每道题作完就不能回去了,更重要的一个是咱们不但愿一次性把全部的slide都渲染出来,这样会致使每张幻灯片里的资源都触发加载,就好比img标签虽然你把它display: none了,可是只要它的src是一个正常的url,它就会请求加载。 因为slide每每会比较多,就不使用这种轮播插件了。

还可使用Vue自带的transition,可是transition的问题是,切下一个的时候,上一个不见了,由于被销毁了,只有下一个的动画,而且不能预加载下一个slide的资源。

因此咱们手动实现一个。

个人想法是每次都准备两个slide,第1个slide是当前展现用的,第2个slide拼在它的后面,准备切过来,当第2个slide切过来以后,删掉第1个slide,而后在第2个的后面再接第3个slide,不断地重复这个过程。若是咱们没有使用Vue,而是本身增删DOM,那么没什么问题,能够很任性地本身发挥。使用Vue能够怎么优雅地实现这个功能呢

在上面一个component的基础上,再添加一个component,刚开始第1个component是当前展现的,而第2个component是拼在它右边的,当第2个切过去以后,就把第1个移到第2的后面,同时把内容改为第3个slide的内容,依此类推。使用Vue不太好动态地改DOM,可是能够借助jssor slider的思想,不移动DOM,只是改变component的translate的值。

给其中一个component套一个next-task的类,具备这个类的组件就表示它是下一张要出现的,它须要translateX(100%),以下代码所示:

<template> <div id="app"> <div class="task-container"> <component :is="'task-' + questions[currentIndex].type" ></component> <component :is="'task-' + questions[currentIndex + 1].type" class="next-task"></component> </div> </div> </template> <style> .next-task { display: none; transform: translateX(100%); /* 添加一个动画,当改变transform的值时就会触发这个动画 */ transition: transform 0.5s ease; } </style>复制代码

上面代码把具备.next-task类的component隐藏了,这样是作个优化,由于display: none的元素只会构建DOM,不会进行layout和render渲染。

因此就把问题转换成怎么在这两个component之间,切换next-task的类。一开始next-task是在第2个,当第2个切过来以后,next-task变成加在第1个上面,这样轮流交替。

进而,发现一个规律,若是currentIndex是偶数话,如o、二、4…,那么next-task是加在第2个component的,而若是currentIndex是奇数,则next-task是加在第1个component的。因此能够根据currentIndex的奇偶性切换。

以下代码所示:

<template> <div id="app"> <div class="task-container"> <component :is="'task-' + questions[evenIndex].type" :class="{'next-task': nextIndex === evenIndex}" ref="evenTask"></component> <component :is="'task-' + questions[oddIndex].type" :class="{'next-task': nextIndex === oddIndex} ref="oddTask"></component> </div> </div> </template> <script> export default { name: 'App', data() { return { currentIndex: 0, // 当前显示的index nextIndex: 1, // 表示下一张index,值为currentIndex + 1 evenIndex: 0, // 偶数的index,值为currentIndex或者是currentIndex + 1 oddIndex: 1 // 奇数的index,值同上 }; }, }复制代码

第1个component用来显示偶数的slide,第2个是用来显示奇数的slide(分别用一个evenIndex和oddIndex表明),若是nextIndex是偶数的,那么偶数的component就会有一个next-task的类,反之则亦然。而后在下一题按钮的响应函数里面改变这几个index的值:

methods: {
    nextQuestion() {
        this.currentIndex = (this.currentIndex + 1) 
            % this.questions.length;
        this._slideToNext();
    },
    // 切到下一步的动画效果
    _slideToNext() {

    }
}复制代码

nextQuestion函数可能还有其它一些处理,在它里面调一下_slideToNext函数,这个函数的实现以下:

_slideToNext() {
    // 当前slide的类型(currentIndex已经加1了,这里要反一下)
    let currentType = this.currentIndex % 2 ? "even" : "odd",
        // 下一个slide的类型
        nextType =  this.currentIndex % 2 ? "odd": "even";
    // 获取下一个slide的dom元素
    let $nextSlide = this.$refs[`${nextType}Task`].$el;
    $nextSlide.style.display = "block";
    // 把下一个slide的translate值置为0,本来是translateX(100%)
    $nextSlide.style.transform = "translateX(0)";
    // 等动画结束后更新数据
    setTimeout(() => {
        this.nextIndex = (this.currentIndex + 1) 
            % this.questions.length;
        // 本来的next是当前显示的slide
        this[`${nextType}Index`] = this.currentIndex;
        // 而本来的current slide要显示下下张的内容了
        this[`${currentType}Index`] = this.nextIndex;
    }, 500);
}复制代码

代码把下一个slide的display改为block,并把它的translateX的值置为0,这个时候不能立刻更新数据触发DOM更新,要等到下一个slide移过去的动画结束以后再开始操做,因此加了一个setTimeout,在回调里面调换nextTask的类,加到本来的current slide,并把它的内容置成下下张的内容。这些都是经过改变相应的index完成的。

这样基本上就完成了,可是咱们发现一个问题,切是切过去了,就是没有动画效果。这个是由于从display: none变到display: block是没有动画的,要么改为visibility: hidden到visible,要么触发动画的操做加到$nextTick或者setTimeout 0里面,考虑到性能问题,这里使用第二种方案:

$nextSlide.style.display = "block";
// 这里使用setimeout,由于$nextTick有时候没有动画,非必现
setTimeout(() => {
    $nextSlide.style.transform = "translateX(0)";
    // ...
}, 0);复制代码

通过这样的处理以后,点下一题就有动画了,可是又发现一个问题,就是偶数的next-task会被盖住,由于偶数的是使用第一个component,它是会被第二个compoent盖住的,因此须要给它加一个z-index:

.next-task { 
    display: none;
    transform: translateX(100%);
    transition: transform 0.5s ease;
    z-index: 2;
}复制代码

这个问题还比较好处理,另一个不太好处理的问题是:动画的时间是0.5s,若是用户点下一题的速度很快在0.5s以内,上面的代码执行就会有问题,会致使数据错乱。若是每次切到下一题以后按钮初始化都是disabled,由于当前题还没答,只有答了才能变成可点状态,能够保证0.5s的时间是够的,那么就能够不用考虑这种状况。可是若是须要处理这种状况呢?

3. 解决点击过快的问题

我想到两个方法,第一个方法是用一个sliding的变量标志当前是不是在进行切换的动画,若是是的话,点击按钮的时候就直接更新数据,同时把setTimeout 0.5s的计时器清掉。这个方法能够解决数据错乱的问题,可是切换的效果没有了,或者是切换到一半的时候忽然就没了,这样体验不是很好。

第二个方法是延后切换,即若是用户点击过快的时候,把这些操做排队,等着一个个作切换的动画。

咱们用一个数组表示队列,若是当前已经在作滑动的动画,则入队暂不执行动画,以下代码所示:

methods: {
    nextQuestion() {
        this.currentIndex = (this.currentIndex + 1) 
            % this.questions.length;
        // 把currentIndex插到队首
        this.slideQueue.unshift(this.currentIndex);
        // 若是当前没有滑动,则执行滑动
        !this.sliding && this._slideToNext();
    },
}复制代码

每次点击按钮都把待处理的currentIndex插到队列里面,若是当前已经在滑动了,则不马上执行,不然执行滑动_slideToNext函数:

_slideToNext() {
    // 取出下一个要处理的元素
    let currentIndex = this.slideQueue.pop();
    // 下一个slide的类型
    let nextType =  currentIndex % 2 ? "odd" : "even";
    let $nextSlide = this.$refs[`${nextType}Task`].$el;
    $nextSlide.style.display = "block";
    setTimeout(() => {
        $nextSlide.style.transform = "translateX(0)";
        this.sliding = true;
        setTimeout(() => {
            this._updateData(currentIndex);
            // 若是当前还有未处理的元素,
            // 则继续处理即继续滑动
            if (this.slideQueue.length) {
                // 要等到两个component的DOM更新了
                this.$nextTick(this._slideToNext);
            } else {
                this.sliding = false;
            }
        }, 500);
    }, 0);
},复制代码

这个函数每次都先取出当前要处理的currentIndex,而后接下来的操做和第2点提到的同样,只是在0.5s动画结束后的异步回调里面须要判断一下,当前队列是否还有未处理的元素,若是有的话,须要继续执行_slideToNext,直到队列空了。这个执行须要挂在nextTick里面,由于须要等到两个component的DOM更新了才能操做。

这样理论上就没问题了,但实际上仍是有问题,感觉以下:


咱们发现有些slide没有过渡效果,并且不是非必现的,没有规律。通过一番排查,发现若是把上面的nextTick改为setTimeout状况就会好一些,而且setTimeout的时间越长,就越不会出现失去过渡效果的状况。可是这个不能从根本上解决问题,这里的缘由应该是Vue的自动更新DOM和transition动画不是很兼容,有多是Vue的异步机制问题,也有多是JS结合transition自己就有问题,但之前没有遇到过,具体没有深刻排查。无论怎么样,只能放弃使用CSS的transition作动画。

若是有使用jQuery的话,可使用jQuery的animation,若是没有的话,那么可使用原生dom的animate函数,以下代码所示:

_slideToNext(fast = false) {
    let currentIndex = this.slideQueue.pop();
    // 下一个slide的类型
    let nextType =  currentIndex % 2 ? "odd" : "even";
    // 获取下一个slide的dom元素
    let $nextSlide = this.$refs[`${nextType}Task`].$el;
    $nextSlide.style.display = "block";
    this.sliding = true;
    // 使用原生animate函数
    $nextSlide.animate([
        // 关键帧
        {transform: "translateX(100%)"},
        {transform: "translateX(0)"}
    ], {
        duration: fast ? 200 : 500,
        iteration: 1,
        easing: "ease"
    // 返回一个Animate对象,它有一个onfinish的回调
    }).onfinish = () => {
        // 等动画结束后更新数据
        this._updateData(currentIndex);
        if (this.slideQueue.length) {
            this.$nextTick(() => {
                this._slideToNext(true);
            });
        } else {
            this.sliding = false;
        }
    };
},复制代码

使用animate函数达到了和transition一样的效果,而且还有一个onfinish的动画结束回调函数。上面代码还作了一个优化,若是用户点得很快的时候,缩短过渡动画的时间,让它切得更快一点,这样看起来更天然一点。使用这样的方式,就不会出现transition的问题了。最后的效果以下:


这个体验感受已经比较流畅了。

原生animate不兼容IE/Edge/Safari,能够装一个polyfill的库,如这个web-animation,或者使用其它一些第三方的动画库,或本身用setInterval写一个。

若是你要加上一题的按钮,支持返回上一题,那么可能须要准备3个component,中间那个用于显示,左右两边各跟着一个,准备随时切过来。具体读者能够自行尝试。

这种模式除了答题的场景,还有多封邮件预览、PPT展现等均可以用到,它除了有一个过渡的效果外,还能提早预加载下一个slide须要的图片、音频、视频等资源,而且不会像传统的轮播插件那样一会儿把全部的slide都渲染了。适用于slide比较多的状况,不须要太复杂的切换动画。


【号外】《高效前端》已上市,京东、亚马逊、淘宝等均有售

相关文章
相关标签/搜索