模块三 · 工程与体验
动画与内置组件:Transition、Teleport、KeepAlive 与 Suspense
体验由过渡、层级与加载态共同决定。这一节讲清四个内置组件的适用边界、类名时序与常见陷阱,并给出对话框与列表动画的完整做法。
读完这一节,你能做到
- 用
<Transition>做出可靠的进入/离开动画,而不是"偶尔生效"。 - 用
<TransitionGroup>处理列表增删与位移。 - 用
<Teleport>解决层级与overflow裁切问题。 - 用
<KeepAlive>与<Suspense>分别处理状态保留与加载边界。
一、Transition:动画是"类名时序"问题
<Transition> 只对单个根节点生效,且只在以下情况触发:v-if、v-show、动态组件切换、key 变化。
<Transition name="fade">
<div v-if="open" class="panel">内容</div>
</Transition>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
类名时序(记住顺序,比记名字有用):
进入:enter-from → enter-active(同时)→ 下一帧移除 enter-from → 动画结束移除 enter-active
离开:leave-from → leave-active(同时)→ 下一帧加 leave-to → 动画结束移除
三个最常踩的点:
- 必须让浏览器先渲染一帧,
enter-from才会产生过渡;Vue 内部用两次requestAnimationFrame保证,但如果你用 JS 钩子手动改样式,要自己保证。 - 离开动画会延迟元素移除,父级布局可能被"看不见的元素"撑开,用
position: absolute或mode="out-in"处理。 v-show用display切换,离开动画期间元素仍在文档流里,可能引起跳动。
mode 解决"两个元素同时存在"的问题:
| 模式 | 行为 | 适用 |
|---|---|---|
out-in | 先离开,再进入 | 路由/步骤切换,避免重叠 |
in-out | 先进入,再离开 | 少用,容易视觉拥挤 |
JS 钩子适合需要第三方动画库或精确控制的场景:
<Transition
@before-enter="el => (el.style.opacity = 0)"
@enter="(el, done) => animate(el, { opacity: 1 }, done)"
@leave="(el, done) => animate(el, { opacity: 0 }, done)"
:css="false"
>
:css="false" 会跳过类名检测,动画必须由 done() 结束,否则元素不会移除。
二、TransitionGroup:列表动画
<TransitionGroup tag="ul" name="list">
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</TransitionGroup>
它比 <Transition> 多一个 *-move 类,用于处理位置变化:
.list-move {
transition: transform 0.3s ease;
}
.list-leave-active {
position: absolute; /* 让离开的元素不占位,其他元素才能平滑移动 */
}
原理是 FLIP:先记录每个元素动画前的位置,修改 DOM 后再记录一次,用 transform 把差值补回去。所以:
.list-leave-active必须脱离文档流,否则其他项不会移动。transform会被父级overflow: hidden裁切,注意容器尺寸。key必须稳定,否则 FLIP 无法判断"谁移动了"。
三、Teleport:把节点送到别处
<Teleport to="body">
<div v-if="open" class="modal">...</div>
</Teleport>
它只移动 DOM 位置,不改变组件层级:props、事件、provide/inject、响应式都仍然走原来的组件树。
典型场景:
| 场景 | 不 Teleport 的问题 |
|---|---|
| 全屏弹窗 | 被父级 overflow: hidden 或 transform 裁切 |
| 下拉菜单 | 父级 z-index 形成层叠上下文,浮层被盖住 |
| 全局 toast | 需要挂到 body 才能保证在最上层 |
两个注意点:
to的目标必须已存在。SSR 下body在服务端不存在,要在onMounted后再渲染,或用<ClientOnly>类方案。disabled可以按条件关闭传送,适合响应式布局下"小屏弹层、大屏内联"。
一个弹窗的最小正确做法:Teleport 到 body + v-if 控制挂载 + @click.self 关闭遮罩 + 打开时锁定 body 滚动 + 关闭时恢复并归还焦点。
四、KeepAlive:缓存组件实例
<KeepAlive :include="['OrderList', 'UserList']" :max="5">
<component :is="current" />
</KeepAlive>
| 属性 | 作用 | 注意 |
|---|---|---|
include | 只缓存匹配的组件名 | 匹配的是组件的 name,不是文件名 |
exclude | 排除匹配的组件 | 与 include 互斥使用更清晰 |
max | 最多缓存几个实例 | 超过按 LRU 淘汰 |
三个易错点:
include匹配的是组件名。<script setup>默认用文件名推导,重命名文件后缓存会静默失效;需要稳定时用defineOptions({ name: "OrderList" })。- 缓存的是实例,不是数据快照。切回来时状态还在,这正是它的价值,也是"数据不刷新"的原因。
- 被缓存的组件不会卸载,定时器、轮询、WebSocket 订阅要在
onDeactivated暂停,在onUnmounted真正清理。
与路由配合时,通常用 meta.keepAlive 控制,并在 router-view 外包一层 KeepAlive。要小心:同一组件不同参数(/order/1 与 /order/2)默认复用同一个实例,缓存后会保留上一份数据,需要在 onActivated 里按 route.params 重新取数。
五、Suspense 与异步组件
<Suspense> 等待的是"子组件 setup 里的顶层 await"或"异步组件":
<Suspense>
<template #default><UserPanel /></template>
<template #fallback><Skeleton /></template>
</Suspense>
// UserPanel.vue
const user = await fetchUser(); // 顶层 await,编译成 async setup
const HeavyChart = defineAsyncComponent({
loader: () => import("./HeavyChart.vue"),
loadingComponent: Skeleton,
errorComponent: ErrorBox,
delay: 200, // 200ms 后才显示 loading,避免闪烁
timeout: 10000, // 超时进入 error
});
四个边界必须知道:
<Suspense>目前仍是实验性 API,生产项目要评估升级风险。- 它捕获的是 Promise,普通
onMounted里的请求不会被捕获。 - 一个
<Suspense>只等第一层异步依赖,嵌套的异步组件需要自己的边界。 - 加载失败要有
errorComponent或onErrorCaptured,否则整块空白。
delay 是个很实用的参数:快速加载时完全不显示 loading,避免"闪一下骨架屏"。
六、动态组件与缓存键
<component :is="currentTab" />
<component :is> 切换时默认销毁旧组件。要保留状态,外面套 KeepAlive;要强制重建,给 :key 一个变化的值:
<component :is="currentTab" :key="route.fullPath" />
判断标准:
| 需求 | 做法 |
|---|---|
| 切走保留状态 | KeepAlive |
| 每次进入都重置 | :key 绑定路由参数 |
| 按权限动态渲染 | :is + 已注册组件映射表 |
| 按需加载 | defineAsyncComponent |
七、常见坑
<Transition>包了多根节点,动画完全不生效且只有一条警告。- 离开动画期间元素仍占位,页面抖动。
TransitionGroup忘了position: absolute,其他项不移动。Teleport到body后,样式依赖父级作用域,导致弹窗样式全丢(scoped 样式不会跟过去)。KeepAlive的include用文件名匹配,重命名后缓存失效。- 缓存组件里的轮询在切走后继续跑,接口被刷爆。
defineAsyncComponent没有errorComponent,加载失败后一片空白。- 用
<Suspense>包普通请求,发现 fallback 从不出现。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 做一个淡入淡出的对话框,要求:Teleport 到 body、锁定滚动、关闭后焦点归还、支持 Esc 关闭。
- 做一个列表,支持新增/删除,用
TransitionGroup实现位移与淡出。 - 给一个标签页加
KeepAlive,验证切回时输入框内容与滚动位置是否保留。 - 把一个重型图表改成
defineAsyncComponent,加上delay与errorComponent,观察弱网下的表现。
② 让 AI 写同一题
把第 2 题(列表过渡 + KeepAlive)交给 AI,补一句 列表增删都要有过渡,不能只写进入动画。
③ 对比要盯的点
<TransitionGroup>它有tag和:key吗?缺 key 动画会错位。<KeepAlive>的include它写了吗?不写就缓存全部页面,内存白涨。<Teleport>的目标节点它在 SSR 下判断过存在性吗?
④ 一句话取舍
往列表中间插一条时,它的动画会错位吗?
九、自测
- 进入动画的类名顺序是什么?
TransitionGroup的*-move类依赖什么机制?- Teleport 会改变组件的父子关系吗?
KeepAlive的include匹配的是什么?- Suspense 捕获不到哪类异步?
参考答案要点:enter-from 与 enter-active 同时加,下一帧移除 from;FLIP 与 transform;不会,只移动 DOM;组件 name;普通 effect 里的请求。
附:动画性能与减少动效偏好
动画卡不卡,取决于改了哪些 CSS 属性。浏览器渲染流水线里,不同属性的代价差一个数量级:
| 属性 | 触发阶段 | 代价 | 建议 |
|---|---|---|---|
transform / opacity | 合成 | 低,可走 GPU | 首选 |
background-color / box-shadow | 绘制 | 中 | 短动画可用 |
width / height / top / left | 布局 | 高 | 改用 transform 或 scale |
所以“高度展开”这类动画不要直接动 height,可以动 transform: scaleY()(注意内容会被拉伸),或用 max-height 配合固定过渡时长,或用 grid-template-rows: 0fr → 1fr 这种新写法。
will-change 要克制:它会让浏览器提前提升合成层,元素多了反而更卡。只在动画开始前加、结束后移除,而不是全局常驻。
最后一条是 prefers-reduced-motion。部分用户因眩晕症会关闭系统动效,正确做法不是删掉所有过渡,而是把位移与缩放换成淡入淡出:
@media (prefers-reduced-motion: reduce) {
.fade-enter-active,
.fade-leave-active {
transition-duration: 0.01ms;
}
}
动画时长的经验值:小元素 150-200ms,整页切换 250-300ms,超过 400ms 会让操作显得迟钝。缓动优先 ease-out(进入)与 ease-in(离开),线性动画看起来最“机械”。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解