纯前端实现 Canvas 手写签名:指针压力、贝塞尔平滑与离线导出
电子签名是一个很典型的「看起来简单、细节不少」的需求。用户拿鼠标或手指划几笔,页面要画出顺滑的线条,还要能导出成图片。这篇文章把实现拆成三块:怎么采样、怎么平滑、怎么导出。
一、用指针事件统一鼠标和触摸
不要分别监听 mousemove 和 touchmove,直接用 Pointer Events,一套逻辑覆盖鼠标、触控和触控笔:
canvas.addEventListener('pointerdown', start);
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointercancel', end);
开始绘制时调用 setPointerCapture,把后续的移动事件都绑到当前元素上,这样指针滑出画布再回来不会断线。触屏上还要加 touch-action: none,避免浏览器把滑动当成页面滚动。
二、采样:别把每个点都当成折线拐点
高刷新率设备一秒能上报上百个点,如果每个点都 lineTo,线条会显得生硬而且文件里的点数爆炸。常见做法是三点一组,用二次贝塞尔把中点当锚点:
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i].x + pts[i + 1].x) / 2;
const my = (pts[i].y + pts[i + 1].y) / 2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
}
先用原始点当控制点、中点当终点,线条会自然很多。还可以顺手做一次距离过滤,两点距离低于 1~2 像素就跳过,去掉抖动噪声。
三、压感控制粗细
触控笔会给出 PointerEvent.pressure,可以在 0 到 1 之间。把线宽映射成一个范围,就能写出有轻重变化的字:
const w = maxWidth - (maxWidth - minWidth) * pressure;
ctx.lineWidth = w;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
鼠标通常只有 0.5 的固定压力,这时直接用一个默认宽度即可。真正的难点是笔锋变化靠逐段设置 lineWidth 会显得一节一节,想更顺可以每段单独 stroke,或者用变宽路径填充。
四、导出:先换算设备像素比
导出的图片如果模糊,多半是没处理 devicePixelRatio。画布的内部尺寸要按 DPR 放大,CSS 尺寸保持逻辑像素:
const dpr = window.devicePixelRatio || 1;
canvas.width = cssW * dpr;
canvas.height = cssH * dpr;
ctx.scale(dpr, dpr);
导出时用一个临时画布,填白色背景再画上笔迹,避免 PNG 透明背景导致部分场景看不见:
const url = canvas.toDataURL('image/png');
需要提交给后端时,用 toBlob 拿二进制比 toDataURL 更省内存。
五、重绘与清空
撤销一笔最简单的做法是保存每一笔的点数组,重绘时把前面几笔全部再画一遍。笔数不多时够用,笔数很多时可以只存路径点、按需重放。清空就是把数组置空并 clearRect。
导出前记得判断是否为空,空签名不要生成图片,直接提示用户先书写。
六、小结
手写签名的核心是采样和平滑:Pointer Events 统一输入,二次贝塞尔让线条顺滑,压感决定粗细,DPR 决定导出清晰度。把这几件事拆开,代码量不大,但手感差别很明显。
- 点赞
- 收藏
- 关注作者
评论(0)