【Vue.js】过渡 & 动画

226 阅读11分钟

概述:

Vue 提供了两个内置组件,可以帮助你制作基于状态变化的过渡和动画:

  • <Transition> 会在一个元素或组件进入和离开 DOM 时应用动画。本章节会介绍如何使用它。
  • <TransitionGroup> 会在一个 v-for 列表中的元素或组件被插入,移动,或移除时应用动画。

补充

除了这两个组件,我们也可以使用 CSS3 动画 和 JS 动画来完成特殊的动画操作

<Transition>

概述:

<Transition> 是 Vue 提供的内置的包裹组件,它无需注册就可以在任何其他的 Vue 组件中使用(字符串模板组件,比如 *.vue, *.tpl)。

作用:

<Transition> 可以将进入和离开动画应用到通过默认插槽传递给它的元素或组件上。

触发条件:

触发<Transition>动画的条件(满足其中之一):

  1. v-if 触发的切换
  2. v-show 触发的切换
  3. 由特殊元素 <component> 切换的动态组件
  4. 改变特殊的 key 属性

最基本使用:

代码示例:

<button @click="show = !show">Toggle</button>
<Transition>
  <p v-if="show">hello</p>
</Transition>
/* 设置切换时的生命周期 */
.v-enter-active,
.v-leave-active {
  transition: opacity 0.5s ease;
}

.v-enter-from,
.v-leave-to {
  opacity: 0;
}

注意

<Transition> 仅支持单个元素或组件作为其插槽内容。如果内容是一个组件,这个组件必须仅有一个根元素。

<Transition>到底做了什么?

  1. Vue 会自动检测目标元素是否应用了 CSS 过渡或动画。如果是,则一些 CSS 过渡 class 会在适当的时机被添加和移除。
  2. 如果在<Transition>上有作为监听器的 JavaScript 钩子函数,这些钩子函数会在适当时机被调用。
<Transition
  @before-enter="onBeforeEnter"
  @enter="onEnter"
  @after-enter="onAfterEnter"
  @enter-cancelled="onEnterCancelled"
  @before-leave="onBeforeLeave"
  @leave="onLeave"
  @after-leave="onAfterLeave"
  @leave-cancelled="onLeaveCancelled"
>
  <!-- ... other slots -->
</Transition>
  1. 如果没有探测到 CSS 过渡或动画、也没有提供 JavaScript 钩子,那么 DOM 的插入、删除操作将在浏览器的下一个动画帧后执行。

CSS 过渡 class:

一共有 6 个应用于进入与离开过渡效果的 CSS class

过渡类名触发时机类名描述
v-enter-from在元素插入之前添加,在元素插入完成后的下一帧移除。进入动画的起始状态。
v-enter-active在元素被插入之前添加,在过渡或动画完成之后移除。进入动画的生效状态。应用于整个进入动画阶段。这个 class 可以被用来定义进入动画的持续时间、延迟与速度曲线类型。
v-enter-to在元素插入完成后的下一帧被添加 (也就是 v-enter-from 被移除的同时),在过渡或动画完成之后移除。进入动画的结束状态。
v-leave-from在离开过渡效果被触发时立即添加,在一帧后被移除。离开动画的起始状态。
v-leave-active在离开过渡效果被触发时立即添加,在过渡或动画完成之后移除。离开动画的生效状态。应用于整个离开动画阶段。这个 class 可以被用来定义离开动画的持续时间、延迟与速度曲线类型。
v-leave-to在一个离开动画被触发后的下一帧被添加 (也就是 v-leave-from 被移除的同时),在过渡或动画完成之后移除。离开动画的结束状态。

补充

v-enter-activev-leave-active 给我们提供了为进入和离开动画指定不同速度曲线的能力 (类比 CSS3 中的 easeease-inease-outease-in-out

<Transition> 过渡效果命名:

  • 如果不给 <Transition> 命名,它就是以 v 作为名称的修饰类名的

(e.g v-enter-tov-leave-to)

  • 我们可以给 <Transition> 命名 (e.g {name} ),他就是以名称 {name} 作为名称修饰类名的

(e.g {name}-enter-to{name}-leave-to)

<template>
  <Transition name="fade">
    ...
  </Transition>
</template>

<script>
  import { defineComponent } from 'vue';
  
  export default defineComponent({
    name: 'Comp'
  });
</script>

<style>
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 0.5s ease;
  }
  
  .fade-enter-from,
  .fade-leave-to {
    opacity: 0;
  }
</style>

结合 CSS3 的 transition 一起使用:

<Transition> 一般都会搭配原生 CSS 过渡一起使用。

以下例子,它使用了不同的持续时间和速度曲线(这里用的是贝塞尔曲线)来过渡多个属性:

<template>
  <Transition name="slide-fade">
    <p v-if="show">hello</p>
  </Transition>
  <button @click="handleSwitchBtnClick">切换显示状态</button>
</template>

<script>
  import { defineComponent } from 'vue';
  
  export default defineComponent({
    name: 'Comp',
    data() {
      return {
        show: true,
      };
    },
    methods: {
      handleSwitchBtnClick() {
        this.show = !this.show;
      }
    },
  });
</script>

<style>
  /*
    进入和离开动画可以使用不同
    持续时间和速度曲线。
  */
  .slide-fade-enter-active {
    transition: all 0.3s ease-out;
  }
  
  .slide-fade-leave-active {
    transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
  }
  
  .slide-fade-enter-from,
  .slide-fade-leave-to {
    transform: translateX(20px);
    opacity: 0;
  }
</style>

CSS 的 animation:

原生 CSS 动画和 CSS transition 的应用方式基本上是相同的,只有一点不同,那就是 *-enter-from 不是在元素插入后立即移除,而是在一个 animationend 事件触发时被移除。

对于大多数的 CSS 动画,我们可以简单地在 *-enter-active*-leave-active class 下声明它们。

<template>
  <Transition name="bounce">
    <p v-if="show" style="text-align: center;">
      Hello here is some bouncy text!
    </p>
  </Transition>
</template>

<script>
  import { defineComponent } from 'vue';
  
  export default defineComponent({
    name: 'Comp',
    data() {
      return {
        show: false
      };
    }
  });
</script>

<style>
  .bounce-enter-active {
    animation: bounce-in 0.5s;
  }
  .bounce-leave-active {
    animation: bounce-in 0.5s reverse;
  }
  @keyframes bounce-in {
    0% {
      transform: scale(0);
    }
    50% {
      transform: scale(1.25);
    }
    100% {
      transform: scale(1);
    }
  }
</style>

结合 animate.css 使用:

<Transition>可以自定义生命周期的类名配合其他的库使用,比如 animate.css

<!-- 假设你已经在页面中引入了 animate.css -->
<Transition
  name="custom-classes"
  enter-active-class="animate__animated animate__tada"
  leave-active-class="animate__animated animate__bounceOutRight"
>
  <p v-if="show">hello</p>
</Transition>

同时使用 transitionanimation

Vue 需要附加事件监听器,以便知道过渡何时结束。可以是 transitionendanimationend,这取决于你所应用的 CSS 规则。如果你仅仅使用二者的其中之一,Vue 可以自动探测到正确的类型。

然而在某些场景中,你或许想要在同一个元素上同时使用它们两个。举例来说,Vue 触发了一个 CSS 动画,同时鼠标悬停触发另一个 CSS 过渡。此时你需要显式地传入 type prop 来声明,告诉 Vue 需要关心哪种类型,传入的值是 animationtransition

深层级过渡与显式过渡时长:

尽管过渡 class 仅能应用在 <Transition> 的直接子元素上,我们还是可以使用深层级的 CSS 选择器,在深层级的元素上触发过渡效果。

  • template
<Transition name="nested">
  <div v-if="show" class="outer">
    <div class="inner">
      Hello
    </div>
  </div>
</Transition>
  • css
/* 应用于嵌套元素的规则 */
.nested-enter-active .inner,
.nested-leave-active .inner {
  transition: all 0.3s ease-in-out;
}

.nested-enter-from .inner,
.nested-leave-to .inner {
  transform: translateX(30px);
  opacity: 0;
}

/* ... 省略了其他必要的 CSS */

我们甚至可以在深层元素上添加一个过渡延迟,从而创建一个带渐进延迟的动画序列:

css

/* 延迟嵌套元素的进入以获得交错效果 */
.nested-enter-active .inner {
  transition-delay: 0.25s;
}

然而,这会带来一个小问题 :默认情况下,<Transition> 组件会通过监听过渡根元素上的第一个 transitionend 或者 animationend 事件来尝试自动判断过渡何时结束。而在嵌套的过渡中,期望的行为应该是等待所有内部元素的过渡完成。

在这种情况下,你可以通过向 <Transition> 组件传入 duration prop 来显式指定过渡的持续时间 (以毫秒为单位)。总持续时间应该匹配延迟加上内部元素的过渡持续时间:

<Transition :duration="550">...</Transition>

<Transition> 中的 JavaScript 钩子

你可以通过监听 <Transition> 组件事件的方式在过渡过程中挂上钩子函数:

<template>
  <Transition
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @after-enter="onAfterEnter"
    @enter-cancelled="onEnterCancelled"
    @before-leave="onBeforeLeave"
    @leave="onLeave"
    @after-leave="onAfterLeave"
    @leave-cancelled="onLeaveCancelled"
  >
    <!-- ... -->
  </Transition>
</template>

<script>
  import { defineComponent } from 'vue';

  export default defineComponent({
    // ...
    methods: {
      // 在元素被插入到 DOM 之前被调用
      // 用这个来设置元素的 "enter-from" 状态
      onBeforeEnter(el) {},
  
      // 在元素被插入到 DOM 之后的下一帧被调用
      // 用这个来开始进入动画
      onEnter(el, done) {
        // 调用回调函数 done 表示过渡结束
        // 如果与 CSS 结合使用,则这个回调是可选参数
        done()
      },
  
      // 当进入过渡完成时调用。
      onAfterEnter(el) {},
      onEnterCancelled(el) {},
  
      // 在 leave 钩子之前调用
      // 大多数时候,你应该只会用到 leave 钩子
      onBeforeLeave(el) {},
  
      // 在离开过渡开始时调用
      // 用这个来开始离开动画
      onLeave(el, done) {
        // 调用回调函数 done 表示过渡结束
        // 如果与 CSS 结合使用,则这个回调是可选参数
        done()
      },
  
      // 在离开过渡完成、
      // 且元素已从 DOM 中移除时调用
      onAfterLeave(el) {},
  
      // 仅在 v-show 过渡中可用
      onLeaveCancelled(el) {}
    }
  });
</script>

补充 - <Transition>中设置 css prop

  1. 仅由 JavaScript 执行的动画时,最好是添加一个 :css="false" prop
  2. 在有了 :css="false" 后,我们就自己全权负责控制什么时候过渡结束了。这种情况下对于 @enter 和 @leave 钩子来说,回调函数 done 就是必须的。否则,钩子将被同步调用,过渡将立即完成。

基于 <Transition>封装一个 <MyTransition>

<MyTransition> 封装:

<!-- MyTransition.vue -->
<script>
// JavaScript 钩子逻辑...
</script>

<template>
  <!-- 包装内置的 Transition 组件 -->
  <Transition
    name="my-transition"
    @enter="onEnter"
    @leave="onLeave">
    <slot></slot> <!-- 向内传递插槽内容 -->
  </Transition>
</template>

<style>
/*
  必要的 CSS...
  注意:避免在这里使用 <style scoped>
  因为那不会应用到插槽内容上
*/
</style>

<MyTransition> 使用

<MyTransition>
  <div v-if="show">Hello</div>
</MyTransition>

过渡的时机:

出现时过渡:

如果你想在某个节点初次渲染时应用一个过渡效果,你可以添加 appear prop:

<Transition appear>
  ...
</Transition>

元素间过渡:

除了通过 v-if / v-show 切换一个元素,我们也可以通过 v-if / v-else / v-else-if 在几个组件间进行切换,只要确保任一时刻只会有一个元素被渲染即可:

<Transition>
  <button v-if="docState === 'saved'">Edit</button>
  <button v-else-if="docState === 'edited'">Save</button>
  <button v-else-if="docState === 'editing'">Cancel</button>
</Transition>

组件间过渡:

<Transition> 也可以作用于动态组件之间的切换:

<Transition name="fade" mode="out-in">
  <component :is="activeComponent"></component>
</Transition>

过渡模式:

手动编排这样的动画是非常复杂的,<Transition>提供了一个可以设置过渡模式(e.g in、out、out-in )的 propmode给我们操作:

<Transition mode="out-in">
  ...
</Transition>

动态过渡:

<Transition> 的 props (比如 name) 也可以是动态的!这让我们可以根据状态变化动态地应用不同类型的过渡:

<Transition :name="transitionName">
  <!-- ... -->
</Transition>

性能考量:

你可能注意到我们上面例子中展示的动画所用到的 CSS 属性大多是 transformopacity 之类的(CSS3 属性)。用这些属性制作动画非常高效,因为:

  1. 他们在动画过程中不会影响到 DOM 结构,因此不会每一帧都触发昂贵的 CSS 布局重新计算。
  2. 大多数的现代浏览器都可以在执行 transform 动画时利用 GPU 进行硬件加速。

相比之下,像 height 或者 margin 这样的属性会触发 CSS 布局变动,因此执行它们的动画效果更昂贵,需要谨慎使用。我们可以在 CSS-Triggers 这类的网站查询哪些属性会在执行动画时触发 CSS 布局变动。

<TransitionGroup>

概述:

<TransitionGroup> 是一个内置组件,用于对 v-for 列表中的元素或组件的插入、移除和顺序改变添加动画效果。

<Transition> 的异同:

相同点:

<TransitionGroup> 支持和 <Transition> 基本相同的 props、CSS 过渡 class 和 JavaScript 钩子监听器。

不同点:

  • 默认情况下,<TransitionGroup>不会渲染一个容器元素。但你可以通过传入 tag prop 来指定一个元素作为容器元素来渲染。
  • 过渡模式在这里不可用,因为我们不再是在互斥的元素之间进行切换。
  • 列表中的每个元素都必须有一个独一无二的 key attribute。
  • CSS 过渡 class 会被应用在列表内的元素上,而不是容器元素上。

进入 / 离开动画

这里是 对一个 v-for 列表添加进入 / 离开动画的示例:

<template>
  <div class="playground">
    <div class="btn-group">
      <button class="btn" @click="handleAddBtnClick">在任意位置添加一项</button>
      <button class="btn" @click="handleRemoveBtnClick">移除任意位置的一项</button>
    </div>
    <TransitionGroup name="list" tag="ul">
      <li v-for="item in items" :key="item">
        {{ item }}
      </li>
    </TransitionGroup>
  </div>
</template>

<script>
  import { defineComponent } from 'vue';

  export default defineComponent({
    name: 'Comp',

    data() {
      return {
        items: [1, 2, 3, 4, 5],
        len: 5,
      };
    },

    methods: {
      handleAddBtnClick() {
        this.len += 1;
        const newItem = this.len;
        this.items.push(newItem);
      },

      handleRemoveBtnClick() {
        const len = this.items.length;
        const removeIdx = parseInt(Math.random() * len);
        this.items.splice(removeIdx, 1);
      }
    }
  });
</script>

<style>
  .list-enter-active,
  .list-leave-active {
    transition: all 0.5s ease;
  }
  .list-enter-from,
  .list-leave-to {
    opacity: 0;
    transform: translateX(30px);
  }
</style>

移动动画

上面的示例有一些明显的缺陷:当某一项被插入或移除时,它周围的元素会立即发生“跳跃”而不是平稳地移动。我们可以通过添加一些额外的 CSS 规则来解决这个问题:

<style>
  .list-move, /* 对移动中的元素应用的过渡 */
  .list-enter-active,
  .list-leave-active {
    transition: all 0.5s ease;
  }
  
  .list-enter-from,
  .list-leave-to {
    opacity: 0;
    transform: translateX(30px);
  }
  
  /* 确保将离开的元素从布局流中删除
    以便能够正确地计算移动的动画。 */
  .list-leave-active {
    position: absolute;
  }
</style>

完整示例:cn.vuejs.org/examples/#l…

渐进延迟列表动画

通过在 JavaScript 钩子中读取元素的 data attribute,我们可以实现带渐进延迟的列表动画。

  1. 首先,我们把每一个元素的索引渲染为该元素上的一个 data attribute:
<template>
  <TransitionGroup
    tag="ul"
    :css="false"
    @before-enter="onBeforeEnter"
    @enter="onEnter"
    @leave="onLeave"
  >
    <li
      v-for="(item, index) in computedList"
      :key="item.msg"
      :data-index="index"
    >
      {{ item.msg }}
    </li>
  </TransitionGroup>
</template>
  1. 接着,在 JavaScript 钩子中,我们基于当前元素的 data attribute 对该元素的进场动画添加一个延迟。
<script>
	import { defineComponent } from 'vue';
  import { gsap } from 'gsap';
  
  export default defineComponent({
    // ...
    methods: {
      onEnter(el, done) {
        gsap.to(el, {
          opacity: 1,
          height: '1.6em',
          delay: el.dataset.index * 0.15,
          onComplete: done
        });
      }
    }
  });
</script>

完整示例:play.vuejs.org/#eNqtVd9v0z…