【Canvas × Vue】day87-state-driven-render

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

Day 87 · 状态驱动重绘 — store → renderer:markRaw 与入向桥实现

今天的主题是入向桥的实现,以及本周最重要的一个 API:markRaw——renderer 实例绝不能被响应式系统代理。还要处理"重绘频率"问题:快照 500ms 一次,但状态色往往几秒才真变——脏检查避免重绘风暴。今天结束,拓扑的节点颜色与温度副标签开始随真实数据跳动。


目录


一、markRaw:为什么 renderer 必须隔离

1.1 危险的默认行为

Vue3 的 reactive 代理是递归的、隐式的

// 昨天的骨架里 renderer 是普通变量,看似安全——但隐患在它被响应式世界"碰"到时:
const renderer = ref(rendererInstance);        // ❌ ref 包实例 → 深层代理启动
const state = reactive({ renderer });          // ❌ 同上
// 甚至:把实例塞进 Pinia state / provide 的 reactive 对象 → 同样触发

被代理后会发生什么:

1. 递归遍历实例的全部属性(三层 ctx、节点数组、监听器闭包…)→ 初始化卡顿
2. 后续每次属性访问都走 Proxy 拦截层 → 每帧绘制的上千次属性读取全部降速
3. 实例内部 this 指向 Proxy 而非原对象 → 部分库内部逻辑行为异常

1.2 三种正确的持有姿势

import { markRaw, shallowRef } from "vue";

// 姿势 1(本周采用):markRaw——永久豁免代理,语义最明确
let renderer: TopoRenderer | null = null;   // 普通变量:组件闭包内,根本不进响应式世界
// 若必须响应式持有(如 UI 需要"渲染器就绪"标志):
const rendererRef = shallowRef<TopoRenderer | null>(null);
rendererRef.value = markRaw(new TopoRenderer(...));   // markRaw 双保险

// 姿势 2:markRaw 标记在类定义处(一劳永逸)
export class TopoRenderer {
  // 类级声明:本类的实例永远不响应式
  static __v_skip = true;
}

// 姿势 3(反例):reactive({ renderer }) —— 任何形式都不允许

团队规范类实例、DOM 句柄、第三方库对象(ECharts/ws/Map 实例)一律 markRaw 或普通变量持有。Day 76 VChart 里的 let chart 用普通变量、Day 81 data-link 的 let source 用模块变量——本周已是第三次同款纪律,形成肌肉记忆


二、入向桥实现

Day 85 的契约今天落地:

<!-- TopoCanvas.vue(在 Day 86 骨架上追加数据接线) -->
<script setup lang="ts">
import { computed, markRaw, onMounted, onUnmounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { useDashboardStore } from "../stores/dashboard";
import { TopoRenderer } from "../core/topo-renderer";
import { TOPO_CONFIG } from "../core/topo-config";
import type { DeviceState } from "../core/types";

const store = useDashboardStore();
const { latest, alarms } = storeToRefs(store);

let renderer: TopoRenderer | null = null;   // 普通变量持有(第一节纪律)

// —— 入向桥核心:store 数据 → 画布最小形态 ——
// 桥口要窄(Day 85 原则):不传 snapshot,只传画布要的两张 Map
const deviceStates = computed<Map<string, DeviceState>>(() => {
  const m = new Map<string, DeviceState>();
  for (const d of latest.value?.devices ?? []) m.set(d.deviceId, d.state);
  return m;
});
const deviceTemps = computed<Map<string, number>>(() => {
  const m = new Map<string, number>();
  for (const d of latest.value?.devices ?? []) m.set(d.deviceId, d.temperature);
  return m;
});

// watch 两张 Map → 驱动重绘(脏检查在 renderer 内部,见第三节)
watch([deviceStates, deviceTemps], ([states, temps]) => {
  renderer?.update(states, temps);
});

onMounted(() => {
  // markRaw 双保险:即使未来重构进响应式容器也不会被代理
  renderer = new TopoRenderer({ bg: bgCanvas.value!, edges: edgeCanvas.value!,
                                nodes: nodeCanvas.value!, config: TOPO_CONFIG });
  renderer.renderStatic();

  // 立即回答:首帧就画当前数据(不等下一次快照——Day 65"回答现在"纪律的延续)
  renderer.update(deviceStates.value, deviceTemps.value);

  ro = new ResizeObserver(() => rafOnce(() => renderer?.resize()));
  ro.observe(rootEl.value!);
});

onUnmounted(() => {
  ro?.disconnect();
  renderer?.destroy();
  renderer = null;
});
</script>

markRaw 在此例的角色:今天 renderer 用普通变量已安全,markRaw 是给未来重构上的保险——组件演化中随手把它塞进 reactive 的概率不低,显式标记让这类事故从"运行时降速"变成"写不进去"。


三、脏检查:跳过无意义的重绘

3.1 问题的量化

快照节奏:500ms/次 → watch 每 500ms 触发 → 整图重绘(20 节点 + 30 连线)
但状态色的真实变化频率:温度抖动每秒变,state(ok/warn) 可能几分钟不变
→ 90% 的重绘画的是一模一样的内容——白画

3.2 renderer 内部的脏检查

// core/topo-renderer.ts(追加)
/**
 * 数据驱动更新入口(Day 68 已有签名,今天补脏检查)
 * 脏检查粒度:状态层与温度层分开判定——
 *   状态变 → 状态层重绘(节点框颜色 + 连线)
 *   仅温度变 → 只重绘节点层的副标签(更轻)
 */
update(states: Map<string, DeviceState>, temps: Map<string, number>): void {
  // 状态脏检查:与上次比较(浅比较每设备一行)
  let stateDirty = false;
  for (const [id, s] of states) {
    if (this.lastStates.get(id) !== s) { stateDirty = true; break; }
  }
  // 温度脏检查:四舍五入到 0.1℃ 再比(避免浮点噪声触发重绘)
  let tempDirty = false;
  for (const [id, t] of temps) {
    if (Math.abs((this.lastTemps.get(id) ?? -999) - t) > 0.05) { tempDirty = true; break; }
  }

  if (stateDirty) {
    this.lastStates = states;
    this.drawEdges(states);
    this.drawNodes(states, temps);      // 状态变必然带温度一起画
  } else if (tempDirty) {
    this.lastTemps = temps;
    this.drawNodeLabels(temps);         // 只画副标签层(第 7 周分层红利的兑现)
  }
  // 都不脏 → 什么都不做(白嫖了 90% 的重绘预算)
}

这就是第 7 周"三层画布"设计在 Vue 架构下的红利兑现:分层让"只重绘副标签"成为可能——单层画布只能整图重画。


四、分频与 watch 源的选择

4.1 为什么拓扑用 computed 而屏 B 曲线直接 watch Map

屏 B 曲线(Day 81) 屏 C 拓扑(今天)
watch 源 tempWindows(Map,deep) computed 派生的两张 Map
触发频率 每快照(500ms) 每快照(同)但脏检查拦下 90%
派生原因 曲线要全量窗口数据 画布只要状态与温度的最小投影

模式总结桥的入口用 computed 做"数据投影"(窄口),renderer 内部做脏检查(频率闸)——两层闸门各司其职。

4.2 2s 分频去哪了

Day 68 原生版有 2s 分频(onSnapshot 分频订阅)。Vue 版不需要:脏检查天然把"数据到达"与"重绘发生"解耦——快照 500ms 来一次也只重绘真变化的部分。分频被脏检查替代——对照表记一行(频率控制策略的演进:订阅端分频 → 消费端节流 → 数据端脏检查)。


五、常见坑点

坑 1:computed 里返回新 Map 每次触发 watch

computed(() => new Map(...)) 的返回值每次都是新引用——watch 比较的是引用,每次快照都触发(哪怕内容没变)。这是设计使然(脏检查在下游兜底),但要知道:如果 watch 回调很重,别把脏检查省略

坑 2:watch 数组源与单个源的触发差异

watch([a, b], ([va, vb]) => ...) 任一变化都触发,回调里拿最新值。误用单源 watch(watch(deviceStates))会漏温度变化——多源显式列出

坑 3:立即回答写在 onMounted 而不是 watch immediate

watch(..., { immediate: true }) 在 setup 时机执行回调——此时 renderer 还没创建(onMounted 未跑)→ 空调用。正确做法:onMounted 里手动喂一次(第二节代码的"立即回答"注释处)。

坑 4:脏检查的浮点噪声

温度 85.30000001 → 85.30000002 也会判脏。阈值(本文 0.05)按显示精度定(副标签显示一位小数,0.05 阈值安全)。


六、自测挑战

T1 · 入向桥 + 脏检查(70 分钟)

完成第二至三节:拓扑节点随数据变状态色、温度副标签跳动。Console 在 update 入口打点计数:稳态下 10 秒内重绘次数应远低于快照到达次数(脏检查生效的量化证据)。

T2 · markRaw 实验(20 分钟)

故意 const r = reactive({ renderer }) 包一次,观察初始化卡顿与每帧属性访问降速(Performance 对比);恢复后把实验结论写进边界文档。

T3 · 分层红利验证(30 分钟)

把脏检查的 tempDirty 分支临时改为整图重绘,对比"只画副标签"的每帧耗时(Performance 火焰图);量化第 7 周分层设计在今天的价值。


七、总结

环节 要点
markRaw 实例/DOM/库对象永不进代理——第三次纪律,成肌肉记忆
入向桥 computed 投影(窄口)+ watch 驱动 + 立即回答
脏检查 状态与温度分层判定;90% 重绘被拦下
分层红利 "只画副标签"依赖三层画布——Week 7 投资的兑现
分频演进 订阅端 → 消费端 → 数据端,三代频率控制对照

数据进来了。明天打通出向:Canvas 事件 → emit → hover 详情面板。

评论