简介:这是一份基于Canvas的网页签名组件源码,面向Web前端开发者,用于在网页中快速实现可交互的签名输入与图像导出功能,适用于电子合同、在线订单确认等需要电子签名的场景。资源压缩包共10个文件,涵盖核心JavaScript绘图逻辑、示例HTML页面、Webpack打包配置、依赖清单及说明文档,整体仅41KB,结构简洁,便于按需复用。已有167人学习下载,适合需要为系统补充手写签名能力的前端开发者、学生及技术爱好者参考。组件支持毛笔字与普通两种笔迹模式,通过监听鼠标及触摸事件捕获书写轨迹,利用Canvas API动态渲染线条;毛笔字模式模拟自然笔锋过渡,普通模式则输出平滑细线,签名完成后可调用toDataURL()方法将画布内容导出为图片。开发中已考虑旧版浏览器兼容降级策略,并提供清晰的示例页面展示书写、清除与导出完整流程;Canvas技术同样常见于HTML5游戏与多媒体交互场景,方便开发者快速集成或二次开发。
1. 把合同发给客户签字,对方回一张背景发黄、光线不均的照片,这种签名基本没法归档
真正能进档案系统的电子签名,应该是弹出一个窗口,手指或鼠标写下去,松开就生成一张高清、透明或纯白底的 PNG,必要时还能把合同多页的签名一次性打包成 zip。这类组件的底座基本都是 canvas 绘图引擎:监听指针轨迹,把笔画按不同模式画到画布,最后用 toBlob 导出图片。难点从来不在"画出来",而在三处:坐标换算是笔迹跟手的前提,普通模式和毛笔字模式的笔迹模型完全是两套思路,导出时的分辨率、底色和文件组织方式决定这份签名能不能直接放进流程。这篇文章把这三个问题逐层拆开,给出一套代码可以直接抄进项目的实现。
2. 基于 canvas 构建签名组件:从 pointer 事件到坐标换算
2.1 事件绑定:pointerdown/pointermove/pointerup 替换 mouse+touch
早期实现通常分开监听 mousedown、mouseup、touchstart、touchmove,还要处理 changedTouches 取触点,一套逻辑写两份。现在的浏览器普遍支持 PointerEvent,同一套接口同时覆盖鼠标、触控笔和触摸屏,而且触控笔还能额外给出 pressure 压力值,这正好是后面做毛笔字模式需要的数据。
还有一个必须在初始化时做的设置:canvas.style.touchAction = 'none'。不设置的话,触屏上手指移动会被浏览器识别为滚动手势,pointermove 事件被中途接管,笔画会变成一段一段的断续线。setPointerCapture 同样关键,它把后续指针事件绑定到 canvas 元素上,手指滑出画布再移回来,事件流也不会断。
const canvas = document.querySelector('#sign'); const ctx = canvas.getContext('2d'); let drawing = false; canvas.style.touchAction = 'none'; canvas.addEventListener('pointerdown', (e) => { drawing = true; beginStroke(getPoint(e), e); canvas.setPointerCapture(e.pointerId); }); canvas.addEventListener('pointermove', (e) => { if (!drawing) return; addPoint(getPoint(e), e); }); canvas.addEventListener('pointerup', (e) => { if (!drawing) return; endStroke(getPoint(e), e); drawing = false; });pointermove 里必须判断 drawing 状态,否则鼠标悬停在画布上时会持续画出无效轨迹。beginStroke、addPoint、endStroke 是下文普通模式和毛笔模式各自实现的三个人口,事件层只负责把坐标送进去,这样签名模式可以替换,事件绑定不用改。pointerup 后 drawing 置 false,保证下一次 pointerdown 重新开始一段新笔画。
2.2 坐标换算与 devicePixelRatio:为什么签名写出来是糊的
canvas 的坐标系用的是画布物理像素,而事件给的 clientX/clientY 是视口 CSS 像素。如果不换算直接画,在 devicePixelRatio 大于 1 的屏幕上,笔迹会整体偏移,而且因为画布 CSS 尺寸和物理尺寸不一致,写出来的字会模糊。
function getPoint(e) { const rect = canvas.getBoundingClientRect(); return { x: (e.clientX - rect.left) * (canvas.width / rect.width), y: (e.clientY - rect.top) * (canvas.height / rect.height), pressure: e.pressure, t: performance.now() }; }换算时要留意一个坐标系陷阱:clientX 相对于视口,getBoundingClientRect 返回的也是视口坐标,两者可以直接相减。不要用 e.pageX,那相对于整个文档,页面一滚动就和 rect 错位,笔迹会整体偏上一个滚动距离。随后乘上canvas.width / rect.width,能自动适配 DPR 带来的物理像素差,也适配后续通过 CSS 缩放画布的情况。
初始化画布尺寸时,一般不会直接把 canvas.width 设成 CSS 像素值,而是乘上设备像素比,否则高分屏上笔迹边缘锯齿严重。注意一点:canvas.width 一旦被重新赋值,画布内容会被清空,所以如果是页面 resize 触发重新设置尺寸,得先用第二个离屏 canvas 保存当前笔迹,完事再 drawImage 画回来。签名组件里 resize 不常见,但弹出确认框时如果布局动画改变了画布宽度,这个小坑一定会踩到。
2.3 一张表看懂签名组件的基础配置
把事件和坐标写完,签名组件已经能"写上去"了。剩下的是把每个环节的出厂参数固定下来,方便后面两种模式共用。
| 参数 | 默认值 | 说明 |
|---|---|---|
| dpr | window.devicePixelRatio | 物理像素比,乘到 canvas.width 上避免模糊 |
| bgColor | 空(透明) | 画布写时保持透明,导出时按目标介质填充 |
| strokeColor | '#000000' | 笔迹颜色,也支持渐变色 |
| touchAction | 'none' | 必须设置,否则触屏手势会吃掉 pointermove |
这些参数里最容易出问题的是 bgColor。如果 canvas 直接设置了 CSS 背景色,导出 PNG 时背景会被当成画布内容的一部分带出去,做不了透明底。所以底色要延迟到导出阶段处理,画布始终以透明为初始状态。strokeColor 也建议放在配置对象里而不是写死在绘图代码中,黑色签名换蓝色笔替时,改一处配置全局生效。
3. 普通模式:用二次贝塞尔把折线变成行云流水的笔迹
3.1 为什么直接 lineTo 会看到折点和粗细不匀
如果 pointermove 时直接拿采样点lineTo再stroke,快速书写时笔迹会呈现明显的多边形折角。原因是浏览器触摸采样频率大约 60 到 120Hz,慢速写字时两点间距小问题不明显,一旦快速划一笔,相邻采样点之间的直线就会暴露出来,轨迹看起来像路径编辑器里的折线。
常见做法是"中点插值"。每次移动时不直接连接原始采样点,而是依次连接相邻两个采样点的中点,把原始点作为贝塞尔曲线的控制点。这样画出来的曲线数学上等价于对原始轨迹做了平滑,视觉上既保留书写速度变化,又去除锯齿折角。需要注意,中点插值只是平滑,不是低通滤波,对连笔字的拐点保留得比单纯抽稀好得多。
3.2 普通模式的完整实现
const history = []; let lastPoint = null; let midPoint = null; function beginStroke(p) { history.length = 0; lastPoint = null; midPoint = null; history.push(p); } function addPoint(p) { history.push(p); if (history.length < 2) return; if (!midPoint) { midPoint = { x: (history[0].x + p.x) / 2, y: (history[0].y + p.y) / 2 }; drawDot(midPoint); lastPoint = history[0]; return; } const newMid = { x: (lastPoint.x + p.x) / 2, y: (lastPoint.y + p.y) / 2 }; ctx.beginPath(); ctx.moveTo(midPoint.x, midPoint.y); ctx.quadraticCurveTo(lastPoint.x, lastPoint.y, newMid.x, newMid.y); ctx.stroke(); midPoint = newMid; lastPoint = p; } function endStroke() { if (!lastPoint || !midPoint) return; ctx.beginPath(); ctx.moveTo(midPoint.x, midPoint.y); ctx.quadraticCurveTo(lastPoint.x, lastPoint.y, lastPoint.x, lastPoint.y); ctx.stroke(); }这段代码把上一段的中点作为起点,上一个原始采样点作为控制点,新采样点的中点作为终点,每次 move 只画半段曲线。开头第一段没有历史中点,直接用历史第一个点和新采样点的中点起笔,保证整条轨迹从左往右都是平滑连接。endStroke 里补画最后一小段到收笔点,否则笔尾会停在倒数第二个中点位置,像被截掉一截。
3.3 普通模式参数调优对照表
签名字体风格差异很大,有人习惯细细的签字笔,有人要用粗记号笔。下面的参数表给出彼此独立的调节入口,lineWidth 管粗细,smoothing 管顺滑度,lineCap 和 lineJoin 管端点形状。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| lineWidth | 2.5(签字笔)/ 6(记号笔) | 普通模式笔迹宽度,单位是 CSS 像素 |
| lineCap | 'round' | 圆头线帽,起点和终点不会出现毛刺 |
| lineJoin | 'round' | 圆角连接,折点视觉更柔和 |
| smoothing | true | 中点插值开关,关闭后退化成直接 lineTo |
| strokeStyle | '#000000' | 笔迹颜色,可换成 rgba 做半透明墨迹 |
lineCap 用 round 是签名场景的默认选择,因为写起来的第一笔往往是快速落笔,butt 线帽会在笔迹起点留下平头切面,和后续的圆润笔迹不搭。如果是做票据批注类组件,反而建议 lineWidth 固定在 1.5 以下,再加一点半透明,批注看起来更接近真实钢笔的轻重变化。这里有个容易被忽略的事:中点插值依赖采样频率,同样的笔迹在鼠标上流畅,在低频触屏上还是会有点钝,必要时可以对 history 做等距重采样,让每两个点之间距离接近,再进入插值逻辑。
4. 毛笔字模式:用速度模拟压感,逐段构建带宽度变化的笔锋
4.1 笔锋不是画出来的,是填充出来的
普通模式里可以随时用 ctx.lineWidth 调整粗细,但毛笔字不能这么干。lineWidth 每次变化都会和下一段重叠,交界处形成凸起的鼓包;如果笔迹带透明通道,重叠区域还会叠出深色节点,写一个"永"字会看到每一笔中间都有几个深色块。
毛笔字的笔锋本质是一段宽度连续变化的闭合轮廓,而不是一根等宽线。工程上更可靠的实现是"轮廓法":把笔画轨迹的每个采样点,沿法线方向分别向左右偏移半个当前笔宽,得到两条轮廓线,再把右侧轮廓反向拼接,形成一个闭合多边形,最后对这个多边形做一次 fill。多边形在同一次 fill 中不会因为形状自交产生色深差异,笔锋过渡干净,这是和逐段 stroke 的核心区别。
这个方案另外一个好处是轮廓可以整体缩放而不改变笔锋结构。导出放大三倍的签名时,如果毛笔轮廓是离散的路径数据,可以重新按放大比例渲染,比放大位图清晰得多。
4.2 笔宽如何随速度和压力变化
大部分设备没有压感,触控笔的 pressure 在普通鼠标上恒为 0.5。因此毛笔字的宽度控制必须有一套不依赖硬件的速度模拟方案,同时保留对真实压感的兼容通道。
const config = { maxWidth: 14, minWidth: 1.2, speedFactor: 2.5, // px/ms,低于该值视为慢速重按 pressureEnabled: true, smoothSpeed: 0.4 // 速度低通系数,值越小笔画越稳定 }; let lastTime = 0; let lastPoint = null; let lastSpeed = 0; function computeWidth(p, dt) { if (!lastPoint) return config.maxWidth; const dist = Math.hypot(p.x - lastPoint.x, p.y - lastPoint.y); const speed = dt > 0 ? dist / dt : 0; lastSpeed = lastSpeed * (1 - config.smoothSpeed) + speed * config.smoothSpeed; const ratio = Math.min(lastSpeed / config.speedFactor, 1); let w = config.maxWidth - (config.maxWidth - config.minWidth) * ratio; if (config.pressureEnabled && p.pressure > 0 && p.pressure < 0.5) { // 只有明显低于 0.5 时才认为有真实压感,鼠标不触发 w *= 0.6 + p.pressure * 0.8; } return Math.max(config.minWidth, Math.min(config.maxWidth, w)); }速度映射的逻辑是:速度越快,ratio 越接近 1,笔宽越小,形成长撇收笔处那种由实到虚的细尖;速度越慢,笔宽越接近 maxWidth,形成捺画的顿笔。speedFactor 是关键调参项,它把速度值归一化到 0 到 1 区间,数值越小,笔宽对速度越敏感,轻微提速就会出细锋。速度本身要经过低通平滑,否则手抖一下宽度会剧烈抖动,写出来的笔画像锯齿。
压感判断做了个保护:pressure 大于 0 且小于 0.5 才进入乘法通道。鼠标的 pressure 恒等于 0.5,直接排除,无压感触屏上报的也往往是 0.5,可以安全跳过。如果设备真的有压感(部分触控笔会报 0 到 1 连续值),这里的乘法系数会在慢速重压时让笔宽额外增加,接近真实毛笔的按压力度变化。
4.3 构建左右轮廓:每个采样点沿法线偏移半宽
有了宽度序列,下一步把轨迹点转换成轮廓多边形。对每个采样点,取它前后两个点连线方向作为笔画方向,旋转 90 度得到法线方向,沿法线左右各偏移半宽,形成两个轮廓点。
function buildOutline(points, widths) { const left = []; const right = []; for (let i = 0; i < points.length; i++) { const p = points[i]; const prev = points[Math.max(i - 1, 0)]; const next = points[Math.min(i + 1, points.length - 1)]; const angle = Math.atan2(next.y - prev.y, next.x - prev.x) + Math.PI / 2; const half = widths[i] / 2; const dx = Math.cos(angle) * half; const dy = Math.sin(angle) * half; left.push({ x: p.x + dx, y: p.y + dy }); right.push({ x: p.x - dx, y: p.y - dy }); } return left.concat(right.reverse()); }笔画首尾两端需要特殊处理。起笔时如果第一个点就按半宽偏移,笔尖是一个平头;理想情况下起笔应该有入笔锋,一般把第一个点的宽度直接乘以 0.2,形成一个尖角。收笔同理,最后两个点的宽度按 0.2 递减,长横收笔时会出现明显的回锋感。如果觉得轮廓多边形在拐弯处棱角太生硬,可以再用一次 3.2 节的中点插值思路,对轮廓点做一遍二次贝塞尔平滑。
填充阶段的代码非常短:
function fillOutline(outline) { ctx.beginPath(); ctx.moveTo(outline[0].x, outline[0].y); for (let i = 1; i < outline.length; i++) { ctx.lineTo(outline[i].x, outline[i].y); } ctx.closePath(); ctx.fill(); }fill 时建议给 ctx.globalAlpha 设一个 0.85 到 0.95 之间的值,模拟墨迹渗入宣纸后稍许透明的质感。如果完全不透明,浓墨和淡墨之间缺少层次,看起来像马克笔。全局透明度对整个笔画统一生效,不会因为笔画自交产生深色叠加,这是和逐段 stroke 最大的体验差异。
提示:在某些低端安卓机的 WebView 上,fill 一个几千个点的多边形会有毫秒级卡顿。可以在 pointermove 里做抽稀,间距小于 0.5px 的点直接丢弃,轮廓点数能减少三分之一而不影响笔锋观感。
毛笔模式的这段实现里,还有一个可以做的变体:在收笔阶段把 globalCompositeOperation 临时切换为 'destination-out',用一个小圆擦除笔锋最末端的一点墨迹,能模拟出飞白枯笔效果。这个技巧不要用在普通模式上,普通模式的半透明笔迹经不起这种擦除,会留下一个突兀的透明缺口。
5. 导出高清签名图片并打包 zip:toBlob、放大重绘与 JSZip
5.1 透明底与白底的正确处理
canvas 默认背景透明,直接 toDataURL('image/png') 导出的就是透明底 PNG,放进 Word、PDF 或者合同系统里显示正常。但有些预览控件不处理 alpha 通道,签名会显示成黑底。所以导出逻辑一般会提供 background 选项,需要白底时先在一个离屏 canvas 上铺白色再绘制签名。
async function exportImage({ scale = 2, background = '#ffffff' } = {}) { const W = canvas.width * scale; const H = canvas.height * scale; const tmp = document.createElement('canvas'); tmp.width = W; tmp.height = H; const tctx = tmp.getContext('2d'); if (background) { tctx.fillStyle = background; tctx.fillRect(0, 0, W, H); } tctx.drawImage(canvas, 0, 0, W, H); const blob = await new Promise((resolve) => tmp.toBlob(resolve, 'image/png')); return blob; }scale 参数直接做放大重绘,原理是把签名原画布整体 drawImage 到一个 N 倍尺寸的画布上,配合 imageSmoothingEnabled 的默认插值,比在 CSS 层面放大清晰很多。如果只把 toDataURL 的结果给 再设置 width 放大,位图已经被固定成小尺寸,插值出来的边缘是虚的。导出 PDF 里的签名,scale 设 2 足够;如果要拿去印刷,建议 3 到 4,文件体积会随之增加,PNG 在纯色签名场景压缩率很高,一般不会超过几百 KB。
5.2 JSZip 打包多张签名并触发下载
合同签署经常一次产生多页签名,后端按文件上传还是前端打包,取决于流程设计。前端方案最直接的是收集所有签名的 blob,塞进 zip 一次性下载,避免循环下载产生的一堆临时文件。JSZip 是最常用的打包库,本身很小,gzip 后不到 30KB。
import JSZip from 'jszip'; async function exportZip(signatures) { const zip = new JSZip(); const folder = zip.folder('signatures'); for (let i = 0; i < signatures.length; i++) { const blob = await exportImage({ scale: 3 }); folder.file(`signature_${i + 1}.png`, blob); } const content = await zip.generateAsync({ type: 'blob', compression: 'DEFLATE' }); const url = URL.createObjectURL(content); const a = document.createElement('a'); a.href = url; a.download = `signatures_${Date.now()}.zip`; a.click(); URL.revokeObjectURL(url); }generateAsync 的 compression 用 DEFLATE 会压缩 zip 中每个文件。PNG 本身已经经过 deflate 压缩,外层再压收益不大,但对签名数量多、文件名长的场景可以让 zip 总字节数略小。fold.file里的路径支持子目录,合同场景下按contractId/pageIndex组织文件结构比平铺一堆名字可读性好得多。
5.3 导出练习前的验证技巧
拿到一张导出的 PNG,第一件事不是放进 Word,而是用代码或在线工具检查三个指标:尺寸是否为目标倍数;四角像素是否为纯白或透明(取决于 background 参数);笔迹最细处是否出现断线。断线问题多半出在毛笔模式 minWidth 设得太低,加上 fill 时坐标取整导致窄于 1px 的轮廓被吞掉。修复方法是最后填充前把 outline 里所有坐标做一次Math.round,或者把 minWidth 下限提到 1.5。
导出空白画布的场景也要兜住:用户打开弹窗没写就直接点确认,会导出一张纯色图片。常见的处理是用 getImageData 遍历 alpha 通道,统计不透明像素比例,低于 0.5% 时提示重新签署。这个校验放在导出函数内部而不是事件层,所有调用方都能覆盖到。
本文还有配套的精品资源,点击获取