【Canvas × Vue】day86-topo-canvas-skeleton

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

Day 86 · TopoCanvas 组件骨架 — 三层画布迁进组件:ref 挂载与 DPR 适配

施工第一天:把第 7 周的三层画布(背景/连线/节点层,Day 46 的分层设计)迁进 Vue 组件骨架。今天的重点是"挂载的时序"——canvas 元素要等 DOM 就位才能拿 getContext,DPR 适配要跟着组件 resize 走。今天只搭骨架:画布能正确创建、resize 时清晰重设、画一组静态节点验证三层协作。数据驱动(watch store)明天接。


目录


一、组件骨架与 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 的关键论证。

评论