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 详情面板。