Vue3 + Canvas 手绘笔记工程化实践:别把画布只当成一张 PNG

Vue3 + Canvas 手绘笔记工程化实践:别把画布只当成一张 PNG

实现一个能画线的 Canvas 很简单:

csharp 复制代码
canvas.addEventListener('pointermove', (event) => {
  context.lineTo(event.offsetX, event.offsetY);
  context.stroke();
});

但当需求继续增加:

  • 撤销与重做
  • 文本元素
  • 选择与移动
  • 局部橡皮擦
  • 自动保存
  • 多端版本冲突
  • 历史版本
  • 列表缩略预览
  • 移动端缩放和平移
  • PNG 与 JSON 导出

Canvas 就不再只是绘图区域,而变成了一个小型文档编辑器。

近期我在 Vue 3 项目中实现了一套轻量手绘笔记。没有直接引入完整白板框架,而是围绕一个固定页面、结构化场景和有限元素类型,搭建了自己的编辑、保存和预览链路。

这篇不介绍按钮怎么摆放,主要讨论几个更容易踩坑的问题:

  1. 为什么不能只保存 PNG
  2. 前后端如何共享画布协议
  3. 如何避免在 pointermove 中反复触发响应式更新
  4. 局部橡皮擦如何切断而不是整条删除笔画
  5. 编辑详情和列表预览为什么要使用不同数据模型
  6. 自动保存如何处理多标签页和多设备冲突

一、为什么不直接把 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
支持的元素类型 stroketext

此外还会检查:

  • 元素 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 的轻量手绘笔记模块:

github.com/VeteranBoLu...

相关推荐
watersink1 小时前
机器学习极大似然估计与EM算法
人工智能·算法·机器学习
IMPYLH1 小时前
HTML 的 <h1>–<h6> 元素
前端·javascript·html
叠层归一研究院1 小时前
如何用程序搭建一个 AGI 种子系统(一):从向量种子到无限生长引擎
人工智能·python·算法·机器学习·agi
IMPYLH1 小时前
HTML 的 <head> 元素
前端·html
科学实验家1 小时前
并 查集
算法
用户841794814561 小时前
如何用 vue 甘特图来实现计划和实际双任务条进度展示
vue.js
卸任1 小时前
AI英语学习助手:从翻译工具到 AI 英语学习助手
前端·electron
计算机魔术师1 小时前
A股人形机器人第一股来了!宇树科技8月19日上市,一签或赚20万
前端
PedroQue991 小时前
Vue Router 4.x风格导航守卫全面升级
前端·uni-app