第四章:CSS动画与<transition>:从基础到复杂动效

说实话,Vue 的 <transition> 组件是个好东西。但很多人用着用着就卡住了——只会用那几个预置的 class 名,稍微复杂点的动效就不知道怎么搞了。我个人习惯是把 <transition> 和 CSS 动画结合起来用,这样既能享受 Vue 的自动管理,又能做出很炫的效果。

4.1 用 @keyframes 定义动画

先说说最基础的。Vue 的 <transition> 默认只支持过渡(transition),但你想用动画(animation)怎么办?其实很简单,直接在 CSS 里写 @keyframes,然后在对应的 class 里引用就行。

/* 定义一个弹入动画 */
@keyframes bounceIn {
  0% {
    transform: scale(0.3);
    opacity: 0;
  }
  50% {
    transform: scale(1.1);
  }
  70% {
    transform: scale(0.9);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}

/* 应用到 Vue 的过渡 class 上 */
.bounce-enter-active {
  animation: bounceIn 0.6s ease-out;
}

.bounce-leave-active {
  animation: bounceIn 0.4s ease-in reverse;
}

嗯,这里要注意一点:leave 动画我用了 reverse 关键字,这样离开动画就是进入动画的倒放。我在项目中遇到过一个问题——如果动画不对称,直接 reverse 可能会有点违和感。这时候我建议你单独写一个离开的 @keyframes。

小技巧: 如果你想让动画只执行一次,记得加 animation-fill-mode: forwards;。不然动画结束后元素会跳回初始状态,那画面...你懂的。

4.2 结合 animate.css 库

自己手写 @keyframes 当然可以,但有时候项目赶进度,或者你懒得想那些贝塞尔曲线参数,这时候用现成的动画库就舒服多了。我最常用的是 animate.css,它提供了几十种现成的动画效果。

怎么用?先安装:

npm install animate.css --save

然后在 main.js 里引入:

import 'animate.css';

接着在 Vue 组件里,直接把 animate.css 的 class 名拼到 enter-active-classleave-active-class 上:

<transition
  enter-active-class="animate__animated animate__bounceIn"
  leave-active-class="animate__animated animate__bounceOut"
>
  <div v-if="show" class="box">我弹进来了</div>
</transition>

你看,就这么简单。但有个坑——animate.css 默认动画时长是 1 秒,如果你觉得太快或太慢,可以用 CSS 变量覆盖:

<transition
  enter-active-class="animate__animated animate__bounceIn"
  leave-active-class="animate__animated animate__bounceOut"
  :duration="800"
>
  <div v-if="show" class="box">我弹进来了</div>
</transition>

或者直接在 CSS 里改:

.my-custom-box {
  --animate-duration: 0.8s;
}
注意: 我曾经在移动端项目里用了 animate.css 的 flash 动画,结果在低端安卓机上卡成 PPT。后来发现是动画里用了 visibility 属性切换,触发了重排。建议在移动端慎用那些频繁改变布局的动画。

4.3 动画钩子函数:精细控制每一帧

有时候光靠 CSS 搞不定。比如你想在动画开始前先加载一张图片,或者在动画结束后执行某个逻辑。这时候就需要 JavaScript 钩子函数 了。

Vue 的 <transition> 提供了 8 个钩子,但常用的就这几个:

钩子名 触发时机 常用场景
@before-enter 元素插入前 设置初始状态
@enter 元素插入后 启动动画,调用 done()
@after-enter 动画结束后 清理状态,触发回调
@before-leave 离开动画前 记录当前位置
@leave 离开动画开始 启动离开动画
@after-leave 离开动画结束 移除元素

举个例子。假设你想做一个「先放大再缩小」的进入效果,用 CSS 写 @keyframes 当然可以,但用 JS 钩子会更灵活:

<transition
  @before-enter="beforeEnter"
  @enter="enter"
  @after-enter="afterEnter"
>
  <div v-if="show" ref="box" class="box">看我表演</div>
</transition>
methods: {
  beforeEnter(el) {
    // 设置初始状态
    el.style.transform = 'scale(0)';
    el.style.opacity = '0';
  },
  enter(el, done) {
    // 强制回流,让浏览器知道初始状态
    el.offsetHeight;

    // 用 Web Animations API 做动画
    el.animate([
      { transform: 'scale(0)', opacity: 0 },
      { transform: 'scale(1.2)', opacity: 0.8 },
      { transform: 'scale(1)', opacity: 1 }
    ], {
      duration: 600,
      easing: 'ease-out'
    }).onfinish = done;  // 动画结束后调用 done()
  },
  afterEnter(el) {
    // 动画完成后的清理工作
    console.log('进入动画完成');
  }
}

这里有个关键点:enterleave 钩子必须调用 done() 回调,否则 Vue 不知道动画什么时候结束。我刚开始用的时候经常忘掉这个,结果动画卡在那里不动,排查了半天才发现是没调 done()。

核心要点: 使用 JS 钩子时,enterleave 里的 done() 必须被调用。如果你用了 el.animate(),记得在 onfinish 里调;如果你用了 setTimeout,就在回调里调。总之,一定要让 Vue 知道「动画结束了」。

4.4 实战:做一个带进度条的加载动画

光说不练假把式。我结合上面讲的知识点,做一个实际点的例子——一个带进度条的加载动画。当数据加载时,进度条从 0 到 100%,加载完成后内容淡入显示。

<template>
  <div>
    <transition
      @before-enter="beforeProgress"
      @enter="startProgress"
      @after-enter="afterProgress"
    >
      <div v-if="loading" class="progress-bar">
        <div class="progress-fill" ref="fill"></div>
        <span class="progress-text">{{ progress }}%</span>
      </div>
    </transition>

    <transition
      enter-active-class="animate__animated animate__fadeInUp"
    >
      <div v-if="!loading" class="content">
        加载完成!这里是内容。
      </div>
    </transition>
  </div>
</template>
data() {
  return {
    loading: true,
    progress: 0
  };
},
methods: {
  beforeProgress(el) {
    this.progress = 0;
    el.style.opacity = '1';
  },
  startProgress(el, done) {
    // 模拟进度增长
    const interval = setInterval(() => {
      this.progress += Math.random() * 10;
      if (this.progress >= 100) {
        this.progress = 100;
        clearInterval(interval);
        // 进度条走完后,延迟一下再切换
        setTimeout(() => {
          this.loading = false;
          done();
        }, 500);
      }
      // 更新进度条宽度
      this.$refs.fill.style.width = this.progress + '%';
    }, 200);
  },
  afterProgress(el) {
    console.log('加载流程结束');
  }
}

这个例子把 CSS 动画(animate.css 的 fadeInUp)和 JS 钩子结合起来了。进度条部分用 JS 控制,内容出现部分用 CSS 动画。说白了,就是各取所长——JS 负责逻辑控制,CSS 负责视觉表现。

个人经验: 在实际项目中,我一般把「入场动画」用 CSS 做,「过程控制」用 JS 做。比如弹窗出现用 CSS 动画,但弹窗里的数据加载进度用 JS 钩子控制。这样代码清晰,也方便后期维护。

4.5 避坑指南

最后分享几个我踩过的坑:

  • 动画不触发? 检查一下 v-ifv-show 是否写对了。我有一回把 v-if 写成了 v-show,结果 <transition> 根本不工作。
  • 动画闪烁? 大概率是初始状态没设置好。在 before-enter 里把元素的初始样式写清楚,比如 opacity: 0transform: scale(0)
  • 动画卡顿? 别用那些会触发重排的属性,比如 widthheightmargin。尽量用 transformopacity,这两个属性 GPU 会帮你加速。
  • animate.css 和自定义动画冲突? 记得给自定义动画的 class 加上 !important,或者提高选择器优先级。不然 animate.css 的样式会覆盖你的。

嗯,差不多就这些。CSS 动画加 <transition> 的组合,说白了就是「Vue 帮你管生命周期,你只管写动画」。用熟了之后,你会发现做动效其实没那么难。