Day 86 · TopoCanvas 组件骨架 — 三层画布迁进组件:ref 挂载与 DPR 适配
施工第一天:把第 7 周的三层画布(背景/连线/节点层,Day 46 的分层设计)迁进 Vue 组件骨架。今天的重点是"挂载的时序"——canvas 元素要等 DOM 就位才能拿 getContext,DPR 适配要跟着组件 resize 走。今天只搭骨架:画布能正确创建、resize 时清晰重设、画一组静态节点验证三层协作。数据驱动(watch store)明天接。
目录
- 一、组件骨架与 ref 挂载
- 二、DPR 适配:从模糊到锐利
- 三、resize 协议:ResizeObserver 的画布版
- 四、三层画布迁移清单
- 五、静态渲染验证
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、组件骨架与 ref 挂载
<!-- src/components/TopoCanvas.vue -->
<script setup lang="ts">
/**
* 拓扑画布组件:第 7 周编辑器内核的展示态骨架(Day 68 迁移版)的 Vue 化
* 本周职责边界(Day 85 文档):
* 本组件 = 画布世界的宿主 + 桥的实现方
* renderer 实例自治(视口/命中/动画)——绝不进响应式
*/
import { onMounted, onUnmounted, ref } from "vue";
import { TopoRenderer } from "../core/topo-renderer"; // Day 68 展示态渲染器原样迁入
import { TOPO_CONFIG } from "../core/topo-config"; // 声明式拓扑配置
// 模板引用:三个 canvas 的 DOM 句柄(挂在 .value 上,非响应式追踪)
const bgCanvas = ref<HTMLCanvasElement>();
const edgeCanvas = ref<HTMLCanvasElement>();
const nodeCanvas = ref<HTMLCanvasElement>();
const rootEl = ref<HTMLDivElement>();
// 渲染器实例:普通变量(今天先这样,明天上 markRaw 论证)
let renderer: TopoRenderer | null = null;
let ro: ResizeObserver | null = null;
onMounted(() => {
// 挂载时序:此时三个 canvas 已在 DOM,getContext 可用
renderer = new TopoRenderer({
bg: bgCanvas.value!,
edges: edgeCanvas.value!,
nodes: nodeCanvas.value!,
config: TOPO_CONFIG,
});
renderer.renderStatic(); // 静态渲染验证(第五节)
// resize 协议(第三节)
ro = new ResizeObserver(() => renderer?.resize());
ro.observe(rootEl.value!);
});
onUnmounted(() => {
ro?.disconnect();
renderer?.destroy(); // 渲染器内部清理(事件监听、rAF)
renderer = null;
});
</script>
<template>
<!-- 三层叠放:bg 最底,nodes 最顶(接收交互事件) -->
<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" />
</div>
</template>
<style scoped>
.topo-canvas { position: relative; width: 100%; height: 100%; }
.topo-canvas__layer { position: absolute; inset: 0; width: 100%; height: 100%; }
.topo-canvas__hit { pointer-events: auto; } /* 只有节点层收事件(第 7 周设计) */
</style>
与原生版挂载的对照:Day 68 是 document.getElementById / 手动 append;Vue 版是 ref 声明 + onMounted 时序保证。心智差异:原生版"找 DOM",Vue 版"声明我需要什么"。
二、DPR 适配:从模糊到锐利
高 DPI 屏幕(值班室大屏常见 2x 缩放)上,canvas 不做 DPR 适配会糊成近视眼。第 7 周的适配逻辑迁移要点:
// core/topo-renderer.ts 内部(迁移时的检查清单)
/**
* DPR 适配:canvas 尺寸 = CSS 尺寸 × devicePixelRatio
* 关键三件套缺一不可:
* 1. canvas.width/height = 物理像素(乘 DPR)
* 2. ctx.scale(dpr, dpr)——绘制坐标系按 CSS 像素算
* 3. CSS 宽高 100%(样式层控制显示尺寸)
*/
resize(): void {
const dpr = window.devicePixelRatio || 1;
const rect = this.root.getBoundingClientRect(); // CSS 尺寸
for (const canvas of [this.bg, this.edges, this.nodes]) {
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
}
// 每层 ctx 重设 scale(注意:canvas.width 赋值会重置变换矩阵!)
for (const ctx of [this.ctxBg, this.ctxEdges, this.ctxNodes]) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
this.renderStatic(); // 尺寸变了必须重绘
}
迁移自查:如果第 7 周版本里 ctx.scale 只在 init 调了一次——迁移时补上 setTransform(canvas.width 赋值会静默清空变换,这是 DPR 适配最常见的"迁移后变糊"元凶)。
三、resize 协议:ResizeObserver 的画布版
3.1 与 VChart(Day 76)的对比
| VChart 的 resize | TopoCanvas 的 resize | |
|---|---|---|
| 触发 | ResizeObserver | ResizeObserver(同) |
| 动作 | chart.resize()(库托管) |
三件套自管:物理尺寸 + setTransform + 重绘 |
| 频控 | 库内部处理 | 高频回调需节流(见坑 3) |
3.2 scale 壳的联动
App 的 transform scale(Day 71)不影响 getBoundingClientRect 的 CSS 尺寸读取(rect 返回的是布局尺寸),所以 scale 壳缩放不需要重设画布——只有布局级变化(grid 尺寸变)才触发。这个结论值得打点验证(T3)。
四、三层画布迁移清单
从第 7 周/Day 68 的工程往 dashboard-vue 搬东西的核对表:
□ core/topo-config.ts 声明式配置(nodes/edges)——纯数据,原样拷
□ core/topo-renderer.ts 展示态渲染器——Day 68 版本(含 update(states) 入口)
□ 三层 ctx 初始化 + DPR 三件套(第二节)
□ ellipsize 文本截断(Day 43 遗产)
□ roundRectPath(Day 42 遗产)
□ resize():setTransform 修正
□ destroy():事件解绑 + rAF 取消(第 7 周若有遗漏今天补齐)
□ 坐标/命中的纯函数(hit-test、世界↔屏幕变换)——不依赖 DOM,原样拷
✗ 编辑器内核的其余部分(拖拽编辑、撤销栈、选区)——不迁(Day 68 已裁剪)
迁移纪律:拷贝的文件里 import 路径改好、console.log 清零;逐文件核对 destroy 的成对性(第 7 周的编辑态监听如果有漏拆的,展示态会把幽灵监听带进来)。
五、静态渲染验证
骨架完成的标志——不接任何实时数据,静态画一屏:
// renderer.renderStatic() 的验证内容(Day 68 逻辑的骨架版)
renderStatic(): void {
// 背景层:网格(第 7 周的 grid 绘制)
this.drawGrid();
// 连线层:TOPO_CONFIG.edges 的连线
this.drawEdges(new Map()); // 空状态集:默认色
// 节点层:TOPO_CONFIG.nodes 的节点框 + 标签
this.drawNodes(new Map(), new Map());
}
验证通过的标准:三层内容正确叠放(网格在底、连线居中、节点置顶)、文字锐利(DPR 生效)、拖拽窗口大小画布跟随重绘。
六、常见坑点
坑 1:onMounted 前访问 ref
setup 阶段 bgCanvas.value 是 undefined——DOM 未挂载。所有 DOM 操作必须在 onMounted 之后。报错特征:Cannot read properties of undefined (reading 'getContext')。
坑 2:KeepAlive 激活后画布空白
屏 C 失活期间容器被摘下(KeepAlive 摘 DOM),若期间发生过 resize 事件,激活回来尺寸错位。对策(今天先记,Day 89 统一处理):onActivated 里调 renderer.resize() 防御性校准——VChart 同款纪律(Day 82)的画布版。
坑 3:ResizeObserver 回调风暴
拖拽窗口时每帧触发 resize → 每帧三画布重设 + 重绘。节流:回调里 requestAnimationFrame 合并(同帧多次回调只执行最后一次):
let pending = false;
ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => { pending = false; renderer?.resize(); });
});
坑 4:canvas.width 赋值的隐式重置
第二节强调的元凶:canvas.width = x 会清空画布内容并重置变换矩阵。迁移时若发现"重绘后坐标全部偏移",九成是 setTransform 没跟着 width 赋值走。
坑 5:样式 100% 与属性宽高的混淆
CSS width: 100%(显示尺寸)与 canvas.width(物理像素)是两套体系。DPR 适配三件套缺了 CSS 层,画布会被 canvas.width 撑爆容器。
七、自测挑战
T1 · 骨架 + 静态渲染(70 分钟)
完成第一至五节:三层画布正确创建、静态拓扑显示、DPR 下文字锐利(截屏放大对比)、窗口拖拽重绘跟手。
T2 · DPR 对照实验(20 分钟)
临时注释 setTransform 三行,在高 DPI 屏看模糊/偏移效果;恢复后对比。亲眼看过一次,坑 4 就永远不会忘。
T3 · scale 壳联动验证(20 分钟)
验证第三节结论:改浏览器窗口缩放(触发 App 的 scale ref 变化),观察画布不触发 resize 重绘(Console 打点);改 grid 布局(容器尺寸真变)才触发。把结论写进边界文档的"复审条件"节。
八、总结
| 环节 | 要点 |
|---|---|
| ref 挂载 | 声明式要 DOM,onMounted 后操作 |
| DPR 三件套 | 物理尺寸 × dpr + setTransform + CSS 100% |
| width 赋值陷阱 | 清内容 + 重置矩阵——setTransform 必须随行 |
| resize 协议 | rAF 合并节流;scale 壳不触发 |
| 迁移清单 | destroy 成对性逐文件核对 |
骨架立起来了。明天接活水:watch store 驱动重绘,以及 markRaw 的关键论证。