
简介这是一份基于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 替换 mousetouch早期实现通常分开监听 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 一张表看懂签名组件的基础配置把事件和坐标写完签名组件已经能写上去了。剩下的是把每个环节的出厂参数固定下来方便后面两种模式共用。参数默认值说明dprwindow.devicePixelRatio物理像素比乘到 canvas.width 上避免模糊bgColor空透明画布写时保持透明导出时按目标介质填充strokeColor#000000笔迹颜色也支持渐变色touchActionnone必须设置否则触屏手势会吃掉 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 管端点形状。参数推荐值说明lineWidth2.5签字笔/ 6记号笔普通模式笔迹宽度单位是 CSS 像素lineCapround圆头线帽起点和终点不会出现毛刺lineJoinround圆角连接折点视觉更柔和smoothingtrue中点插值开关关闭后退化成直接 lineTostrokeStyle#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. 导出高清签名图片并打包 ziptoBlob、放大重绘与 JSZip5.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% 时提示重新签署。这个校验放在导出函数内部而不是事件层所有调用方都能覆盖到。本文还有配套的精品资源点击获取