你到底懂不懂 Transition 组件?

1,690 阅读9分钟

前言

欢迎关注同名公众号《熊的猫》,文章会同步更新!

本来没有建群的打算,因为看到蛮多的群归于平静,但是部分同学想进入进阶群一起学习,既然有需求就整起,可以加 WX:Mr10212021,回复 加群 即可,原先加过的默认进群!

<Transition> 作为一个 Vue 中的内置组件,它可以将 进入动画离开动画 应用到通过 默认插槽 传递给目标元素或组件上。

也许你有在使用,但是一直不清楚它的原理或具体实现,甚至不清楚其内部提供的各个 class 到底怎么配合使用,想看源码又被其中各种引入搞得七荤八素...

本篇文章就以 Transition 组件为核心,探讨其核心原理的实现,文中不会对其各个属性再做额外解释,毕竟这些看文档就够了,希望能够给你带来帮助!!!

6685FEDA.png

Transition 内置组件

触发条件

<Transition> 组件的 进入动画离开动画 可通过以下的条件之一触发:

  • 由 v-if 所触发的切换
  • 由 v-show 所触发的切换
  • 由特殊元素 <component name="x"> 切换的动态组件
  • 改变特殊的 key 属性

再分类

其实我们可以将以上情况进行 再分类

  • 组件 挂载销毁
    • v-if 的变化
    • <component name="x"> 的变化
    • key 的变化
  • 组件 样式 属性 display: none | x 设置
    • v-show 的变化

扩展v-ifv-for 一起使用时,在 Vue2Vue3 中的不同

  • Vue2 中,当它们处于同一节点时,v-for 的优先级比 v-if 更高,即 v-if 将分别重复运行于每个 v-for 循环中,也就是 v-if 可以正常访问 v-for 中的数据
  • Vue3 中,当它们处于同一节点时,v-if 的优先级比 v-for 更高,即此时只要 v-if 的值为 falsev-for 的列表就不会被渲染,也就是 v-if 不能访问到 v-for 中的数据

六个过渡时机

image.png

总结起来就分为 进入离开 动画的 初始状态、生效状态、结束状态,具体如下:

  • 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 前缀是允许修改的,可以 <Transition> 组件传一个 name 的 prop 来声明一个过渡效果名,如下就是将 v 前缀修改为 modal 前缀:

<Transition name="modal"> ... </Transition>

Transition 组件 & CSS transition 属性

9.gif

以上这个简单的效果,核心就是两个时机:

  • v-enter-active 进入动画的 生效状态
  • v-leave-active 离开动画的 生效状态

再配合简单的 CSS 过渡属性就可以达到效果,代码如下:

<template>
  <div class="home">
    <transition name="golden">
      <!-- 金子列表 -->
      <div class="golden-box" v-show="show">
        <img
          class="golden"
          :key="idx"
          v-for="idx in 3"
          src="../assets/golden.jpg"
        />
      </div>
    </transition>
  </div>
  <!-- 钱袋子 -->
  <img class="purse" @click="show = !show" src="../assets/purse.png" alt="" />
</template>

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

const show = ref(true)
</script>

<style lang="less" scoped>
.home {
  min-height: 66px;
}

.golden-box {
  transition: all 1s ease-in;

  .golden {
    width: 100px;
    position: fixed;
    transform: translate3d(0, 0, 0);
    transition: all .4s;

    &:nth-of-type(1) {
      left: 45%;
      top: 100px;
    }
    &:nth-of-type(2) {
      left: 54%;
      top: 50px;
    }
    &:nth-of-type(3) {
      right: 30%;
      top: 100px;
    }
  }

  &.golden-enter-active {
    .golden {
      transform: translate3d(0, 0, 0);
      transition-timing-function: cubic-bezier(0, 0.57, 0.44, 1.97);
    }
    .golden:nth-of-type(1) {
      transition-delay: 0.1s;
    }
    .golden:nth-of-type(2) {
      transition-delay: 0.2s;
    }
    .golden:nth-of-type(3) {
      transition-delay: 0.3s;
    }
  }

  &.golden-leave-active {
    .golden:nth-of-type(1) {
      transform: translate3d(150px, 140px, 0);
      transition-delay: 0.3s;
    }
    .golden:nth-of-type(2) {
      transform: translate3d(0, 140px, 0);
      transition-delay: 0.2s;
    }
    .golden:nth-of-type(3) {
      transform: translate3d(-100px, 140px, 0);
      transition-delay: 0.1s;
    }
  }
}

.purse {
  position: fixed;
  width: 200px;
  margin-top: 100px;
  cursor: pointer;
}
</style>

当然动画的效果是多种多样的,不仅只是局限于这一种,例如可以配合:

  • CSStransition 过渡属性(上述例子使用的方案)
  • CSSanimation 动画属性
  • gsap 库

核心原理

通过上述内容其实不难发现其核心原理就是:

  • 组件(DOM)挂载 时,将过渡动效添加到该 DOM 元素上
  • 组件(DOM)卸载 时,不是直接卸载,而是等待附加到 DOM 元素上的 动效执行完成,然后在真正执行卸载操作,即 延迟卸载时机

在上述的过程中,<Transition> 组件会为 目标组件/元素 通过添加不同的 class 来定义 初始、生效、结束 三个状态,当进入下一个状态时会把上一个状态对应的 class 移除。

6B7EA0AD.jpg

那么你可能会问了,v-show 的形式也不符合 挂载/卸载 的形式呀,毕竟它只是在修改 DOM 元素的 display: none | x 的样式!

让源码中的注释来回答:

image.png

v-if<component name="x">key 控制组件 显示/隐藏 的方式是 挂载/卸载 组件,而 v-show 控制组件 显示/隐藏 的方式是 修改/重置 display: none | x 属性值,从本质上看方式不同,但从结果上看都属于控制组件的 显示/隐藏,即功能是一致的,而这里所说的 挂载/卸载 是针对大部分情况来说的,毕竟四种触发方式中就有三种符合此情况。

实现 Transition 组件

所谓 Transition 组件毕竟是 Vue 的内置组件,换句话说,组件的编写要符合 Vue 的规范(即 声明式写法),但为了更好的理解核心原理,我们应该从 原生 DOM 的过渡开始(即 命令式写法)探讨。

原生 DOM 如何实现过渡?

所谓的 过渡动效 本质上就是一个 DOM 元素在 两种状态间的转换浏览器 会根据我们设置的过渡效果 自行完成 DOM 元素的过渡

状态的转换 指的就是 初始化状态结束状态 的转换,并且配合 CSS 中的 transition 属性就可以实现两个状态间的过渡,即 运动过程

原生 DOM 元素移动示例

假设要为一个元素在垂直方向上添加进场动效:从 原始位置 向上移动 200px 的位置,然后在 1s 内运动回 原始位置

进场动效

用 CSS 描述

  // 描述物体
  .box {
    width: 100px;
    height: 100px;
    background-color: red;
    box-shadow: 0 0 8px;
    border-radius: 50%;
  }
  
  // 初始状态
  .enter-from {
    transform: translateY(-200px);
  }
  // 运动过程
  .enter-active {
    transition: transform 1s ease-in-out;
  }
  // 结束状态
  .enter-to {
    transform: translateY(0);
  }

用 JavaScript 描述

// 创建元素
const div = document.createElement('div')
div.classList.add('box')

// 添加 初始状态 和 运动过程
div.classList.add('enter-from')
div.classList.add('enter-active')

// 将元素添加到页面上
document.body.appendChild(div)

// 切换元素状态
div.classList.remove('enter-from')
div.classList.add('enter-to')

命令式编程 的步骤上来看,似乎每一步都没有问题,但实际的过渡动画是不会生效的,虽然在代码中我们有 状态的切换,但这个切换的操作对于 浏览器 来讲是在 同一帧中进行的,所以只会渲染 最终状态,即 enter-to 类所指向的状态。

requestAnimationFrame 实现下一帧的变化

window.requestAnimationFrame(callback) 会在浏览器在 下次重绘之前 调用指定的 回调函数 用于更新动画。

也就是说,单个的 requestAnimationFrame() 方法是在 当前帧 中执行的,也就是如果想要在 下一帧 中执行就需要使用两个 requestAnimationFrame() 方法嵌套的方式来实现,如下:

  // 嵌套的 requestAnimationFrame 实现在下一帧中,切换元素状态
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      div.classList.remove("enter-from");
      div.classList.add("enter-to");
    });
  });

transitionend 事件监听动效结束

以上就完成元素的 进入动效,那么在动效结束之后,别忘了将原本和 进入动效 相关的 移除掉,可以通过 transitionend 事件 监听动效是否结束,如下

// 嵌套的 requestAnimationFrame 实现在下一帧中,切换元素状态
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      div.classList.remove("enter-from");
      div.classList.add("enter-to");

      // 动效结束后,移除和动效相关的类
      div.addEventListener("transitionend", () => {
        div.classList.remove("enter-to");
        div.classList.remove("enter-active");
      });
    });
  });

以上就是 进场动效 的实现,如下:

9.gif

离场动效

有了进场动效的实现过程,在定义 离场动效 时就可以选择和 进场动效 相对应的形式,即 初始状态过渡过程结束状态

用 CSS 描述

   // 初始状态
  .leave-from {
    transform: translateY(0);
  }
  // 过渡状态
  .leave-active {
    transition: transform 2s ease-out;
  }
  // 结束状态
  .leave-to {
    transform: translateY(-300px);
  }

用 JavaScript 描述

所谓的 离场 就是指 DOM 元素卸载,但因为要有离场动效要展示,所以不能直接卸载对应的元素,而是要 等待离场动效结束之后在进行卸载

为了直观一些,我们可以添加一个离场的按钮,用于触发离场动效。

  // 创建离场按钮
  const btn = document.createElement("button");
  btn.innerText = "离场";
  document.body.appendChild(btn);

  // 绑定事件
  btn.addEventListener("click", () => {
    // 设置离场 初始状态 和 运动过程
    div.classList.add("leave-from");
    div.classList.add("leave-active");

    // 嵌套的 requestAnimationFrame 实现在下一帧中,切换元素状态
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        div.classList.remove("leave-from");
        div.classList.add("leave-to");

        // 动效结束后,移除和动效相关的类
        div.addEventListener("transitionend", () => {
          div.classList.remove("leave-to");
          div.classList.remove("leave-active");

          // 离场动效结束,移除目标元素
          div.remove();
        });
      });
    });
  });

离场动效,如下:

9.gif

实现 Transition 组件

以上的实现过程,可以将其进行抽象化为三个阶段:

  • beforeEnter
  • enter
  • leave

image.png

现在要从 命令式编程 转向 声明式编程 了,因为我们要去编写 Vue 组件 了,即基于 VNode 节点来实现,为了和普通的 VNode 作为区分,Vue 中会为目标元素的 VNode 节点上添加 transition 属性:

  • Transition 组件 本身不会渲染任何额外的内容,它只是通过 默认插槽 读取过渡元素,并渲染需要过渡的元素
  • Transition 组件 作用,是在过渡元素的 VNode 节点上添加和 transition 相关的 钩子函数
<script lang="ts">
import { defineComponent } from 'vue';

const nextFrame = (callback: () => unknown) => {
  requestAnimationFrame(() => {
    requestAnimationFrame(callback)
  })
}

export default defineComponent({
  name: 'Transition',
  setup(props, { slots }) {
    // 返回 render 函数
    return () => {
      // 通过默认插槽,获取目标元素
      const innerVNode = (slots as any).default()

      // 为目标元素添加 transition 相关钩子
      innerVNode.transition = {
        beforeEnter(el: any) {
          console.log(111)
          // 设置 初始状态 和 运动过程
          el.classList.add("enter-from");
          el.classList.add("enter-active");
        },
        enter(el: any) {
          // 在下一帧切换状态
          nextFrame(() => {
            // 切换状态
            el.classList.remove("enter-from");
            el.classList.add("enter-to");

            // 动效结束后,移除和动效相关的类
            el.addEventListener("transitionend", () => {
              el.classList.remove("enter-to");
              el.classList.remove("enter-active");
            });
          })
        },
        leave(el: any) {
          // 设置离场 初始状态 和 运动过程
          el.classList.add("leave-from");
          el.classList.add("leave-active");

          // 在下一帧中,切换元素状态
          nextFrame(() => {
            // 切换元素状态
            el.classList.remove("leave-from");
            el.classList.add("leave-to");

            // 动效结束后,移除和动效相关的类
            el.addEventListener("transitionend", () => {
              el.classList.remove("leave-to");
              el.classList.remove("leave-active");

              // 离场动效结束,移除目标元素
              el.remove();
            });
          })
        }
      }

      // 返回修改过的 VNode
      return innerVNode
    }
  }
})
</script>

最后

欢迎关注同名公众号《熊的猫》,文章会同步更新!

从整体来看,Transition 组件 的核心并不算复杂,特别是以 命令式编程 实现之后,但话说回来在 Vue 源码中实现的还是很全面的,比如:

  • 提供 props 实现用户自定义类名
  • 提供 内置模式,即先进后出(in-out)、后进先出(enter-to
  • 支持 v-show 方式触发过渡效果
  • ...

095387C3.gif

希望以上内容对你有所帮助!!!