Vue 过渡动画:什么时候是在补信息,什么时候只是添动作

过渡动画最容易被误解成“做得更花一点”,其实它真正的价值是补信息。一个弹窗淡入,是在告诉用户新的操作层已经接管视线;一条列表项平滑移走,是在确认刚才那次删除已经生效;状态切换有过渡,也是在减少“是不是没点上”的迟疑。

一旦把这个目的想清楚,动画就不再是装饰,而是交互反馈的一部分。反过来,如果只是为了“显得丝滑”而加动效,最后往往只会拖慢操作、分散注意力。

后面会从几个具体交互场景切进去,看 Vue 的 <transition><transition-group> 分别适合补哪类信息、又有哪些常见坑。

第一个动效:弹窗淡入,先在类名上栽一跤

Vue 的 <transition> 把进入和离开的状态整理得比较清楚,弹窗、下拉层、提示信息这类"出现/消失"的场景,理论上不需要手写太多切换细节。基础弹窗是这样:

1<template>
2  <transition name="fade">
3    <div v-if="visible" class="dialog-mask">
4      <section class="dialog">
5        <slot />
6      </section>
7    </div>
8  </transition>
9</template>
1.fade-enter-active,
2.fade-leave-active {
3  transition: opacity 0.2s ease;
4}
5
6.fade-enter,
7.fade-leave-to {
8  opacity: 0;
9}

Vue 会在进入和离开过程中自动添加这些类名,你只需要把不同阶段的样式写清楚。听起来很美,但我周二上午的第一版弹窗是"啪"地出现的,没有任何过渡——因为我只写了 fade-enter,没在 fade-enter-active 里写 transition。查文档才把六个类名彻底理顺,踩坑基本都跟它们有关:

  • fade-enter:进入的起始状态,元素插入前的那一帧
  • fade-enter-active:进入过程中一直生效,transition 写在这里
  • fade-enter-to:进入的结束状态(Vue 2.1.8+)
  • fade-leave:离开的起始状态
  • fade-leave-active:离开过程中生效
  • fade-leave-to:离开的结束状态

规律是:transition 属性必须挂在 -active 类上,起止状态类只负责描述"从哪到哪"-active 是过程,enter/leave-to 是两端,少了过程,两端之间就是瞬移。

同一个上午我还撞了另一个更蠢的坑:有个二级确认框的 v-if 外面忘了包 <transition>,类名写得再全也纹丝不动。Vue 是靠 <transition> 这个包裹组件去监听子元素的插入和移除的,没有它,那些 .fade-* 类根本没人往元素上挂。

0.5s 之争:和设计师拉扯了一个下午

弹窗动效跑通之后,第一轮联调设计师就提了意见:设计稿标的是 0.5s 缓动,我写的 0.2s,"太快了,没有呼吸感"。

我把两个版本都做出来让她自己点。0.5s 的版本第一次看确实优雅,但这是运营后台——配置一场大促活动要开关几十次弹窗,每次都等半秒,点到第十次她自己先烦了。最后我们商定了一条规矩:交互动画控制在 200~300ms,装饰性的进场可以放到 400ms,高频操作的动画宁短勿长。设计稿上的时长是给"第一眼"设计的,代码里的时长要为"第一百次操作"负责。

这个下午没白费,后面所有动效的时长都不用再讨论了。顺便说个题外话:上周 Vue 2.6 刚发布,改的主要是插槽语法那一块,过渡系统没动,这篇里的写法新旧版本都一样。

第二个动效:筛选面板侧滑,性能的账要算在属性上

筛选面板从右侧滑出,滑动比淡入更能表达"从侧边进入"的空间关系,抽屉、侧边栏、移动端筛选面板都适用:

1<transition name="slide-panel">
2  <aside v-if="open" class="panel">
3    <button type="button" @click="$emit('close')">关闭</button>
4    <slot />
5  </aside>
6</transition>
1.slide-panel-enter-active,
2.slide-panel-leave-active {
3  transition: transform 0.25s ease, opacity 0.25s ease;
4}
5
6.slide-panel-enter,
7.slide-panel-leave-to {
8  opacity: 0;
9  transform: translateX(100%);
10}

这里有笔性能账。我第一稿是动 right 属性从 -400px 滑到 0,在我的开发机上看不出问题,换到测试那台老机器上,面板滑出时整个页面都在抖。原因是 lefttopwidthheight 这类属性一变就触发重排(reflow),浏览器要重新计算布局,页面元素一多就掉帧;而 transformopacity 只触发合成层,能走 GPU。动画尽量只动 transform 和 opacity,这个习惯从这次开始养成了。如果想进一步提示浏览器提前准备合成层,可以加 will-change: transform,但别滥用——我试过给列表里每一项都挂,内存直接多吃一截,挂太多 will-change 是负优化。

第三个动效:列表增删,index 当 key 的报应来了

活动列表的增删轮到 <transition-group>

1<transition-group name="list" tag="ul" class="activity-list">
2  <li v-for="item in items" :key="item.id">
3    {{ item.title }}
4  </li>
5</transition-group>
1.list-enter-active,
2.list-leave-active {
3  transition: opacity 0.2s ease, transform 0.2s ease;
4}
5
6.list-enter,
7.list-leave-to {
8  opacity: 0;
9  transform: translateY(8px);
10}

这段代码我周三下午一次写对了,但动画效果一塌糊涂:删一条数据,被删的那条没有离场动画,反倒是它后面所有的项都在闪。排查半天发现是历史代码里 v-forindex 当 key——删掉第 3 条,后面所有项的 key 集体前移,Vue 认为"一堆元素同时变了",进出场判断全乱。换成数据自带的 id 立刻正常。没有稳定的 key,transition-group 根本分不清谁进、谁出、谁只是挪了位置。平时用 index 当 key 顶多是渲染复用的隐患,加上动画就是当场处刑。

key 修好之后我又白捡了一个能力:移动动画。列表排序变化时,配上 .list-move 这个类,没被增删但位置变了的元素会平滑挪过去,而不是瞬移:

1.list-move {
2  transition: transform 0.3s ease;
3}

不过 move 有个前提坑:离场的元素如果不脱离文档流,后面的元素没法及时补位,挪动看起来会一顿一顿的。解法是给离场元素加绝对定位:

1.list-leave-active {
2  position: absolute;
3}

离场的元素脱离布局,剩下的元素就能顺畅地 move 过来补位。move 加 leave-active 绝对定位这个组合,这次在活动排序的拖拽列表里也用上了,基本是配套出现的。

第四个动效:三种状态的切换,mode 和 key 缺一不可

列表区域有三个状态:加载中、加载失败、正常列表。设计稿要求它们之间淡入淡出切换,不能生硬跳变:

1<transition name="fade" mode="out-in">
2  <p v-if="loading" key="loading">加载中...</p>
3  <p v-else-if="errorMessage" key="error" role="alert">{{ errorMessage }}</p>
4  <ul v-else key="list">
5    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
6  </ul>
7</transition>

这一小段里埋着三个坑,我一个没漏全踩了。

第一个是 mode。不写 mode 时,进入和离开是同时发生的——loading 文案和列表会短暂叠在一起,看着像 bug。mode="out-in" 让旧内容先走、新内容再进,状态区域切换基本都用它。还有个对应的 in-out,新的先进来旧的再走,用得少,主要是有些场景不希望中间出现空白。

第二个是 key。几个分支必须带不同的 key,否则 Vue 觉得"都是 p 标签,复用同一个节点就行",节点没有销毁重建,过渡根本不触发。我习惯按状态语义命名 key(loading / error / list),出问题时一眼能看出哪个状态没切过去。

第三个是子元素数量。<transition> 一次只能有一个直接子元素,多状态切换必须靠 v-if / v-else-if 保证同一时刻只渲染一个。我有一处写了两个并列的 v-if,控制台直接甩脸:"<transition> can only be used on a single element"。

状态切换做完还牵出一件动画之外的事:错误状态的文案。接口错误必须统一成稳定的人话,不能让每个组件各自把 Network Error 之类的原始报错甩给用户。我们在请求层统一收口:

1// 请求封装里统一归一错误,页面只管拿 message 展示
2service.interceptors.response.use(
3  function (response) {
4    return response.data.data
5  },
6  function (error) {
7    var message = error.response
8      ? '列表加载失败,请稍后重试'
9      : '网络异常,请检查连接'
10    return Promise.reject({ message: message })
11  }
12)

动画可以让状态切换更自然,但错误本身必须表达清楚——错误提示慢慢淡入没问题,内容含糊就是本末倒置。

白捡的两个小开关:appear 和 duration

联调时设计师又追了一个细节:页面首次加载时,列表也要有进场动画,不能第一屏"啪"地铺满。我本来以为要在 mounted 里搞个延时把 v-if 翻一下,结果发现 <transition> 自带这个开关——appear

1<transition name="fade" appear>
2  <div class="banner">...</div>
3</transition>

加上 appear,初始渲染也会走一遍进入过渡,什么额外状态都不用维护。它还有配套的 appear-classappear-active-class 可以单独定制首次进场的样式,不定制就复用 enter 那一套。

另一个开关是 duration。Vue 默认靠监听 transitionend/animationend 事件判断过渡结束,但如果一个元素上有多个时长不同的 transition(比如 opacity 0.2s、transform 0.4s),Vue 可能在第一个事件到达时就提前收工,后半段动画的类名被摘掉。这时可以显式声明总时长:

1<transition name="slide-panel" :duration="{ enter: 400, leave: 250 }">
2  ...
3</transition>

另外过渡的类名也不是只能用 name 前缀那一套。<transition> 支持 enter-active-classleave-active-class 这些属性直接指定自定义类名,最常见的用法是接 Animate.css 这类现成动画库,一行 CSS 都不用自己写:

1<transition
2  enter-active-class="animated fadeInDown"
3  leave-active-class="animated fadeOutUp"
4>
5  <div v-if="showTip" class="promo-tip">大促配置已保存</div>
6</transition>

运营后台顶部那个保存成功的提示条我就是这么做的,五分钟搞定。要注意 Animate.css 的动画时长是它自己定的,嫌快嫌慢就配合上面的 :duration 或者覆盖 animation-duration

顺带记一个相关知识点:v-show 控制的元素也能触发 <transition> 过渡——它切的是 display,元素不销毁,适合频繁开关的场景(比如那个筛选面板,一天开关几十次,用 v-show 省掉反复创建组件的开销);v-if 才是真正的插入和移除。两者的过渡类名机制是一样的,选哪个看开关频率和初始渲染成本。

最难的一个:高度自适应展开,CSS 直接投降

设计稿里最后一个动效是活动规则的折叠面板:点击标题,内容区从 0 平滑展开到实际高度。这是 CSS 过渡的老大难——height: auto 是没法直接 transition 的,从 0auto 浏览器不知道怎么插值,直接跳变。

取巧的办法是 max-height:给一个肯定够大的最大高度,从 max-height: 0 过渡到 max-height: 600px。能用,但有两个毛病:展开速度不均匀(前半段在过渡"用不到的高度"),而且 600px 写死了,哪天内容超了就被裁掉。

要做得精确,就得请出 <transition> 的 JS 钩子,动画交给 JS 接管:

1<transition
2  @enter="onEnter"
3  @leave="onLeave"
4  :css="false"
5>
6  <div v-if="visible" class="rule-body">...</div>
7</transition>
1methods: {
2  onEnter(el, done) {
3    // 用 Velocity 或 GSAP 之类的库做动画,结束后必须调 done
4    Velocity(el, { opacity: 1, height: el.scrollHeight }, { duration: 300, complete: done })
5  },
6  onLeave(el, done) {
7    Velocity(el, { opacity: 0, height: 0 }, { duration: 300, complete: done })
8  },
9}

两个要点。一是 :css="false",告诉 Vue 别再管 CSS 过渡,避免和 JS 动画打架;二是 done 必须调用,否则 Vue 永远以为动画没结束,离场元素卡在 DOM 里不消失。我第一次用 JS 钩子就是忘了调 done,折叠面板收起后元素一直留在页面里,找了好久才反应过来。

不过说实话,这次改版十来个动效,真正用到 JS 钩子的就这一个。2019 年做后台和移动端,绝大多数动画 CSS 过渡就够了,JS 钩子留给确实需要按帧控制、或者高度自适应这种 CSS 无能为力的场景。能用 CSS 解决的动画别请 JS,能用 transform 解决的别动布局属性,这两条按顺序守住,性能问题就很难找上门。

验收前的减法:砍掉一半动效

周四把所有动效做完过了一遍,我自己先觉得不对劲:满屏都在动,注意力全被打散了,批量选择活动时每一行都要淡入一下,操作节奏明显被拖慢。我拉着设计师把动效清单重新过了一遍,砍掉了将近一半。留下来的判断标准就是开头那句话——这个动画在补什么信息?补不出来的删掉。

有几类场景我们明确写进了后台的动效规范里,要特别克制:高频操作(输入、连续筛选、批量选择)不加动画;长列表滚动路径上不加动画,容易掉帧;错误反馈直接出现,不要慢慢淡入——报错就该清楚直接。动画如果延迟了用户完成任务,视觉上再好看也是不合适的。

最后还补了一个不占工时但该做的事。Vue 过渡最终还是 CSS,可以用媒体查询整体关闭:

1@media (prefers-reduced-motion: reduce) {
2  .fade-enter-active,
3  .fade-leave-active,
4  .slide-panel-enter-active,
5  .slide-panel-leave-active,
6  .list-enter-active,
7  .list-leave-active,
8  .list-move {
9    transition: none;
10  }
11}

前庭功能障碍、晕动症的用户会打开系统的"减弱动态效果",我们就该尊重这个设置。这不是可选项,加上也就十行 CSS。

交付那天

周五给运营演示,反馈里最有意思的一条是:没人夸动画好看,但"点了没反应"的抱怨消失了。这大概就是过渡动画做对了的样子——它不该被注意到,它只是让每次状态变化都有迹可循。

这一周沉淀下来的判断就三条。Vue 的过渡能力很方便,但真正决定体验的不是 API,而是动画放没放在合适的位置:先服务交互,再谈效果。技术上,类名六件套里 transition-active、列表动画必须有稳定 key、状态切换必须 modekey 双保险,这三个坑占了我这周返工量的八成。最后,做完动效清单先做一轮减法——每一次动都应该有明确目的,解释状态变化,而不是抢用户注意力