Day 89 · 告警脉冲动画 — rAF 生命周期:danger 节点的呼吸感
屏 C 的最后一块拼图:告警脉冲动画(Day 68 的 danger 节点红底脉冲)。它是"每帧动画"与"组件生命周期"的正面试题——rAF 循环必须在失活时暂停(KeepAlive 摘 DOM 后继续跑是纯浪费)、激活时恢复、卸载时取消。今天还要处理动画与数据重绘的图层协作:脉冲画在专用的动画帧里,不能踩脏状态层。
目录
一、脉冲动画的渲染设计
1.1 Day 68 版回顾
danger 节点的红底脉冲:透明度随 sin 波呼吸(0.1 → 0.25 → 0.1),周期约 1.2s。原生版直接在节点层每帧重绘。
1.2 本周的问题:动画帧不能踩脏状态层
Day 87 的脏检查刚把重绘频率降到最低——如果脉冲每帧重绘整个节点层,脏检查的成果清零:
❌ 脉冲画在节点层:60fps × 整层重绘(20 节点)→ 脏检查白做
✅ 脉冲画在专用动画层(第四层画布):只清自己、只画 danger 节点的光晕
升级为四层画布(第 7 周三层设计的本次扩展——边界文档的"复审条件"触发了一次扩桥):
层 1 背景(网格) —— 静态,resize 才重绘
层 2 连线 —— 状态脏才重绘
层 3 节点 —— 状态/温度脏才重绘
层 4 动画(新增) —— 告警脉冲:rAF 驱动,只画 danger 光晕
1.3 脉冲绘制实现
// core/topo-renderer.ts(追加动画层)
/**
* 告警脉冲动画:只在存在 danger 节点时运行
* 设计:
* 1. 独立动画层——清空重画只影响光晕,不碰状态层
* 2. 相位自治(sin 波):renderer 内部状态,绝不进 Vue(Day 85 裁决 #10)
* 3. 无 danger 时 rAF 停转(第四节)——空转也是浪费
*/
private animCanvas: HTMLCanvasElement;
private animCtx: CanvasRenderingContext2D;
private rafId = 0;
private phase = 0; // 脉冲相位(0→2π 循环)
private dangerIds: Set<string> = new Set();
/** 数据更新时同步 danger 名单(Day 87 update 的扩展点) */
update(states: Map<string, DeviceState>, temps: Map<string, number>): void {
/* Day 87 脏检查原样 */
// 追加:维护 danger 名单 + 按需启停动画
const next = new Set([...states].filter(([, s]) => s === "danger").map(([id]) => id));
if (!setsEqual(next, this.dangerIds)) {
this.dangerIds = next;
this.setAnimation(next.size > 0); // 有 danger 才转,没有就停
}
}
/** 动画主循环:清动画层 → 画每个 danger 节点的呼吸光晕 */
private animLoop = (): void => {
this.phase = (this.phase + 0.05) % (Math.PI * 2);
const { animCtx: ctx } = this;
ctx.clearRect(0, 0, this.width, this.height);
const alpha = 0.12 + 0.13 * (0.5 + 0.5 * Math.sin(this.phase)); // 0.12~0.25 呼吸
for (const id of this.dangerIds) {
const node = this.config.nodes.find((n) => n.id === id);
if (!node) continue;
const { x, y } = this.worldToScreen(node.x, node.y);
// 光晕:径向渐变圆(比节点框大一圈)
const grad = ctx.createRadialGradient(
x + node.w / 2, y + node.h / 2, 8,
x + node.w / 2, y + node.h / 2, Math.max(node.w, node.h) * 0.9
);
grad.addColorStop(0, `rgba(255, 77, 79, ${alpha})`);
grad.addColorStop(1, "rgba(255, 77, 79, 0)");
ctx.fillStyle = grad;
ctx.fillRect(x - 20, y - 20, node.w + 40, node.h + 40); // 渐变的包围盒
}
this.rafId = requestAnimationFrame(this.animLoop);
};
/** 动画开关(Day 85 桥契约成员) */
setAnimation(on: boolean): void {
cancelAnimationFrame(this.rafId);
if (on) {
this.rafId = requestAnimationFrame(this.animLoop);
} else {
this.animCtx.clearRect(0, 0, this.width, this.height); // 停转时清残留
}
}
二、rAF 生命周期:暂停、恢复、取消
动画循环的三种状态转换,对应组件的三个时机:
组件挂载(onMounted) → 按 dangerIds 决定启停(通常首帧无 danger → 停)
屏激活(onActivated) → 若有 danger:恢复 rAF
屏失活(onDeactivated) → 暂停 rAF(DOM 已摘下,画给谁看?)
组件卸载(onUnmounted) → 取消 rAF + destroy 兜底
<!-- TopoCanvas.vue(追加动画生命周期接线) -->
<script setup lang="ts">
import { onActivated, onDeactivated, onMounted, onUnmounted } from "vue";
onMounted(() => {
renderer = new TopoRenderer({ bg, edges, nodes, anim: animCanvas.value!, config: TOPO_CONFIG });
/* Day 87 接线原样 */
});
// KeepAlive 激活协议(Day 82 骨架的动画版):
onActivated(() => {
renderer?.resize(); // 防御性尺寸校准(Day 86 坑 2 的正式处理)
renderer?.resumeAnimation(); // 恢复(内部判断 dangerIds 空则不转)
});
onDeactivated(() => {
renderer?.pauseAnimation(); // 暂停 rAF(状态保留,恢复续播)
});
onUnmounted(() => {
ro?.disconnect();
renderer?.destroy(); // destroy 内部含 setAnimation(false)(Day 86 清单核对项)
renderer = null;
});
</script>
<template>
<div ref="rootEl" class="topo-canvas">
<canvas ref="bgCanvas" class="topo-canvas__layer" />
<canvas ref="edgeCanvas" class="topo-canvas__layer" />
<canvas ref="nodeCanvas" class="topo-canvas__layer topo-canvas__hit" />
<!-- 第四层:动画层(插在节点层之下更合理?——见坑 2) -->
<canvas ref="animCanvas" class="topo-canvas__layer" />
</div>
</template>
renderer 侧补两个语义化方法(暂停/恢复与开关的区别:保留启停意图):
private animWanted = false; // 语义状态:动画"应该"转吗(danger 存在)
setAnimation(on: boolean): void {
this.animWanted = on;
this.applyAnimation();
}
pauseAnimation(): void { cancelAnimationFrame(this.rafId); } // 暂停:只停不记
resumeAnimation(): void { this.applyAnimation(); } // 恢复:按意图重启
private applyAnimation(): void {
cancelAnimationFrame(this.rafId);
if (this.animWanted) this.rafId = requestAnimationFrame(this.animLoop);
else this.animCtx.clearRect(0, 0, this.width, this.height);
}
双层开关的必要性:animWanted(业务意图:有 danger)与 rAF 实际状态(激活/失活)是两个正交维度——失活暂停的动画,恢复时不能因为"还暂停着"就永远停了,也不能因为"恢复"就把无 danger 的空转开起来。
三、与 KeepAlive 的协作
三态验证矩阵(今天的核心测试):
| 场景 | danger 存在 | danger 不存在 |
|---|---|---|
| 屏 C 激活 | rAF 转动(脉冲可见) | rAF 静默 |
| 屏 C 失活 | rAF 停(Performance CPU ≈ 0) | rAF 静默 |
| 失活期间告警解除 | — | 激活回来:无脉冲且不空转(watch 照常跑了,dangerIds 已更新) |
第三行是失活期间数据仍在流动的动画版验证(Day 84 场景 3 的姊妹)——失活不退订 store(Day 82 结论),dangerIds 名单在失活时也会更新,激活恢复时动画状态与数据一致。
四、动画开关:告警驱动的按需运行
动画的空转是纯浪费——无 danger 时 60fps 清一个空画布。Day 87 的 update 扩展(dangerIds 变化 → setAnimation)实现了告警驱动启停:
告警到达(store)→ watch → renderer.update
→ dangerIds 变化 → setAnimation(true) → rAF 起转
告警解除 → 同链路反向 → setAnimation(false) → rAF 停 + 清残留
对照原生版 Day 68:脉冲动画常转(不分有无 danger)——Vue 版因为有了 store 联动,白赚了按需运行。对照表记一行("告警驱动的动画启停"是架构红利的又一例)。
五、常见坑点
坑 1:失活不停 rAF
忘了 onDeactivated——KeepAlive 只摘 DOM 不叫 unmounted,onUnmounted 里的 destroy 不触发。症状:切到屏 A/B 后 Performance 里仍有 topo 的 rAF 帧。今天 T1 的验收点。
坑 2:动画层的 z 序
光晕应该衬在节点之下(光晕是背景氛围)还是之上?Day 68 原生版是"红底脉冲"(节点底下)。四层 canvas 的 DOM 顺序 = z 序——动画层插在连线与节点层之间(animCanvas 放 nodeCanvas 之前)。放错了节点会被光罩住,视觉发闷。
坑 3:相位跳变
暂停后恢复,this.phase 保留了旧值(连续)——但如果每次恢复都从 0 起,脉冲会"跳一下"。pause/resume 用 cancelAnimationFrame(相位字段不动)天然连续;若你实现了"重置式恢复",肉眼可见跳变。
坑 4:resize 后动画层模糊
DPR 三件套(Day 86)必须覆盖第四层——迁移时容易只处理了原三层。核对 resize() 里的 canvas 数组包含 anim。
坑 5:destroy 里的清残留
setAnimation(false) 清画布,但 destroy 时如果 rafId 恰好是"转着"状态,必须先 cancel 再清——顺序反了会在 cancel 前又排入一帧。
六、自测挑战
T1 · 脉冲动画 + 生命周期矩阵(70 分钟)
完成第一至三节:Devtools 造 danger 告警 → 脉冲呼吸可见;切屏 → CPU 归零(Performance 验证 rAF 停);切回 → 续播相位连续。三态矩阵逐格打勾。
T2 · 按需启停验证(30 分钟)
无 danger 状态 5 分钟:rAF 零帧(Console 在 animLoop 打点计数);告警到达 → 起转;解除 → 停 + 光晕清干净(无残影)。
T3 · 四层 z 序实验(20 分钟)
把动画层移到节点层之上,观察"光罩节点"的发闷感与 hover 详情(浮层在最上不受影响);移回正确位置。层序是视觉工程的一部分,亲手错一次才知道对的价值。
七、总结
| 环节 | 要点 |
|---|---|
| 第四层画布 | 动画独立层——脏检查成果不被每帧动画清零 |
| 双层开关 | animWanted(业务意图)× 激活态(宿主状态)正交管理 |
| 激活协议 | 失活暂停、激活续播、相位连续 |
| 按需运行 | 告警驱动启停——store 联动白赚的红利 |
| z 序 | 光晕衬底:anim 层在 nodes 之下 |
屏 C 的零件全部就位。明天组装整屏:配置驱动 + TechCard 包装 + 图例。