Vue.js 系列教程 5:动画
译者:nzbin
译者的话:经过两周的努力,终于完成了这个系列的翻译,由于时间因素及个人水平有限,并没有详细的校对,其中仍然有很多不易理解的地方。我和原作者的初衷一样,希望大家能够通过这个系列文章有所收获,至少可以增加学习的乐趣,我也在学习的路上,所学心得必将与大家共勉。
这是 JavaScript 框架 Vue.js 五篇教程的第五部分。在这个系列的最后一部分,我们将学习动画(如果你了解我,你知道这一章迟早会来)。这个系列教程并不是一个完整的用户手册,而是通过基础知识让你快速了解 Vuejs 以及它的用途。
系列文章:
- 渲染, 指令, 事件
- 组件, Props, Slots
- Vue-cli
- Vuex
- 动画 (你在这!)
背景知识
内置的 <transition>
和 <transition-group>
组件同时支持 CSS 和 JS 钩子。如果你熟悉 React , transition 组件的概念对你并不陌生,因为在生命周期钩子中,它与 ReactCSSTransitionGroup
类似,但也有显著的差异 ,这让书呆子的我很兴奋。
我们先讨论 CSS 过渡,然后再讨论 CSS 动画,之后介绍 JS 动画钩子以及动画的生命周期方法。过渡状态超出了本文的范围,但这是可能的。这是我为此做的一个评价不错的例子 。只要能得到充足的休息,我确信会写那篇文章。
过渡 vs. 动画
你可能不明白为什么过渡和动画在这篇文章中分成了不同的部分,让我解释一下,虽然它们很相似,但也有不同的地方。过渡就是从一个状态向另一个状态插入值。我们可以做很多复杂的事情,但是很简单。从起始状态,到结束状态,再回来。
动画有点不同,你可以在一个声明中设置多个状态。比如,你可以在动画 50% 的位置设置一个关键帧,然后在 70% 的位置设置一个完全不同的状态,等等。你可以通过设置延迟属性实现很复杂的运动。动画也可以实现过渡的功能,只需要从头到尾插入状态,但是过渡无法像动画一样插入多个值。
在工具方面,两者都是有用的。过渡如同一把“锯”而动画如同“电锯”。有时你需要明白一件事,购买昂贵的设备可能是愚蠢的。对于大型项目,投资“电锯”更有意义。
了解了这些知识之后,再来讨论 Vue!
CSS 过渡
假设有一个简单的模态窗。通过点击按钮显示或隐藏模态窗。根据前面的部分, 我们可以这样做:创建一个按钮的 Vue 实例,在实例中创建一个子组件,设置数据的状态,这样可以通过切换布尔值并添加事件处理实现子组件的显示及隐藏。我们可以使用 v-if
或者 v-show
来切换组件可见性。也可以使用 slot 放置模态窗的切换按钮。
<div id="app"> <h3>Let's trigger this here modal!</h3> <button @click="toggleShow"> <span v-if="isShowing">Hide child</span> <span v-else>Show child</span> </button> <app-child v-if="isShowing" class="modal"> <button @click="toggleShow"> Close </button> </app-child> </div> <script type="text/x-template" id="childarea"> <div> <h2>Here I am!</h2> <slot></slot> </div> </script>
const Child = { template: '#childarea' }; new Vue({ el: '#app', data() { return { isShowing: false } }, methods: { toggleShow() { this.isShowing = !this.isShowing; } }, components: { appChild: Child } });
可以正常工作,但是这样的模态窗有点不和谐。 😳
我们已经使用 v-if
实现组件的加载及卸载,因此我们如果在过渡组件上添加条件,Vue 可以跟踪事件变化:
<transition name="fade"> <app-child v-if="isShowing" class="modal"> <button @click="toggleShow"> Close </button> </app-child> </transition>
现在,我们可以使用现成的 <transition>
组件。过渡钩子会添加 v-
前缀,我们可以在 CSS 中使用。其中 enter/leave
定义动画开始第一帧的位置, enter-active/leave-active
定义动画运行阶段—— 你需要把动画属性放在这里, enter-to/leave-to
指定元素在最后一帧上的位置。
我打算使用官网文档中的示意图说明,因为我认为它把类名描述的直观清晰:
就我个人而言,我并不经常使用默认的 v-
前缀。我经常给过渡命名,这样如果我想应用到另一个动画时就不会有冲突。这是不难做到的,正如你所看到的,我们只是简单地给过渡组件添加了一个 name
属性: name="fade"
。
既然有了钩子,我们就可以利用它们创建过渡:
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s ease-out; } .fade-enter, .fade-leave-to { opacity: 0; }
.fade-enter-active
和 .fade-leave-active
类将会应用到实际的过渡中。这是普通的 CSS ,你可以在过渡中使用 cubic-beziers 实现 eases, delays, 或者指定其它属性。其实,如果把这些类的过渡属性放到组件的类中作为默认设置,也同样有效。这些并不一需要由过渡组件钩子来定义。它们只是静静地等待组件的变化然后将变化添加到过渡中 ( 因此你仍然需要过渡组件以及 .fade-enter ,.fade-leave-to )。我使用 enter-active 和 leave-active 类的原因是我可以在其它元素上重用这些过渡属性,而不需要在每个实例上应用同样的 CSS 。
需要注意的另外一点:我在每一个 active 类上都使用了 ease-out
属性。这些属性只适用于透明元素。但是如果你使用了过渡属性比如 transform ,你可能想把两者分开, 将 ease-out
应用于 enter-active 类而将 ease-in
应用于 enter-leave 类 (或者大致表现相同曲线的 cubic-beziers )。我发现它使动画看起来更…优雅的(哈哈)。
你也注意到我将 .fade-enter 和 the .fade-to 属性设置为 opacity: 0
。这是动画的初始和结束位置,载入时的初始状态,卸载时的结束状态。你可能认为 .fade-enter-to
和 .fade-leave
应该设置 opacity: 1
。但是没有必要,因为它是组件的默认状态,所以这将是多余的。CSS 过渡和动画如果没有设置,总是会使用默认状态。
运行很好!但是,如果我们想使背景内容淡出视野,使模态窗居中显示而背景丢失焦点,会发生什么呢? 我们不能使用 <transition>
组件,因为组件是基于被加载或被卸载的部分工作的,而背景只是围绕在周围。我们可以使用基于状态的过渡类,使用类改变 CSS 过渡来变换背景:
<div v-bind:class="[isShowing ? blurClass : '', bkClass]"> <h3>Let's trigger this here modal!</h3> <button @click="toggleShow"> <span v-if="isShowing">Hide child</span> <span v-else>Show child</span> </button> </div>
new Vue({ el: '#app', data() { return { isShowing: false, bkClass: 'bk', blurClass: 'blur' } }, ... });
CSS 动画
既然已经了解了过渡(transitions)的工作原理,我们可以通过这些核心概念创建不错的 CSS 动画。我们仍然使用 <transition>
组件,并且给它命名,这样就可以使用类钩子(class hooks)了。动画和过渡的区别并不仅仅是设置最终的状态或者在开始和结束之间插入状态,我们将使用 CSS 中的 @keyframes
创建有趣可爱的效果。
在上一部分中,我们讲了可以给 transition 组件起一个特殊的名字,这样可以作为类钩子使用。但是在这一部分中,我们将进一步, 在不同的动画中应用不同的类钩子。你可能还记得所有有趣的动画都是基于 enter-active 和 leave-active 。我们可以给每一个类钩子设置不同的属性,但是我们可以进一步给每个实例一个特殊的类 :
enter-active-class="toasty"
leave-active-class="bounceOut"
这意味着我们可以重用这些类,甚至可以设置 CSS 动画库中的类。
比如我们希望一个小球弹进来再滚出去:
<div id="app"> <h3>Bounce the Ball!</h3> <button @click="toggleShow"> <span v-if="isShowing">Get it gone!</span> <span v-else>Here we go!</span> </button> <transition name="ballmove" enter-active-class="bouncein" leave-active-class="rollout"> <div v-if="isShowing"> <app-child class="child"></app-child> </div> </transition> </div>
对于反弹动画,如果使用 CSS 的话,我们需要设置大量关键帧(而使用 JS 只需要一行代码),我们会使用 SASS mixin 保持样式的简练 (无需重复设置)。为了让小球组件从屏幕外开始,我们设置了一个 .ballmove-enter
的类:
@mixin ballb($yaxis: 0) { transform: translate3d(0, $yaxis, 0); } @keyframes bouncein { 1% { @include ballb(-400px); } 20%, 40%, 60%, 80%, 95%, 99%, 100% { @include ballb() } 30% { @include ballb(-80px); } 50% { @include ballb(-40px); } 70% { @include ballb(-30px); } 90% { @include ballb(-15px); } 97% { @include ballb(-10px); } } .bouncein { animation: bouncein 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; } .ballmove-enter { @include ballb(-400px); }
对于小球滚出动画,我们需要创建两个不同的动画。这是因为 transform 会应用于整个子组件,那样的话整个组件都会旋转。所以我们使用 translation 将组件移出屏幕, 通过 rotation 给小球添加旋转:
@keyframes rollout { 0% { transform: translate3d(0, 300px, 0); } 100% { transform: translate3d(1000px, 300px, 0); } } @keyframes ballroll { 0% { transform: rotate(0); } 100% { transform: rotate(1000deg); } } .rollout { width: 60px; height: 60px; animation: rollout 2s cubic-bezier(0.55, 0.085, 0.68, 0.53) both; div { animation: ballroll 2s cubic-bezier(0.55, 0.085, 0.68, 0.53) both; } }
过渡模式
你是否还记得我说过 Vue 在过渡中提供了好用的功能让我这个书呆子很高兴?这是我非常喜欢的一点。如果一个组件过渡离开的时候,你给另一个组件添加过渡,你将在一个奇怪的时刻看到两个组件同时存在,然后又迅速回到原位(这是 Vue 文档中的例子):
Vue 提供了过渡模式,这样当一个组件过渡出去的时候,另一个过渡进来的组件并不会有奇怪的位置的闪动或阻塞。其原因就是通过有序的过渡而不是同时发生。有两种模式可供选择:
In-out: 新元素先进行过渡,完成之后当前元素过渡离开。
Out-in: 当前元素先进行过渡,完成之后新元素过渡进入。
看看下面的例子。你可以观察过渡组件的- out-in
模式,只有当一张图片翻过去之后,组件才会出现:
<transition name="flip" mode="out-in"> <slot v-if="!isShowing"></slot> <img v-else src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/28963/cartoonvideo14.jpeg" /> </transition>
如果我们去掉这种过渡模式,你会看到一部分翻转的时候会挡住另一部分,而且动画有些不协调,这并不是我们想要的效果:
JS 动画
有很多适合我们动画的易于使用的 JS 钩子。所有的钩子都会传入 el
参数 ( element 的缩写) ,除了动画钩子(enter 和 leave),还会传入 done
作为参数,正如你所猜的,它的作用就是告知 Vue 动画结束。你会注意到我们给 CSS 绑定了 false 值,这是为了让组件知道我们将使用 JavaScript 而不是 CSS 。
<transition @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter" @enter-cancelled="enterCancelled" @before-Leave="beforeLeave" @leave="leave" @after-leave="afterLeave" @leave-cancelled="leaveCancelled" :css="false"> </transition>
从最基本的层面看,这是开始动画和结束动画所需要的,包括相关的方法:
methods: { enterEl(el, done) { //entrance animation done(); }, leaveEl(el, done) { //exit animation done(); }, }
在下面是例子中,我在钩子中接入了一个 GreenSock timeline:
new Vue({ el: '#app', data() { return { message: 'This is a good place to type things.', load: false } }, methods: { beforeEnter(el) { TweenMax.set(el, { transformPerspective: 600, perspective: 300, transformStyle: "preserve-3d", autoAlpha: 1 }); }, enter(el, done) { ... tl.add("drop"); for (var i = 0; i < wordCount; i++) { tl.from(split.words[i], 1.5, { z: Math.floor(Math.random() * (1 + 150 - -150) + -150), ease: Bounce.easeOut }, "drop+=0." + (i/ 0.5)); ... } } });
在上面的动画中注意两个有趣的事情,我向 Timeline 实例中传递 {onComplete:done}
作为参数,并且我使用 beforeEnter
钩子来放置 TweenMax.set
代码,这允许我在动画开始前对单词设置任意属性,这种情况类似 transform-style: preserve-3d
。
很重要的一点是,你也可以直接在 CSS 中为动画设置你想要的默认状态。有人问我如何决定是在 CSS 中还是在 TweenMax.set
中设置属性。根据经验来说,我通常把我需要的一些动画的特殊属性设置在 TweenMax.set
中。这样,如果动画中的某些东西发生变化而我需要更新的话,它已经在我的工作流程中。
动画中的生命周期钩子
一切都很好,但是如果动画很复杂,需要操作大量 DOM 元素会怎样?现在就是使用生命周期方法的最佳时机。在下面的例子中,我们使用了 <transition>
组件以及 mounted()
方法来创建动画。
如果我们给一个单独的元素添加过渡,我们将使用 transition 组件,比如,当电话按钮周围的线条显示的时候:
<transition @before-enter="beforeEnterStroke" @enter="enterStroke" :css="false" appear> <path class="main-button" d="M413,272.2c5.1,1.4,7.2,4.7,4.7,7.4s-8.7,3.8-13.8,2.5-7.2-4.7-4.7-7.4S407.9,270.9,413,272.2Z" transform="translate(0 58)" fill="none"/> </transition>
beforeEnterStroke(el) { el.style.strokeWidth = 0; el.style.stroke = 'orange'; }, enterStroke(el, done) { const tl = new TimelineMax({ onComplete: done }); tl.to(el, 0.75, { strokeWidth: 1, ease: Circ.easeOut }, 1); tl.to(el, 4, { strokeWidth: 0, opacity: 0, ease: Sine.easeOut }); },
但是当一个组件首次显示的时候,会有 30 个元素以及更多的动画,把每一个都放进 transition
组件中效率较低。所以,我们将使用第三部分提到的生命周期钩子绑定和 transition 钩子使用的相同事件: mounted()
const Tornadoarea = { template: '#tornadoarea', mounted () { let audio = new Audio('https://s3-us-west-2.amazonaws.com/s.cdpn.io/28963/tornado.mp3'), tl = new TimelineMax(); audio.play(); tl.add("tornado"); //tornado timeline begins tl.staggerFromTo(".tornado-group ellipse", 1, { opacity: 0 }, { opacity: 1, ease: Sine.easeOut }, 0.15, "tornado"); ... } };
我们可以使用更有效率的方法以及创建复杂的效果。Vue 提供了直观灵活的 API ,不只是创建组件化的前端架构,还有流畅的运动和视图间的无缝衔接。
总结
这个系列的文章并不打算成为文档。虽然我们已经讲了很多,但仍然还有很多没有涉及的内容:路由、mixins、服务端渲染等等。有如此多的令人称奇的东西可以使用。深入研究的话可以看 详细的官方文档 ,这个仓库中有很全的 例子和资源 。 有一本名为 The Majesty of Vue.js 的书,还有 Egghead.io 和 Udemy 上面的课程。
感谢 Robin Rendle、Chris Coyier、Blake Newman 及 Evan You 对本系列各部分的校对。我希望这个系列可以解释为什么我对 Vue 如此兴奋,并且帮助你入门以及尝试新鲜东西。