Canvas绘图性能优化与实战技巧全解析

宇文淑涵 交互 阅读 2,075
赞 90 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个需求,要做一个动态签名板,用户能在手机上手写签名,然后保存成图片。听起来挺简单的,但一上手才发现水挺深。最初我考虑用 SVG,毕竟它支持矢量、缩放清晰,而且 DOM 操作熟悉。但试了两天发现,频繁的 touchmove 事件在移动端会卡顿,尤其低端机上,笔迹明显断断续续。后来一想,Canvas 天生就是为这种像素级绘制而生的,干脆切过去。

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 都重新设置 strokeStylelineWidth,虽然不影响功能,但 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 绘图上有其他经验,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
鑫钰
鑫钰 Lv1
这篇文章帮我优化了代码结构,现在代码的可读性和维护性都提升了。
点赞 1
2026-03-07 11:25