Canvas绘图性能优化与实战技巧全解析
项目初期的技术选型
上个月接了个需求,要做一个动态签名板,用户能在手机上手写签名,然后保存成图片。听起来挺简单的,但一上手才发现水挺深。最初我考虑用 SVG,毕竟它支持矢量、缩放清晰,而且 DOM 操作熟悉。但试了两天发现,频繁的 touchmove 事件在移动端会卡顿,尤其低端机上,笔迹明显断断续续。后来一想,Canvas 天生就是为这种像素级绘制而生的,干脆切过去。
选 Canvas 的另一个原因是性能——直接操作像素,不走 DOM 渲染流程,理论上更流畅。而且最终要导出图片,Canvas 原生支持 toDataURL(),省事。不过说实话,当时对 Canvas 的 API 只停留在“画个圆”的水平,真正用起来才发现坑不少。
核心代码就这几行(但调了三天)
基础绘制逻辑其实不复杂:监听 touchstart、touchmove、touchend,记录坐标点,用 lineTo 连线。但实际写的时候,各种细节问题冒出来。比如坐标转换,移动端的 touch 事件返回的是页面坐标,得减去 canvas 元素的 offset 才能转成 canvas 内部坐标。还有,为了视觉平滑,我加了线宽渐变和抗锯齿。
下面是最简可运行版本(省略了部分样式和边界处理):
<canvas id="signatureCanvas" width="400" height="300" style="border:1px solid #ccc;"></canvas>
const canvas = document.getElementById('signatureCanvas');
const ctx = canvas.getContext('2d');
let isDrawing = false;
let lastX = 0, lastY = 0;
// 初始化画笔
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = '#000';
function getPos(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top
};
}
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const pos = getPos(e);
[lastX, lastY] = [pos.x, pos.y];
isDrawing = true;
});
canvas.addEventListener('touchmove', (e) => {
if (!isDrawing) return;
e.preventDefault();
const pos = getPos(e);
// 关键:开启新路径再画线
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
[lastX, lastY] = [pos.x, pos.y];
});
canvas.addEventListener('touchend', () => {
isDrawing = false;
});
看起来挺顺?但上线前测试时发现,在 iOS Safari 上偶尔会出现“断线”——手指快速滑动时,中间某些点没连上。后来查资料才知道,touchmove 事件在高频触发时可能被浏览器节流,尤其当主线程忙的时候。这直接导致路径不连续。
最大的坑:性能问题 + 断线修复
断线问题折腾了我快两天。一开始想用 requestAnimationFrame 补帧,但效果不好,反而更卡。后来参考了一些开源库(比如 signature_pad),发现他们用的是“插值”策略:在两个已知点之间,用贝塞尔曲线或直线细分,模拟中间缺失的点。
但我觉得太重了,我们项目只需要基本签名功能。最后我用了个土办法:把 lineWidth 调大一点(比如 3-4),再配合 lineCap: 'round',这样即使两点之间有空隙,视觉上也会被圆头覆盖住,看起来像连着的。亲测有效,低端安卓机上也基本看不出断点。
另一个性能问题是内存泄漏。早期代码里,每次 touchmove 都重新设置 strokeStyle 和 lineWidth,虽然不影响功能,但 profiler 显示频繁的上下文属性设置会拖慢速度。后来我把这些初始化移到外面,只在开始绘制前设一次,帧率明显稳了。
还有一点容易忽略:canvas 的宽高必须用 JS 动态设置,不能只靠 CSS。否则在 Retina 屏上会模糊。我加了这段:
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
// 重新设置画笔(因为 scale 会影响 lineWidth)
ctx.lineWidth = 2 / dpr; // 注意这里要除以 dpr
ctx.lineCap = 'round';
ctx.strokeStyle = '#000';
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas(); // 初始调用
这里注意我踩过好几次坑:scale 之后 lineWidth 会被放大,所以必须反向调整,否则线条粗得离谱。
导出图片的隐藏雷区
签名完要生成图片上传,本来以为 canvas.toDataURL('image/png') 就完事了。结果测试时发现,某些安卓机生成的 base64 图片是空白的!查了半天,原来是跨域问题——虽然 canvas 本身没加载外部资源,但如果页面里有其他跨域图片(比如背景图),哪怕没画到 canvas 上,也会污染整个 canvas,导致 toDataURL 抛出安全错误。
解决方案很简单:确保 canvas 完全干净,或者用 try-catch 包裹:
function exportSignature() {
try {
return canvas.toDataURL('image/png');
} catch (e) {
console.error('Export failed:', e);
alert('签名导出失败,请重试');
return null;
}
}
另外,生成的图片太大(尤其 Retina 屏),上传慢。后来加了压缩:
function compressImage(dataUrl, quality = 0.8) {
const img = new Image();
img.src = dataUrl;
return new Promise((resolve) => {
img.onload = () => {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCanvas.width = img.width * 0.5; // 缩小一半
tempCanvas.height = img.height * 0.5;
tempCtx.drawImage(img, 0, 0, tempCanvas.width, tempCanvas.height);
resolve(tempCanvas.toDataURL('image/jpeg', quality));
};
});
}
虽然损失了点清晰度,但文件体积从 500KB 降到 50KB,用户上传快多了。
回顾与反思
整体来说,Canvas 在这个场景下表现不错,比 SVG 流畅太多。但有几个地方现在想想还能优化:
- 断线问题其实可以用更科学的插值算法,但时间紧就用了视觉 trick,长期看不够 robust
- 没做撤销(undo)功能,用户写错了只能清空重来,体验打折扣
- 触摸采样率还是依赖系统,极端快速书写会有轻微失真
不过这些都不影响核心功能,上线后用户反馈签名流畅、导出稳定,算是达到了目标。如果重做,我会考虑引入现成的库(比如 signature_pad),但这次自己造轮子也让我对 Canvas 的底层机制理解更深了——尤其是坐标系、像素密度、事件节流这些细节。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的断线修复方案,或者在移动端 Canvas 绘图上有其他经验,欢迎评论区交流!
