知

模块三 · 工程与体验

第 12 / 17 节18 分钟阅读型

动画与内置组件:Transition、Teleport、KeepAlive 与 Suspense

体验由过渡、层级与加载态共同决定。这一节讲清四个内置组件的适用边界、类名时序与常见陷阱,并给出对话框与列表动画的完整做法。

读完这一节,你能做到

  1. 用 <Transition> 做出可靠的进入/离开动画,而不是"偶尔生效"。
  2. 用 <TransitionGroup> 处理列表增删与位移。
  3. 用 <Teleport> 解决层级与 overflow 裁切问题。
  4. 用 <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 淘汰

三个易错点:

  1. include 匹配的是组件名。<script setup> 默认用文件名推导,重命名文件后缓存会静默失效;需要稳定时用 defineOptions({ name: "OrderList" })。
  2. 缓存的是实例,不是数据快照。切回来时状态还在,这正是它的价值,也是"数据不刷新"的原因。
  3. 被缓存的组件不会卸载,定时器、轮询、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

七、常见坑

  1. <Transition> 包了多根节点,动画完全不生效且只有一条警告。
  2. 离开动画期间元素仍占位,页面抖动。
  3. TransitionGroup 忘了 position: absolute,其他项不移动。
  4. Teleport 到 body 后,样式依赖父级作用域,导致弹窗样式全丢(scoped 样式不会跟过去)。
  5. KeepAlive 的 include 用文件名匹配,重命名后缓存失效。
  6. 缓存组件里的轮询在切走后继续跑,接口被刷爆。
  7. defineAsyncComponent 没有 errorComponent,加载失败后一片空白。
  8. 用 <Suspense> 包普通请求,发现 fallback 从不出现。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 做一个淡入淡出的对话框,要求:Teleport 到 body、锁定滚动、关闭后焦点归还、支持 Esc 关闭。
  2. 做一个列表,支持新增/删除,用 TransitionGroup 实现位移与淡出。
  3. 给一个标签页加 KeepAlive,验证切回时输入框内容与滚动位置是否保留。
  4. 把一个重型图表改成 defineAsyncComponent,加上 delay 与 errorComponent,观察弱网下的表现。

② 让 AI 写同一题

把第 2 题(列表过渡 + KeepAlive)交给 AI,补一句 列表增删都要有过渡,不能只写进入动画。

③ 对比要盯的点

  • <TransitionGroup> 它有 tag 和 :key 吗?缺 key 动画会错位。
  • <KeepAlive> 的 include 它写了吗?不写就缓存全部页面,内存白涨。
  • <Teleport> 的目标节点它在 SSR 下判断过存在性吗?

④ 一句话取舍

往列表中间插一条时,它的动画会错位吗?

九、自测

  1. 进入动画的类名顺序是什么?
  2. TransitionGroup 的 *-move 类依赖什么机制?
  3. Teleport 会改变组件的父子关系吗?
  4. KeepAlive 的 include 匹配的是什么?
  5. 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(离开),线性动画看起来最“机械”。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解