Vue 3 JavaScript动画钩子

1. JavaScript动画钩子概述

1.1 什么是JavaScript动画钩子

JavaScript动画钩子是Vue过渡系统提供的一组事件监听器,允许我们在过渡的各个阶段执行自定义JavaScript代码。这些钩子函数为我们提供了更多的控制权,可以实现复杂的动画效果,甚至可以与第三方动画库集成。

1.2 JavaScript动画钩子的应用场景

  • 实现复杂的自定义动画
  • 与第三方动画库(如GSAP、Velocity.js)集成
  • 执行动画前后的DOM操作
  • 实现基于JavaScript的状态驱动动画
  • 处理动画的开始、进行中和结束事件

2. JavaScript动画钩子列表

Vue提供了以下JavaScript动画钩子:

钩子函数 描述 调用时机 参数
@before-enter 进入过渡开始前 元素被插入DOM之前 el(元素DOM节点)
@enter 进入过渡过程中 元素被插入DOM之后 el, done(结束回调)
@after-enter 进入过渡结束后 进入过渡完成后 el
@enter-cancelled 进入过渡取消 进入过渡被取消时(仅在@enter钩子中调用done之前有效) el
@before-leave 离开过渡开始前 离开过渡开始前 el
@leave 离开过渡过程中 离开过渡开始后 el, done(结束回调)
@after-leave 离开过渡结束后 离开过渡完成后 el
@leave-cancelled 离开过渡取消 离开过渡被取消时(仅在@leave钩子中调用done之前有效) el

3. 基本使用示例

3.1 简单的JavaScript动画

<template>
  <div>
    <button @click="show = !show">Toggle</button>
    
    <transition
      name="custom"
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @enter-cancelled="enterCancelled"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
      @leave-cancelled="leaveCancelled"
      :css="false"
    >
      <div v-if="show" class="box">
        JavaScript Animation
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    }
  },
  methods: {
    // 进入过渡钩子
    beforeEnter(el) {
      console.log('Before enter');
      el.style.opacity = 0;
      el.style.transform = 'translateX(-100px)';
    },
    
    enter(el, done) {
      console.log('Enter');
      
      // 手动实现动画
      let opacity = 0;
      let x = -100;
      const duration = 500; // 动画持续时间(毫秒)
      const startTime = performance.now();
      
      const animate = (currentTime) => {
        const elapsed = currentTime - startTime;
        const progress = Math.min(elapsed / duration, 1);
        
        // 使用easeOutCubic缓动函数
        const easeProgress = 1 - Math.pow(1 - progress, 3);
        
        opacity = easeProgress;
        x = -100 + (100 * easeProgress);
        
        el.style.opacity = opacity;
        el.style.transform = `translateX(${x}px)`;
        
        if (progress < 1) {
          requestAnimationFrame(animate);
        } else {
          done(); // 必须调用done()通知Vue动画结束
        }
      };
      
      requestAnimationFrame(animate);
    },
    
    afterEnter(el) {
      console.log('After enter');
      // 可以在这里执行动画完成后的操作
    },
    
    enterCancelled(el) {
      console.log('Enter cancelled');
    },
    
    // 离开过渡钩子
    beforeLeave(el) {
      console.log('Before leave');
      el.style.opacity = 1;
      el.style.transform = 'translateX(0)';
    },
    
    leave(el, done) {
      console.log('Leave');
      
      let opacity = 1;
      let x = 0;
      const duration = 500;
      const startTime = performance.now();
      
      const animate = (currentTime) => {
        const elapsed = currentTime - startTime;
        const progress = Math.min(elapsed / duration, 1);
        
        const easeProgress = 1 - Math.pow(1 - progress, 3);
        
        opacity = 1 - easeProgress;
        x = 0 - (100 * easeProgress);
        
        el.style.opacity = opacity;
        el.style.transform = `translateX(${x}px)`;
        
        if (progress < 1) {
          requestAnimationFrame(animate);
        } else {
          done();
        }
      };
      
      requestAnimationFrame(animate);
    },
    
    afterLeave(el) {
      console.log('After leave');
    },
    
    leaveCancelled(el) {
      console.log('Leave cancelled');
    }
  }
}
</script>

<style>
.box {
  width: 200px;
  height: 200px;
  background-color: #42b983;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
}
</style>

3.2 与GSAP动画库集成

GSAP(GreenSock Animation Platform)是一个功能强大的JavaScript动画库,与Vue的动画钩子配合使用可以实现复杂的动画效果。

npm install gsap

使用示例:

<template>
  <div>
    <button @click="show = !show">Toggle with GSAP</button>
    
    <transition
      name="gsap"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
      :css="false"
    >
      <div v-if="show" class="box">
        GSAP Animation
      </div>
    </transition>
  </div>
</template>

<script>
import { gsap } from 'gsap'

export default {
  data() {
    return {
      show: true
    }
  },
  methods: {
    beforeEnter(el) {
      // 设置初始状态
      gsap.set(el, {
        opacity: 0,
        scale: 0.5,
        rotation: -180
      })
    },
    
    enter(el, done) {
      // GSAP动画
      gsap.to(el, {
        opacity: 1,
        scale: 1,
        rotation: 0,
        duration: 0.6,
        ease: 'back.out(1.7)',
        onComplete: done // 动画完成时调用done()
      })
    },
    
    leave(el, done) {
      // GSAP离开动画
      gsap.to(el, {
        opacity: 0,
        scale: 0.5,
        rotation: 180,
        duration: 0.4,
        ease: 'power2.in',
        onComplete: done
      })
    }
  }
}
</script>

<style>
.box {
  width: 200px;
  height: 200px;
  background-color: #3498db;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
}
</style>

4. 高级用法

4.1 结合CSS过渡和JavaScript钩子

可以同时使用CSS过渡和JavaScript钩子,实现更复杂的动画效果:

<template>
  <div>
    <button @click="show = !show">Toggle Combined Animation</button>
    
    <transition
      name="combined"
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
    >
      <div v-if="show" class="box">
        Combined Animation
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    }
  },
  methods: {
    beforeEnter(el) {
      console.log('Before enter - JS hook');
      // 可以在这里执行一些DOM操作
      el.dataset.animated = 'true';
    },
    
    enter(el, done) {
      console.log('Enter - JS hook');
      // CSS过渡会自动执行,这里可以添加额外的JavaScript逻辑
      
      // 监听CSS过渡结束事件
      const transitionEndHandler = () => {
        el.removeEventListener('transitionend', transitionEndHandler);
        done();
      };
      
      el.addEventListener('transitionend', transitionEndHandler);
    },
    
    afterEnter(el) {
      console.log('After enter - JS hook');
      // 动画完成后的操作
      el.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.2)';
    }
  }
}
</script>

<style>
.box {
  width: 200px;
  height: 200px;
  background-color: #e74c3c;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  transition: all 0.4s ease;
}

/* CSS过渡样式 */
.combined-enter-from {
  opacity: 0;
  transform: translateY(-50px);
}

.combined-enter-active {
  transition: all 0.4s ease;
}

.combined-enter-to {
  opacity: 1;
  transform: translateY(0);
}

.combined-leave-from {
  opacity: 1;
  transform: translateY(0);
}

.combined-leave-active {
  transition: all 0.4s ease;
}

.combined-leave-to {
  opacity: 0;
  transform: translateY(50px);
}
</style>

4.2 动态调整动画参数

可以根据组件状态动态调整动画参数:

<template>
  <div>
    <button @click="toggle">Toggle Animation</button>
    <button @click="changeDuration">Change Duration</button>
    
    <transition
      :css="false"
      @enter="enter"
      @leave="leave"
    >
      <div v-if="show" class="box">
        Dynamic Animation
      </div>
    </transition>
    
    <p>Current duration: {{ duration }}ms</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true,
      duration: 500
    }
  },
  methods: {
    toggle() {
      this.show = !this.show
    },
    
    changeDuration() {
      this.duration = this.duration === 500 ? 1000 : 500
    },
    
    enter(el, done) {
      gsap.fromTo(el, 
        {
          opacity: 0,
          x: -100
        },
        {
          opacity: 1,
          x: 0,
          duration: this.duration / 1000,
          ease: 'power2.out',
          onComplete: done
        }
      )
    },
    
    leave(el, done) {
      gsap.to(el, {
        opacity: 0,
        x: 100,
        duration: this.duration / 1000,
        ease: 'power2.in',
        onComplete: done
      })
    }
  }
}
</script>

5. 性能优化

5.1 使用will-change属性

will-change属性可以提示浏览器哪些属性可能会发生变化,以便浏览器提前优化:

<style>
.box {
  /* 提示浏览器这些属性可能会变化 */
  will-change: opacity, transform;
}
</style>

5.2 避免在动画过程中修改布局

尽量避免在动画过程中修改会导致重排的属性(如width、height、top、left等),优先使用transform和opacity属性,这些属性的变化不会触发重排。

5.3 使用requestAnimationFrame

在JavaScript动画中,使用requestAnimationFrame而不是setTimeoutsetInterval,因为requestAnimationFrame会与浏览器的刷新频率同步,提供更流畅的动画效果。

5.4 及时清理事件监听器

在动画结束后,及时清理添加的事件监听器,避免内存泄漏:

enter(el, done) {
  const transitionEndHandler = () => {
    el.removeEventListener('transitionend', transitionEndHandler);
    done();
  };
  
  el.addEventListener('transitionend', transitionEndHandler);
}

6. 实际应用案例

6.1 滚动触发动画

实现元素进入视口时的滚动触发动画:

<template>
  <div class="scroll-container">
    <h1>Scroll Down to See Animations</h1>
    
    <div v-for="item in items" :key="item.id" class="scroll-item">
      <transition
        name="scroll"
        :css="false"
        @enter="enter"
      >
        <div v-if="item.visible" class="box">
          {{ item.text }}
        </div>
      </transition>
    </div>
  </div>
</template>

<script>
import { gsap } from 'gsap'

export default {
  data() {
    return {
      items: [
        { id: 1, text: 'Item 1', visible: false },
        { id: 2, text: 'Item 2', visible: false },
        { id: 3, text: 'Item 3', visible: false },
        { id: 4, text: 'Item 4', visible: false },
        { id: 5, text: 'Item 5', visible: false }
      ]
    }
  },
  mounted() {
    // 监听滚动事件
    window.addEventListener('scroll', this.handleScroll)
    // 初始化检查
    this.handleScroll()
  },
  beforeUnmount() {
    // 清理事件监听器
    window.removeEventListener('scroll', this.handleScroll)
  },
  methods: {
    handleScroll() {
      this.items.forEach(item => {
        const element = document.querySelector(`[key="${item.id}"] .box`)
        if (element) {
          const rect = element.getBoundingClientRect()
          // 当元素进入视口时显示
          if (rect.top < window.innerHeight * 0.8 && !item.visible) {
            item.visible = true
          }
        }
      })
    },
    
    enter(el, done) {
      gsap.fromTo(el, 
        {
          opacity: 0,
          y: 50,
          scale: 0.9
        },
        {
          opacity: 1,
          y: 0,
          scale: 1,
          duration: 0.6,
          ease: 'power2.out',
          onComplete: done
        }
      )
    }
  }
}
</script>

<style>
.scroll-container {
  min-height: 2000px;
  padding: 20px;
}

.scroll-item {
  margin: 50px 0;
  height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.box {
  width: 200px;
  height: 200px;
  background-color: #42b983;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
}
</style>

6.2 数字计数器动画

实现数字从0到目标值的计数动画:

<template>
  <div>
    <button @click="startCount">Start Counting</button>
    
    <transition
      name="count"
      @enter="enter"
    >
      <div v-if="show" class="counter">
        {{ displayCount }}
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: false,
      targetCount: 1000,
      displayCount: 0
    }
  },
  methods: {
    startCount() {
      this.show = false
      this.displayCount = 0
      
      // 重新触发过渡
      this.$nextTick(() => {
        this.show = true
      })
    },
    
    enter(el, done) {
      const startTime = performance.now()
      const duration = 2000 // 2秒
      const startCount = 0
      const endCount = this.targetCount
      
      const count = (currentTime) => {
        const elapsed = currentTime - startTime
        const progress = Math.min(elapsed / duration, 1)
        
        // 使用easeOutQuad缓动
        const easeProgress = 1 - Math.pow(1 - progress, 2)
        
        this.displayCount = Math.floor(startCount + (endCount - startCount) * easeProgress)
        
        if (progress < 1) {
          requestAnimationFrame(count)
        } else {
          this.displayCount = endCount
          done()
        }
      }
      
      requestAnimationFrame(count)
    }
  }
}
</script>

<style>
.counter {
  font-size: 48px;
  font-weight: bold;
  color: #3498db;
  text-align: center;
  margin: 50px 0;
}

/* CSS过渡样式 */
.count-enter-from {
  opacity: 0;
  transform: scale(0.5);
}

.count-enter-active {
  transition: opacity 0.5s ease;
}

.count-enter-to {
  opacity: 1;
  transform: scale(1);
}
</style>

7. 常见问题与解决方案

7.1 动画没有触发

问题:JavaScript动画钩子没有被调用。

解决方案

  • 确保使用了&lt;transition&gt;组件包裹元素
  • 确保元素是通过v-if或v-show来控制显示/隐藏的
  • 检查动画钩子的名称是否正确(注意连字符格式)

7.2 动画没有结束

问题:动画开始后,元素一直停留在DOM中,没有被移除。

解决方案

  • 确保在@enter@leave钩子中调用了done()函数
  • 检查done()函数是否在正确的时机被调用
  • 确保没有在调用done()后继续修改元素样式

7.3 动画效果不流畅

问题:JavaScript动画效果不流畅,出现卡顿。

解决方案

  • 使用requestAnimationFrame而不是setTimeoutsetInterval
  • 避免在动画过程中修改会导致重排的属性
  • 使用GSAP等优化过的动画库
  • 添加will-change属性提示浏览器优化

7.4 多个动画同时执行

问题:多个元素的动画同时执行,导致性能问题。

解决方案

  • 为动画添加延迟,错开执行时间
  • 使用节流或防抖控制动画触发频率
  • 考虑使用transition-group组件处理列表动画

8. 总结

JavaScript动画钩子为Vue的过渡系统提供了强大的扩展能力,允许我们实现各种复杂的动画效果。通过合理使用JavaScript动画钩子,我们可以:

  1. 实现基于JavaScript的自定义动画
  2. 与第三方动画库(如GSAP)集成
  3. 执行动画前后的DOM操作
  4. 实现状态驱动的动画
  5. 处理动画的开始、进行中和结束事件

在使用JavaScript动画钩子时,需要注意以下几点:

  • 始终在@enter@leave钩子中调用done()函数,否则动画将永远不会结束
  • 优先使用transform和opacity属性进行动画,避免触发重排
  • 使用requestAnimationFrame实现流畅的动画效果
  • 及时清理事件监听器,避免内存泄漏
  • 考虑使用will-change属性优化动画性能

JavaScript动画钩子为我们提供了无限的可能性,可以创建出各种炫酷的动画效果,提升用户体验。但也要注意不要过度使用复杂的JavaScript动画,避免影响应用的性能。

9. 练习

  1. 使用JavaScript动画钩子实现一个简单的淡入淡出效果
  2. 集成GSAP库,实现一个复杂的3D旋转动画
  3. 实现一个滚动触发的动画效果
  4. 创建一个数字计数器动画,从0计数到指定值
  5. 实现一个基于鼠标位置的交互式动画
  6. 结合CSS过渡和JavaScript钩子,实现一个组合动画效果

10. 进一步阅读

« 上一篇 过渡动画基础类名 下一篇 » 列表过渡transition-group