Canvas 多层画布架构 — 静态层、动态层、交互层,各司其职
脏矩形解决了"重绘多少",但它要求所有内容共享一块画布——网格背景、设备图元、选中框搅在一起,每次都要小心维护 z 序和脏区。多层画布从架构层面根治问题:几块同尺寸的 canvas 像千层饼一样叠在 DOM 里,按"变化频率"分层——静态层只画一次、动态层低频更新、交互层高频重绘——层与层互不干扰,每层的渲染策略可以最简单化。这是大屏、组态编辑器、地图产品的标准架构,也是明天编辑器的骨架。
目录
- 一、从"一锅炖"到"千层饼"
- 二、分层依据:变化频率
- 三、层的物理实现: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 的虚线动效够用) |
九、自测挑战
- 设计题:一个组态编辑器包含:网格背景、300 个设备图元(选中才变)、连线(拖节点才变)、选中框(交互时变)、实时数据流动画(每帧变)。设计分层方案(层名/内容/更新策略)。
- 实现题:给第六节加第四层"fx":被选中设备周期性发出一圈扩散波纹(circle 半径增大透明度减小)。写出该层的渲染循环。
- 改错题:下面的 resize 处理有问题,找出来:
window.addEventListener("resize", () => {
const c = stage.querySelector("#scene") as HTMLCanvasElement;
c.width = stage.clientWidth * dpr;
c.height = stage.clientHeight * dpr;
renderScene();
});
- 思考题:为什么"按类型分层"(矩形一层圆一层)是反模式,而"按频率分层"(静态/动态/交互)是最佳实践?(从"重绘的最小单位是谁"角度回答)
- 进阶题:多层画布 + 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 战编辑器的交互层直接整体复用今天的产品。