做后台管理系统的时候我碰到最多的一种业务需求就是“让用户在线签个字”。以前遇到这种需求第一反应是去 npm 上找一个签名插件但试过几个之后发现要么样式和项目风格对不上要么 API 设计得不符合业务需要要么导出图片的清晰度总是差一口气。后来我干脆用 Vue 自己封装了一个独立的手写签名组件把绘制、撤销、清空、导出这些能力全部收敛在一个组件里业务方只负责传参和拿结果。这篇文章就把我整套实现思路、核心代码和踩过的坑完整写出来。这篇文章适合谁看如果你正在做 Vue 2 或 Vue 3 项目需要实现在线签字、手写批注、画板等功能或者你刚接触组件封装想看看一个“真能用”的业务组件应该怎么拆解设计那这篇文章应该能给你不少启发。我会从组件设计、Canvas 绘制原理、高清屏适配、事件兼容、导出示图到异常排查一条线讲完。1. 为什么要把手写签名封装成独立组件1.1 业务场景里的核心痛点先说场景。我当时的项目是一个移动端为主的审批系统领导需要在小程序风格的 H5 页面里审阅文件并签字确认。听起来简单但实际落地时牵扯的东西很多签名区域要适配不同手机的屏幕宽度用户在手指写字的瞬间不能被页面滚动干扰签错了要能一键清空签完之后要把签名图片传给后端存到审批记录里。如果不做组件封装这些逻辑散落在页面里会非常痛苦。A 页面要签字复制一坨 Canvas 初始化代码B 页面也要签字再复制一份后端接口返回格式变了你要去每个页面改导出逻辑样式想统一调整又是一轮全局搜索替换。把签名能力封装成独立组件后页面层只需要写一行SignaturePad v-modelsignData /其余全部交给组件内部处理。这也引出一个更重要的理念组件封装不是为了“看起来高级”而是为了把复杂度和变化点隔离在一个地方。签名组件的复杂度在于 Canvas 绘制细节、事件兼容、高清屏适配这些和具体业务无关而变化点在于业务方对线宽、颜色、背景、导出格式的不同要求这些应该通过 props 暴露出去。别人用你的组件时不需要知道 Canvas 怎么画线只需要知道这个组件接收什么参数、返回什么数据。1.2 封装方案怎么选能力模型与边界划分我在设计这个组件之前先罗列了它应该具备的能力清单手写绘制支持鼠标和手指两种输入方式绘制过程平滑自然操作控制支持清空画布、撤销上一笔避免用户一笔写错就要全部重来数据导出输出 base64 图片数据同时输出签名轨迹数据供业务方灵活处理视图定制可配置画布宽高、画笔颜色、线条宽度、背景颜色数据回显能根据已有的轨迹数据重新绘制签名用于查看历史审批记录能力确定之后就是组件边界问题。我见过有些签名组件把“生成 PNG 下载文件”也做进去了这其实是不合理的。组件应该专注在“签名绘制和签名数据输出”至于下载到本地、上传服务器、OCR 识别应该由业务层去处理。这样组件才够独立不依赖业务环境你自己测试时打开一个空白页面就能跑。技术路线上我直接选择原生 Canvas 实现没有引入第三方签名库。原因有三点第一签名场景的核心路径其实很短就是画线、取图原生 Canvas 30 行核心代码就能完成没必要为了这些引入额外依赖第二第三方库的 API 设计不一定符合你的业务模型比如你希望用 v-model 绑定签名数据库未必支持第三自己实现遇到问题你能完全掌控排查方向而不是去 GitHub issues 里大海捞针。当然如果你的需求很复杂比如需要笔锋模拟、压感曲线那还是建议直接用成熟的绘图引擎但明确是“签名”这个场景自研完全够用。2. 组件的核心设计拆解数据、绘制与导出2.1 画布坐标系与高清屏适配手写签名组件的底层就是一块 Canvas 画布。前端 Canvas 有一个老生常谈但特别容易出错的点就是高清屏适配。手机屏幕的 devicePixelRatio简称 dpr通常是 2 甚至 3也就是一个 CSS 像素背后对应 2x2 或 3x3 个物理像素。如果你只按 CSS 尺寸设置 canvas.width绘制出来的内容在 Retina 屏上会被浏览器拉伸放大视觉上就是模糊的。解决方法是把 Canvas 的物理尺寸设置成“CSS 尺寸乘以 dpr”同时通过 CSS 把 Canvas 显示尺寸固定为原来的大小再用ctx.scale(dpr, dpr)把所有绘制坐标统一换算到 CSS 像素坐标系。看起来有点绕可以简单类比你要在一块“实际格子更多”的画板上作画但对外展示时还是原来那块大小这样每个格子都足够细腻。具体代码是这样的const canvas ref(null) const ctx ref(null) function initCanvas() { const dpr window.devicePixelRatio || 1 const rect canvas.value.getBoundingClientRect() canvas.value.width rect.width * dpr canvas.value.height rect.height * dpr canvas.value.style.width rect.width px canvas.value.style.height rect.height px ctx.value canvas.value.getContext(2d) ctx.value.scale(dpr, dpr) }这里有个很容易踩的坑你不能在 CSS 里固定width: 100%然后又用offsetWidth去设置 canvas 的物理宽高。因为 getBoundingClientRect 拿到的已经包含 padding 和 border 的边界框而 canvas 元素本身如果设置了 border绘制区域会被压缩。所以我的做法是外层用一个 div 包住 canvascanvas 的宽高由 props 传入不依赖父容器布局这样可以最大程度保持组件行为可预测。2.2 轨迹数据结构从单点到笔画手写签名的核心交互逻辑很简单手指或者鼠标按下时开始记录移动时绘图松开时结束。但如果你把每个移动点直接画成一个圆点会发现线条一节一节断开很难看。连续、平滑的线条需要用lineTo连接相邻两个点同时设置线条的lineCap和lineJoin为round这样转角处才是圆滑的。我在设计数据结构时把一次完整的书写分成三层单点Point包含x、y坐标可能还有time时间戳笔画Stroke一次按下到松开之间所有点的集合签名Signature整块画布上所有笔画的数组这个结构非常关键。它让“撤销”变得极其简单——strokes.pop()之后整体重绘一遍就行它让“回显”变得可行——后端存的是笔画数组前端拿到后重新画出它还支持“动画重播”之类的扩展玩法因为每个点都有时间戳。绘制一个笔画的思路是function drawStroke(stroke) { const context ctx.value context.beginPath() stroke.forEach((point, index) { if (index 0) { context.moveTo(point.x, point.y) } else { context.lineTo(point.x, point.y) } }) context.stroke() }注意每次绘制新笔画前都要beginPath否则前一笔的样式会影响后一笔。我在组件里维护了一个reDrawAll函数清空画布后遍历所有笔画重新绘制撤销和回显都复用它。2.3 导出与回显toDataURL 再绘制的完整链路签名数据给业务方有两种形态。第一种是图片数据直接调用canvas.toDataURL(image/png)就能拿到 base64 字符串可以丢给上传接口或者回显到img标签。第二种是轨迹数据就是前面说的笔画数组适合需要二次编辑或者做笔迹识别的场景。这里我踩过一个有意思的坑。如果 Canvas 背景是透明的导出 PNG 后签名图片的背景也是透明的——这在大多数审批场景下没问题但你如果直接把图片塞到 PDF 或者打印模板里某些环境会显示黑色背景或者出现锯齿边缘因为透明 PNG 在不同渲染引擎下的表现不一样。解决方案是导出之前先填充一个白色背景。我不能直接在 Canvas 上画白色矩形因为那会把已经画好的签名盖住。正确做法是用一个临时 Canvas把白色背景画上去再把原 Canvas 的内容drawImage上去最后从临时 Canvas 导出function exportImage() { const sourceCanvas canvas.value const exportCanvas document.createElement(canvas) exportCanvas.width sourceCanvas.width exportCanvas.height sourceCanvas.height const exportCtx exportCanvas.getContext(2d) exportCtx.fillStyle #ffffff exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height) exportCtx.drawImage(sourceCanvas, 0, 0) return exportCanvas.toDataURL(image/png) }回显则更简单。业务方拿到的是 base64 图片直接img :srcdataUrl /展示即可。如果拿到的是笔画数组就把组件设置成一个只读模式调用reDrawAll把轨迹画出来。这两种模式我都在组件里用readonly这个 prop 做了区分。3. 从零实现一个可用的签名组件3.1 定义组件对外接口组件设计的第一步是定义 props 和事件。我沉淀下来的接口是这样的如果你在业务中使用大概率不需要改动太多props: { modelValue: { type: Array, default: () [] }, // 签名轨迹数据 width: { type: Number, default: 300 }, // 画布 CSS 宽度 height: { type: Number, default: 160 }, // 画布 CSS 高度 lineWidth: { type: Number, default: 2 }, // 线条宽度 lineColor: { type: String, default: #333333 }, // 线条颜色 bgColor: { type: String, default: #ffffff }, // 背景颜色透明传 transparent readonly: { type: Boolean, default: false } // 只读模式用于回显 }事件方面我使用update:modelValue配合defineModel或emit(update:modelValue)手动实现 v-model 语义。每次一笔画结束就把最新的笔画数组通过update:modelValue抛给父组件。这样父组件可以随时拿到完整轨迹数据不需要等用户点击“确定”。同时组件内部维护一个isEmpty状态表示画布是否为空。这个状态对业务表单校验特别有用比如“未签名不能提交”的校验规则可以直接绑定到这个状态上。3.2 初始化画布与事件绑定组件挂在后立即调用initCanvas。事件绑定的选择上我没有用 mouse 事件和 touch 事件分开处理的两套逻辑而是统一使用 Pointer Events指针事件。Pointer Events 是 W3C 标准同时兼容鼠标、触摸和触控笔代码更简洁canvas.value.addEventListener(pointerdown, handlePointerDown) canvas.value.addEventListener(pointermove, handlePointerMove) canvas.value.addEventListener(pointerup, handlePointerUp) canvas.value.addEventListener(pointerleave, handlePointerUp)使用 Pointer Events 的好处是少写一大坨if (e.touches)分支坏处是老旧的 iOS 版本iOS 12 以下不支持。如果你需要兼容超老设备就得用 touch 事件回退。我在组件里加了特性检测如果window.PointerEvent存在就用 Pointer Events否则回退到 mouse touch 组合。坐标计算的统一处理也很关键。不要直接使用event.offsetX和event.offsetY因为在某些浏览器和 Canvas 缩放场景下这两个值不靠谱。我会统一通过getBoundingClientRect换算function getPointerPosition(event) { const rect canvas.value.getBoundingClientRect() return { x: event.clientX - rect.left, y: event.clientY - rect.top } }注意这里的坐标单位是 CSS 像素刚好对应前面ctx.scale(dpr, dpr)之后的坐标系不需要再除以 dpr。3.3 绘制核心流程按下、移动、松开整个绘制流程可以拆成三个函数。handlePointerDown记录笔画起点并新建一个空笔画数组handlePointerMove把移动过程中的点持续加进当前笔画并实时绘制handlePointerUp把当前笔画推到总笔画数组里触发update:modelValue。function handlePointerDown(event) { if (props.readonly) return canvas.value.setPointerCapture(event.pointerId) isDrawing.value true currentStroke.value [getPointerPosition(event)] } function handlePointerMove(event) { if (!isDrawing.value) return const point getPointerPosition(event) currentStroke.value.push(point) drawPoints(currentStroke.value) } function handlePointerUp() { if (!isDrawing.value) return isDrawing.value false if (currentStroke.value.length 0) { strokes.value.push(currentStroke.value) emit(update:modelValue, strokes.value.map(s [...s])) } currentStroke.value [] }drawPoints的细节是“增量绘制”每次拿到新点不再重画整块画布而是接着已有的路径继续画。这样性能更好尤其当笔画点很多时不会出现卡顿。这点和重绘整块画布的策略不同需要区分开实时绘制时是增量画撤销和回显时才整块重绘。移动端还有一个关键问题是滚动穿透。用户在签名区滑动手指时页面背景可能会跟着滚动体验非常糟糕。Pointer Events 的解法是在 canvas 上监听touchstart和touchmove调用preventDefault如果你用 pointer 事件则需要在相应事件中处理。我实测下来更稳妥的方式是在touchmove事件上preventDefault同时把 canvas 的 CSS 设置为touch-action: none双保险。3.4 public 方法清空、撤销、获取签名一个合格组件不能只靠事件通信还得给父组件暴露命令式的 API。比如点击“清空”按钮时父组件需要调用组件内部的方法。我在 Vue 3 里用defineExpose暴露了四个方法clear()清空画布、清空笔画数组、重置状态undo()移除最后一笔并重绘getDataUrl()返回签名图片的 base64 数据getStrokes()返回当前轨迹数据defineExpose({ clear, undo, getDataUrl, getStrokes })父组件调用方式很简单SignaturePad refsignatureRef /signatureRef.value.clear() signatureRef.value.undo() const dataUrl signatureRef.value.getDataUrl()这样一个签名组件的核心闭环就完成了。接下来我把自己在真实项目中遇到的几个高频问题原原本本列出来这些坑不是文档里会写的但遇上了特别浪费时间。4. 常见问题与排查技巧实录4.1 画布模糊不清这个问题的表现是签名线的边缘发虚锯齿感强烈。绝大多数情况都是因为没有做 dpr 适配或者适配代码写错位置。比如有些人只在 mounted 里设置一次 canvas 尺寸但如果页面有转场动画或者 canvas 在组件 mount 时还没完成布局getBoundingClientRect拿到的宽高可能是 0 或者不对导致画布尺寸异常。我的排查建议是在浏览器控制台直接打印 canvas.width 和 canvas.height对比 CSS 宽高乘以 dpr 的计算值。如果不一致说明适配逻辑没执行。另外要注意如果页面尺寸发生变化比如横竖屏切换、浏览器窗口 resize需要重新计算画布物理尺寸并重绘现有轨迹。我在组件里加了一个ResizeObserver来监听画布外层容器的尺寸变化实测在手机上切横屏时效果明显。4.2 移动端滚动穿透滚动穿透的典型场景是用户在签名区域上下滑动页面背景跟着上下滚动签名轨迹就乱七八糟。前面提到设置touch-action: none是最直接的 CSS 兜底但还要注意一点只有真正需要绘制的元素才应该加上这个属性如果你给整个 body 都加了那页面将无法滚动属于因噎废食。另外使用 Pointer Events 时在pointermove阶段调用event.preventDefault()往往无效因为浏览器可能在pointerdown或者手势识别阶段就已经决定是否滚动。比较可靠的方案仍然是在touchmove事件上拦截这也是我最后保留 touch 事件监听的原因——不是为了兼容老设备而是为了阻止默认滚动。4.3 导出图片出现阴影或边缘模糊用 Canvas 导出图片时如果签名内容比较大导出图片在部分浏览器里会出现边缘发虚、甚至有细微的白边或阴影。这和drawImage的区域计算有关。临时 Canvas 的尺寸如果不加 dpr 直接使用 CSS 尺寸导出图自然就糊了。我的做法是临时 Canvas 的宽高直接沿用原 Canvas 的物理宽高已经乘过 dpr然后在绘制背景和内容时都不再做缩放这样导出的图片和屏幕显示一样清晰。还有一点如果你用了lineCap: round线条两端会有半圆形的延长视觉上笔画会超出实际坐标范围如果 Canvas 的 padding 不够笔画可能被画布边缘截断。我建议在组件初始化时给画布预留 8~10px 的 padding导出时不用裁掉直接保留观感更自然。4.4 组件尺寸变化后签名被裁切这个问题出现得很隐蔽。用户先用手机竖屏签了一个名然后旋转屏幕或者从详情页返回时组件重新初始化画布变宽了或者变高了签名还在但位置错乱甚至被裁切。根因是画布尺寸改变后Canvas 的物理像素会被清空而重绘时如果还按照原来的坐标和缩放比例内容自然出问题。我的解决思路是ResizeObserver 触发时把当前画布内容截取成一个图片缓存设置完新尺寸、重置坐标系后再把图片重新画到新画布上。这样用户的签名不会丢。但需要注意如果缩得太小签名内容会被压缩显示这属于正常表现如果缩放比例过大建议直接清空画布提示用户重新签名避免生成一张模糊或变形的“伪签名”。下面把几个高频问题整理成一个速查表方便你在开发时快速定位问题现象根因处理方式签名线模糊未做 dpr 适配canvas.width rect.width * dprctx.scale(dpr, dpr)画布尺寸为 0mounted 时布局未完成使用 nextTick 或用 ResizeObserver 延迟初始化移动端页面跟着滚动Canvas 默认可触摸滚动设置 touch-action: none拦截 touchmove导出图片发白或透明Canvas 背景无填充临时 Canvas 先填白底再 drawImage 原画布笔画边缘被切断线条 round cap 超出边界绘制区域预留 padding撤销后其他笔画变淡context 状态污染每次重绘前重新设置 strokeStyle 和 lineWidth5. 签名识别率低的原因与组件侧改进思路5.1 识别率低不全是组件的问题热搜词里有一项“手写签名识别率低”我单独说一下自己的理解。手写签名的识别率核心取决于识别算法模型本身也就是后端或者离线推理引擎对笔迹的特征提取能力而前端组件主要保证“源头数据质量”。很多人遇到识别率低第一反应是前端画得不够好其实不完全是。举个具体例子用户签名时写得很快笔画之间断开严重或者用户把名字写得特别潦草人眼都难辨认再或者签名区域太小用户只能用力挤着写笔画都叠在一起——这些情况放到再强的识别引擎面前也很难处理。前端能做的是尽量提供一份干净、完整、清晰、不变形的签名图给识别端。5.2 组件层面提升识别率的三个细节第一个细节是导出清晰度。前面提到的 dpr 适配直接决定识别引擎拿到的是 100x50 的模糊小图还是 300x150 的高清图。这个影响是决定性的所以高清导出不是“锦上添花”而是识别场景的硬性要求。第二个细节是笔画信息完整度。我输出的轨迹数据里包含了每个点的坐标和时间戳时间戳其实能体现书写速度。有些识别引擎会对笔画顺序和速度做时序分析如果前端只给一张静态图这类时序特征就丢了。所以如果你的后端接入了轨迹识别建议优先把笔画数组传过去而不是只传 PNG。第三个细节是签名区域的合理尺寸。我在组件里把默认宽度设成 300、高度设成 160实际使用中这个尺寸在手机上需要根据屏幕宽度动态调整不然用户写起来会很难受。一个简单的公式是宽度取父容器宽度的 90%高度取宽度的 50%再设置最小高度 120px。这样保证了书写空间充足笔画不会被压缩变形识别率自然会有提升。从业务飞轮的角度来看在线签名这类能力未来大概率还会延伸出手写批注、电子合同签署、签名防伪核验等方向。而这次封装的“签名组件”只是起点值得花时间把它打磨好。根据我个人的经验组件设计的前两个小时投入能在后续所有业务页面里省下几十倍的时间。如果你也要在项目里做类似的签名功能照着这套思路实现一个足够用了重点是把对外接口和数据结构定义好这样不管后续是换肤、换识别算法还是接电子签章平台改动都能控制在一个组件内部。
阅读完成 · 觉得有帮助?