用原生 JS 实现一个支持撤销/重做的画板:命令模式与快照策略
在网页上做画板并不难,难的是让「撤销 / 重做」既好用又不吃内存。这篇笔记记录我用原生 JavaScript 实现一个轻量画板时,围绕撤销栈做的一系列取舍:什么时候用命令模式记录增量,什么时候改用整张画布的快照,以及怎么把两者混在一起,让清空、填充这类「大操作」也不至于把历史撑爆。
一、先把需求缩到最小
我只保留四类绘画动作:落笔(一笔连续线条)、橡皮、填充底色、清空画布。画板本身用一张 <canvas>,所有绘制都走 2D 上下文。真正的技术点只有一个:如何用尽量小的代价,把用户任意一步操作记录下来并还原。
二、两条路:命令模式与快照
最直观的做法是快照——每次操作前把整张画布导出成图片存起来。还原时把图片画回去即可,代码极其简单:
function snapshot(canvas) {
return canvas.toDataURL('image/png');
}
function restore(canvas, dataUrl) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
resolve();
};
img.src = dataUrl;
});
}
问题也很明显:一张 1280×720 的画布,PNG 快照动辄几百 KB。用户连续画五十笔,历史栈就能吃掉几十兆内存,而且每次落笔都要编码一次图片,长按拖动时明显掉帧。
命令模式则相反,只记录「做了什么」:一笔线条就是坐标点数组加线宽、颜色,一个填充就是颜色和区域。回放时按顺序重画即可。它体积小、写入快,但缺点是「重做整段历史」的代价随步数线性增长。
三、混合策略:笔画用命令,破坏性操作用快照
真正让我满意的方案是把两者结合起来。绘制类操作(落笔、橡皮)天然适合命令模式,因为它们只影响局部,回放成本低;而清空、撤销一大段历史这类操作,适合用快照做「锚点」。我的历史栈里因此混着两种节点:
const history = {
entries: [], // { type: 'stroke', points, color, width } 或 { type: 'snapshot', dataUrl }
cursor: -1, // 指向当前生效的历史位置
};
function pushStroke(stroke) {
history.entries = history.entries.slice(0, history.cursor + 1);
history.entries.push({ type: 'stroke', ...stroke });
history.cursor = history.entries.length - 1;
}
撤销时,先看当前节点是不是可增量回退的笔画。如果最近若干步都是笔画,我就逐条回退;一旦遇到快照锚点,就直接把锚点图片画回去,一次性「跳」过多步。这样既保留了指令的轻量,也避免了长历史回放的卡顿。
四、用固定时间步把一条笔迹收成一个命令
如果每收到一个 pointermove 就压一条历史,栈会迅速膨胀,而且一条平滑曲线的回放会碎成几百个点。我的做法是:在指针按下时开启一次「绘制会话」,用 requestAnimationFrame 按固定节奏采样坐标,指针抬起时把这次会话合并成一个 stroke 节点。
function startStroke(x, y) {
current = { points: [[x, y]], color, width };
}
function extendStroke(x, y) {
const last = current.points[current.points.length - 1];
if (Math.hypot(x - last[0], y - last[1]) < 2) return; // 抖动过滤
current.points.push([x, y]);
drawSegment(last, [x, y], current);
}
function endStroke() {
if (current.points.length > 1) pushStroke(current);
current = null;
}
这里顺带做了两件小事:一是用一个很小的距离阈值过滤手抖产生的重复点,二是把「采样」和「绘制」解耦,绘制依然可以每帧做,历史只在会话结束时落一次。用户看到的是流畅的线条,历史里存的是一整笔。
五、几个容易踩的坑
坐标要按设备像素比缩放。 画布的 width/height 属性和 CSS 尺寸是两回事。如果只在 CSS 里放大画布而不缩放上下文,笔迹位置会整体偏移。稳妥写法是根据 devicePixelRatio 设置画布分辨率,再 ctx.scale(dpr, dpr),历史里统一存逻辑坐标。
撤销后继续画,要截断「未来」。 撤销到中间位置时,新的一笔必须丢弃原来后续的重做记录,否则重做会画到错误的分支上。上面 pushStroke 里的 slice(0, cursor + 1) 就是干这个的。
橡皮不是「擦除命令」。 我直接把橡皮实现成 globalCompositeOperation = 'destination-out' 的笔画,这样它天然复用笔画的采样与历史逻辑,撤销时也只是重画一条线而已。
六、小结
一个够用的画板,核心不是绘图 API,而是历史模型。命令模式负责高频、局部的笔画,快照负责低频、全局的重置,两者用「锚点」衔接,就能在内存、性能和实现复杂度之间找到平衡。整套逻辑不到三百行,没有引入任何框架,也足够支撑一个日常够用的涂鸦工具。
- 点赞
- 收藏
- 关注作者
评论(0)