做前端最容易被产品经理一句话带偏的需求,就是"加个签名功能"。我接到的版本是:客户下单时要确认一份线上协议,用户直接在页面上签个名,签完保存成图片传后端归档。听着简单,真做起来要处理的事情比想象中多得多——画布在高分屏上的清晰度、手写笔迹是否顺滑、鼠标和触屏的兼容、导出图片的格式与背景、甚至签名数据在业务上怎么才算"有效"。
这篇文章把我从前端实现在线签名操作的全过程拆开来讲,包括核心实现代码、每一步选型背后的理由,以及上线后真机踩过的一堆坑。适合正在做电子合同、线上确认单、审批流、快递签收这类场景的前端同学参考。不管你是打算从零手写,还是想评估用第三方库,这篇文章都能给你一份可以直接抄作业的答案。
1. 业务场景还原:一个签名功能背后的完整交付链路
1.1 为什么"在线签名"不是一个画板那么简单
表面上看,在线签名就是让用户拿手指或者鼠标在页面上画几笔,然后把画的内容存下来。但落到真实业务里,这个功能通常出现在具有法律效力的环节:电子合同签署、保险回执确认、知情同意书、收件签收。
这些场景的共同点是,
签名的价值不在于"画得像不像",而在于它作为用户主动行为的留痕记录
。所以前端要做的不只是"能画",还要保证:
- 签名过程操作足够自然,用户不用被逼着练字
- 最后的图片清晰、完整,不会出现断线或模糊
- 签名数据能够稳定地传给后端,不丢、不乱码
- 交互上能防止误触、误清空,因为一份作废的签名可能引发纠纷
我在项目里遇到过最尴尬的一次事故,就是用户在 iPad 上签完名,图片导出后是一片空白。排查到最后发现是画布在设备旋转时被浏览器重置了,而签完名之后又刚好发生了 resize。这类问题,只有真正做过一遍才能在代码里提前堵住。
1.2 前后端职责边界:前端交付什么、不交付什么
很多第一次做这个功能的同学容易把"在线签名"理解成纯前端的事情,其实它是一条完整链路里的第一个环节。我习惯在开工前和产品、后端把边界划清楚:
| 签名采集与绘制 | 前端 | 用户交互、画布渲染、实时预览 |
| 签名数据导出 | 前端 | 转图片或矢量路径数据 |
| 签名数据存储 | 后端 | 保存图片文件或签名数据,关联业务单号 |
| 签名有效性校验 | 后端 | 结合验证码、实名认证等确认签署人身份 |
| 合同/文件生成 | 后端 | 把签名合成到最终文档,加盖时间戳 |
前端要交付的是一个"可靠的签名采集器",而不是"签名真伪鉴定器"。身份验证、法律有效性这些事,前端做了也白做,必须靠后端配合业务逻辑来保证。
但前端可以做一件事:在用户签名前,通过业务方已有的验证手段(短信验证码、人脸识别、实名认证)确认身份,然后再进入签名环节。这一点我在项目里是和产品确认过的,属于"前端集成、后端验证"的典型做法。
2. 方案选型:手写 Canvas、矢量 SVG 还是引第三方库
2.1 三条路线的优缺点对比
在线签名的主流实现方案有三条:Canvas 位图绘制、SVG 路径记录、直接集成第三方签名库。我在做技术选型时列了一张对比表,把每个方案的真实情况摆出来看:
| 笔迹平滑 | 需要自己实现贝塞尔插值 | 路径天然是矢量曲线 | 内置平滑算法,开箱即用 |
| 高分屏清晰度 | 需手动处理 devicePixelRatio | 矢量无限清晰 | 已处理 DPR |
| 导出图片 | toDataURL / toBlob 直接转位图 | 需要序列化路径并转图片,麻烦 | 提供导出 API |
| 包体积 | 零依赖 | 零依赖 | 通常 10~50KB,可接受 |
| 自定义 UI | 完全可控 | 完全可控 | 受限于库的封装 |
| 维护成本 | 自己维护,出问题能定位 | 路径计算复杂,维护成本高 | 跟随社区更新,但升级有风险 |
| 学习价值 | 高 | 中 | 低 |
2.2 我的选择逻辑与理由
最终我选择了
从零手写 Canvas 实现,同时参考了 signature_pad 的设计思路
。理由有三个:
第一,签名这个交互本身不复杂,核心就是"笔画轨迹采集 + 曲线平滑 + 导出位图",手写一遍代码量不会超过 300 行,但能获得百分之百的控制权。业务方经常会提"签名区加个浅色背景""清空按钮要带二次确认"这类个性化需求,用第三方库的时候往往会发现改一个样式都要翻源码。
第二,Canvas 的位图方案最适合导出。签名最终要合成到合同 PDF 里,后端需要的是一张白底或透明底的 PNG,Canvas 的
toBlob
和
toDataURL
原生支持,不需要额外做格式转换。
第三,这是延续性考虑。团队后续还要做图片批注、表单涂鸦等功能,底层都是同一套 Canvas 绘制逻辑,这次把基础打好,后面能复用。
如果你接的项目时间非常紧,或者团队没有 Canvas 基础,直接用 signature_pad 这类成熟库也没什么问题,它能处理掉大部分兼容性细节。但如果想在签名功能上做个性化,我建议还是手写一遍,踩坑的过程本身就是经验积累。
3. Canvas 签名板从零实现:分辨率、事件与平滑笔迹
3.1 第一步:画布初始化与高分屏适配
直接给 Canvas 设置 CSS 宽高然后开画,是第一版最容易犯的错误。浏览器里 Canvas 的
width
和
height
属性是物理像素,而 CSS 控制的是布局尺寸。在普通屏幕上 1 比 1 没问题,但在 Retina 屏上,CSS 1px 对应 2 个甚至 3 个物理像素,如果 Canvas 的物理尺寸小于 CSS 尺寸,画出来的线条就会被放大拉伸,边缘全是锯齿。
正确的做法是读取
devicePixelRatio
,让 Canvas 的物理分辨率等于 CSS 尺寸乘以 DPR,然后用
scale
把坐标系放大,这样绘制代码可以继续使用 CSS 像素坐标。
function setupCanvas(canvas) {
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);
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#1a1a1a';
return ctx;
}
这里有一个容易忽略的点:
getBoundingClientRect()
必须在 Canvas 可见之后调用,否则拿到的宽高是 0。如果签名区是在弹窗里动态渲染的,要等弹窗完全展示后再初始化画布。
3.2 第二步:统一事件处理,搞定鼠标和触屏
过去做画板类功能要同时监听
mousedown/mousemove/mouseup
和
touchstart/touchmove/touchend
两套事件,还得处理
touchcancel
,代码很容易变得又臭又长。现在主流的现代浏览器都已经支持 Pointer Events,一套
pointerdown/pointermove/pointerup
就能同时覆盖鼠标、触屏和手写笔。
let isDrawing = false;
let lastPoint = null;
let points = [];
canvas.addEventListener('pointerdown', (e) => {
isDrawing = true;
canvas.setPointerCapture(e.pointerId);
points = [getCanvasPoint(e)];
lastPoint = points[0];
});
canvas.addEventListener('pointermove', (e) => {
if (!isDrawing) return;
points.push(getCanvasPoint(e));
drawSmoothLine(points);
});
const endStroke = (e) => {
if (!isDrawing) return;
isDrawing = false;
saveSnapshot();
points = [];
};
canvas.addEventListener('pointerup', endStroke);
canvas.addEventListener('pointercancel', endStroke);
function getCanvasPoint(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX – rect.left,
y: e.clientY – rect.top
};
}
setPointerCapture
很关键,它能把后续的
pointermove
事件持续绑定到画布上,即使手指滑出了画布边界,笔画也不会断。这在用户快速书写时体验差异非常明显,没有它,写到边缘就"掉笔"了。
3.3 第三步:用中点二次贝塞尔曲线让笔迹顺滑
如果直接把
moveTo
加
lineTo
连起来画,每条线段之间的转折会是生硬的折角,写出来的字在笔画拐弯处像锯齿一样。最常用的平滑处理思路是
用相邻点的中点作为二次贝塞尔曲线的端点,原有点作为控制点
。
原理不复杂:二次贝塞尔曲线需要三个点——起点、控制点、终点。如果直接用原始采样点 A、B、C 画,曲线在 B 点会形成一个明显拐角。但如果用 A 和 B 的中点 M1 作为曲线的起点,B 作为控制点,B 和 C 的中点 M2 作为终点,那么生成的曲线就会平滑地穿过两个中点,同时被控制点 B"吸"向原始轨迹。这样既能保持笔迹的走势,又能去掉折角。
function drawSmoothLine(ctx, points) {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length – 1; i++) {
const midX = (points[i].x + points[i + 1].x) / 2;
const midY = (points[i].y + points[i + 1].y) / 2;
ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY);
}
const last = points[points.length – 1];
ctx.lineTo(last.x, last.y);
ctx.stroke();
}
注意
moveTo
用第一个点,然后从第二个点开始循环到倒数第二个点,最后再用
lineTo
连到最后一个点,保证整条笔画是连续的。
这里有个性能细节:如果每个
pointermove
事件都对整个
points
数组重新绘制一遍,事件频繁触发时会累积大量重复绘制,卡顿感明显。更高效的做法是维护一段"上一笔的终点"和"当前采样的新段",每次只绘制新增段。上面的代码为了展示平滑算法写得简单,实际项目里我建议改成增量绘制,只画
points[i-1]
到
points[i]
之间新生成的曲线段。
4. 交互细节打磨:清空、撤销、导出与画布状态管理
4.1 清空与撤销:栈式快照的取舍
签名过程中误操作是必然的,用户画歪了想重来,最简单的方案是提供一个"清空"按钮。代码上清除画布内容就一行:
ctx.clearRect(0, 0, canvas.width, canvas.height);
但清空按钮要谨慎,一旦点了整张签名就没了。我做的第一版没有撤销,结果用户在 iPad 上画得差不多了,手掌误触点了一下清空,整单重来,差点被投诉。就因为这个反馈,我加了个撤销功能。
撤销的核心是"一笔一状态的快照栈"。在每次
pointerup
后,把当前画布内容用
getImageData
存进栈里,撤销时
pop
出来用
putImageData
恢复。
const undoStack = [];
const MAX_UNDO_STEPS = 10;
function saveSnapshot() {
undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
if (undoStack.length > MAX_UNDO_STEPS) {
undoStack.shift();
}
}
function undo() {
if (undoStack.length === 0) return;
const snapshot = undoStack.pop();
ctx.putImageData(snapshot, 0, 0);
checkHasContent();
}
一个很现实的问题:快照占内存。普通桌面场景下,签名区宽度大约 600 物理像素、高度 400,一张快照大约 600×400×4 = 960KB,存 10 张也就 10MB 左右,可以接受。但如果在 DPR 为 3 的手机上,物理像素变成 1800×1200,一张快照就要 8.6MB,10 张就是 86MB,这时候就需要限制栈深度,或者改为记录笔画路径数据、在撤销时重绘。我目前的做法是:桌面端栈深 10,移动端限制为 5,够用且不会有明显内存压力。
4.2 导出图片:白底合成与透明背景的纠结
签名画布的底色默认是透明的,直接用
canvas.toDataURL('image/png')
导出的 PNG 透明背景。透明背景在网页预览时没问题,但合成到白底合同文档里时,某些渲染引擎会把透明区域渲染成黑色。我在联调时就被后端反馈过"签名图黑背景",印象极其深刻。
所以导出时必须做一个
白底合成
:新建一个临时 Canvas,先填充白色,再把签名画布绘制上去,最后才导出。
function exportSignature() {
const exportCanvas = document.createElement('canvas');
exportCanvas.width = canvas.width;
exportCanvas.height = canvas.height;
const exportCtx = exportCanvas.getContext('2d');
exportCtx.fillStyle = '#ffffff';
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
exportCtx.drawImage(canvas, 0, 0);
return new Promise((resolve) => {
exportCanvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
});
}
这里用
toBlob
而非
toDataURL
是有讲究的。数据量大的时候,
toDataURL
生成的是 Base64 字符串,体积比原图大 33%,传输效率低;
toBlob
直接产出二进制,配合 FormData 上传更合适。如果你的后端接口坚持要 Base64,也可以
toDataURL
,但要注意在移动端网络环境下,大体积字符串上传会明显变慢。
顺带提醒:如果签名区背景在页面上是浅灰色或带网格线的,导出时用户看到的是"灰底签名",但导出图是白底,视觉上会有点差异。建议在画布下方写一行小字提示"签名将以黑色笔迹保存至白色背景文档",避免用户误解。
4.3 占位提示与空签名校验
画布本身是透明的,用户进来看到的是一片空白,得告诉用户"在这签"。
我用的方案是:在画布上方叠一个
div
,里面写"请在此区域签名",有笔画时隐藏,清空后重新显示。判断"有没有笔画"有两种思路:一是维护一个
strokeCount
计数器,
pointerdown
时加一;二是扫描画布像素,检测是否有非透明像素。计数器方案简单高效,但在撤销和清空时也要维护,容易漏;像素扫描更可靠,但性能开销大。实际项目中我推荐:以计数器为主,导出前做一次像素级校验兜底。
像素级校验其实不贵,用
getImageData
拿到整块画布数据,遍历判断非透明像素的数量即可,注意这个操作要在导出前执行,不能放在
pointermove
的高频回调里。
function hasSignatureContent() {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// data 是 RGBA 数组,每隔 4 个取一个 alpha 通道
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) return true;
}
return false;
}
产品上还会要求"签得太少视为无效",比如用户只点了一个点。这时可以设置一个最小笔画像素比例,比如签名内容占画布面积的千分之一以上,否则提示"签名过于简单,请重新书写"。这类阈值要根据真实测试调,不同设备上的书写习惯差异很大。
5. 移动端与真机踩坑:误触、缩放和性能问题实录
5.1 滑动页面和画板打架:touch-action 的坑
移动端打开签名板,用户手指在画布上一滑动,页面就开始滚动,笔画根本画不连贯。这个问题的根源是浏览器默认的触摸行为:触摸滑动时先滚动页面,而不是触发绘制事件。
解决办法是在画布上禁用默认触摸行为,两条路都走:
.signature-canvas {
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
touch-action: none
是标准方案,告诉浏览器这个元素的触摸操作由 JavaScript 全权处理。
user-select: none
是为了防止长按时选中文字或弹出系统菜单。如果再遇到某些 WebView 不生效,还需要在
touchmove
事件里手动
preventDefault()
,但新项目强烈建议直接用 CSS 方案,代码更干净。
5.2 iOS 真机上的一系列问题
iOS 相关的坑是最多的,我逐个记录如下:
第一个是 iOS Safari 从 13 开始才完整支持 Pointer Events。如果你的用户群还有旧设备,需要加一层降级:检测
window.PointerEvent
不存在时,退回
touch
事件和
mouse
事件双绑定。我在代码里封装了一个
bindEvents
函数,按特性检测分支处理。
第二个是 iOS 上点击画布会触发双击缩放。虽然
touch-action: none
可以解决大部分缩放,但某些 WebView 里用户双击仍会触发页面缩放,遮罩层、弹窗场景尤其明显。必要的时候要在 viewport meta 里加上
user-scalable=no
:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
第三个经典问题是页面滚动后画布坐标偏移。很多人的画布不是固定在页面顶部,用户滚动到签名区再签名时,直接用
e.clientY
当成画布坐标就会错位。正确做法是用
getBoundingClientRect()
取画布当前相对视口的位置,再做差值计算。我在 3.2 节的
getCanvasPoint
里就是用的这个方法,保证滚动之后依然准确。
第四个问题比较隐蔽:iOS 上
canvas.toBlob
里如果传了
image/jpeg
并且画布里有透明区域,默认会填成黑色。所以如果后端要求 JPG,前端必须在导出前先做白底合成(4.2 的方案),不然后端收到全是黑底的签名图。
5.3 手写笔压感不支持,别过度设计
很多产品经理会问"能不能做得跟手写板一样有笔锋"。这里要泼一盆冷水:浏览器里通过 Pointer Events 拿到的
e.pressure
属性,在绝大多数消费级触屏和鼠标上都是恒定值 1.0,只有 Apple Pencil、Surface Pen 这类原生手写笔设备才会返回真实的压感数值。
如果非要实现笔锋效果,主流做法是引入每帧的笔画速度,速度越快线条越细,速度越慢线条越粗,通过动态修改
lineWidth
来模拟笔锋。我在一个小项目里试过,效果在慢速书写时还凑合,但快速签名时笔画宽度会剧烈抖动,不如不做。
我的建议是:除非你的目标用户明确是配合手写笔使用的(比如平板端的保险签约场景),否则不要在这一版做压感,把精力放在笔迹平滑和防误触上,这两个才是用户能直接感知的体验。
5.4 高频事件与 DPR 放大的性能压力
现在手机普遍是 120Hz 刷新率,
pointermove
事件触发频率可以到每秒上百次。再加上 DPR 放大后的画布物理尺寸,每一笔的绘制开销都会被放大很多倍。
我遇到过一次很典型的问题:在 DPR 为 3 的手机上,签名区 CSS 尺寸是 350×250,物理尺寸变成 1050×750,事件每帧都在画,页面肉眼可见地掉帧。优化的核心思路是
控制每帧的绘制次数
:
let rafId = null;
let pendingPoints = [];
canvas.addEventListener('pointermove', (e) => {
if (!isDrawing) return;
pendingPoints.push(getCanvasPoint(e));
if (!rafId) {
rafId = requestAnimationFrame(() => {
drawSmoothLine(ctx, pendingPoints);
pendingPoints = [];
rafId = null;
});
}
});
把高频事件累积到一个缓冲区,通过
requestAnimationFrame
把绘制合并到每一帧,这样哪怕事件每秒触发 120 次,绘制最多执行 60 次(跟随屏幕帧率),视觉上完全无感,CPU 开销却少一半以上。
另外,如果你的签名区还有背景网格或水印文字,不要每次都重绘背景,先把背景绘制到 Canvas 上,然后用
clearRect
只清除签名内容区域,背景会被保留——但要注意
clearRect
清的是透明区域,如果背景是绘制在同一个 Canvas 上的,清掉签名时背景也会被清掉。更稳妥的做法是分两层 Canvas:底层画静态背景,顶层画签名,导出时只导出顶层。
6. 服务端对接与验收清单:从图片格式到数据校验
6.1 前端和后端先约定好这些东西
签名功能联调时扯皮最多的地方,往往不是绘制逻辑,而是格式约定。我在项目总结里梳理了一份清单,新项目直接照着确认,能少走很多弯路:
| 图片格式 | PNG | 无损、支持透明(虽然导出时已合成白底) |
| 传输方式 | FormData + Blob | 体积小、传输快,比 Base64 字符串高效 |
| 图片尺寸 | 保持画布物理尺寸 | 不要在前端压缩,由后端按需处理 |
| 命名规则 | 业务单号 + 时间戳 | 避免文件名冲突 |
| 附带信息 | 用户 ID、签署时间、页面标识 | 作为元信息传给后端,用于审计 |
| 失败重试 | 前端捕获上传失败并提示 | 签名图上传失败不能静默处理 |
这里特别强调一下"附带信息":签名一旦发生纠纷,需要回答三个问题——谁签的、什么时候签的、在哪个页面上签的。前端把这几个字段一起传给后端,后端落库时关联起来,这份签名才具备完整的业务审计价值。前端层面的用户 ID 可以从登录态拿,签署时间建议用后端时间,避免用户本机时间不准。
6.2 签名完整性的前端自检
交付之前,我还给签名模块加了一道"自检"流程,直接降低了事后客诉率:
- 签名区域是否可见且尺寸正确(Canvas 宽高不能为 0)
-
签名是否包含实际内容(走
hasSignatureContent
校验)
-
导出的 Blob 是否可以正常解码为图片(前端用
createImageBitmap
做一次解码验证,解码失败直接报错提示用户重新签名)
- 上传失败时的处理逻辑是否有兜底(重试按钮 + 提示文案)
这些都确认完之后,才把"确认签名"按钮置为可点击状态。别小看这些校验,它们能拦截掉大量"用户签了但后端没收到图"的隐形问题。
写在最后的实际操作体会
前端实现在线签名这个需求,难度从来不在"画线"本身,而在那些用户看不到的细节里:高分屏的锯齿、移动端的误触、导出图片的背景色、快照栈的内存、事件频率的性能、以及后端对接时的格式约定。我把这些坑用自己的代码实现方式记录下来,希望你能少走一些弯路。
如果你接下来也要做这个功能,我的建议是先别慌着写代码,把签名区的尺寸、DPR、事件处理、导出格式这四个核心决策想清楚,再动手。画布的初始化、平滑算法的选型、导出时的白底合成,这三个就是整个功能的骨架,骨架对了,剩下的都是往里面添细节。
最后再分享一个小技巧:签名功能写完后,一定要准备一份"真机测试清单",覆盖 iPhone、iPad、安卓手机、桌面 Chrome、带触摸屏的 Windows 笔记本这几种终端。每个终端上分别测试快速签名、慢速签名、断笔、撤销、清空、导出、页面滚动后签名这七个动作,跑完一圈,你基本就能确信这个功能在任何环境下都不会掉链子。

