{T}

概述

Vue 的过渡系统提供了简单的方法来设置进入、离开和列表的动效。但对于数据元素本身的动效,例如:

  • 数字和运算:数值变化时的平滑过渡
  • 颜色的显示:颜色值之间的渐变动画
  • SVG 节点的位置:图形元素的位移动画
  • 元素的大小和其他 property:尺寸、透明度等属性的变化动画

这些数据要么本身就以数值形式存储,要么可以转换为数值。结合 Vue 的响应式系统和组件系统,配合第三方动画库,可以实现丰富的状态过渡效果。

与元素过渡的区别

特性元素过渡状态过渡
触发方式v-if/v-show 切换数据值变化
实现方式CSS 过渡/动画JavaScript 动画
适用场景DOM 元素的显示/隐藏数值、颜色等数据的平滑变化
核心组件<transition>侦听器 + 动画库

核心概念

补间动画(Tweening)

补间动画是指在两个状态之间插入中间帧,使变化过程平滑过渡的技术。状态过渡的核心就是补间动画:

图表渲染中…

响应式数据驱动

Vue 的响应式系统可以监听数据变化,当数据更新时触发动画:

javascript
watch: {
  number: function (newValue) {
    // 当 number 变化时,执行补间动画
    gsap.to(this.$data, { duration: 0.5, tweenedNumber: newValue });
  }
}

适用场景

1. 数据可视化

  • 图表数值变化动画
  • 进度条平滑过渡
  • 仪表盘指针移动

2. 交互反馈

  • 购物车数量变化
  • 评分组件动画
  • 计数器效果

3. 游戏与创意应用

  • SVG 图形变形
  • 粒子效果
  • 角色状态动画

状态动画与侦听器

数字动画

通过侦听器监听数值变化,使用 GSAP 实现数字的平滑过渡:

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>数字动画示例</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.2.4/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  </head>
  <body>
    <div id="animated-number-demo">
      <input v-model.number="number" type="number" step="20" />
      <p>{{ animatedNumber }}</p>
    </div>
    <script>
      new Vue({
        el: "#animated-number-demo",
        data: {
          number: 0,
          tweenedNumber: 0
        },
        computed: {
          animatedNumber: function () {
            return this.tweenedNumber.toFixed(0)
          }
        },
        watch: {
          number: function (newValue) {
            gsap.to(this.$data, { duration: 0.5, tweenedNumber: newValue })
          }
        }
      })
    </script>
  </body>
</html>

代码说明

部分作用
tweenedNumber存储动画过程中的中间值
animatedNumber计算属性,格式化显示数值
watch监听 number 变化,触发 GSAP 动画
gsap.to()tweenedNumber 从当前值过渡到新值

颜色动画

对于 CSS 颜色值,可以使用 Tween.jsColor.js 实现颜色渐变:

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>颜色动画示例</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/tween.js@16.3.4"></script>
    <script src="https://cdn.jsdelivr.net/npm/color-js@1.0.3"></script>
    <style>
      .color-preview {
        display: inline-block;
        width: 50px;
        height: 50px;
        border: 1px solid #ccc;
      }
    </style>
  </head>
  <body>
    <div id="example">
      <input
        v-model="colorQuery"
        v-on:keyup.enter="updateColor"
        placeholder="输入颜色值(如 #ff0000)" />
      <button v-on:click="updateColor">更新颜色</button>
      <p>预览:</p>
      <span v-bind:style="{ backgroundColor: tweenedCSSColor }" class="color-preview"></span>
      <p>{{ tweenedCSSColor }}</p>
    </div>
    <script>
      var Color = net.brehaut.Color

      new Vue({
        el: "#example",
        data: {
          colorQuery: "",
          color: {
            red: 0,
            green: 0,
            blue: 0,
            alpha: 1
          },
          tweenedColor: {}
        },
        created: function () {
          this.tweenedColor = Object.assign({}, this.color)
        },
        watch: {
          color: function () {
            function animate() {
              if (TWEEN.update()) {
                requestAnimationFrame(animate)
              }
            }

            new TWEEN.Tween(this.tweenedColor).to(this.color, 750).start()

            animate()
          }
        },
        computed: {
          tweenedCSSColor: function () {
            return new Color({
              red: this.tweenedColor.red,
              green: this.tweenedColor.green,
              blue: this.tweenedColor.blue,
              alpha: this.tweenedColor.alpha
            }).toCSS()
          }
        },
        methods: {
          updateColor: function () {
            this.color = new Color(this.colorQuery).toRGB()
            this.colorQuery = ""
          }
        }
      })
    </script>
  </body>
</html>

颜色动画原理

图表渲染中…

动态状态过渡

数据驱动的状态过渡可以实时更新,非常适合原型设计。通过修改变量,即使是简单的 SVG 多边形也能实现复杂效果:

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>动态 SVG 多边形</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/1.18.5/TweenLite.min.js"></script>
    <style>
      svg {
        display: block;
      }
      polygon {
        fill: #41b883;
      }
      circle {
        fill: transparent;
        stroke: #35495e;
      }
      input[type="range"] {
        display: block;
        width: 100%;
        margin-bottom: 15px;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <svg width="200" height="200">
        <polygon :points="points"></polygon>
        <circle cx="100" cy="100" r="90"></circle>
      </svg>
      <label>边数: {{ sides }}</label>
      <input type="range" min="3" max="500" v-model.number="sides" />
      <label>最小半径: {{ minRadius }}%</label>
      <input type="range" min="0" max="90" v-model.number="minRadius" />
      <label>更新间隔: {{ updateInterval }} 毫秒</label>
      <input type="range" min="10" max="2000" v-model.number="updateInterval" />
    </div>
    <script>
      new Vue({
        el: "#app",
        data: function () {
          var defaultSides = 10
          var stats = Array.apply(null, { length: defaultSides }).map(function () {
            return 100
          })
          return {
            stats: stats,
            points: generatePoints(stats),
            sides: defaultSides,
            minRadius: 50,
            interval: null,
            updateInterval: 500
          }
        },
        watch: {
          sides: function (newSides, oldSides) {
            var sidesDifference = newSides - oldSides
            if (sidesDifference > 0) {
              for (var i = 1; i <= sidesDifference; i++) {
                this.stats.push(this.newRandomValue())
              }
            } else {
              var absoluteSidesDifference = Math.abs(sidesDifference)
              for (var i = 1; i <= absoluteSidesDifference; i++) {
                this.stats.shift()
              }
            }
          },
          stats: function (newStats) {
            TweenLite.to(this.$data, this.updateInterval / 1000, {
              points: generatePoints(newStats)
            })
          },
          updateInterval: function () {
            this.resetInterval()
          }
        },
        mounted: function () {
          this.resetInterval()
        },
        methods: {
          randomizeStats: function () {
            var vm = this
            this.stats = this.stats.map(function () {
              return vm.newRandomValue()
            })
          },
          newRandomValue: function () {
            return Math.ceil(this.minRadius + Math.random() * (100 - this.minRadius))
          },
          resetInterval: function () {
            var vm = this
            clearInterval(this.interval)
            this.randomizeStats()
            this.interval = setInterval(function () {
              vm.randomizeStats()
            }, this.updateInterval)
          }
        }
      })

      function valueToPoint(value, index, total) {
        var x = 0
        var y = -value * 0.9
        var angle = ((Math.PI * 2) / total) * index
        var cos = Math.cos(angle)
        var sin = Math.sin(angle)
        var tx = x * cos - y * sin + 100
        var ty = x * sin + y * cos + 100
        return { x: tx, y: ty }
      }

      function generatePoints(stats) {
        var total = stats.length
        return stats
          .map(function (stat, index) {
            var point = valueToPoint(stat, index, total)
            return point.x + "," + point.y
          })
          .join(" ")
      }
    </script>
  </body>
</html>

动态过渡特点

  • 实时响应参数变化
  • 支持复杂的 SVG 图形变形
  • 可自定义动画更新频率

组件化过渡

当状态过渡逻辑变得复杂时,可以将其封装为可复用的子组件:

html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>组件化数字动画</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/tween.js@16.3.4"></script>
  </head>
  <body>
    <div id="example">
      <input v-model.number="firstNumber" type="number" step="20" /> +
      <input v-model.number="secondNumber" type="number" step="20" /> = {{ result }}
      <p>
        <animated-integer v-bind:value="firstNumber"></animated-integer>
        +
        <animated-integer v-bind:value="secondNumber"></animated-integer>
        =
        <animated-integer v-bind:value="result"></animated-integer>
      </p>
    </div>
    <script>
      Vue.component("animated-integer", {
        template: "<span>{{ tweeningValue }}</span>",
        props: {
          value: {
            type: Number,
            required: true
          }
        },
        data: function () {
          return {
            tweeningValue: 0
          }
        },
        watch: {
          value: function (newValue, oldValue) {
            this.tween(oldValue, newValue)
          }
        },
        mounted: function () {
          this.tween(0, this.value)
        },
        methods: {
          tween: function (startValue, endValue) {
            var vm = this

            function animate() {
              if (TWEEN.update()) {
                requestAnimationFrame(animate)
              }
            }

            new TWEEN.Tween({ tweeningValue: startValue })
              .to({ tweeningValue: endValue }, 500)
              .onUpdate(function () {
                vm.tweeningValue = this.tweeningValue.toFixed(0)
              })
              .start()

            animate()
          }
        }
      })

      new Vue({
        el: "#example",
        data: {
          firstNumber: 20,
          secondNumber: 40
        },
        computed: {
          result: function () {
            return this.firstNumber + this.secondNumber
          }
        }
      })
    </script>
  </body>
</html>

组件化的优势

优势说明
复用性动画逻辑可在多处使用
可维护性复杂度从主实例中分离
可测试性组件可独立测试
可配置性通过 props 定制动画参数

第三方动画库

GSAP

GSAP (GreenSock Animation Platform) 是业界最强大的 JavaScript 动画库之一。

特点

  • 高性能,流畅的动画效果
  • 丰富的缓动函数(Easing)
  • 支持时间线(Timeline)控制
  • 优秀的浏览器兼容性

基本用法

javascript
// 简单过渡
gsap.to(obj, { duration: 1, x: 100, opacity: 0.5 })

// 时间线动画
var tl = gsap.timeline()
tl.to(obj, { duration: 0.5, x: 100 }).to(obj, { duration: 0.5, y: 50 })

// 缓动函数
gsap.to(obj, {
  duration: 1,
  x: 100,
  ease: "power2.out"
})

常用缓动函数

缓动名称效果描述
power1.out轻微减速
power2.out中等减速
power3.out强烈减速
elastic.out弹性效果
bounce.out弹跳效果

Tween.js

Tween.js 是一个轻量级的补间动画库。

特点

  • 体积小巧
  • API 简洁
  • 支持链式调用

基本用法

javascript
// 创建补间动画
var tween = new TWEEN.Tween({ x: 0 })
  .to({ x: 100 }, 1000)
  .easing(TWEEN.Easing.Quadratic.Out)
  .onUpdate(function () {
    console.log(this.x)
  })
  .start()

// 动画循环
function animate() {
  requestAnimationFrame(animate)
  TWEEN.update()
}
animate()

最佳实践

1. 选择合适的动画库

场景推荐库
简单数值过渡原生 JavaScript 或 Tween.js
复杂动画序列GSAP
SVG 动画GSAP 或 Snap.svg
物理动画Popmotion 或 dynamics.js

2. 性能优化

javascript
// 使用 requestAnimationFrame
function animate() {
  if (TWEEN.update()) {
    requestAnimationFrame(animate)
  }
}

// 避免在动画中频繁访问 DOM
// 将计算结果缓存到变量中

3. 合理设置动画时长

类型建议时长
微交互100-300ms
状态变化300-500ms
复杂动画500-1000ms

4. 提供无障碍支持

javascript
// 检测用户偏好
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches

if (prefersReducedMotion) {
  // 跳过动画,直接设置最终值
  this.tweenedNumber = this.number
} else {
  // 执行动画
  gsap.to(this.$data, { duration: 0.5, tweenedNumber: this.number })
}

常见问题

Q1: 为什么动画不流畅?

可能原因

  1. 动画更新频率过高,导致性能问题
  2. 在动画回调中执行了耗时操作
  3. 同时运行过多动画实例

解决方案

javascript
// 使用节流控制更新频率
var lastTime = 0;
watch: {
  value: function (newValue) {
    var now = Date.now();
    if (now - lastTime > 16) { // 约 60fps
      lastTime = now;
      gsap.to(this.$data, { duration: 0.5, tweenedValue: newValue });
    }
  }
}

Q2: 如何处理动画中断?

当动画过程中数据再次变化时,需要处理动画中断:

javascript
watch: {
  number: function (newValue) {
    // GSAP 会自动处理中断,继续从当前位置过渡到新值
    gsap.to(this.$data, {
      duration: 0.5,
      tweenedNumber: newValue
    });
  }
}

Q3: 如何实现非线性动画?

使用缓动函数实现非线性效果:

javascript
// GSAP
gsap.to(obj, {
  duration: 1,
  x: 100,
  ease: "elastic.out(1, 0.3)"
})

// Tween.js
new TWEEN.Tween(obj).to({ x: 100 }, 1000).easing(TWEEN.Easing.Elastic.Out).start()

Q4: 如何在 Vue 组件销毁时清理动画?

javascript
export default {
  data() {
    return {
      animation: null
    }
  },
  methods: {
    startAnimation() {
      this.animation = gsap.to(this.$data, {
        duration: 1,
        value: 100
      })
    }
  },
  beforeDestroy() {
    // 清理动画
    if (this.animation) {
      this.animation.kill()
    }
  }
}

Q5: 状态过渡与 CSS 过渡如何选择?

特性状态过渡CSS 过渡
实现方式JavaScriptCSS
控制粒度精细粗粒度
性能依赖 JS 引擎GPU 加速
适用场景数值、颜色、SVGDOM 元素显示/隐藏
复杂度较高较低

赋予设计以生命

动画可以为静态设计带来生命力。当设计师创建图标、logo 和吉祥物时,交付的通常是图片或静态 SVG。通过 Vue 的状态过渡,可以让这些元素"活起来"。

GitHub 的章鱼猫、Twitter 的小鸟等 logo 都可以通过状态过渡实现:

  • 兴奋状态(快速摆动)
  • 思考状态(缓慢晃动)
  • 警戒状态(注视动画)

参考示例:Sarah Drasner 的交互式动画演示

状态过渡示例