【Vue3】day82-keepalive-teleport

作者:mario 发布时间: 2026-09-03 阅读量:4 评论数:0

Day 82 · KeepAlive 与 Teleport — 缓存与传送:大屏交互的两件利器

路由切换=卸载重建(Day 78 的痛)今天解决:KeepAlive 缓存屏幕组件实例,切走不销毁、切回零等待——它就是 Day 64"opacity 保实例"策略的官方实现,还附赠 onActivated/onDeactivated 激活协议。第二件利器 Teleport:危险告警弹窗要浮在 scale 壳之上、不受任何屏的 overflow 裁剪——把它"传送"到 body。今天完成后,Day 79 守卫里预留的"告警确认中不许切屏"正式启用。


目录


一、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 总览页。

评论