深入解析 Vue3 过渡动画(文末附视差滚动的实现)

643 阅读10分钟

在普通的项目中,想实现过渡动画我们可能需要自己定义些 css 的类,写好样式,然后在适当的实际添加或移除对应的类名。而在基于 vue 的项目中,我们可以使用 vue 提供的内置组件 <Transition><TransitionGroup> 来实现过渡效果。

<Transition>

<Transition> 适用于给单个元素或组件添加过渡动画,如果传入 <Transition> 的是组件,该组件必须只有一个根元素。

通过 css

比如现有一个 div 元素,可以通过按钮来切换其显示与隐藏,要想让切换不过于生硬,就在 div 外包裹上 <transition> 组件,并且给过渡效果取名为 msg

<!-- 例 1 -->
<script setup lang="ts">
import { ref } from 'vue'
const showMsg = ref(true)
</script>
<template>
  <div>
    <transition name="msg">
      <div v-show="showMsg">
        Hello Juejin!
      </div>
    </transition>
    <button @click="showMsg = !showMsg">按钮</button>
  </div>
</template>

然后我们就可以根据过渡效果名,定义 css 的过渡或者动画:

css 过渡

/* 例 1.1 */
.msg-enter-from,
.msg-leave-to {
  opacity: 0;
}

.msg-enter-active,
.msg-leave-active {
  transition: opacity 2s ease;
}

如果不给 <transition> 定义 name 属性,则上面这些 class 的名字默认都是 v-开头, 比如 v-enter-from

  • msg-enter-from 用于定义元素插入/显示前的样式;

  • msg-leave-to 用于定义元素移除/隐藏后的样式。

这里也可以定义 msg-enter-to 来描述元素插入/显示后的样式和 msg-leave-from 来描述移除/隐藏前的样式:

.msg-enter-to,
.msg-leave-from {
  opacity: 1;
}

但是因为本案例 v-showtrue 时元素默认的 opacity 值就是 1,所以可以忽略不写。

msg-enter-activemsg-leave-active 则用于描述过渡的生效状态,比如指定参与过渡的属性,过渡持续的时间以及速度曲线类型。更多具体解释可参见官方文档

css 动画

除了先定义好 enter 和 leave 的状态然后通过 transition 实现过渡效果,我们也可以通过定义帧动画,使用 animation 来实现:

/* 例 1.2 */
@keyframes opacity-in {
  0% {
    opacity: 0;
  }

  100% {
    opacity: 1;
  }
}

接着就可以分别定义元素进入和离开时要应用的动画了,其中 reverse 表明对动画进行反转:

/* 例 1.2.1 */
.msg-enter-active{
  animation: opacity-in 2s ease
}

.msg-leave-active {
  animation: opacity-in 2s ease reverse
}
结合 animate.css 使用

在项目中我们可以结合第三方动画库 animate.css 来方便地实现诸多动画。

先安装:

pnpm add animate.css

在 main.js 导入

// src\main.js
import 'animate.css'

使用时可以直接将例 1.2.1 中 animation 的 name 值替换为 animate.css 的。(如果你发现动画很奇怪,可能是应用动画的内容为块级元素,默认宽度为 100% 所致,可以尝试添加样式 display: inline-block 后查看):

.msg-enter-active{
  animation: bounceIn 1s ease
}
.msg-leave-active {
  animation: bounceOutDown 1s ease
}

注意,如果你是去 animate.css 的官网查看的动画预览,上面这种用法我们可以复制如下图中画了横线的动画名字,而不是直接点击后面那个箭头指向的复制图标:

因为查看 animate.css 源码可知,横线所示的就是帧动画定义的名字:

如果直接点击复制图标,复制内容为类名,比如 animate__bounceInDown,查看源码可知其已经通过 animation-name 应用了帧动画 bounceOutDown

/* node_modules\animate.css\animate.css */
.animate__bounceOutDown {
  -webkit-animation-name: bounceOutDown;
  animation-name: bounceOutDown;
}

那么我们直接在 <Transition> 上通过对应的属性(自定义过渡 class)来传递类名即可。

自定义过渡 class

我们可以使用如下属性来自定义相应阶段的默认 class 名:

  • enter-from-class
  • enter-active-class
  • enter-to-class
  • leave-from-class
  • leave-active-class
  • leave-to-class

比如下例,我们使用了 enter-active-classleave-active-class 来自定义过渡的 class,结合 animate.css,就可以将上面所说的通过点击复制图标得到的类名传入:

<transition 
  enter-active-class="animate__animated animate__bounceIn" 
  leave-active-class="animate__animated animate__bounceOutDown" 
  mode="out-in" 
  appear
>

请注意,还需要传入 animate__animated,因为诸如 animate__bounceIn 这些动画类名只定义了 animation-name,而像 animation-duration 这些属性是定义在 animate__animated 中的:

/* node_modules\animate.css\animate.css */
.animate__animated {
  -webkit-animation-duration: 1s;
  animation-duration: 1s;
  -webkit-animation-duration: var(--animate-duration);
  animation-duration: var(--animate-duration);
  -webkit-animation-fill-mode: both;
  animation-fill-mode: both;
}

css 过渡和动画同时存在

如下, css 过渡和动画可以同时存在:

/* 例 1.3 */
/* transition */
.msg-enter-from,
.msg-leave-to {
  opacity: 0;
}
.msg-enter-active,
.msg-leave-active {
  transition: opacity 1s ease;
}

/* animation */
.msg-enter-active{
  animation: bounce-in 2s ease
}
.msg-leave-active {
  animation: bounce-in 2s ease reverse
}
@keyframes bounce-in {
  0% {
    transform: scale(0);
    transform-origin:left top
  }

  100% {
    transform: scale(1);
    transform-origin:left top
  }
}

但因为 vue 需要在适当的实际添加或移除类 msg-enter-active/ msg-leave-active,可以看到 transition 定义的动画持续时间为 1s,而 animation 的为 2s,那么为了告诉 vue 到底以哪个为准,可以在 <transition> 添加 type 属性来指定:

<transition name="msg" type="animation">
<!-- 或 -->
<transition name="msg" type="transition">

通过 js

官方文档介绍

通过 css 设置动画有个不足之处是不够灵活,我们可以通过 <Transition> 组件提供的一些钩子函数,通过 js 来设置动画,这样就可以动态地设置动画了。下面举两个常用的钩子为例:

<!-- 例 1.4 -->
<template>
  <div>
    <transition 
      @enter="onEnter" 
      @leave="onLeave"
      mode="out-in" 
      appear
      :css="false"
    >
      <div v-if="showMsg">
        Hello Juejin!
      </div>
      <div v-else>
        又是进步的一天
      </div>
    </transition>
    <button @click="showMsg = !showMsg">按钮</button>
  </div>
</template>
  • enter 在动画开始进入时触发;
  • leave 在动画开始离开时触发;
  • :css="false" 是为了让 Vue 跳过对 CSS 动画的自动探测。

结合 GSAP 使用

在定义 js 动画时,我们可以借助第三方库 GSAP(GreenSock Animation Platform) 来实现。

安装:

pnpm add gsap

使用时导入并调用相关方法,下面介绍 gsap.from()gsap.to()

<script setup lang="ts">
import { ref } from 'vue'
import gsap from 'gsap'

const showMsg = ref(true)
const onEnter = (el: Element, done: () => void) => {
  gsap.from(el, {
    opacity: 0,
    x: -100,
    onComplete: done
  })
}

const onLeave = (el: Element, done: () => void) => {
  gsap.to(el, {
    opacity: 0,
    x: -100,
    onComplete: done
  })
}
</script>

gsap.from() 方法用于设置目标元素从什么状态变为显示时的默认状态。传入 2 个参数,第 1 个是目标元素,也就是 vue 往钩子函数中传入的 el,第 2 参数是一个对象,用于设置一些变量:

  • x 相当于是 css 的 transform: translateX(100px)
  • onComplete表明动画执行完毕。因为例 1.4 我们给 <Transition> 设置了 :css="false",所以这里必须调用 done 告诉 vue 动画何时完成,否则钩子将被同步调用,过渡将立即完成。

gsap.to() 方法则用于定义目标元素从当前显示时的状态离开后要变成的状态。

效果如下:

GSAP 实现数字动画

这里趁便介绍一个与 <Transition> 无关的关于用 gsap 实现数字变化的动画的小案例:

<script setup lang="ts">
import { ref, watch } from 'vue'
import gsap from 'gsap'

const num = ref(0)
const displayNum = ref(0)

watch(num, newVal => {
  const animationTarget = { value: displayNum.value }
  gsap.to(animationTarget, {
    value: newVal,
    duration: 1,
    onUpdate: () => {
      displayNum.value = animationTarget.value
    },
    ease: 'power1.out'
  })
})
</script>

<template>
  <div>
    <input v-model="num">
    <div>{{ Math.round(displayNum) }}</div>
  </div>
</template>

使用 animationTarget 作为 GSAP 动画的中间对象,在 onUpdate 中同步更新 displayNum.value,实现动画过渡效果,ease 的可选值可以参看 GSAP 的文档

效果如下:

元素/组件间过渡

元素间过渡

前面说过 <Transition> 只适用于给单个元素或组件添加过渡动画,但是只要保证任一时刻只会有一个元素被渲染,那么也是可以在 <Transition> 内放入多个元素的:

<!-- 例 2 -->
<transition name="msg" type="animation">
  <div v-if="showMsg">
    Hello Juejin!
  </div>
  <div v-else>
    又是进步的一天
  </div>
</transition>

默认情况下,进入和离开的元素是同时开始动画的:

如果我们想让进入动画在离开动画执行完毕后执行,可以给 <Transition> 添加 mode 属性为 out-in

<transition name="msg" mode="out-in">

现在效果如下:

组件间过渡

在例 1 中我们是使用 v-show 触发的切换过渡动画,例 2 中使用 v-ifv-else 触发了元素间的过渡动画。我们还可以通过 vue 提供的动态组件元素 <component> 来实现组件间的切换过渡效果:

<script setup lang="ts">
import { ref } from 'vue'
import ComA from './components/ComA.vue'
import ComB from './components/ComB.vue'

// 使用 ref 定义响应式变量并添加类型注解(限制只能为 'ComA' 或 'ComB')
const currentCom = ref<'ComA' | 'ComB'>('ComA')
const components = {
  ComA,
  ComB
}
</script>

<template>
  <div>
    <transition name="msg" mode="out-in">
      <component :is="components[currentCom]"></component>
    </transition>
    <button @click="currentCom = currentCom === 'ComA' ? 'ComB' : 'ComA'">
      按钮
    </button>
  </div>
</template>

初次渲染时显示动画

上面这些动画都是在元素/组件发生切换时才生效,如果想在初次渲染时就显示过渡动画,可以在 <transition> 添加 appear 属性:

<transition name="msg" appear>

原理

当我们提供给 <transition> 的内容发生了由以下条件触发的显示隐藏的切换:

  • v-if
  • v-show
  • 动态组件 <component> 切换;
  • 在 nuxt 项目中,给 <nuxt /> 外面包一层 <transition> 也会触发动画。

vue 就会自动检测我们是否定义了对应的 css 过渡/动画,比如上文例 1.1 中的 msg-enter-frommsg-enter-active 等过渡。如果有,则会在适当的时机添加或移除这些类;

如果 <transition> 提供了 js 钩子函数,则在适当时机调用这些钩子函数;

如果既没有定义 css 过渡/动画,也没有定义 js 钩子,则在浏览器的下一个动画帧后执行执行对 DOM 的切换操作,可以看成是立即执行。

<TransitionGroup>

<TransitionGroup> 也是 vue 的内置组件,用于对 v-for 列表中的元素或组件的插入移除顺序改变添加动画效果。在默认情况下,<TransitionGroup> 不会渲染一个容器元素,但是可以通过 tag 属性来指定一个元素作为容器渲染,内部元素需要提供唯一的 key。比如下例中,就没有直接写一个 <ul> 标签来包裹 <li>,而是通过 tag 指定:

<script setup lang="ts">
import { ref } from 'vue'

const list = ref<number[]>([])
let num = 0
const handleAdd = () => {
  list.value.unshift(num++)
}
const handleSub = () => {
  list.value.shift()
}
</script>

<template>
  <div>
    <transition-group tag="ul" name="list">
      <li v-for="item in list" :key="item">{{ item }}</li>
    </transition-group>
    <button @click="handleAdd">添加</button>
    <button @click="handleSub">减少</button>
  </div>
</template>

<TransitionGroup>的属性、通过 css 或 js 实现过渡动画的方法基本上和 <Transition> 一样,但也有些区别,比如 <TransitionGroup> 的 css 过渡的类是应用于内部元素上,而不是容器元素。

v-move

<TransitionGroup> 还提供了 v-move 类来对移动中的元素应用的过渡,以实现在我们添加或移除元素时,其它元素能有个平稳的过渡动画:

<style scoped>
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(50px);
}

.list-move, /* 对移动中的元素应用的过渡 */
.list-enter-active,
.list-leave-active {
  transition: all 2s ease;
}

.list-leave-active {
  position: absolute;
}
</style>

最后给 list-leave-activeposition 设定为 absolute 是为了在移除元素时,其它元素无需等待被移除元素移除后再开始动画,否则被移除元素本身占着位置其它元素是无法移动过去的。

效果如下:

渐进延迟

结合 gsap 的 delay 属性与元素的 data 属性,我们可以做一个列表在一次性离开多个元素时,让每个元素是渐进离开的:

<script setup lang="ts">
import gsap from 'gsap'
import { ref } from 'vue'

const list = ref<number[]>([])
let num = 0
const handleAdd = () => {
  list.value.unshift(num++)
}
const handleSubMul = () => {
  list.value.splice(0, 3)
}
const onEnter = (el: Element, done: () => void) => {
  gsap.to(el, {
    duration: 2,
    opacity: 1,
    height: '1.5em',
    onComplete: done
  })
}
const onLeave = (el: Element, done: () => void) => {
  const index = parseFloat((el as HTMLElement).dataset.index || '0')
  gsap.to(el, {
    opacity: 0,
    height: 0,
    delay: index * 0.5,
    onComplete: done
  })
}
const onBeforeEnter = (el: Element) => {
  ;(el as HTMLElement).style.opacity = '0'
  ;(el as HTMLElement).style.height = '0'
}
</script>

<template>
  <transition-group
    tag="ul"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @leave="onLeave"
  >
    <li v-for="(item, index) in list" :key="item" :data-index="index">
      {{ item }}
    </li>
  </transition-group>
  <button @click="handleAdd">添加</button>
  <button @click="handleSubMul">减少多个</button>
</template>

我们在 <li> 上绑定了 :data-index="index",然后我们在 onLeave 中通过 el.dataset.index 获取绑定的值,然后配合 delay 实现:

无限视差滚动效果

最后,综合上面的知识点,配合对滚轮事件的监听,可以实现如下所示的无限视差滚动效果:

代码如下:

<script setup lang="ts">
import { onMounted, ref, useTemplateRef } from 'vue'
const list = ref([
  {
    text: '让心灵在青山绿水间悠然旅行',
    img: 'https://picsum.photos/id/94/3000/2000'
  },
  {
    text: '带你领略大自然的鬼斧神工',
    img: 'https://picsum.photos/id/29/3000/2000'
  },
  {
    text: '邂逅千年古城与浪漫小镇',
    img: 'https://picsum.photos/id/61/3000/2000'
  }
])
const divEl = useTemplateRef<HTMLDivElement>('wrap')
const cur = ref(0)
const isDown = ref(true) // 是否向下滑动滚轮
let isAnimate = false
onMounted(() => {
  // https://developer.mozilla.org/zh-CN/docs/Web/API/Element/wheel_event
  divEl.value?.addEventListener('wheel', e => {
    e.preventDefault()
    if (!e.deltaY || isAnimate) return
    isAnimate = true
    const length = list.value.length
    if (e.deltaY > 0) {
      // 向下滚:e.deltaY > 0
      isDown.value = true
      cur.value = cur.value === length - 1 ? 0 : cur.value + 1
    } else {
      // 向上滚
      isDown.value = false
      cur.value = cur.value === 0 ? length - 1 : cur.value - 1
    }
  })
})

function onAfterEnter() {
  isAnimate = false
}
</script>

<template>
  <div class="wrap" ref="wrap">
    <transition-group name="item" @after-enter="onAfterEnter">
      <template v-for="(item, index) in list" :key="item">
        <div
          class="item"
          v-show="index === cur"
          :class="[isDown ? 'down' : 'up']"
        >
          <transition name="msg" appear>
            <h2 v-show="index === cur">{{ list[index].text }}</h2>
          </transition>
          <img :src="list[index].img" alt="" />
        </div>
      </template>
    </transition-group>
  </div>
</template>

<style>
* {
  margin: 0;
}
body {
  overflow: hidden;
}
.wrap {
  height: 100vh;
  overflow: hidden;
}

.item {
  position: absolute;
  left: 0;
  width: 100%;
  height: 100vh;
  overflow: hidden;
}
.down {
  top: 0;
}
.up {
  bottom: 0;
}
.item h2 {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 50%;
  transform: translate(-50%, -50%);
  font-size: 7vw;
  color: aliceblue;
  text-shadow: 2px 2px 16px black;
}
.item img {
  width: 100%;
  height: 100vh;
  object-fit: cover;
}
.item-leave-to {
  height: 0;
  z-index: 1;
}
.down.item-enter-from {
  opacity: 0;
  transform: translateY(20%);
}
.up.item-enter-from {
  opacity: 0;
  transform: translateY(-20%);
}
.item-enter-active,
.item-leave-active {
  transition: height 2s ease, transform 1s ease;
}
.msg-enter-from {
  opacity: 0;
  transform: translate(-55%, -50%) !important;
}
.msg-leave-to {
  opacity: 0;
}
.msg-enter-active,
.msg-leave-active {
  transition: opacity 2s ease-in, transform 0.5s ease 2s;
}
</style>

另外,我使用渐变色替换图片做背景,方便诸君在线调试:

感谢.gif 点赞.png