第 27 章

Transition 动画系统:CSS 类名状态机、JS 钩子与 FLIP 原理

第27章:Transition 动画系统——CSS 类名状态机、JS 钩子与 FLIP 原理

<Transition> 组件的核心秘密:v-enter-fromv-enter-active 必须在同一帧内、DOM 插入前添加到元素上,否则浏览器看不到起始状态,动画直接跳到终点——这是 95% 的 Vue 动画不生效 bug 的根本原因。

本章核心问题:Vue 的动画系统是如何在 DOM 操作与浏览器渲染帧之间精确协调类名切换的?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

1.1 六个类名:动画的完整生命周期

<Transition> 组件的工作方式,是在元素进入/离开时,按照精确的时序添加和移除 CSS 类名。一共有 6 个类名,分为进入和离开两组:

进入动画(Enter)

离开动画(Leave)

最常见的淡入淡出动画写法:

.v-enter-active,
.v-leave-active {
  transition: opacity 0.3s ease;
}

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

这里的关键逻辑是:

1.2 基本使用:包裹单个元素

<Transition> 组件只能包裹单个子元素(或单个组件):

<template>
  <button @click="show = !show">切换</button>
  
  <Transition name="fade">
    <div v-if="show" class="box">内容</div>
  </Transition>
</template>

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

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

注意 name="fade" 改变了类名前缀,从默认的 v- 变为 fade-。这样可以在同一页面使用多种不同的动画效果。

1.3 CSS animation 与 CSS transition 的区别

v-enter-active 可以使用 CSS transition,也可以使用 CSS animation

使用 CSS transition

.slide-enter-active {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

.slide-enter-from {
  transform: translateX(-20px);
  opacity: 0;
}

使用 CSS animation

.bounce-enter-active {
  animation: bounce-in 0.5s;
}

@keyframes bounce-in {
  0%   { transform: scale(0); }
  50%  { transform: scale(1.2); }
  100% { transform: scale(1); }
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

Vue 内部会检测 v-enter-active 上是否有 transitionanimation,通过监听 transitionendanimationend 事件来判断动画何时结束。

当两者同时存在时,Vue 会以持续时间更长的那个为准。也可以通过 type 属性强制指定:

<Transition type="animation">
  <!-- 同时有 transition 和 animation 时,以 animation 为准 -->
</Transition>

1.4 appear:初始渲染也播放动画

默认情况下,<Transition> 只在元素切换(显示/隐藏)时播放动画,初始渲染不播放。添加 appear 属性可以改变这一行为:

<Transition name="fade" appear>
  <div>页面加载时也有淡入效果</div>
</Transition>

初始渲染使用专属的类名:v-appear-fromv-appear-activev-appear-to。如果没有定义这些类名,Vue 会回退使用进入动画的类名(v-enter-from 等)。

1.5 多元素切换:mode 属性

当需要在两个元素之间切换时(例如按钮状态切换),默认情况下离开和进入动画会同时播放,这可能导致两个元素短暂同时出现:

<Transition name="fade" mode="out-in">
  <button v-if="isEditing" key="save">保存</button>
  <button v-else key="edit">编辑</button>
</Transition>

mode="out-in" 表示先播放离开动画,结束后再播放进入动画。mode="in-out" 则相反。

注意:多元素切换时必须给每个元素设置不同的 key,否则 Vue 会认为是同一个元素在更新内容,不会触发过渡。

1.6 TransitionGroup:列表动画

<TransitionGroup> 用于处理 v-for 列表的动画:

<TransitionGroup name="list" tag="ul">
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</TransitionGroup>

<style>
.list-enter-active,
.list-leave-active {
  transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
/* 移动动画 */
.list-move {
  transition: transform 0.3s ease;
}
/* 离开时脱离文档流,让其他元素能移动到位 */
.list-leave-active {
  position: absolute;
}
</style>

v-move 类名(这里是 list-move)是 <TransitionGroup> 独有的,用于元素位移动画,基于 FLIP 算法实现(Level 2 会详细讲解)。


Level 2 · 它是怎么运行的(3-5年经验)

2.1 类名添加/移除的精确时序

理解 Vue 动画的关键,是精确理解每个类名在哪一帧被添加和移除。下面是进入动画的完整时序:

帧 N(触发 v-if="true")
│
├── Vue 创建 VNode,准备插入 DOM
├── 添加 v-enter-from(起始状态:如 opacity: 0)
├── 添加 v-enter-active(定义 transition 属性)
├── 将 DOM 节点插入文档
│   └── 此时浏览器尚未渲染(还在同一个 JS 任务中)
│
帧 N+1(下一个渲染帧,通过 requestAnimationFrame)
├── 添加 v-enter-to(终止状态:如 opacity: 1)
├── 移除 v-enter-from
│   └── 浏览器看到:从 opacity:0 过渡到 opacity:1 → 动画开始
│
动画结束(监听 transitionend 事件)
└── 移除 v-enter-active 和 v-enter-to
    └── 动画清理完成

为什么必须在同一帧插入 DOM 并添加类名?

浏览器的渲染流程是:JS 执行样式计算布局绘制。在同一个 JS 任务中,即使多次修改 DOM 和样式,浏览器也只会在任务结束后统一进行一次样式计算。

因此,Vue 在同一帧内:

  1. 添加 v-enter-fromopacity: 0
  2. 插入 DOM 节点

浏览器看到的是:带有 opacity: 0 样式的新节点

然后在下一帧(通过 requestAnimationFrame):

  1. 移除 v-enter-from
  2. 添加 v-enter-to

浏览器看到样式从 opacity: 0 变为 opacity: 1,触发 CSS transition。

错误场景:如果插入 DOM 后立即(同一帧)移除 v-enter-from,浏览器在做样式计算时已经是 opacity: 1 了,没有起点,动画不触发。

错误时序(动画不生效):
帧 N: 插入 DOM + 添加 v-enter-from + 立即移除 v-enter-from
      → 浏览器计算样式时 opacity 已经是 1,没有过渡

ASCII 流程图——进入动画的完整状态机:

触发条件(v-if=true / v-show=true)
         │
         ▼
┌────────────────────────────────────┐
│  同一帧(Frame N)                  │
│  1. 添加 v-enter-from              │  opacity: 0
│  2. 添加 v-enter-active            │  transition: opacity 0.3s
│  3. 插入 DOM 节点                   │
└────────────────┬───────────────────┘
                 │ requestAnimationFrame
                 ▼
┌────────────────────────────────────┐
│  下一帧(Frame N+1)               │
│  4. 移除 v-enter-from              │  opacity: 0 → 消失
│  5. 添加 v-enter-to               │  opacity: 1 → 浏览器触发过渡
└────────────────┬───────────────────┘
                 │ transitionend 事件
                 ▼
┌────────────────────────────────────┐
│  动画结束                           │
│  6. 移除 v-enter-active            │
│  7. 移除 v-enter-to               │
└────────────────────────────────────┘

2.2 离开动画的特殊处理

离开动画与进入动画有一个重要区别:元素必须在动画结束后才能从 DOM 中移除,而不是立即移除。

触发条件(v-if=false / v-show=false)
         │
         ▼
┌────────────────────────────────────┐
│  Frame N                           │
│  1. 添加 v-leave-from              │  当前状态(opacity: 1)
│  2. 添加 v-leave-active            │  transition: opacity 0.3s
│  (元素仍在 DOM 中)               │
└────────────────┬───────────────────┘
                 │ requestAnimationFrame
                 ▼
┌────────────────────────────────────┐
│  Frame N+1                         │
│  3. 移除 v-leave-from              │
│  4. 添加 v-leave-to               │  opacity: 0 → 触发过渡
└────────────────┬───────────────────┘
                 │ transitionend 事件
                 ▼
┌────────────────────────────────────┐
│  动画结束                           │
│  5. 移除元素(或设置 display:none) │
│  6. 移除 v-leave-active            │
│  7. 移除 v-leave-to               │
└────────────────────────────────────┘

这就是为什么离开动画期间元素还在 DOM 里。如果设置了 position: absolute,可以防止它占用布局空间(这在 <TransitionGroup> 中很常用)。

2.3 duration 自动检测:Vue 如何知道动画何时结束

Vue 不依赖硬编码的时间,而是监听 CSS 事件:

// 伪代码,简化自 Vue 3 源码 packages/runtime-dom/src/components/Transition.ts
function whenTransitionEnds(el, expectedType, resolve) {
  // 检测元素上的 transition 和 animation
  const { type, timeout, propCount } = getTransitionInfo(el, expectedType);
  
  if (!type) {
    return resolve(); // 没有动画,直接完成
  }
  
  const endEvent = type === 'transition' ? 'transitionend' : 'animationend';
  let ended = 0;
  
  const onEnd = (e) => {
    if (e.target === el) {
      ended++;
      if (ended >= propCount) {
        // 所有属性的过渡都完成了
        el.removeEventListener(endEvent, onEnd);
        resolve();
      }
    }
  };
  
  el.addEventListener(endEvent, onEnd);
  
  // 超时保险:防止 transitionend 未触发
  setTimeout(() => {
    if (ended < propCount) {
      resolve();
    }
  }, timeout + 1);
}

getTransitionInfo 会读取元素的计算样式(getComputedStyle(el)),解析 transition-durationanimation-duration 等属性,计算出最长的持续时间。

多属性过渡:如果同时过渡 opacitytransform,Vue 等待所有属性的 transitionend 都触发后才认为动画完成。

2.4 JavaScript 钩子与 done() 机制

<Transition> 提供了完整的 JavaScript 钩子,允许使用 GSAP 等第三方动画库:

<Transition
  @before-enter="onBeforeEnter"
  @enter="onEnter"
  @after-enter="onAfterEnter"
  @enter-cancelled="onEnterCancelled"
  @before-leave="onBeforeLeave"
  @leave="onLeave"
  @after-leave="onAfterLeave"
  @leave-cancelled="onLeaveCancelled"
>
  <div v-if="show">内容</div>
</Transition>

<script setup>
import gsap from 'gsap';

function onEnter(el, done) {
  gsap.from(el, {
    opacity: 0,
    y: -20,
    duration: 0.5,
    onComplete: done  // 必须调用 done!
  });
}

function onLeave(el, done) {
  gsap.to(el, {
    opacity: 0,
    y: 20,
    duration: 0.5,
    onComplete: done  // 必须调用 done!
  });
}
</script>

done() 的作用:告诉 Vue 动画已经完成。对于 @enter@leave 钩子,done 是第二个参数。如果不调用 done()

当使用 JS 钩子时,建议添加 :css="false" 告诉 Vue 跳过 CSS 过渡检测,避免干扰:

<Transition :css="false" @enter="onEnter" @leave="onLeave">

各钩子的完整签名:

钩子 参数 说明
before-enter (el) DOM 插入前,元素已有 v-enter-from
enter (el, done) DOM 插入后,必须调用 done
after-enter (el) 进入动画完成
enter-cancelled (el) 进入动画被中断(元素又被隐藏)
before-leave (el) 离开动画开始前
leave (el, done) 离开动画进行中,必须调用 done
after-leave (el) 离开动画完成,元素已移除
leave-cancelled (el) 离开动画被中断(元素又被显示)

2.5 FLIP 算法:TransitionGroup 的位移动画原理

FLIP 是 First-Last-Invert-Play 的缩写,是实现元素位移动画的核心技术。普通的 CSS transition 只能动画化样式值的变化,但无法直接动画化 DOM 的重新排列(因为 DOM 操作是瞬间的)。FLIP 通过以下步骤绕过这个限制:

FLIP 算法的四个步骤:

First(记录初始位置)
│  getBoundingClientRect() 记录所有元素的位置
│  
Last(执行 DOM 操作)
│  实际重排列表(添加、删除、重新排序)
│  getBoundingClientRect() 记录所有元素的新位置
│  
Invert(计算逆变换)
│  对每个移动的元素,计算 deltaX = first.left - last.left
│                              deltaY = first.top - last.top
│  设置 transform: translate(deltaX, deltaY) → 让元素"看起来"还在原位
│  (这一步是同步的,浏览器还没渲染)
│  
Play(播放动画)
│  移除强制的 transform(或将其动画到 translate(0, 0))
│  浏览器渲染:元素从"原位"移动到"新位" → 视觉上产生流畅动画

Vue 的 <TransitionGroup> 内置了 FLIP 实现:

// 伪代码,简化自 Vue 3 源码 packages/runtime-dom/src/components/TransitionGroup.ts

// 1. First:更新前记录位置
children.forEach(child => {
  child._moveCb = null;
  child._enterCb = null;
  // 保存旧位置
  const info = (child._transition = {});
  info.oldPos = child.el.getBoundingClientRect();
});

// (Vue 虚拟 DOM diff 和真实 DOM 更新在这里发生)

// 2. Last + Invert:更新后,对需要移动的元素应用反向 transform
nextTick(() => {
  children.forEach(child => {
    const c = child._transition;
    const newPos = child.el.getBoundingClientRect();
    const dx = c.oldPos.left - newPos.left;
    const dy = c.oldPos.top - newPos.top;
    
    if (dx || dy) {
      // Invert:让元素看起来在原来的位置
      child.el.style.transform = `translate(${dx}px, ${dy}px)`;
      child.el.style.transitionDuration = '0s'; // 关闭过渡(避免闪烁)
    }
  });
  
  // 3. Play:强制浏览器重排,然后清除 transform
  document.body.offsetHeight; // 强制重排(读取布局信息)
  
  children.forEach(child => {
    if (child.el.style.transform) {
      // 添加 v-move 类(定义 transition),然后清除 transform
      child.el.classList.add('v-move');
      child.el.style.transform = '';
      child.el.style.transitionDuration = '';
    }
  });
});

为什么需要 position: absolute

当列表项离开时,它仍然占据 DOM 空间,会影响其他元素的"Last"位置计算。设置 position: absolute 让离开的元素脱离文档流,使其他元素能正确地移动到目标位置。

.list-leave-active {
  position: absolute; /* 关键:离开元素脱离文档流 */
}
.list-move {
  transition: transform 0.3s ease;
}

2.6 路由切换动画

与 Vue Router 配合实现页面切换动画:

<!-- App.vue -->
<template>
  <RouterView v-slot="{ Component, route }">
    <Transition :name="route.meta.transition || 'fade'" mode="out-in">
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<style>
/* 向前跳转:新页面从右侧进入 */
.slide-left-enter-from { transform: translateX(100%); }
.slide-left-leave-to  { transform: translateX(-100%); }

/* 向后跳转:新页面从左侧进入 */
.slide-right-enter-from { transform: translateX(-100%); }
.slide-right-leave-to  { transform: translateX(100%); }

.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
  transition: transform 0.3s ease;
}
</style>

路由配置:

const routes = [
  { path: '/', component: Home, meta: { transition: 'slide-left' } },
  { path: '/detail', component: Detail, meta: { transition: 'slide-right' } },
];

更智能的方案是根据路由历史动态决定方向:

// router.js
let previousDepth = 0;

router.afterEach((to, from) => {
  const toDepth = to.path.split('/').length;
  const fromDepth = from.path.split('/').length;
  to.meta.transition = toDepth > fromDepth ? 'slide-left' : 'slide-right';
});

Level 3 · 设计文档与源码(资深开发者)

3.1 Transition 组件的源码结构

Vue 3 的 Transition 实现分布在两个包中:

BaseTransition 的核心packages/runtime-core/src/components/BaseTransition.ts):

// 进入动画的完整调度逻辑(简化版)
function performEnter(el: Element, hooks: TransitionHooks) {
  const {
    beforeEnter, enter, afterEnter, 
    enterCancelled, delayLeave, delayedLeave
  } = hooks;
  
  const resolve = (cancelled?: boolean) => {
    if (cancelled) {
      enterCancelled(el);
    } else {
      afterEnter(el);
    }
    // 清理状态
    if (el._enterCb) {
      el._enterCb = null;
    }
  };
  
  // 防止重复执行
  if (el._enterCb) {
    el._enterCb(true /* cancelled */);
  }
  el._enterCb = resolve;
  
  // 调用 beforeEnter 钩子
  if (beforeEnter) beforeEnter(el);
  
  // 下一帧执行(这就是 requestAnimationFrame 调用的地方)
  nextFrame(() => {
    if (el._enterCb) {
      // 如果已被取消,不执行
      if (enter) {
        enter(el, resolve);
      } else {
        resolve();
      }
    }
  });
}

DOM 层的类名操作packages/runtime-dom/src/components/Transition.ts):

const duringEnterFrom: TransitionHooks = {
  beforeEnter(el) {
    // 添加 v-enter-from 和 v-enter-active(Frame N)
    addTransitionClass(el, enterFromClass);  // v-enter-from
    addTransitionClass(el, enterActiveClass); // v-enter-active
  },
  enter(el, done) {
    // 这里在 Frame N+1(nextFrame 回调中)
    // 移除 v-enter-from,添加 v-enter-to
    removeTransitionClass(el, enterFromClass);
    addTransitionClass(el, enterToClass);
    
    if (!hasExplicitCallback(done)) {
      // 没有显式的 JS done 回调,用 CSS 事件
      whenTransitionEnds(el, type, () => {
        removeTransitionClass(el, enterToClass);
        removeTransitionClass(el, enterActiveClass);
        done();
      });
    }
  },
  afterEnter(el) {
    removeTransitionClass(el, enterToClass);
    removeTransitionClass(el, enterActiveClass);
  }
};

3.2 nextFrame 的实现

Vue 内部用 requestAnimationFrame 的双重嵌套来确保在下一个绘制帧执行:

// packages/runtime-dom/src/components/Transition.ts
const raf = window.requestAnimationFrame
  ? window.requestAnimationFrame.bind(window)
  : setTimeout;

function nextFrame(fn: () => void) {
  raf(() => {
    raf(fn); // 双重 RAF,确保在样式计算后
  });
}

为什么要双重 RAF?

单个 requestAnimationFrame 的回调仍然可能在浏览器处理样式之前执行(取决于浏览器实现)。双重 RAF 确保:

  1. 第一个 RAF:浏览器完成当前帧的样式计算和布局
  2. 第二个 RAF:在下一帧开始时执行,此时 DOM 和样式都已稳定

注意:部分浏览器(Chrome 64+)对 RAF 的实现已经足够精确,单个 RAF 也可以工作。但双重 RAF 是更保险的写法,Vue 沿用了这个 pattern。

3.3 TransitionGroup 的 FLIP 源码分析

packages/runtime-dom/src/components/TransitionGroup.ts 中的位移检测逻辑:

function callPendingCbs(c: VNode) {
  const el = c.el as any;
  if (el._moveCb) {
    el._moveCb();
  }
  if (el._enterCb) {
    el._enterCb();
  }
}

function recordPosition(c: VNode) {
  newPositionMap.set(c, (c.el as Element).getBoundingClientRect());
}

function applyTranslation(c: VNode): VNode | undefined {
  const oldPos = positionMap.get(c)!;  // First 阶段记录的位置
  const newPos = newPositionMap.get(c)!; // Last 阶段记录的位置
  const dx = oldPos.left - newPos.left;
  const dy = oldPos.top - newPos.top;
  
  if (dx || dy) {
    const s = (c.el as HTMLElement).style;
    // Invert:设置反向 transform
    s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`;
    s.transitionDuration = '0s'; // 立即跳到反向位置(无动画)
    return c;
  }
}

3.4 性能优化:GPU 合成层

动画性能的关键是避免触发 Layout(重排)和 Paint(重绘),只触发 Composite(合成):

只触发 Composite 的属性(可以 GPU 加速):

会触发 Layout 的属性(性能杀手,避免动画化):

最佳实践:用 will-change 提前告知浏览器:

.animated-element {
  will-change: transform, opacity;
  /* 浏览器会提前为该元素创建独立的合成层 */
}

注意滥用 will-change:每个合成层消耗 GPU 内存(约 4 * width * height 字节),不要对所有元素使用。只对即将动画的元素使用,动画结束后移除:

el.addEventListener('mouseenter', () => {
  el.style.willChange = 'transform';
});
el.addEventListener('animationend', () => {
  el.style.willChange = 'auto'; // 动画结束后释放
});

Level 4 · 边界与陷阱(全体适用)

陷阱 1:同一帧内修改 CSS 类名导致动画跳帧

错误代码:自定义动画逻辑,手动在同一帧内移除起始类名:

// 错误!动画不会生效
function startAnimation(el) {
  el.classList.add('fade-enter-from');
  el.classList.add('fade-enter-active');
  el.style.display = 'block';
  
  // 错误:同一帧内就移除了 fade-enter-from
  el.classList.remove('fade-enter-from');
  el.classList.add('fade-enter-to');
  // 浏览器看到的是:元素直接以 fade-enter-to 状态出现
}

正确做法

function startAnimation(el) {
  el.classList.add('fade-enter-from');
  el.classList.add('fade-enter-active');
  el.style.display = 'block';
  
  // 强制重排(让浏览器"看到"初始状态)
  el.offsetHeight; // 读取任何布局属性都能强制重排
  
  // 或使用 requestAnimationFrame
  requestAnimationFrame(() => {
    el.classList.remove('fade-enter-from');
    el.classList.add('fade-enter-to');
  });
}

陷阱 2:忘记给 @enter/@leave 钩子调用 done(),导致元素永远不消失

错误代码

<Transition @leave="onLeave">
  <div v-if="show">内容</div>
</Transition>

<script setup>
function onLeave(el, done) {
  gsap.to(el, {
    opacity: 0,
    duration: 0.5,
    onComplete: () => {
      // 忘记调用 done()!
      console.log('动画完成');
    }
  });
}
</script>

现象:离开动画播放完毕,元素的 opacity 变为 0,但元素仍然占用 DOM 空间(因为 Vue 在等待 done() 才移除元素)。

正确代码

function onLeave(el, done) {
  gsap.to(el, {
    opacity: 0,
    duration: 0.5,
    onComplete: done  // 直接传入 done 作为回调
  });
}

陷阱 3:v-for 列表没有 key,或 key 设置不当

错误场景

<!-- 错误:使用 index 作为 key -->
<TransitionGroup name="list">
  <div v-for="(item, index) in items" :key="index">
    {{ item.name }}
  </div>
</TransitionGroup>

当在列表头部插入元素时,所有元素的 index 都改变了,Vue 认为是所有元素都在"更新",而不是在"新增",FLIP 动画会产生错误。

正确做法:使用稳定的唯一 id 作为 key:

<TransitionGroup name="list">
  <div v-for="item in items" :key="item.id">
    {{ item.name }}
  </div>
</TransitionGroup>

陷阱 4:TransitionGroup 的 position: absolute 忘记设置导致 FLIP 抖动

错误场景:列表项在离开时没有脱离文档流:

/* 缺少 position: absolute */
.list-leave-active {
  transition: opacity 0.3s;
}
.list-leave-to {
  opacity: 0;
}

现象:删除一个列表项时,该项慢慢消失,但在消失过程中它仍然占用位置,导致其他元素的"Last"位置是错误的,FLIP 动画会先跳到错误位置再移动到正确位置(视觉上表现为抖动)。

正确代码

.list-leave-active {
  position: absolute; /* 脱离文档流 */
  transition: opacity 0.3s;
  width: 100%; /* 防止 absolute 导致宽度塌陷 */
}
.list-leave-to {
  opacity: 0;
}
.list-move {
  transition: transform 0.3s ease;
}

陷阱 5:mode="out-in" 与 keep-alive 组合导致动画卡死

错误场景

<!-- 错误:keep-alive 包裹时使用 mode="out-in" 可能导致问题 -->
<Transition mode="out-in">
  <KeepAlive>
    <component :is="currentComponent" />
  </KeepAlive>
</Transition>

<KeepAlive> 缓存的组件在"离开"时不会真正卸载,而是调用 deactivated 钩子。这与 mode="out-in" 的"等待离开完成"逻辑可能产生冲突。

正确做法:将 <Transition> 放在 <KeepAlive> 内部:

<KeepAlive>
  <Transition mode="out-in">
    <component :is="currentComponent" />
  </Transition>
</KeepAlive>

本章小结

  1. 状态机时序是核心v-enter-fromv-enter-active 必须在 DOM 插入前同帧添加,下一帧(通过 requestAnimationFrame)才切换到 v-enter-to,这是动画能够触发的根本原因。

  2. done() 不可省略:使用 JavaScript 钩子时,@enter@leave 的第二个参数 done 必须调用,否则 Vue 的状态机永远不会推进,元素要么永远不显示完成状态,要么永远不从 DOM 中移除。

  3. FLIP 算法是 TransitionGroup 的基础:先记录位置(First)→ 执行 DOM 操作(Last)→ 计算反向偏移(Invert)→ 清除偏移触发过渡(Play),实现了在不移动实际 DOM 位置的情况下产生视觉位移动画。

  4. 性能优化只动画 transform 和 opacity:这两个属性只触发 GPU 合成,不触发 Layout 和 Paint,是动画性能的金标准;will-change 可以提前创建合成层,但要避免滥用。

  5. TransitionGroup 离开时必须 position: absolute:让离开的元素脱离文档流,其他元素才能正确计算 FLIP 的 Last 位置,避免位移动画抖动。

本章评分
4.8  / 5  (4 评分)

💬 留言讨论