如果你用原生 Canvas 写过超过三个可交互的绘图功能,应该体会过那种“画面一复杂就失控”的感觉。图形一多,每次鼠标移动后都不知道要重绘哪一片;想给某个矩形绑定点击事件,还得反过来手工做坐标范围判断;如果画面上再叠上拖拽、框选、缩放、网格吸附这些交互,代码很快就变成一张用 save/restore 铺出来的状态补丁网。
这种情况用一个专注前端 Canvas 的分层绘制框架会舒服很多。Konva 就是一个基于 HTML5 Canvas 的 2D 绘图框架,核心思路是把画布组织成 Stage、Layer、Group、Shape 的树形结构,把整个画面拆成多层绘制。哪一层需要更新就只重绘哪一层,事件命中也不再依赖手工算坐标。它特别适合流程图编辑器、海报设计器、拓扑图、可视化白板这类需要频繁交互和分层的绘图应用。
这篇文章我会结合自己在内部绘图小工具里的实际经验,讲一讲 Konva 的分层绘制到底该怎么用。内容包括它为什么要把画面拆成多个 Layer、哪些内容该放进业务层和临时层、Group 和 Layer 的区别、性能优化的几个关键点,以及最后那些不太容易从文档里看出来的坑。
1. 原生 Canvas 的痛点:没有“图层感”的绘制就是灾难
1.1 全局重绘与状态残留,让简单交互失控
很多刚接触 Canvas 的人写交互,最容易写成这样:每次状态一变化,就clearRect把整块画布清掉,然后从头按顺序把所有对象画一遍。这个方案在小场景下不是不能用,但它有几个非常难受的地方。
第一个问题是重绘成本不可控。假设画布上有 1000 个节点,你只是想拖动其中一个节点,结果所有节点都要跟着重建一遍。拖拽过程中如果每帧都这么做,性能会直线下坠。有人会尝试用脏矩形优化,但一旦图形有旋转、缩放、裁剪路径,计算局部重绘区域很快就变成一个比业务本身还复杂的工程。
第二个问题更难查:上下文状态残留。ctx.save()和ctx.restore()必须严格配对,稍微漏了一个 transform 或者 clip,后面的图形就会跑到莫名其妙的位置。我在一个编辑器项目里遇到过按钮点击后整体图形偏移的 bug,最后排查半天才发现是某次绘制漏了restore,导致后续坐标体系完全错乱。
第三个痛点是命中检测。Canvas 上的图形渲染完以后就只是一堆像素,没有“对象”的概念。你想知道鼠标点中了哪个矩形,得自己遍历所有图形,做坐标反算。图形一旦加了旋转角度,普通的矩形包含判断就失效,处理起来非常繁琐。
这三点叠加在一起,就形成了一个很尴尬的现状:业务本身并不复杂,但绘制层把这些复杂性全暴露给了开发者。
1.2 Konva 的分层思想:让绘制对象回到树形结构里
Konva 的解决办法,是把 Canvas 的“一次性绘制”变成“对象树管理”。它定义了四层核心结构:
- Stage:对应整个舞台,也就是你在页面上指定的容器。
- Layer:对应独立的一层画布,可以理解为透明胶片。
- Group:一层画布内的逻辑分组,用来组合多个图形。
- Shape:真正的图形单元,包括矩形、圆形、线条、文字等。
这种结构最大的好处,是绘制语义和业务语义能对应上。你不需要再在 render 函数里费力维护“谁应该盖住谁”的顺序,因为这些顺序天然被树结构表达出来。修改某个 Shape 的位置、颜色、尺寸以后,只需要让对应节点所在层重绘,其他层完全不用动。
我拿一个简单对照来感受一下原生写法和 Konva 写法的差异:
| 对比维度 | 原生 Canvas 常规写法 | Konva 分层写法 |
|---|---|---|
| 重绘单位 | 通常整张画布清掉重画 | 只重绘发生变化的 Layer |
| 命中检测 | 手动遍历图形做坐标判断 | 由 Konva 的 hit canvas 自动映射 |
| 组合关系 | 通过绘制顺序隐式表达 | 用 Group 显式组合 |
| 数据状态 | 存在数组或业务对象里 | 节点本身就是可查询的结构 |
有了这些基础认知,再看 Konva 的代码会觉得顺很多。接下来我会从零开始搭一个最小画布,把 Stage 和 Layer 怎么落地讲清楚。
2. 从零搭一个可运行的分层画布:Stage 与 Layer 如何落地
2.1 最小工程:一个舞台、两个图层、一些形状
先装依赖,我这里用的是常规 npm 方式:
npm install konva页面里只需要放一个容器 div:
<div id="drawingBox" style="width: 100%; height: 600px;"></div>然后初始化 Stage,并创建两个 Layer:
import Konva from 'konva'; const stage = new Konva.Stage({ container: 'drawingBox', width: 1024, height: 600, }); const backgroundLayer = new Konva.Layer(); const editorLayer = new Konva.Layer(); stage.add(backgroundLayer); stage.add(editorLayer);接着往 backgroundLayer 里画一个网格背景,往 editorLayer 里放一个可拖拽矩形:
const gridGroup = new Konva.Group({ name: 'grid' }); for (let x = 0; x < 1024; x += 40) { gridGroup.add( new Konva.Line({ points: [x, 0, x, 600], stroke: '#e5e7eb', strokeWidth: 1, listening: false, }) ); } for (let y = 0; y < 600; y += 40) { gridGroup.add( new Konva.Line({ points: [0, y, 1024, y], stroke: '#e5e7eb', strokeWidth: 1, listening: false, }) ); } backgroundLayer.add(gridGroup); const box = new Konva.Rect({ x: 160, y: 100, width: 180, height: 120, fill: '#3b82f6', draggable: true, }); editorLayer.add(box);运行以后,页面上看到的是一张网格背景,以及一个可以拖动的蓝色矩形。灰色网格会被独立画在 backgroundLayer 这张画布里,矩形则画在另一张画布里。
这里网格线条之所以设置listening: false,是因为网格只是视觉参考,不应该参与鼠标命中判断。如果不设置,Konva 的 hit canvas 会把每一根细线都算作可点击区域,鼠标事件在判断目标时白白增加开销。
2.2 每个 Layer 都是独立 canvas,别把 Layer 当成数组
很多初学者会把 Layer 理解成“一组图形的集合”,这个理解不够准确。Konva 的每个 Layer 在底层都对应了一个真实的 canvas 元素,多个 Layer 会被叠加放置在同一容器内。
也就是说,你在 backgroundLayer 画网格,浏览器就是创建了一个 canvas 来绘制网格;你在 editorLayer 画矩形,浏览器又创建了另一个 canvas 来绘制矩形。它们通过透明叠加呈现最终画面。
这个机制带来的直接收益,就是重绘隔离。编辑器里拖动矩形时,我只需要触发 editorLayer.draw(),网格层完全不用动。如果同样内容放在原生态 Canvas 里,至少要花心思确定矩形的移动范围是否影响到背景层。
Layer 的另一个价值是让代码职责更清晰。谁负责底色,谁负责交互,谁负责业务内容,打开代码一眼就能看出来,而不是在一个上千行的 draw 函数里翻到底哪块画的是哪层。
2.3 形状事件不用再手工算坐标
Konva 自带一套命中检测方案,这套方案底层基于每个 Layer 维护的 hit canvas。
每个 Layer 除了绘制真实画面外,还会在隐藏的 hit canvas 里画一份同尺寸的“颜色映射图”。每个 Shape 在 hit canvas 里会被分配一个特殊颜色,Konva 把颜色和节点对应关系记录下来。鼠标点击时,Konva 从 hit canvas 里读取目标像素颜色,反向查找出鼠标落在哪个节点上。
因为命中检测是框架层完成的,所以我写业务时很少再写那种“遍历所有图形判断点是否在内部”的代码。节点自身就有 click、mouseenter、mousedown、dragmove 等事件,交互逻辑可以直接挂在对应的 Shape 或者 Group 上。
这也意味着,分层时不需要担心“事件层级”问题。哪个节点想响应交互,就直接在那个节点上监听,和渲染顺序解耦。
3. 分层绘制的正确姿势:业务层、交互层、临时层各管什么
3.1 底座层:网格、参考线、水印
接触过设计类工具的人都清楚,网格和参考线是典型的“背景层内容”。它们几乎不变,偶尔需要切换显示,但不会每秒钟都重画。
我之前做编辑器时,把网格放进了单独的 backgroundLayer。因为这一层不需要参与交互,所有线条都设置listening: false,然后在后续操作里尽可能避免触碰这个 Layer。只有点击“显示网格”开关时,才去修改它的 visible 属性并触发一次 batchDraw。
底部层还有一个作用:给整个编辑器提供统一的比例尺和视觉基准。如果你做的是海报编辑器,背景层可能放的是安全边距虚线;如果你做的是思维导图,背景层可能放的是无限网格。这些东西不适合和业务图形混在同一层,因为它们一旦和业务对象混在一起,重绘时就会被迫不断重复计算。
3.2 业务数据层:用户真正要编辑的对象
业务层放的是用户创建和修改的真实内容。流程编辑器里的节点矩形,拓补图里的服务器图标,海报设计器里的图片和文字,都应该放在这里。
业务层内部还可以继续拆分。比如流程编辑器,我会把节点和连线拆成两个 Layer,或者拆成两个 Group。拆 Layer 的想法是:连线层永远放在节点层下面,这样线条不会遮挡节点文字,而节点被拖动时,连线层可以单独重绘,不需要把节点层所有对象都重算一遍。
拆 Group 的想法则更轻量,适合同一层内逻辑上需要组合的对象。拆 Layer 还是拆 Group,主要看两个维度:是否经常整体重绘、是否可能被大量节点命中检测。
有一个原则我在很多项目里都验证过:不要把交互临时状态直接塞进业务 Layer。后面第三小节详细说这个。
3.3 临时浮层:框选、预览、选中框
临时浮层是最容易被忽略的一层,但它往往决定了编辑器交互是否流畅。
所谓临时浮层,指的是框选矩形、拖拽时的虚线预览、鼠标悬浮高亮框、节点选中后的边界框这些东西。它们的特点是:随着交互频繁变化,但不是业务数据的一部分。
我见过不少放在业务层里的做法,类似这样:拖动时往 editorLayer 里 add 一个 Rect 当作选中框,鼠标弹起后再 remove 掉。短期看能用,长期看问题很多。首先,这个临时 Rect 会被序列化函数遍历到,除非你每次对它做特殊标记;其次,它会让业务层的历史记录和撤销重做逻辑变得特别混乱。
正确的做法是专门建一个 overlayLayer,放在所有业务层最上面:
const overlayLayer = new Konva.Layer(); stage.add(overlayLayer); const selectionBox = new Konva.Rect({ visible: false, fill: 'rgba(22, 119, 255, 0.12)', stroke: '#1668dc', strokeWidth: 1, }); overlayLayer.add(selectionBox);框选、拖动预览、Transformer 全部往这个 overlayLayer 里放。因为这一层始终在最上方,它不会被业务内容遮挡;因为它是独立的 Layer,重绘时也只会动 overlayLayer,不会影响底下密密麻麻的节点。
临时层还有一个隐藏的好处:如果你做序列化,只需要把 overlayLayer 排除出去,就不会把选择框或者选框残留误存进项目文件里。
4. Group 不是图层:组合、联动与继承权该怎么用
4.1 Layer 管“怎么合成”,Group 管“怎么组合”
我刚接触 Konva 时也分不太清 Layer 和 Group,总感觉它们都是“把东西装在一起”。实际用下来,两者服务的层次完全不同。
Layer 决定的是画布合成顺序,它最终会变成多个可叠加的 canvas。Group 则是在同一个 canvas 内部做逻辑分组,它不会产生新的 canvas,只是让若干图形可以共享坐标变换、事件和层级关系。
换一个生活化的比喻:Layer 是多张透明胶片,Stage 是叠胶片的台子;Group 是放在同一张胶片上的“道具组”,比如一个人、一把椅子、一盏灯绑在一起,方便整体移动。
在业务上,Group 最重要的用途是“组合”和“联动”。
4.2 用 Group 把图形、文字和附件绑成一个可拖拽单元
假设你在做一个架构图工具,每个节点由矩形、标题文字、底部状态圆点三个部分组成。如果不用 Group,拖拽时你得分别同步三个图形的位置,每次改动都要维护一组坐标。而用 Group,只需要把三个图形放进一个 Group,然后让 Group 本身可拖拽:
const nodeGroup = new Konva.Group({ x: 200, y: 120, draggable: true, name: 'service-node', }); const body = new Konva.Rect({ x: 0, y: 0, width: 160, height: 80, fill: '#ffffff', stroke: '#94a3b8', strokeWidth: 1, cornerRadius: 6, }); const title = new Konva.Text({ x: 12, y: 16, text: '订单服务', fontSize: 14, fontStyle: 'bold', fill: '#1e293b', }); const statusDot = new Konva.Circle({ x: 140, y: 60, radius: 5, fill: '#22c55e', }); nodeGroup.add(body, title, statusDot); editorLayer.add(nodeGroup);这样做的效果很直观:拖动 nodeGroup 时,矩形、文字、圆点会一起移动。子节点的坐标是基于 Group 的局部坐标计算的,Group 一旦移动,子节点跟着走,不需要手工同步。
4.3 Group 的坐标、包围盒和命中区域需要单独熟悉
Group 本身通常没有绘制内容,它的坐标只影响子节点的相对坐标。在计算某个组合在舞台上的绝对位置时,不能直接拿子节点的 x、y 去猜,而应该用:
const absolutePosition = nodeGroup.getAbsolutePosition(); const clientRect = nodeGroup.getClientRect();getClientRect 会返回 Group 实际的包围盒,包含所有子节点在屏幕上的尺寸和位置。做框选检测、选中框定位时,这个接口非常关键。
还有一点需要注意:如果 Group 里没有任何填充区域,命中测试只会落在子节点的可见区域内。如果用户想点击组合中间的空隙也能选中整组,就需要额外放一个透明矩形底座作为命中范围。这个底座要不要真的画出来,取决于你的交互设计习惯,但一定要明确它参与命中检测。
5. 分层带来的性能收益:重绘粒度、缓存与 DPR
5.1 draw 与 batchDraw:两种重绘粒度不能搞混
Konva 中触发重绘最常见的是两个方法:draw()和batchDraw()。
layer.draw()是立即重绘,调用后马上把当前 Layer 的所有节点重新画一遍。一般在关键操作完成后用,比如新增节点、修改颜色、切换显示状态。
layer.batchDraw()则是批量重绘。它不会立刻执行,而是把重绘请求收集起来,放在浏览器的 requestAnimationFrame 回调里统一处理。连续高频的拖动、缩放、动画帧里用 batchDraw 更合适,可以避免同一帧内多次重复绘制。
我实际编码时的习惯是:拖拽移动 mousemove 事件里一律用 batchDraw,因为一帧内可能触发很多次事件;在 mouseup、click 等低频操作里才用 draw,确保视觉结果马上反映到画布上。
5.2 静态层用 cache 缓存成位图
Konva 每个节点都有 cache 方法,原理是把节点绘制到一个离屏 canvas 上,之后每次 draw 时直接把这个位图贴到主画布,而不是重新执行向量绘制。对于阴影、滤镜、复杂渐变这类绘制代价较高的场景,效果立竿见影。
比如前面提到的网格背景层,一旦画完基本不变,就可以直接整层缓存:
backgroundLayer.cache(); backgroundLayer.draw();缓存以后,网格这层每次重绘就是贴一张位图,CPU 开销非常低。
但缓存的坑也很明显:如果你缓存后又改了某个节点的 fill、stroke、text 内容,离屏位图并不会自动更新。改完样式必须调用一次clearCache()再重新cache(),否则画面会一直停留在旧状态。这个 bug 在文字或颜色动态变化的节点上特别常见,我在一个图表联动功能里就被它坑过一次。
5.3 Layer 数量不是越多越好
虽然 Konva 鼓励分层,但每个 Layer 都是独立 canvas,还带一份 hit canvas。Layer 过多意味着内存里要维护多份画布,浏览器合成压力也会变大。
我的实践建议是:正常情况下控制在 4~6 层以内。静态底座一层、动态业务内容一两层、连线层一层、交互浮层一层,基本够用。如果业务模型本身非常复杂,优先用 Group 做内部组织,而不是无脑继续拆 Layer。
5.4 高分屏下的 DPR 适配
高分辨率屏幕上如果直接使用默认配置,Konva 绘制出来的线条会发虚,这是因为 canvas 实际像素和 CSS 显示像素不一致。Konva 提供了一个非常方便的全局属性:
Konva.pixelRatio = window.devicePixelRatio || 1;在初始化 Stage 之前设置这一行,Konva 内部会按照设备像素比创建画布,图形就会清晰很多。如果你遇到“画出来的文字模糊、线条发虚”的问题,优先检查这一项。
6. 一个完整交互链路:拖拽、框选与层级维护
6.1 拖拽其实也是分层的
Konva 让节点可拖拽只需要一步:
const box = new Konva.Rect({ x: 200, y: 100, width: 120, height: 80, fill: '#6366f1', draggable: true, });拖拽过程中,Konva 会自动更新节点的 x、y 坐标,并触发节点的 dragmove 事件。如果你需要在节点旁边显示实时坐标或对齐线,可以把这些提示放在 overlayLayer,然后通过 dragmove 事件更新提示节点的位置:
box.on('dragmove', () => { hintText.position(box.position()); overlayLayer.batchDraw(); });这里的关键是,业务节点在 editorLayer,提示文字在 overlayLayer,两层互不干扰。拖动过程中我只需要重绘 overlayLayer,不需要把 editorLayer 里的所有对象再算一遍。
6.2 框选功能的图层设计
框选是编辑器类工具绕不开的功能。实现思路不复杂:在 overlayLayer 放一个不可见的 Rect 作为框选框,鼠标按下、移动、弹起时更新这个 Rect 的属性,并在 mouseup 时做一次相交检测。
代码大致长这样:
const rubber = new Konva.Rect({ visible: false, fill: 'rgba(22, 119, 255, 0.12)', stroke: '#1668dc', strokeWidth: 1, }); overlayLayer.add(rubber); let isSelecting = false; let startPoint = null; stage.on('mousedown', (e) => { if (e.target !== stage) return; isSelecting = true; startPoint = stage.getRelativePointerPosition(); rubber.setAttrs({ x: startPoint.x, y: startPoint.y, width: 0, height: 0, visible: true, }); overlayLayer.batchDraw(); }); stage.on('mousemove', () => { if (!isSelecting) return; const p = stage.getRelativePointerPosition(); rubber.setAttrs({ x: Math.min(startPoint.x, p.x), y: Math.min(startPoint.y, p.y), width: Math.abs(p.x - startPoint.x), height: Math.abs(p.y - startPoint.y), }); overlayLayer.batchDraw(); }); stage.on('mouseup', () => { if (!isSelecting) return; isSelecting = false; rubber.visible(false); overlayLayer.batchDraw(); });真正框选相交时,用 Konva.Util.haveIntersection 判断最方便:
const rubberRect = rubber.getClientRect(); const targets = editorLayer.find('.editable'); const selected = targets.filter((node) => { return Konva.Util.haveIntersection(node.getClientRect(), rubberRect); });这样一个完整框选功能就出来了,全程没有污染业务层的数据结构。
6.3 层级顺序:zIndex、moveToTop 和层间遮盖
Konva 里每个节点都有 zIndex。同 Layer 内的图形,zIndex 大的会绘制在上层。跨 Layer 时,Stage.add 的先后顺序决定画布叠加顺序,后加入的 Layer 默认在上方。
如果业务层和交互层之间出现遮盖,优先检查 Layer 的顺序。比如我在初始化流程里习惯这样写:
stage.add(backgroundLayer); stage.add(edgeLayer); stage.add(editorLayer); stage.add(overlayLayer);这样可以保证 overlayLayer 永远画在最上面。如果需要动态调整,比如临时把一个业务层浮到最上层,可以调用:
editorLayer.moveToTop(); stage.batchDraw();注意 moveToTop 对 Layer 和 Shape 都有效,而且调用后最好触发一次重绘,否则会出现逻辑上已经移层,画面却没更新的情况。
6.4 Transformer 与 Group 的联动
要做缩放、旋转这类操作,Konva 提供现成的 Transformer。它本质上也是一个 Shape,应该放在 overlayLayer 里:
const tr = new Konva.Transformer(); overlayLayer.add(tr); nodeGroup.on('click', () => { tr.nodes([nodeGroup]); overlayLayer.batchDraw(); });把点击目标从 body 变成 nodeGroup,是为了让用户点击组合中任何一个子元素都能选中整组。Transformer 操作的是传入的 nodes 数组,所以这里传入 Group 而不是逐个传入子 Shape。
如果在拖拽过程中发现 Transformer 选框跟不上节点位置,多半是 transform 事件触发后没有重新读取 getClientRect,或者没有调用 batchDraw 同步 overlayLayer。
7. 那些容易踩坑的“看不见的层”:事件穿透、Transformer 与序列化
7.1 透明区域也会拦截事件,不是看不见就没有 hit 区域
这是 Konva 初学者最容易卡住的问题:往 overlayLayer 加了一个全屏透明矩形当作水印或蒙层,结果发现底下所有节点都点不到了。
原因在于,Konva 的 hit canvas 关心的是图形有没有真实的绘制路径,而不是它视觉上看起来是否透明。一个透明的 fill 遮罩,依然会在 hit canvas 里占据完整区域,于是鼠标落在遮罩上时,命中的是遮罩,而不是底下的节点。
解决办法是明确告诉这个节点不需要参与命中:
const mask = new Konva.Rect({ x: 0, y: 0, width: 1024, height: 768, fill: 'rgba(0, 0, 0, 0)', listening: false, });同样的道理也适用于背景文字、水印、装饰线等,凡是只提供视觉参考、不需要交互的元素,统一加上listening: false。
我把这些常见问题整理成一个速查表,排查时可以直接对照:
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 下层图形点不到 | 上层有透明节点仍在 hit canvas 里有区域 | 给节点加 listening: false |
| 修改样式后画面没变化 | 节点已 cache 成离屏位图 | 先 clearCache() 再重新 cache() |
| 拖拽后选中框位置不对 | 没有在 dragmove 后同步 overlayLayer | 更新 getClientRect 并 batchDraw |
| 保存后文件里出现选择框 | 临时节点被序列化进了业务层 | 临时节点统一放 overlayLayer 并排除 |
7.2 缓存后改样式不生效,必须清缓存
前面提到过缓存需要清楚。写代码时凡是遇到静态节点做 cache 优化,最好在样式发生变化的地方加一个集中的处理方法:
function updateNodeStyle(node, attrs) { if (node.isCached()) { node.clearCache(); } node.setAttrs(attrs); node.getLayer().batchDraw(); }这个封装看起来不起眼,但能帮你省掉无数“改了颜色为什么不变”的排查时间。
7.3 Transformer 千万不要放进业务数据层
Transformer 一旦被加入 editorLayer,序列化本地项目文件时,它就会被当作业务节点保存。下次打开项目,用户还没操作,编辑框却出现在画面里。
我的做法是,所有交互控件统一放进 overlayLayer,并且序列化时对 Layer 做名称过滤。比如初始化时给每一层命名:
backgroundLayer.name('bg'); editorLayer.name('main'); overlayLayer.name('overlay');保存时只取 main 图层的数据,overlay 直接跳过。这样既保住了交互控件的独立性,也让撤销重做、历史记录这些机制不用忍受临时节点的干扰。
7.4 序列化时保留层信息,比保存一张大图长远得多
有些绘图工具会把整个画布导出成一张图片当存档,简单是简单,但后续想改某一层就会非常痛苦。我更推荐把 Layer 信息一起序列化。
Konva 本身支持节点的 toJSON 和 Node.create,可以直接保存一棵完整节点树。如果不想整棵树序列化,也可以手动遍历业务层节点:
const data = editorLayer .getChildren() .map((node) => node.toObject());恢复时再按层名创建 Layer,把数据对象还原成 Konva 节点并加入对应层。
如果你从一开始就把层信息保留进数据模型,后面做增量保存和差量对比都会顺手很多。这个习惯帮我避免了不止一次返工,也是我在多个绘图项目里最想回头提醒自己的一件事。