【Canvas × Vue】day89-pulse-animation

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

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 包装 + 图例。

评论