【Canvas 2D】day47-multi-layer

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

Canvas 多层画布架构 — 静态层、动态层、交互层,各司其职

脏矩形解决了"重绘多少",但它要求所有内容共享一块画布——网格背景、设备图元、选中框搅在一起,每次都要小心维护 z 序和脏区。多层画布从架构层面根治问题:几块同尺寸的 canvas 像千层饼一样叠在 DOM 里,按"变化频率"分层——静态层只画一次、动态层低频更新、交互层高频重绘——层与层互不干扰,每层的渲染策略可以最简单化。这是大屏、组态编辑器、地图产品的标准架构,也是明天编辑器的骨架。


目录


一、从"一锅炖"到"千层饼"

1.1 单画布的困境

单画布架构下的一帧(假设大屏内容):

clearRect 全屏
→ 画网格背景(静态!每帧白画)
→ 画 200 个设备图元(数据 1 秒才变一次)
→ 画实时曲线(每帧都变)
→ 画选中框(用户交互时才变,但变了就要全屏重绘?)

问题:
① 静态内容反复重画(浪费 80% 帧预算)
② 脏矩形能救一部分,但所有内容共用 z 序/坐标系,管理复杂
③ 想给曲线单独加特效(比如拖尾),会污染同层的设备图元

1.2 多层画布的解法

DOM 结构(自下而上叠放):

<div id="stage" style="position: relative">        ← 舞台容器
  <canvas id="bg"    />   ← 静态层:网格+装饰(画 1 次,之后不动)
  <canvas id="scene" />   ← 动态层:设备图元(数据变化时局部更新)
  <canvas id="fx"    />   ← 特效层:曲线动画(每帧重绘,但只有曲线)
  <canvas id="ui"    />   ← 交互层:选中框/框选(交互时重绘)
</div>

每层都是同尺寸、绝对定位、上下叠放:
  - 各层独立 ctx,独立渲染策略
  - 浏览器自动做"层合成"(GPU 合成一帧画面,比手画快得多)
  - 上层透明区域自动"透出"下层内容

二、分层依据:变化频率

2.1 分层的唯一原则

按【变化频率】分层,频率差异越大,分层收益越大:

层            变化频率          渲染策略              典型内容
─────────────────────────────────────────────────────────
静态层        从不变/极少变     画一次 + 失效重建      网格、底图、边框装饰
数据层        秒级(数据驱动)  事件驱动局部更新        设备图元、状态颜色
动画层        每帧              rAF 全量重绘            曲线、粒子、流水动效
交互层        交互瞬间          交互事件驱动            选中框、框选、拖拽预览

2.2 频率决定策略(呼应 Day 46 决策表)

// 静态层:终身只画一次(除非 resize / 内容定义变了)
bgCtx.drawGrid();                    // 启动时
// resize 时:
bgCtx.redrawAll();

// 数据层:WebSocket 推送到达才更新(第 8 周接入)——脏矩形的主场
sceneCtx.updateDevice(id, value);    // 事件驱动

// 动画层:rAF 循环,但只画动画内容(这层内容少,全量重绘无所谓)
fxCtx.clear(); fxCtx.drawCurve();

// 交互层:mousedown/mousemove 时更新,平时不动
uiCtx.drawSelectionBox(rect);

三、层的物理实现:DOM 叠放

3.1 HTML/CSS 骨架

<style>
  #stage {
    position: relative;         /* 容器相对定位:层的定位基准 */
    width: 800px;
    height: 600px;
    background: #0a1526;        /* 底色兜底(画布未覆盖前的初始视觉) */
  }
  #stage canvas {
    position: absolute;         /* 全部绝对定位 → 精确叠放 */
    left: 0;
    top: 0;
    width: 100%;                /* CSS 尺寸撑满容器 */
    height: 100%;
    /* ⚠ pointer-events 很关键,见第四节 */
  }
</style>

<div id="stage">
  <canvas id="bg"></canvas>     <!-- z 序 = DOM 顺序:越靠下越上层 -->
  <canvas id="scene"></canvas>
  <canvas id="fx"></canvas>
  <canvas id="ui"></canvas>     <!-- 最上层 -->
</div>

3.2 DPR 适配:所有层一起做

/**
 * 多层画布的统一初始化
 * ⭐ 关键:所有层的【物理分辨率必须完全一致】
 *   (不一致 → 层间内容错位,比如设备图元和它的选中框对不上)
 */
function setupLayers(stage: HTMLElement, ids: string[]): Map<string, CanvasRenderingContext2D> {
  const dpr = window.devicePixelRatio || 1;
  const w = stage.clientWidth, h = stage.clientHeight;
  const layers = new Map<string, CanvasRenderingContext2D>();

  for (const id of ids) {
    const c = stage.querySelector(`#${id}`) as HTMLCanvasElement;
    c.width = Math.round(w * dpr);       // 物理分辨率统一
    c.height = Math.round(h * dpr);
    const ctx = c.getContext("2d")!;
    ctx.scale(dpr, dpr);                 // CSS 像素思维统一
    layers.set(id, ctx);
  }
  return layers;
}

3.3 层间坐标天然同步

多层架构的最大红利:所有层同尺寸、同 DPR、同 (0,0) 原点
→ 同一个坐标 (300, 200) 在每层都指向同一个视觉位置
→ 设备画在 scene 层 (300,200),选中框画在 ui 层 (300,200) —— 自动对齐!

对比单画布方案:需要手动维护"内容坐标 → 画布坐标"的映射,
多层方案里这个问题直接不存在。

四、事件路由:最上层接单,逐层问询

4.1 问题:事件只落在最上层

// 浏览器的鼠标事件只会派发给"最上层响应指针的元素"。
// 如果 ui 层 canvas 默认接收事件 → bg/scene 层永远收不到点击!

// 方案 A(错误示范):给每层都加监听 → 点击穿透混乱
// 方案 B(正解):ui 层统一接单,按坐标"问询"各层谁要处理

4.2 事件路由器实现

/**
 * 事件路由器:ui 层接单 → 逆序问询各层(上层优先)→ 谁命中谁处理
 * 
 * 设计要点:
 * ① ui 层 pointer-events: auto(接单),其余层 none(不抢)
 * ② 路由顺序 = DOM 逆序(fx → scene → bg),保证上层图元优先命中
 * ③ 事件对象统一带换算好的画布坐标(Day 34 的 eventToCanvas)
 */
interface LayerHit {
  hitTest(p: { x: number; y: number }): boolean;
  onEvent(type: string, p: { x: number; y: number }): void;
}

class EventRouter {
  private layers: LayerHit[] = [];    // 逆序注册(最上层的在最前)

  register(layer: LayerHit): void {
    this.layers.unshift(layer);
  }

  dispatch(type: string, p: { x: number; y: number }): void {
    for (const layer of this.layers) {          // 上层优先
      if (layer.hitTest(p)) {
        layer.onEvent(type, p);                  // 命中 → 交给它 → 停止下传
        return;
      }
    }
    // 全都没命中 → 画布空白处(框选起点通常在这)
    this.onBackground(type, p);
  }

  private onBackground(type: string, p: { x: number; y: number }): void {
    /* 空白点击的默认行为(如取消选中) */
  }
}

4.3 CSS 配合

#ui { pointer-events: auto; }       /* 只有 ui 层接收指针 */
#fx, #scene, #bg { pointer-events: none; }   /* 其余层穿透 */

五、层间协作:坐标系与渲染协议

5.1 各层的渲染协议(谁负责画什么)

一个设备图元的"全家福"被拆到各层:

ui 层    ┌────────┐
         │▒▒▒▒▒▒▒▒│ ← 选中框(虚线呼吸动效也在这层)
         │┌──────┐│
fx 层    ││✨光晕 ││ ← 报警时的发光脉冲(特效,随时消失)
         │└──────┘│
scene 层 │ ▓▓▓▓▓ │ ← 图元本体(矩形+文字,数据驱动)
bg 层    ┴──┬┬┬┬┬┴── ← 网格(永远不变)

拆分规则:
- 本体归数据层(跟数据走)
- 会"消失"的效果归特效/交互层(用完清掉,不碰本体)
- 静态装饰归背景层

5.2 viewport 变换的跨层同步

/**
 * ⭐ 多层 + Day 38 viewport 的配合纪律:
 * 所有层的 ctx 变换必须【同步设置】——缩放平移时四层一起动
 */
const view = { zoom: 1, panX: 0, panY: 0 };   // 唯一的 viewport 状态

function applyViewToAllLayers(layers: Map<string, CanvasRenderingContext2D>, dpr: number): void {
  for (const ctx of layers.values()) {
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);                 // 基准
    ctx.transform(view.zoom, 0, 0, view.zoom, view.panX, view.panY);   // 场景矩阵
  }
}
// 忘同步的症状:缩放后背景动了、图元没动 → "分层漂移"
// (静态层如果内容不随场景缩放——比如铺满的装饰——则不参加同步,设计时想清楚)

5.3 一个图元的跨层更新流程

/**
 * 设备报警:数据层变红 + 特效层加光晕 —— 一次业务动作,两层更新
 */
function setAlarm(deviceId: string, on: boolean): void {
  // 数据层:本体变红
  sceneLayer.updateDevice(deviceId, { color: on ? "#ff4d4f" : "#4a6fa5" });

  // 特效层:报警光晕(独立渲染循环,只管自己的动画生命周期)
  if (on) fxLayer.addGlow(deviceId);
  else fxLayer.removeGlow(deviceId);
}

六、实战:三层架构的设备监控墙

今天的实战目标:三层画布的设备监控墙——静态层网格(画一次)、数据层设备图元(定时"推送"模拟数据变色)、交互层选中框(点击选中,框体呼吸动效)。

6.1 完整代码

// day47-layers.ts —— 三层架构设备监控墙
// HTML 骨架见第三节(三层:bg / scene / ui)

const stage = document.querySelector("#stage") as HTMLElement;
const layers = setupLayers(stage, ["bg", "scene", "ui"]);   // 第三节的工具函数
const dpr = window.devicePixelRatio || 1;
const W = stage.clientWidth, H = stage.clientHeight;

const bgCtx = layers.get("bg")!;
const sceneCtx = layers.get("scene")!;
const uiCtx = layers.get("ui")!;

// ===== 1. 静态层:网格 + 标题装饰(终身画一次)=====
function paintBackground(): void {
  bgCtx.fillStyle = "#0a1526";
  bgCtx.fillRect(0, 0, W, H);

  bgCtx.strokeStyle = "rgba(58,74,106,0.35)";
  bgCtx.lineWidth = 1;
  bgCtx.beginPath();
  for (let x = 0; x <= W; x += 40) { bgCtx.moveTo(x, 0); bgCtx.lineTo(x, H); }
  for (let y = 0; y <= H; y += 40) { bgCtx.moveTo(0, y); bgCtx.lineTo(W, y); }
  bgCtx.stroke();
}
paintBackground();

// ===== 2. 数据层:设备图元(模拟推送驱动)=====
interface Device {
  id: string; x: number; y: number; w: number; h: number;
  label: string; value: number;   // 0~100
}

const devices: Device[] = Array.from({ length: 12 }, (_, i) => ({
  id: `dev-${i}`,
  x: 80 + (i % 4) * 180, y: 100 + Math.floor(i / 4) * 160,
  w: 120, h: 60,
  label: `设备 ${i + 1}`,
  value: 20 + Math.random() * 60,
}));

/** 数据层渲染:全量重画(12 个图元,够快;图元多时换脏矩形) */
function renderScene(): void {
  sceneCtx.clearRect(0, 0, W, H);
  for (const d of devices) {
    const alarming = d.value > 85;   // 高温报警
    sceneCtx.fillStyle = alarming ? "#5c1f24" : "#1a2740";
    sceneCtx.fillRect(d.x, d.y, d.w, d.h);
    sceneCtx.strokeStyle = alarming ? "#ff4d4f" : "#3a4a6a";
    sceneCtx.lineWidth = 2;
    sceneCtx.strokeRect(d.x, d.y, d.w, d.h);

    // 标签(Day 43 的排版函数可复用,这里从简)
    sceneCtx.fillStyle = "#e6f1ff";
    sceneCtx.font = "14px sans-serif";
    sceneCtx.fillText(d.label, d.x + 10, d.y + 22);
    sceneCtx.fillStyle = alarming ? "#ff7875" : "#00ff88";
    sceneCtx.fillText(`${d.value.toFixed(0)}℃`, d.x + 10, d.y + 44);
  }
}
renderScene();

// 模拟数据推送(第 8 周换成 WebSocket)
setInterval(() => {
  const d = devices[Math.floor(Math.random() * devices.length)];
  d.value = 15 + Math.random() * 85;
  renderScene();          // 数据层局部/全量更新 —— 不碰其他层!
}, 800);

// ===== 3. 交互层:选中框(呼吸动效)=====
let selected: Device | null = null;

/** 交互层渲染:只有选中框,全量重绘毫无压力 */
function renderUI(): void {
  uiCtx.clearRect(0, 0, W, H);
  if (!selected) return;

  const t = performance.now() / 1000;
  const pad = 5 + Math.sin(t * 4) * 2;             // 呼吸:±2px 波动
  uiCtx.strokeStyle = "#00ff88";
  uiCtx.lineWidth = 2;
  uiCtx.setLineDash([8, 5]);                        // 虚线框
  uiCtx.strokeRect(selected.x - pad, selected.y - pad, selected.w + pad * 2, selected.h + pad * 2);
  uiCtx.setLineDash([]);
}
setInterval(renderUI, 33);    // ~30fps 的动效(比 60fps 省,虚线动画够用)

// ===== 4. 事件路由(第四节方案的落地)=====
const uiCanvas = stage.querySelector("#ui") as HTMLCanvasElement;

uiCanvas.addEventListener("click", (e) => {
  const rect = uiCanvas.getBoundingClientRect();
  const px = e.clientX - rect.left, py = e.clientY - rect.top;

  // 命中检测(Day 45 AABB,倒序 = 最上层优先……单层数据时正序即可)
  selected = devices.find((d) =>
    px >= d.x && px <= d.x + d.w && py >= d.y && py <= d.y + d.h
  ) ?? null;
  renderUI();
});

6.2 分层收益实测

模拟推送每 0.8 秒改一个设备值:
  → 只有 scene 层重绘(12 个 fillRect,<0.2ms)
  → bg 层纹丝不动(它的像素在 GPU 里躺着)
  → ui 层按自己的节奏呼吸(与数据推送完全解耦)

对比单画布:每次数据变化都要
  clearRect 全屏 + 重画网格 + 全部设备 + 选中框
  (还得小心虚线动效别污染了图元 —— 分层后这些心智负担全部消失)

七、层数权衡与反模式

7.1 不是层数越多越好

每层一个 canvas = 每层一块 GPU 纹理 + 合成开销:

1~4 层:浏览器合成的甜蜜区 ✅
5~8 层:开始有合成开销,但通常仍然划算 ⚠
10+ 层:合成开销反噬,且内存暴涨 ❌

经验:按"变化频率档位"分层,同一档位的内容合在一层。
     (200 个设备都秒级变化 → 一个数据层足够,别一个设备一层!)

7.2 反模式清单

反模式 1:每图元一层
  200 个设备 200 层 canvas —— DOM 爆炸 + 合成灾难
  ✅ 图元是数据层【内部】的绘制内容,不是层

反模式 2:分层不看频率看"类型"
  "所有矩形一层、所有圆一层" —— 类型和重绘频率无关,纯找罪受
  ✅ 只按变化频率分

反模式 3:层间逻辑耦合
  数据层渲染函数里偷偷画了选中框 —— 分了层还写成一锅炖
  ✅ 每层的渲染函数只碰自己的 ctx

反模式 4:忘记层间同步(viewport/resize)
  缩放后有的层动了有的没动 —— "分层漂移"灵异现场
  ✅ applyViewToAllLayers / resize 全层重建(第五节)

7.3 与离屏缓存(Day 41)的关系

两者都是"别重复画"的思想,但维度不同:

离屏缓存(Day 41):内容画到【不显示的画布】,用时 drawImage 贴
                   → 服务于"一块画布内部"的优化
多层画布(今天):  内容画在【真实叠放的可见画布】
                   → 服务于"架构分层",是产品级骨架

大屏项目的组合用法:
  静态层内部用离屏缓存进一步优化(比如超大背景图预渲染)
  动态层内部用脏矩形优化局部更新
  → 架构(多层)+ 战术(缓存/脏矩形)双剑合璧

八、常见坑点与最佳实践

# 症状 解法
1 层的物理分辨率不一致 层间内容错位(框和图元对不上) 统一 setupLayers 一次初始化
2 每层都监听指针事件 事件穿透混乱 / 双重触发 只留最上层 auto,其余 none
3 viewport 只同步了部分层 缩放后"分层漂移" applyViewToAllLayers 统一入口
4 resize 只重建了一层 有的层拉伸模糊 resize = 全层尺寸重算 + 全层重绘
5 层数太多 内存与合成开销反噬 按频率档位分层(≤4 层常见)
6 上层整层不透明色 盖住下层内容 各层内容之间的区域必须保持透明
7 clearRect 用物理坐标 清不干净/清错区域 绘制类 API 用 CSS 坐标(老规矩)
8 层渲染函数互踩 ctx 数据层代码里画了 UI 内容 每层渲染只用自己的 ctx(架构纪律)
9 动效层用 60fps 跑低频动画 白烧电量 按需定频率(30fps 的虚线动效够用)

九、自测挑战

  1. 设计题:一个组态编辑器包含:网格背景、300 个设备图元(选中才变)、连线(拖节点才变)、选中框(交互时变)、实时数据流动画(每帧变)。设计分层方案(层名/内容/更新策略)。
  2. 实现题:给第六节加第四层"fx":被选中设备周期性发出一圈扩散波纹(circle 半径增大透明度减小)。写出该层的渲染循环。
  3. 改错题:下面的 resize 处理有问题,找出来:
window.addEventListener("resize", () => {
  const c = stage.querySelector("#scene") as HTMLCanvasElement;
  c.width = stage.clientWidth * dpr;
  c.height = stage.clientHeight * dpr;
  renderScene();
});
  1. 思考题:为什么"按类型分层"(矩形一层圆一层)是反模式,而"按频率分层"(静态/动态/交互)是最佳实践?(从"重绘的最小单位是谁"角度回答)
  2. 进阶题:多层画布 + Day 38 viewport + Day 46 脏矩形三合一:设计"动态层内部"的脏矩形更新方案——数据推送改变一个设备时,如何只重绘该设备区域?(提示:脏区标记 + 相交重画,注意本层 clearRect 不影响其他层)

十、总结与知识图谱

Day 47 多层画布架构
├── 分层原则
│   └── 按变化频率:静态/数据/动画/交互
├── 物理实现
│   ├── DOM 绝对定位叠放(z 序 = DOM 序)
│   ├── 全层统一物理分辨率 + DPR
│   └── 层间坐标天然同步(最大红利)
├── 事件路由
│   ├── 最上层 pointer-events: auto 接单
│   ├── 其余层 none 穿透
│   └── 命中检测逆序问询(上层优先)
├── 跨层协作
│   ├── viewport 全层同步(防分层漂移)
│   ├── 一个业务动作 → 多层各自更新
│   └── 每层渲染只碰自己的 ctx
├── 权衡
│   ├── 1~4 层甜蜜区 / 反模式清单
│   └── 与离屏缓存、脏矩形的组合(架构+战术)
└── 实战
    └── 三层监控墙(bg 画一次 / scene 推送驱动 / ui 呼吸框)

明天预告(Day 48)图元交互内核——把点击、hover、拖拽(含 viewport 逆变换)、框选、光标反馈做成一个 InteractionManager 模块。它处理"谁被点中"的优先级、拖拽时的坐标换算(缩放后的场景里拖拽)、框选矩形与图元的求交——明天 BOSS 战编辑器的交互层直接整体复用今天的产品。

评论