在合同签署、表单确认、物流签收等业务场景中,电子签名已经成为标配功能。要在前端实现一个体验良好的签名板,核心方案就是HTML5的Canvas。本文将结合React,从事件监听、轨迹平滑、高分屏适配,到最终导出PNG图片,完整讲解一个可投入生产的签名组件应该如何编写。

一、基础实现:监听鼠标与触摸事件绘制轨迹
签名板的核心逻辑其实并不复杂:监听用户在画布上的按下、移动、抬起三类事件,在移动过程中不断绘制线段,就形成了笔迹。在React中,我们通常在useEffect中获取canvas的2D上下文,并在useRef中保存引用,避免组件重渲染时丢失绘制状态。
需要注意的一点是,移动端浏览器支持触摸事件与Pointer事件两种方式。Pointer Events是更现代的方案,它统一了鼠标、触摸、触控笔三种输入,一套代码即可兼容所有设备。下面是一个基础版本的实现:
import { useEffect, useRef } from 'react';
export default function SignaturePad() {
const canvasRef = useRef(null);
const drawing = useRef(false);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000';
const getPos = (e) => {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const start = (e) => {
drawing.current = true;
const { x, y } = getPos(e);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + 0.01, y); // 画一个点,处理单击落笔的情况
ctx.stroke();
};
const move = (e) => {
if (!drawing.current) return;
const { x, y } = getPos(e);
ctx.lineTo(x, y);
ctx.stroke();
};
const end = () => {
drawing.current = false;
};
canvas.addEventListener('pointerdown', start);
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', end);
canvas.addEventListener('pointerleave', end);
return () => {
canvas.removeEventListener('pointerdown', start);
canvas.removeEventListener('pointermove', move);
canvas.removeEventListener('pointerup', end);
canvas.removeEventListener('pointerleave', end);
};
}, []);
return <canvas ref={canvasRef} width={600} height={300} style={{ border: '1px solid #ccc' }} />;
}
这个版本已经可以正常书写,但直接用lineTo连点会出现明显的折线感,因为采集到的坐标点是离散的,快速书写时相邻两点距离很大,连线就显得生硬。另外还有一个隐患:手指在触摸设备上滑动会触发页面的滚动或缩放,需要在canvas上添加touch-action: none样式来阻止浏览器的默认手势行为,否则签名过程中页面会跟着乱跑。
二、笔迹平滑与高分辨率屏幕适配
用二次贝塞尔曲线平滑笔迹
改善笔迹观感的常用技巧是使用二次贝塞尔曲线。思路是:把相邻采集点的中点作为线段端点,采集点本身作为控制点,这样绘制出来的曲线过渡自然,非常接近真实笔迹。改造后的绘制逻辑如下:
const points = useRef([]);
const start = (e) => {
drawing.current = true;
points.current = [getPos(e)];
};
const move = (e) => {
if (!drawing.current) return;
const pts = points.current;
pts.push(getPos(e));
ctx.clearRect(0, 0, canvas.width, canvas.height); // 配合重绘时使用
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length - 1; i++) {
const midX = (pts[i].x + pts[i + 1].x) / 2;
const midY = (pts[i].y + pts[i + 1].y) / 2;
ctx.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY);
}
ctx.stroke();
};
如果采用边画边累积的方式(即不clearRect,直接在旧笔迹上追加),则只需要对新进入的两个中点之间做一次quadraticCurveTo即可,性能更好,也是签名类库的主流做法。两种方式的选择取决于是否需要撤销功能:累积式绘制可以配合快照实现撤销,重绘式则更简单直接。
解决高分屏模糊问题
很多开发者发现签名在Retina屏幕上发虚,原因在于canvas的像素尺寸与CSS尺寸被当成了同一个东西。canvas的width和height属性定义的是绘图缓冲区的物理像素,而CSS样式控制的是显示尺寸。当设备像素比(devicePixelRatio)大于1时,如果两者相等,浏览器会把物理像素拉伸显示,自然就模糊了。解决办法是让缓冲区尺寸等于CSS尺寸乘以像素比,再对上下文做缩放:
useEffect(() => {
const canvas = canvasRef.current;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr); // 之后所有坐标按CSS像素计算即可
}, []);
经过这样的处理,逻辑坐标仍然按照CSS像素书写,导出的图片却是高分辨率的,放大查看也不会出现锯齿。
三、导出PNG、撤销与空白检测
导出为PNG图片
Canvas原生提供了toDataURL方法,可以把画布内容序列化为Base64字符串。签名场景通常希望背景透明,只保留笔迹本身,这样后端可以把图片合成到合同的任意位置。只要不主动填充背景色,canvas默认就是透明的:
const handleSave = () => {
const canvas = canvasRef.current;
// 检查是否为空白画布
if (isEmpty(canvas)) {
alert('请先签名');
return;
}
const dataUrl = canvas.toDataURL('image/png');
// 方式一:直接提交给后端
fetch('/api/signature', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: dataUrl }),
});
// 方式二:触发本地下载
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'signature.png';
a.click();
};
需要注意的是,Base64编码会让数据体积膨胀约三分之一,如果签名图片较大,建议把dataUrl转成Blob后用FormData上传,可以减少传输体积。转换方法也很简单:atob解码后再借助Uint8Array构造Blob对象即可。
空白检测与撤销实现
空白检测有两种思路。一种是记录用户是否落过笔,用一个标志位判断,但这无法覆盖“点了一下又清空”的边界情况。更稳妥的方式是直接扫描像素数据:遍历getImageData返回的alpha通道,只要存在非零值就说明有笔迹。为了性能,可以按步长采样:
const isEmpty = (canvas) => {
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 0) return false; // alpha通道非零说明有内容
}
return true;
};
撤销功能建议采用快照方案:每次pointerup时调用ctx.getImageData保存当前画布的快照并压入数组,撤销时pop出上一张快照,用putImageData恢复。快照会占用内存,一般限制保存最近20步左右即可满足绝大多数用户的操作习惯。清空功能则直接clearRect整个画布并清空快照栈。
四、组件封装与产品化建议
把上述能力整合成一个完整的受控组件后,建议对外暴露这些Props:宽高、笔触颜色、笔触粗细、背景色、onChange回调(签名状态变化时通知父组件)。同时在内部处理好响应式尺寸变化——当容器宽度变化时,需要先把当前画布内容保存为图片,再重设尺寸,最后把图片绘制回去,避免窗口缩放导致签名丢失。
如果想进一步降低维护成本,社区中成熟的signature_pad库也是不错的选择,它内置了速度感应的笔锋模拟(书写越快线条越细,越慢越粗),手写观感更接近真实钢笔。但无论是否使用库,理解背后的Canvas原理都有助于你在遇到定制需求时游刃有余,比如在签名上叠加水印、限制签名区域形状、或者把多页签名合并导出等场景。
React Canvas签名板电子签名canvas手写签名保存PNG修改时间:2026-09-08 00:38:35