Vue3 + Canvas 手绘笔记工程化实践:别把画布只当成一张 PNG
实现一个能画线的 Canvas 很简单:
csharp
canvas.addEventListener('pointermove', (event) => {
context.lineTo(event.offsetX, event.offsetY);
context.stroke();
});
但当需求继续增加:
- 撤销与重做
- 文本元素
- 选择与移动
- 局部橡皮擦
- 自动保存
- 多端版本冲突
- 历史版本
- 列表缩略预览
- 移动端缩放和平移
- PNG 与 JSON 导出
Canvas 就不再只是绘图区域,而变成了一个小型文档编辑器。
近期我在 Vue 3 项目中实现了一套轻量手绘笔记。没有直接引入完整白板框架,而是围绕一个固定页面、结构化场景和有限元素类型,搭建了自己的编辑、保存和预览链路。
这篇不介绍按钮怎么摆放,主要讨论几个更容易踩坑的问题:
- 为什么不能只保存 PNG
- 前后端如何共享画布协议
- 如何避免在
pointermove中反复触发响应式更新 - 局部橡皮擦如何切断而不是整条删除笔画
- 编辑详情和列表预览为什么要使用不同数据模型
- 自动保存如何处理多标签页和多设备冲突

一、为什么不直接把 Canvas 保存成 PNG
只保存图片的优点很明显:
- 数据格式简单
- 预览方便
- 不需要恢复绘制状态
- 服务端无需理解画布结构
但只要允许用户二次编辑,PNG 很快就不够用了。
保存成图片后,下面这些信息都会丢失:
哪一段像素属于哪条笔画
文字原本是什么内容
文字的位置和字号
某个对象是否被移动过
橡皮擦应该删除哪些线段
历史版本之间改了哪些元素
如果把每次修改都当成整张位图,会带来:
- 撤销只能保存多份大图片
- 文本无法原位再次编辑
- 局部橡皮擦只能擦像素
- 差异比较只能做图像级比较
- 高分辨率导出依赖当前显示尺寸
- 服务端无法校验内容复杂度
因此,PNG 更适合作为导出格式,而不是权威编辑格式。
最终存储的是一个结构化场景:
css
interface DrawingScene {
v: 1;
page: {
width: 1024;
height: 1448;
};
elements: DrawingElement[];
}
type DrawingElement =
| {
id: string;
kind: 'stroke';
color: string;
width: number;
points: number[];
}
| {
id: string;
kind: 'text';
x: number;
y: number;
width: number;
fontSize: number;
color: string;
text: string;
};
固定页面尺寸的好处是,所有设备都使用同一逻辑坐标系。
浏览器窗口、缩放比例和设备像素比只影响显示,不改变场景中的原始坐标。
二、场景协议应该由前后端共享
只在前端定义 TypeScript 类型不够,因为类型在运行时不存在。
服务端仍然可能收到:
json
{
"v": 999,
"page": null,
"elements": [
{
"kind": "script",
"payload": "..."
}
]
}
因此项目在共享包中提供运行时解析和规范化:
ini
const scene = parseDrawingScene(input);
const serialized = serializeDrawingScene(scene);
校验范围包括:
| 项目 | 当前边界 |
|---|---|
| 序列化总大小 | 750000 字节 |
| 元素总数 | 1000 |
| 笔画数量 | 800 |
| 轨迹点对数 | 50000 |
| 文本元素数量 | 200 |
| 文本总字符数 | 50000 |
| 单个文本字符数 | 4000 |
| 页面尺寸 | 1024 × 1448 |
| 支持的元素类型 | stroke、text |
此外还会检查:
- 元素 ID 格式和重复 ID
- 坐标是否为有限数值
- 坐标是否超出合理范围
- 颜色是否符合十六进制格式
- 笔画宽度是否在允许区间
- 字体大小是否在允许区间
- 笔画坐标是否成对出现
- 场景版本是否支持
为什么要限制轨迹点,而不只是限制字符串大小?
因为两个 500KB 的 JSON,解析和绘制成本可能完全不同:
500KB 长文本
500KB、数万段短笔画
后者会产生更多循环、路径构建、命中检测和对象分配。
所以协议同时限制:
- 字节大小
- 对象数量
- 几何复杂度
- 文本复杂度
规范化比单纯校验更重要
保存前会对坐标精度、颜色大小写和字段结构做统一处理,再重新序列化。
这样可以减少:
- 无意义的小数抖动
- 字段顺序差异
- 客户端实现差异
- 历史版本中的伪变化
- 内容比较时的误判
当规范化后的字符串相同,就可以认为场景正文没有变化。
三、不要在 pointermove 中直接修改权威场景
最容易实现的写法是:
csharp
function handlePointerMove(event: PointerEvent) {
scene.value.elements[currentIndex].points.push(
event.offsetX,
event.offsetY,
);
}
但如果 scene 是深度响应式对象,这会在一次笔画中触发大量响应式追踪。
同时,如果每个点都立刻:
- 写入撤销栈
- 触发父组件更新
- 启动自动保存
- 重新序列化完整场景
性能会很快下降。
更合适的做法,是区分临时交互状态和已提交场景。
ini
const scene = shallowRef<DrawingScene>(createEmptyDrawingScene());
let activeStroke: DrawingStrokeElement | null = null;
let dragPreview: DrawingElement | null = null;
let eraserPreviewElements: DrawingElement[] | null = null;
let mutationSnapshot = '';
它们分别承担:
| 状态 | 作用 |
|---|---|
scene |
已提交的权威编辑场景 |
activeStroke |
当前尚未结束的一条笔画 |
dragPreview |
元素移动过程中的临时位置 |
eraserPreviewElements |
一次擦除手势中的临时结果 |
mutationSnapshot |
本次操作开始前的撤销快照 |
一次笔画的状态流转为:
pointerdown
↓
保存 mutationSnapshot
创建 activeStroke
↓
pointermove
只向 activeStroke 追加采样点
↓
requestAnimationFrame 绘制预览
↓
pointerup
将 activeStroke 合入 scene
写入撤销栈
通知父组件正文变化
这意味着一次笔画只产生一次正式内容更新,而不是每个坐标点都更新一次。

四、使用 requestAnimationFrame 合并绘制
高频指针事件中不要直接调用完整 draw()。
可以设置一个帧调度器:
ini
let frameId = 0;
function scheduleDraw() {
if (frameId) return;
frameId = requestAnimationFrame(() => {
frameId = 0;
draw();
});
}
无论同一帧内收到多少次 pointermove,最多只绘制一次。
这不是丢失轨迹点。轨迹采样和画面刷新是两件事:
事件可以高频收集坐标
画面按浏览器帧率刷新
在支持的浏览器中,还可以读取合并事件:
ini
const samples =
typeof event.getCoalescedEvents === 'function'
? event.getCoalescedEvents()
: [event];
for (const sample of samples) {
appendStrokePoint(activeStroke, canvasPoint(sample));
}
getCoalescedEvents() 可以取回浏览器为性能而合并的高频指针样本,减少快速书写时轨迹缺口。
五、一次手势只读取一次布局
坐标转换通常依赖:
css
canvas.getBoundingClientRect();
如果在每个 pointermove 中调用,浏览器可能反复进行布局读取。
优化方法是在 pointerdown 时缓存:
arduino
let activeCanvasRect: DOMRect | null = null;
function handlePointerDown(event: PointerEvent) {
activeCanvasRect =
canvasRef.value?.getBoundingClientRect() || null;
}
function canvasPoint(event: PointerEvent) {
const rect =
activeCanvasRect ||
canvasRef.value?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
return {
x:
((event.clientX - rect.left) / rect.width) *
DRAWING_PAGE.width,
y:
((event.clientY - rect.top) / rect.height) *
DRAWING_PAGE.height,
};
}
function releasePointer() {
activeCanvasRect = null;
}
只要一次手势期间 Canvas 本身没有改变位置,这个矩形就可以复用到手势结束。
另一个小优化是过滤过密的点:
arduino
function appendStrokePoint(
stroke: DrawingStrokeElement,
point: DrawingPoint,
) {
const lastX = stroke.points.at(-2) ?? point.x;
const lastY = stroke.points.at(-1) ?? point.y;
if (
stroke.points.length > 2 &&
Math.hypot(point.x - lastX, point.y - lastY) < 0.8
) {
return;
}
stroke.points.push(point.x, point.y);
}
小于逻辑坐标 0.8 的移动不会新增点,可以减少几乎重叠的轨迹数据。
六、Canvas 分辨率不能无限跟随设备像素比
在高 DPR 设备上,直接使用:
ini
canvas.width = cssWidth * window.devicePixelRatio;
canvas.height = cssHeight * window.devicePixelRatio;
可能创建非常大的像素缓冲区。
例如一张 1024 × 1448 的画布,在 DPR 3 下会变成:
yaml
3072 × 4344 ≈ 1334 万像素
而编辑器还可能处于 30% 或 50% 缩放状态,没有必要维持完整三倍分辨率。
可以结合当前缩放限制内部像素比:
javascript
const ratio = Math.min(
1.5,
Math.max(
0.5,
(window.devicePixelRatio || 1) * zoom.value,
),
);
显示缩小时降低内部画布,显示放大时也限制上限。
真正导出 PNG 时,再创建独立离屏 Canvas,以固定 2 倍或其他目标分辨率重新绘制,而不是直接截图当前编辑画布。
这样编辑性能和导出清晰度可以分别控制。
七、文本布局和元素边界要缓存
文本换行需要反复调用:
ini
context.measureText(candidate);
命中检测、选中框和拖动也需要计算元素边界。
可以分别建立:
typescript
const textLayoutCache = new Map<string, string[]>();
const elementBoundsCache = new WeakMap<
DrawingElement,
{
x: number;
y: number;
width: number;
height: number;
}
>();
文本缓存键可以包含:
arduino
element.id
fontSize
width
text
当文本被编辑后清空相关缓存。
元素对象没有变化时,WeakMap 可以直接复用其边界;当拖动产生新对象时,自然会重新计算。
这也是使用不可变替换而不是原地深度修改的一个额外收益。
八、局部橡皮擦不是"命中后删除整条笔画"
最简单的橡皮擦逻辑是:
scss
if (strokeHitByEraser(stroke)) {
deleteStroke(stroke.id);
}
用户只擦掉一小段线,整条笔画却消失,体验会非常突兀。
另一种方案是像素级擦除,但这样会破坏结构化场景,之后无法继续移动、比较或导出向量轨迹。
当前实现采用的是线段裁切:
原笔画:
──────────────
橡皮擦命中中间:
───── ○ ─────
裁切后:
───── ─────
一条折线由多个线段组成。对每条线段,计算它与圆形橡皮擦的交点,并求出落在圆外的参数区间。
线段可以写成:
scss
P(t) = P1 + t(P2 - P1), 0 ≤ t ≤ 1
将其代入圆方程:
css
|P(t) - C|² = r²
得到一元二次方程。根据判别式和两个根,可以把线段拆成圆内和圆外区间。
处理流程为:
markdown
先用笔画包围盒做粗筛
↓
逐线段计算与擦除圆的交点
↓
保留所有圆外区间
↓
把连续区间重新组成笔画片段
↓
第一个片段沿用原 ID
后续片段生成新 ID
包围盒通过 WeakMap 缓存。没有命中时复用原元素和原数组,避免在 pointermove 中产生无意义对象。
如果切割后会突破场景的元素数或笔画数上限,本次擦除会整体回退,而不是保存一个违反协议的场景。
文本没有交给橡皮擦处理。文本是可编辑对象,应通过选择后删除,避免用户擦到一小块文字时生成不可解释的半个文本元素。

九、本地撤销和服务端历史版本不是一回事
编辑器内部需要即时撤销,因此维护两个栈:
csharp
const undoStack = ref<string[]>([]);
const redoStack = ref<string[]>([]);
但不能无限保存完整 JSON。
当前客户端同时限制:
最多 20 个历史状态
所有状态字符串合计最多 800 万字符
每次正式编辑动作结束时:
操作前场景 → undoStack
新场景 → 当前 scene
redoStack → 清空
客户端撤销解决的是当前编辑会话中的即时操作。
服务端历史版本解决的是:
- 关闭页面后恢复
- 多设备修改后的回退
- 较长时间跨度的还原
- 版本差异查看
- 恢复旧版本后仍保留恢复前内容
手绘场景的服务端版本策略也和普通文字笔记不同:
手绘版本合并窗口:10 分钟
每篇最多保留:10 条
原因是手绘 JSON 通常比短文本更大,且自动保存频率和编辑节奏不同。
十、自动保存必须带版本号
只做防抖保存仍然无法处理多端冲突。
假设同一篇笔记:
ini
页面 A 读取 revision = 5
页面 B 读取 revision = 5
页面 A 保存,服务端变成 revision = 6
页面 B 随后保存 revision = 5
如果服务端直接覆盖,页面 A 的内容会丢失。
手绘保存请求应携带客户端基于的版本:
css
{
"id": "drawing-note-id",
"title": "架构草图",
"revision": 5,
"scene": {
"v": 1,
"page": {
"width": 1024,
"height": 1448
},
"elements": []
}
}
服务端事务流程:
sql
BEGIN
↓
SELECT 当前笔记 FOR UPDATE
↓
确认类型为 drawing
↓
规范化数据库当前场景
↓
比较 currentRevision 与 expectedRevision
├─ 不一致:返回 409 和当前服务端版本
└─ 一致:保存历史、更新正文、revision + 1
↓
COMMIT
伪代码:
scss
const current = await selectNoteForUpdate(
connection,
noteId,
userId,
);
if (current.revision !== expectedRevision) {
throw new VersionConflict({
current: {
id: current.id,
title: current.title,
content: canonicalize(current.content),
revision: current.revision,
},
});
}
await snapshotHistory(connection, current);
await updateNote(connection, {
content: serializeDrawingScene(scene),
revision: current.revision + 1,
});
版本冲突不能只返回一句"保存失败",还应返回当前服务端内容,让前端能够:
- 提示用户
- 对比两个版本
- 重新加载
- 将本地内容复制为新笔记
- 在未来实现更细粒度合并
为什么必须使用专用保存接口
普通笔记保存接口可能包含:
- HTML 净化
- 图片扫描
- 引用关系提取
- Markdown 规范化
手绘场景不需要这些流程。
更重要的是,旧客户端不知道 drawing 类型。通用接口必须禁止它提交手绘正文,否则旧页面中的空编辑器可能覆盖完整画布。
专用写接口不仅是代码拆分,也是写入域隔离。
十一、列表预览不能直接返回完整场景
假设列表中有 30 篇手绘笔记,每篇场景几百 KB。
即使只展示 240 × 180 的卡片,浏览器仍要:
javascript
下载完整 JSON
解析完整 JSON
创建大量对象
遍历所有轨迹点
初始化多个 Canvas
这会让普通文字笔记也被拖慢。
因此,笔记列表只返回基础元数据,手绘正文不进入通用列表响应。
卡片接近可见区域时,再调用专用预览接口:
json
{
"ids": [
"drawing-1",
"drawing-2"
]
}
单次最多请求 12 篇。
服务端解析完整场景后,生成轻量预览:
yaml
元素最多 120 个
轨迹点最多 1600 对
文本总计最多 4000 字符
单个文本最多保留 240 字符
元素和轨迹点采用等距采样,保留:
- 原始绘制顺序
- 首尾点
- 大致轮廓
- 文本位置
但不保留编辑级精度。
这种设计的关键不是"压缩 JSON",而是承认两个页面需要不同数据:
编辑详情需要可恢复的完整事实
列表卡片只需要可辨认的视觉摘要

十二、移动端需要独立考虑缩放和滚动职责
固定 1024 × 1448 页面在手机上不可能按 100% 显示。
编辑器提供离散缩放级别:
csharp
const ZOOM_LEVELS = [
0.3,
0.35,
0.4,
0.5,
0.6,
0.75,
1,
1.25,
1.5,
] as const;
初始缩放会根据容器宽高计算。
手机竖屏如果只按宽度适配,可能在画纸底部留下大量不可用区域。因此可以允许有限横向平移,换取更大的实际书写面积。
不同工具也应有不同手势职责:
画笔:在画布坐标系中采样
橡皮擦:局部裁切笔画
选择:命中并拖动元素
手形工具:移动工作区滚动位置
不要让浏览器页面滚动、画布平移和元素拖动同时争夺同一个手势。
使用 Pointer Capture 可以确保手指或鼠标离开 Canvas 边界后,当前操作仍能正常结束或取消。
十三、刻意不做的能力同样重要
第一版手绘笔记没有直接支持:
- 插入图片
- 手写识别
- 无限画布
- 多人实时协同
- AI 直接读取和修改画布
- 转换为富文本或 Markdown
- 复杂图形和连线
- 图层面板
这些能力都不是加一个按钮就能完成。
例如让 AI 理解画布,需要先回答:
AI 读取原始轨迹还是渲染图片?
文本和笔画如何建立语义关系?
哪些图形只是装饰?
模型修改后如何通过同一场景协议校验?
如何生成可审阅的差异?
用户如何撤销一次 AI 改图?
在这些问题没有稳定答案前,明确不把原始坐标 JSON 送进 AI,比做一个无法验证的"智能分析"更可靠。
十四、这套实现的核心取舍
最终,这套轻量手绘编辑器没有追求通用白板能力,而是坚持了几个边界:
固定页面,而不是无限画布
更容易处理:
- 缩放
- 移动端
- 预览
- PNG 导出
- 历史版本
- 坐标限制
结构化 JSON,而不是只存图片
获得:
- 可编辑性
- 元素级命中
- 局部橡皮擦
- 文本再次编辑
- 版本差异基础
临时交互状态,而不是高频修改响应式树
降低:
- 深度响应式成本
- 序列化频率
- 自动保存频率
- 撤销栈膨胀
共享协议,而不是只相信 TypeScript
保证:
- 前后端一致校验
- 服务端安全边界
- 历史内容可规范化
- 未来场景版本升级
独立预览模型,而不是列表返回完整正文
保护:
- 首屏响应大小
- 主线程解析成本
- 普通笔记列表性能
- 移动端弱网体验
结语
Canvas 编辑器真正困难的部分通常不是 lineTo(),而是围绕绘制建立一整套文档系统:
场景协议
交互状态
渲染调度
几何算法
撤销重做
并发保存
历史版本
轻量预览
移动端适配
能力边界
当这些部分被拆清楚后,后续增加新元素类型、离线草稿或 AI 可审阅变更,才有稳定基础。
本文实现来自开源知识工作区 LightNote 的轻量手绘笔记模块: