Day 82 · KeepAlive 与 Teleport — 缓存与传送:大屏交互的两件利器
路由切换=卸载重建(Day 78 的痛)今天解决:KeepAlive 缓存屏幕组件实例,切走不销毁、切回零等待——它就是 Day 64"opacity 保实例"策略的官方实现,还附赠
onActivated/onDeactivated激活协议。第二件利器 Teleport:危险告警弹窗要浮在 scale 壳之上、不受任何屏的 overflow 裁剪——把它"传送"到 body。今天完成后,Day 79 守卫里预留的"告警确认中不许切屏"正式启用。
目录
- 一、KeepAlive:屏幕缓存
- 二、激活协议:onActivated / onDeactivated
- 三、实战:三屏缓存接线
- 四、Teleport:弹窗传送
- 五、实战:危险告警确认弹窗
- 六、守卫联动:告警确认中阻止切屏
- 七、常见坑点
- 八、自测挑战
- 九、总结
一、KeepAlive:屏幕缓存
1.1 问题回顾
不缓存:切到 /b → ScreenA 卸载 → VChart dispose(实例销毁)
切回 /a → ScreenA 重建 → VChart 重新 init(100-200ms 白窗 + 数据从零起)
缓存后:切到 /b → ScreenA 失活(DOM 摘下,实例保留)
切回 /a → ScreenA 激活(DOM 装回,ECharts 实例原地满血)
1.2 基本用法
<!-- App.vue -->
<template>
<router-view v-slot="{ Component }">
<Transition name="screen" mode="out-in">
<!-- include 白名单:只缓存三屏(纪律见坑 1) -->
<KeepAlive :include="['ScreenA', 'ScreenB', 'ScreenC']">
<component :is="Component" />
</KeepAlive>
</Transition>
</router-view>
</template>
三件套嵌套顺序是官方规定的:Transition → KeepAlive → component(过渡管外观、缓存管生命、component 是本体)。
include 匹配的是组件名——三屏组件必须显式命名:
<script setup lang="ts">
// script setup 默认取文件名为组件名,但显式声明更稳(构建压缩不丢)
defineOptions({ name: "ScreenB" });
</script>
二、激活协议:onActivated / onDeactivated
KeepAlive 下组件有了第二套生命周期:
| 钩子 | 触发时机 | 大屏用途 |
|---|---|---|
| onMounted | 首次创建(只一次) | init 类:订阅、图表初始化 |
| onActivated | 每次切回(含首次) | resize 类:图表尺寸校准 |
| onDeactivated | 每次切走 | 节流类:降频/暂停重活 |
| onUnmounted | 真正销毁(KeepAlive 下基本不触发) | 清理类:退订、dispose |
import { onActivated, onDeactivated, onMounted } from "vue";
// 屏 B 的协议示例
onMounted(() => {
// 一次性:图表 init 已由 VChart 内聚,这里只做屏级初始化
});
onActivated(() => {
// 每次切回:防御性 resize(v-show 时代 Day 69 纪律的 KeepAlive 版)
// ——ECharts 实例活着,但容器经历了摘下/装回,尺寸需要校准
resizeAllCharts();
});
onDeactivated(() => {
// 每次切走:可暂停的"重活"挂起
// 例:屏 B 的排名图带 3s 轮询刷新 → 失活时停表(数据照常进 store,不丢)
rankTimer.pause();
});
与原生版 activate/deactivate 协议的对照(Day 64):
| 原生版(自觉调用) | KeepAlive(结构保证) | |
|---|---|---|
| 触发方 | ScreenRouter 主动调 activate() | 框架在组件摘/装时自动触发钩子 |
| 遗漏风险 | 新屏忘记写协议 → 数据不更新 | 只要组件在 KeepAlive 内,钩子必达 |
三、实战:三屏缓存接线
<!-- App.vue 完整结构(本周累积成果合体) -->
<script setup lang="ts">
import { onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "./stores/dashboard";
import { useCarousel } from "./composables/useCarousel";
import { bindCarousel } from "./router";
import ConnIndicator from "./components/ConnIndicator.vue";
import ScreenNav from "./components/ScreenNav.vue";
import DangerDialog from "./components/DangerDialog.vue";
const store = useDashboardStore();
const { connState } = storeToRefs(store);
onMounted(() => store.startDataLink()); // Day 81 接线
bindCarousel(useCarousel()); // Day 79 轮播
</script>
<template>
<div class="screen-frame">
<div class="screen-canvas" :style="{ transform: `scale(${scale})` }">
<header class="top-bar">
<h1>智慧工厂监控中心</h1>
<ConnIndicator :state="connState" />
</header>
<router-view v-slot="{ Component }">
<Transition name="screen" mode="out-in">
<KeepAlive :include="['ScreenA', 'ScreenB', 'ScreenC']">
<component :is="Component" />
</KeepAlive>
</Transition>
</router-view>
<ScreenNav />
<!-- Teleport 弹窗(第五节):逻辑放这,渲染传送到 body -->
<DangerDialog />
</div>
</div>
</template>
四、Teleport:弹窗传送
4.1 问题:scale 壳里的弹窗困境
DangerDialog 放在 .screen-canvas(被 transform: scale() 缩放)内部时:
1. 弹窗被缩放:1366×768 机器上 scale≈0.71,弹窗文字小到难读
2. 被裁剪:任何祖先 overflow: hidden 都可能切掉投影/阴影
3. z-index 失控:缩放壳的层叠上下文让"全局置顶"失去意义
4.2 解法
<!-- to="body":DOM 传送,逻辑原地 -->
<Teleport to="body">
<div class="dialog-mask">...</div>
</Teleport>
Teleport 的本质:组件的逻辑树位置不变(父级数据照传),DOM 树位置迁移——鱼与熊掌兼得。
五、实战:危险告警确认弹窗
<!-- src/components/DangerDialog.vue -->
<script setup lang="ts">
/**
* 危险告警确认弹窗
* 触发条件:store 里出现未确认的 danger 级告警
* 行为:暂停轮播 + 弹窗置顶(Teleport 到 body,不受 scale 壳影响)
* 确认后:恢复轮播、告警标记 acked
*/
import { watch } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "../stores/dashboard";
import { useCarousel } from "../composables/useCarousel";
const store = useDashboardStore();
const { dangerCount } = storeToRefs(store);
const { setAutoplay } = useCarousel();
// 弹窗可见性 = 存在未确认危险告警(纯派生,无需本地状态——Day 73 哲学)
const visible = computed(() => dangerCount.value > 0);
// 弹窗出现 → 暂停轮播;消失 → 恢复(成对纪律的响应式版)
watch(visible, (show) => setAutoplay(!show), { immediate: true });
// 最新一条危险告警(弹窗正文)
const currentDanger = computed(() =>
[...store.alarms].reverse().find((a) => a.level === "danger" && !a.acked)
);
</script>
<template>
<!-- 传送门:逻辑属于大屏,渲染属于 body -->
<Teleport to="body">
<Transition name="dialog">
<div v-if="visible" class="dialog-mask">
<div class="dialog">
<div class="dialog__title">⚠ 危险告警</div>
<div class="dialog__text">{{ currentDanger?.text }}</div>
<div class="dialog__actions">
<button class="dialog__btn" @click="store.ackAlarm(currentDanger!.id)">
确认处理
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* 注意:传送后的样式仍在组件 scoped 体系内(属性选择器跟随 DOM 走) */
.dialog-mask {
position: fixed; inset: 0; z-index: 9999; /* body 直挂,置顶不受限 */
display: flex; align-items: center; justify-content: center;
background: rgba(8, 12, 20, 0.7); /* token 化的遮罩 */
}
.dialog {
background: var(--bg-card); border: 1px solid var(--state-danger);
border-radius: var(--radius-card); padding: var(--gap-lg);
box-shadow: 0 0 24px rgba(255, 77, 79, 0.35); /* 呼吸边框的红版 */
}
</style>
六、守卫联动:告警确认中阻止切屏
Day 79 预留的关卡今天启用——弹窗未关时,路由切换全部拦截:
// src/router/index.ts(替换 Day 79 的空守卫)
import { useDashboardStore } from "../stores/dashboard";
router.beforeEach(() => {
const store = useDashboardStore();
// 危险告警未确认 → 拦截一切切屏(轮播自切也会被拦,因为轮播也走 push)
if (store.dangerCount > 0) {
console.warn("[router] 危险告警确认中,切屏已拦截");
return false; // 取消导航
}
return true;
});
三件套的合围:watch 暂停轮播(调度层)+ 守卫拦截切屏(路由层)+ Teleport 置顶(视觉层)——同一个业务状态(dangerCount)驱动三层联动,而三层彼此不知道对方存在。这就是状态驱动架构的红利。
七、常见坑点
坑 1:KeepAlive 不加 include 缓存一切
路由懒加载的每个组件都被缓存,将来加"调试页/配置页"后内存悄悄翻倍。纪律:include 白名单显式列出,新增可缓存页面是显式决策。
坑 2:include 不生效(九成是组件名)
<script setup> 的自动命名在某些构建配置下不是文件名。规范:三屏组件 defineOptions({ name: "ScreenX" }) 显式声明,与 include 严格一致。
坑 3:onActivated 里做重活
每次切回都 resize 全部图表 + 重拉数据 → 切屏卡顿。onActivated 只放"轻校准"(resize 是 O(1) 的尺寸校准);数据重拉不需要——store 活水常开,切回即是最新(Day 75"隐藏期间数据也在长"的 Pinia 版继承)。
坑 4:Teleport 内使用 scoped 样式失效的错觉
传送后的 DOM 仍带组件的 scoped 属性,样式正常生效。真正失效的场景是:样式选择器依赖了祖先结构(如 .screen-canvas .dialog)——传送后祖先变了。规范:传送组件的样式自包含,不依赖外部祖先。
坑 5:弹窗关闭后轮播没恢复
watch visible 的 setAutoplay 成对逻辑被某处异常打断。排查:Console 找 watch 是否触发两次 false;确认 useCarousel 是同一实例(App.vue 创建的与 DangerDialog 里调用的——composable 无单例保证,两处各建会拿到不同 timer!)。修正:DangerDialog 不再调 useCarousel,改由 watch visible emit 事件给 App 处理,或把 setAutoplay 收进 Pinia。今天先用"App 统一持有"的方案(把 watch 挪到 App.vue)。
八、自测挑战
T1 · KeepAlive 接线 + 激活协议(60 分钟)
完成第一至三节:三屏互切切回零等待(ECharts 实例不重建——Performance 录制切屏过程验证无 init 峰);onActivated 的 resize 生效(隐藏期间窗口变化后切回不破版)。
T2 · 弹窗 + 守卫联动(60 分钟)
完成第五至六节:Devtools 手动 pushAlarms 一条 danger → 弹窗弹出 + 轮播停 + 切屏被拦 → 确认后全恢复。坑 5 的单例问题按修正方案处理(App 统一持有轮播控制)。
T3 · 缓存内存审计(30 分钟)
三屏各切 20 次后 Memory 三快照:对比 Day 76 VChart 时期,确认缓存带来的实例常驻(预期 Heap 略升)但无增长趋势(每次切换零泄漏)。
九、总结
| 利器 | 解决的问题 | 大屏对应 |
|---|---|---|
| KeepAlive | 路由切换的销毁重建 | Day 64"保实例"策略的官方版 |
| onActivated/Deactivated | 激活时机的结构化 | 原生 activate 协议的强制版 |
| Teleport | scale 壳内弹窗的缩放/裁剪/层级 | 告警弹窗置顶 |
| 守卫拦截 | 关键状态下的切屏保护 | 告警确认中锁定当前屏 |
| 状态驱动 | 一个状态三层联动 | dangerCount → 轮播/路由/弹窗 |
生态四件套(Router/Pinia/KeepAlive/Teleport)全部就位。明天迁移最后一块内容:屏 A 总览页。