ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Canvas签名组件核心实现:事件归一化、笔锋模拟与图片导出

Canvas签名组件核心实现:事件归一化、笔锋模拟与图片导出 简介基于Canvas的网页签名组件面向Web前端开发者提供毛笔字与普通两种书写模式并支持将签名内容导出为图片可快速集成至电子合同、在线审批等页面中。压缩包共10个文件以5个JavaScript源文件为核心辅以HTML演示页、README说明文档以及package.json、webpack.config等工程配置整体仅41KB结构紧凑、便于部署与二次开发。组件利用Canvas绘图API监听鼠标与触摸事件实时捕捉笔迹通过toDataURL完成图像导出同时兼顾了旧浏览器的兼容性处理。目前已有167人学习下载代码量适中且示例完整适合希望掌握Canvas交互绘图或需要落地签名功能的前端开发者参考使用。1. canvas 签名组件把手写感搬进 Web 的最小可行方案如果产品经理要你在三天内给合同审核系统加一个在线签名框最稳的方案不是接第三方 SDK而是基于 canvas 自己写一个网页签名组件。鼠标按下、手指划过笔迹实时落在画布上抬手后一键导出 PNG 或 JPEG后端只负责收图存档。组件默认提供毛笔字和普通两种书写模式前者用速度变化模拟笔锋与墨迹浓淡后者是干净细线适合电子合同、物流签收、医疗知情这类要留痕的业务。项目为 canvas-signature-master核心逻辑在 handWrite.js 与 index.jsexample 目录可直接跑通演示。对要快速集成签名流程的 Web 团队它是无需采购第三方服务的轻量方案对想摸清 canvas 事件链和绘图原理的前端开发者它又是一份结构清晰的源码样本值得逐行读一遍。2. 事件采集与坐标归一化统一鼠标、手指和触控笔的输入链路签名组件的第一道分水岭不在画线而在输入。鼠标只有按键和位移手指有触摸面触控笔还带压感如果只对着 mouse 事件写放到 iPad 上就是一场灾难。这个项目把事件层和绘制层分开index.js 暴露初始化入口handWrite.js 维护笔迹点集和绘制状态。先理清文件边界再进入事件适配。2.1 先从项目结构看职责边界解压 zip 之后先看文件分布能省下不少阅读时间。dist 是构建产物example 是演示页源码真正干活的只有两个文件。webpack.config.js 和 webpack.prod.js 分别对应开发与生产构建.babelrc 负责 ES6 转译这些都可以等跑通演示之后再回头研究。文件职责index.js组件入口初始化与对外 APIhandWrite.js笔迹核心点集、曲线插值、笔锋example/demo.js演示逻辑绑定导出按钮example/index.html演示页骨架这种拆分的好处是入口不碰绘图 API绘图文件不碰 DOM 查询。index.js 实例化组件后handWrite.js 内部维护 strokes 数组每个 stroke 是一次完整落笔的点集合。后面所有模式切换、撤销、导出本质都是在操作这份数据。2.2 事件监听PointerEvent 优先mouse/touch 兜底以构造器里的监听绑定为例constructor(canvas, options {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.strokes []; if (window.PointerEvent) { canvas.addEventListener(pointerdown, this.onPointerDown); canvas.addEventListener(pointermove, this.onPointerMove); canvas.addEventListener(pointerup, this.onPointerUp); canvas.addEventListener(pointercancel, this.onPointerUp); } else { canvas.addEventListener(mousedown, this.onMouseDown); canvas.addEventListener(mousemove, this.onMouseMove); canvas.addEventListener(mouseup, this.onMouseUp); canvas.addEventListener(touchstart, this.onTouchStart, { passive: false }); canvas.addEventListener(touchmove, this.onTouchMove, { passive: false }); canvas.addEventListener(touchend, this.onTouchEnd); } }PointerEvent 把鼠标、触控笔、手指统一成同一种事件用 event.pointerType 区分 mouse、pen、touch一次监听覆盖三种设备。兜底分支针对不支持 PointerEvent 的旧版 WebView此时必须同时挂 mouse 和 touch 两套否则桌面端或移动端必挂一边。touch 监听必须传 { passive: false }否则 touchmove 里的 preventDefault() 会被浏览器忽略手指签名时页面跟着滚动体验直接报废。无论走哪条分支canvas 的 CSS 都要加 touch-action: none否则 PointerEvent 分支在移动端仍然会触发浏览器手势。多指触控同样要过滤记录初始事件的 pointerId或 identifier后续 move、up 只认同一个 id避免一只手指画线、另一只手指一碰就把笔迹带歪。这套事件适配思路在 HTML5 游戏里处理摇杆、划动手势时同样适用理解了签名组件的输入层canvas 游戏的触摸适配也能顺手解决。2.3 坐标归一化DPR、CSS 尺寸与位图尺寸initSize() { const rect this.canvas.getBoundingClientRect(); const dpr window.devicePixelRatio || 1; this.canvas.width rect.width * dpr; this.canvas.height rect.height * dpr; this.ctx.scale(dpr, dpr); } getPos(event) { const rect this.canvas.getBoundingClientRect(); const source event.touches ? event.touches[0] : event; return { x: source.clientX - rect.left, y: source.clientY - rect.top, t: Date.now() }; }canvas.width 决定位图分辨率getBoundingClientRect().width 决定 CSS 尺寸两者不是一回事。DPR 为 3 的手机屏如果只把 canvas.width 设成 CSS 宽度位图被拉伸三倍显示签名边缘全是锯齿。initSize 里按 DPR 放大位图再用 ctx.scale(dpr, dpr) 让绘图上下文自动放大之后坐标、线宽都按 CSS 像素写绘制代码不用反复乘 DPR。getPos 里的 source 兼容了 touch 事件的 touches 列表t 字段是当前时间戳第三章毛笔模式的速度计算完全依赖它只画普通线条时可能用不到但统一带上可以让数据结构保持稳定。initSize 必须在 canvas 插入 DOM 之后调用否则 getBoundingClientRect 返回 0canvas.width 被设成 0整块画布直接变空白。3. 双模式绘制普通签名的平滑曲线与毛笔笔锋的实现差异两种模式的差距在绘制层完全暴露。普通模式追求一笔是一笔、线宽恒定毛笔字模式要起笔重、行笔稳、收笔轻的书写质感。两者共用贝塞尔曲线技术真正的分水岭只有两点曲线是否做中点平滑线宽是否随速度动态变化。3.1 普通模式中点二次贝塞尔消除折线感drawSmoothLine(points) { if (points.length 3) return; const ctx this.ctx; 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); } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.lineWidth 2; ctx.lineCap round; ctx.lineJoin round; ctx.stroke(); }事件层采集到的 points 是离散采样点mousemove 触发频率在 60~120Hz快速划动时相邻点间距能拉到 10px 以上直接 lineTo 逐点连线折角很扎眼。中点二次贝塞尔的思路是放弃曲线必须经过每个采样点改用采样点做控制点、相邻两点中点做曲线端点曲线贴近真实轨迹又不出现尖角。lineCap 和 lineJoin 必须设 round否则一笔的端头和转折处会露方角签名放大看破绽明显。普通模式还有一个细节一段笔迹结束时要主动调一次 ctx.beginPath()否则下一笔会从上一笔的终点连过来拖出一条不该存在的尾巴。3.2 毛笔字模式速度驱动线宽并处理起收笔drawBrushStroke(points, config {}) { const baseWidth config.baseWidth || 6; const minWidth config.minWidth || 1; const speedFactor config.speedFactor || 0.01; const ctx this.ctx; for (let i 1; i points.length; i) { const prev points[i - 1]; const curr points[i]; const dist Math.hypot(curr.x - prev.x, curr.y - prev.y); const dt Math.max(curr.t - prev.t, 1); const speed (dist / dt) * 1000; // 换算成 px/s const width Math.max(minWidth, baseWidth - speed * speedFactor); ctx.lineWidth width; ctx.strokeStyle this.options.inkColor || #1a1a1a; ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(curr.x, curr.y); ctx.stroke(); } }毛笔和普通笔的生理差异是写慢时笔毫铺开、笔道宽快速甩笔时笔毫收拢、笔道窄。代码里用两点距离除以时间差得到瞬时速度再用 baseWidth - speed * speedFactor 映射成线宽用 Math.max 钳制到 minWidth 防止线宽变负。速度驱动参数的思路在 canvas 绘图引擎的笔刷实现里很常见属于通用的笔迹模拟手段理解了这里的映射关系其它笔刷参数也就通了。逐段设置 lineWidth 会导致粗细突变笔画看起来像竹节。我一般会对线宽做指数平滑w prevW * 0.7 targetW * 0.3。起笔处还要先 fill 一个与线宽同半径的实心圆让落笔自然过渡收笔时把线宽收敛到 minWidth模拟提笔离纸。handWrite.js 里每个 stroke 首尾插入的虚点作用就在这里。参数作用建议范围baseWidth低速时的基础线宽5~10minWidth高速时最小线宽防止断线0.5~2speedFactor速度衰减系数越大粗细反差越强0.008~0.015inkColor墨色印章场景可换朱红#1a1a1aspeedFactor 的量级依赖速度单位代码把 dt 从毫秒换算成秒speed 单位是 px/s系数落在 0.008~0.015。如果直接在毫秒上算系数要放大千倍这是毛笔模式调参最常翻车的地方。3.3 撤销与清空离屏 canvas 快照takeSnapshot() { this.snapshotCanvas document.createElement(canvas); this.snapshotCanvas.width this.canvas.width; this.snapshotCanvas.height this.canvas.height; this.snapshotCanvas.getContext(2d).drawImage(this.canvas, 0, 0); } undo() { if (!this.snapshotCanvas) return; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(this.snapshotCanvas, 0, 0); }画板工具的撤销通常维护全部点集重绘签名组件不适合这么做业务方经常先铺背景水印、先盖印章再签字撤销时重绘整棵渲染树成本高。更稳的是完成一笔即快照拿离屏 canvas 把当前画布整体拷贝undo 时 clearRect 清空主画布再把快照贴回来。快照要放在 mouseup、touchend 之后触发不能在 move 里频繁调用否则高频序列化会卡顿。一张 1000x400 的画布快照约占 1.2MB 像素数据撤销栈建议限制 5~10 步清空功能则直接 clearRect 并重置 strokes 和快照栈等价于把历史一笔勾销。4. 导出图片toDataURL 格式选择、白底合成与高清输出导出这步决定组件能不能接进正式业务。实际踩坑最密集的环节就是这里透明背景提交后显示成黑块、Retina 屏导出糊、空签名被当成有效数据提交。这三个问题都能在导出函数里一次性解决。4.1 导出格式PNG、JPEG 与白底合成exportImage(options {}) { const type options.type || image/png; const background options.background || #ffffff; const temp document.createElement(canvas); temp.width this.canvas.width; temp.height this.canvas.height; const ctx temp.getContext(2d); if (background) { ctx.fillStyle background; ctx.fillRect(0, 0, temp.width, temp.height); } ctx.drawImage(this.canvas, 0, 0); return temp.toDataURL(type, options.quality || 0.92); }toDataURL 的第二个参数 quality 只对 image/jpeg 生效对 image/png 完全无效这是最容易踩的盲区。导出空画布时如果不先 fill 背景透明区域在 Word、PDF 阅读器里会被渲染成黑色或灰底网格合成顺序必须先背景后签名反过来签名会被盖住。参数 type 决定 MIMEbackground 决定是否需要白底印章场景传 null 可以保留透明通道。选型原则要存档、留痕、做笔迹鉴定的选 PNG无损且保留边缘细节只当附件流转、对体积敏感的选 JPEG配合 quality 0.85~0.92 能压掉大量体积适合嵌进工单、报告这类多媒体文档。个别 WebView 不支持导出 webp初始化时可以用 canvas.toDataURL(image/webp).startsWith(data:image/webp) 探测支持才开放 WebP 选项。格式透明背景quality 参数适用场景image/png支持无效合同存档、笔迹鉴定image/jpeg不支持有效附件、消息流图4.2 高清导出放大临时画布与重采样exportScaledPNG(scale 2) { const temp document.createElement(canvas); temp.width this.canvas.width * scale; temp.height this.canvas.height * scale; const ctx temp.getContext(2d); ctx.fillStyle #ffffff; ctx.fillRect(0, 0, temp.width, temp.height); ctx.scale(scale, scale); ctx.drawImage(this.canvas, 0, 0); return temp.toDataURL(image/png); }2.3 节初始化时按 DPR 放大位图解决的是屏幕上看不清这里再乘 scale 解决存档分辨率不够。ctx.scale(scale, scale) 之后 drawImagecanvas 会对源图做双线性重采样签字笔迹边缘会被二次平滑。这种放大适合线条为主的签名肉眼几乎察觉不到柔化但画布上若有细小的水印或印章文字放大会更糊这类场景应在初始化时直接按目标输出分辨率建画布。我一般让导出函数接收 scale 参数预览用 1正式提交用 2 或 3。4.3 空签名校验扫 alpha 通道而不是数点数isEmpty() { const { width, height } this.canvas; const data this.ctx.getImageData(0, 0, width, height).data; for (let i 3; i data.length; i 4) { if (data[i] ! 0) return false; } return true; }判断用户有没有签名不能只数 points 长度。用户按一下立刻抬手points 里有一个点但画布上什么都没有清空后 points 数组也可能残留数据。最可靠的是扫 alpha 通道getImageData 返回的 data 是 RGBA 一维数组每四字节一组alpha 在组内第 4 位所以 i 从 3 开始步进 4只读 alpha 位。全零代表画布完全透明即没有有效笔迹。这段扫描在提交按钮的 click 里执行一次O(n) 代价在签名画布上约 2ms 以内不影响体验。配合导出链路先 isEmpty() 拦截、再 exportImage 出图、最后把 dataURL 转 Blob 随表单提交避免后端收到透明空图再打回来省一次交互往返。5. 进阶笔迹序列化PNG 与 JSON 双轨校验签名组件做到能画、能导出只是及格线。合同场景经常要把这一笔本身存下来而不只是存一张图——位图可以被编辑工具修改点序列却保留着原始坐标和时间。把 strokes 序列化成紧凑 JSON与 PNG 一起提交后端做一致性校验是这类组件最常见的进阶需求。5.1 紧凑序列化相对时间戳与坐标舍入serialize() { return JSON.stringify({ mode: this.options.mode, lines: this.strokes.map(stroke { const start stroke.points[0].t; return stroke.points.map(p [ Math.round(p.x * 100) / 100, Math.round(p.y * 100) / 100, p.t - start ]); }) }); }序列化有两个压缩点时间戳转成相对值避免每个点带一整段 13 位毫秒数坐标保留两位小数对 400px 宽的画布已是亚像素精度肉眼不可见差异。两个动作能让序列化结果比原始对象小 40% 左右这个体积差距在移动端弱网环境下很明显。5.2 双轨提交后端重绘比对async submit() { const png this.exportImage({ type: image/png }); const json this.serialize(); const blob this.dataURLToBlob(png); const digest await sha256(blob); // crypto.subtle 实现需 https 或 localhost await fetch(/api/signature/save, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ png, json, digest }) }); }PNG 给人看、进合同存档JSON 给机器校验。后端拿到请求后把 lines 用组件同一套绘制逻辑重放到离屏 canvas导出 PNG 与提交的图片比对先比尺寸再比 digest能识别图片被二次编辑或替换。digest 的输入应该是 Blob 字节而不是 dataURL 字符串避免 base64 膨胀带来的额外比对成本。提示dataURL 转 Blob 时atob 后的二进制串要逐字节转成 Uint8Array 再传给 Blob 构造函数直接 new Blob([atob(str)]) 会因 Latin-1 编码产生字节错误中文签名场景必踩。sha256 计算在画布放大到 2x 后dataURL 可能超过 1MB建议放进 Web Worker避免主线程哈希计算卡掉页面滚动。把这一步向前推进点序列、图片、时间三要素对齐就构成了电子签名存证的最小闭环任何一环被改动重绘比对时都会直接暴露。本文还有配套的精品资源点击获取
返回列表