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,在我的开发机上看不出问题,换到测试那台老机器上,面板滑出时整个页面都在抖。原因是 left、top、width、height 这类属性一变就触发重排(reflow),浏览器要重新计算布局,页面元素一多就掉帧;而 transform 和 opacity 只触发合成层,能走 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-for 用 index 当 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-class、appear-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-class、leave-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 的,从 0 到 auto 浏览器不知道怎么插值,直接跳变。
取巧的办法是 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、状态切换必须 mode 加 key 双保险,这三个坑占了我这周返工量的八成。最后,做完动效清单先做一轮减法——每一次动都应该有明确目的,解释状态变化,而不是抢用户注意力。