上个月给一个内部审批系统加签名功能,业务方提的需求其实很简单:用户在小程序/H5页面里用手指或鼠标写名字,写完后把签名图片存到后端,后面打印单据时把签名贴到指定位置。我第一反应肯定是自己用canvas写一个,但转念一想,又不是第一次做这种需求了,之前踩过的坑——触摸事件兼容、笔画平滑、导出透明底图、高清屏模糊——每个都能耗掉一下午。项目排期又不允许我造轮子,于是去社区找了一圈,最后锁定了vue3-signature这个组件。用下来整体感受是:上手极快、API设计顺手,但还是有几个必须二次开发的细节。这篇文章就基于我这次实战,把vue3-signature在Vue3项目里的完整用法、前后端对接方案和踩坑记录整理出来,给后面要做电子签名的朋友一个可以参考的落地方案。
1. 为什么选vue3-signature而不是自己用canvas硬写
1.1 原生canvas方案的痛点
我先把话放这儿:如果只是画一条能动的线,原生canvas确实几十行就能搞定,但真实业务里的签名功能远不止“画线”这么简单。第一关是笔画平滑度。用手写板或手指在触屏上书写时,获取到的坐标点是离散的,每两个mousemove或touchmove事件之间可能隔着好几个像素,如果直接用lineTo去连,笔画边缘全是锯齿和折角,跟真实手写的圆润感差很远。第二关是触摸事件兼容,PC上用鼠标事件没问题,到了移动端必须处理touchstart/touchmove/touchend,还得注意在touchmove里调用preventDefault防止页面跟着滚动,但passive: true的默认行为又经常让这个调用失效。第三关是压感模拟——虽然网页拿不到硬件的真实压感,但至少可以通过笔画速度或者移动距离动态调整线条宽度,让写出来的字看起来不那么死板。
这些如果都自己实现,没有一个下午是打磨不完的。而且这些问题是有成熟解决方案的,前端圈里signature_pad早就把曲线平滑、速度和宽度映射这些算法写得很成熟了。vue3-signature本质上就是围绕这些底层能力做了一层Vue3组件封装,把canvas操作、事件绑定、参数配置、导出功能全部收口成声明式的props和methods。
1.2 主流签名组件横向对比
选型的时候我简单对比了社区里几个方案,各有特点:
| 方案 | 框架依赖 | 封装度 | 事件/方法丰富度 | 维护状态 |
|---|---|---|---|---|
| vue3-signature | Vue3 | 高,开箱即用 | 支持start/end/change事件,save/clear/undo等内置方法 | 社区活跃,文档较全 |
| signature_pad | 纯JS | 低,只提供核心绘制逻辑 | 事件少,需要自己绑DOM | 历史悠久,但没Vue封装 |
| vue-signature-pad | Vue2为主 | 中 | 基本签名功能,Vue3版本少 | 更新缓慢 |
| 自己封装canvas | 无 | 无 | 全部自己写 | 自己维护 |
vue3-signature最打动我的点是它的API设计非常直观,save()方法支持传图片格式和质量参数,导出、截图、校验这些高频需求都有内置实现。它的底层虽然借鉴了signature_pad的思路,但在Vue3的响应式数据流里用起来会更顺手,比如通过ref拿到组件实例后,可以直接在业务逻辑里调用方法,不用操心底层canvas的上下文管理。
1.3 vue3-signature的组件设计思路
从整体思路上说,这个组件做的事情可以拆成三层:最底层是canvas画布,负责像素级绘制;中间层是事件系统和曲线算法,把鼠标/触摸轨迹变成平滑的笔画数据;最上层就是Vue组件,把这些能力包装成props、events和methods。这种分层带来的好处是,你既可以用最基础的配置五分钟跑通一个最小可用demo,也可以把每个参数拆出来精细控制。比如我这次业务需要导出“白底黑字”的签名图,但同时希望页面展示时背景是透明的,那就得同时控制background属性和save()的参数——这种细节不把组件结构看透,光靠试是试不出来的。
2. 把vue3-signature接进Vue3项目:从安装到第一个可写字的画布
2.1 安装与组件引入
先看安装,直接用npm或yarn装:
npm install vue3-signature --save # 或者 yarn add vue3-signature引入方式有两种,建议按你项目的模块规范来:
// 方式一:具名导入(我项目里用的这个) import { VueSignature } from 'vue3-signature' // 方式二:默认导入(取决于包版本和构建工具) import VueSignature from 'vue3-signature'我在Vite + Vue3.4的项目里用方式一是没问题的,如果你的编辑器没提示,建议翻一下node_modules/vue3-signature/package.json里的exports字段,看包的入口是ESM还是CommonJS,避免导入后undefined。这种小细节很容易被忽略,但真出了问题排查起来最费时间。
2.2 最小可用示例
先给你一个可以直接跑起来的最简版:
<template> <div> <VueSignature ref="signatureRef" width="600" height="300" :line-color="'#333'" :line-width="3" background="rgba(255,255,255,0)" @end="handleSignEnd" /> <button @click="saveSignature">保存签名</button> <button @click="clearSignature">清空</button> </div> </template> <script setup> import { ref } from 'vue' import { VueSignature } from 'vue3-signature' const signatureRef = ref(null) function handleSignEnd() { // 结束一次笔画时触发 console.log('一笔结束') } function saveSignature() { const dataURL = signatureRef.value.save({ type: 'image/png', quality: 1 }) // dataURL 是 base64 字符串,可以直接预览、上传或转File console.log(dataURL) } function clearSignature() { signatureRef.value.clear() } </script>这段代码已经覆盖了签名功能的主链路:在画布上写字 -> 监听笔画结束 -> 导出图片 -> 清空重写。实际业务里的“电子签名”流程,核心也就是这几步,后面所有的高级玩法都是在这个主链路上做扩展。
2.3 常用API速查:属性、事件、方法
我把实际用到的API整理成一张表,方便你快速检索:
属性(props)
| 属性名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| width / height | Number | 800 / 300 | 画布尺寸,单位px |
| lineColor | String | '#000' | 笔画颜色 |
| lineWidth | Number | 2 | 基础笔宽 |
| minWidth / maxWidth | Number | 0.5 / 2.5 | 动态笔宽的上下限,配合笔速模拟压感 |
| minDistance | Number | 5 | 两次采集点之间的最小距离,防抖 |
| dotSize | Number | 1 | 单击/点按时的圆点大小 |
| background | String | 'rgba(0,0,0,0)' | 画布背景色,导出时也会带上 |
| 其他 | - | - | 还有滚动、禁笔模式等扩展项,具体见文档 |
事件(events)
@start:开始一笔(鼠标按下或手指落下)时触发。@end:结束一笔(鼠标抬起或手指离开)时触发。@change:画布内容发生变化时触发,回调参数里能拿到当前canvas上下文,可以用来做“是否有签名”的校验。
方法(methods,通过组件ref调用)
save({ type, quality }):导出画布内容为dataURL,type支持image/png、image/jpeg,quality是压缩质量,0~1之间。clear():清空画布。isEmpty():判断画布是否为空。undo():撤销上一步,但注意它是整笔撤销,不是逐步回退。resizeCanvas():手动触发画布尺寸重算,适配容器变化。fromDataURL()/toDataURL():从图片数据恢复画布 / 导出画布数据(用canvas原生能力绕开组件封装时可以用)。
看到这里你可能发现了,@change事件可以用来实时监测用户是否在写。我在业务里就靠它控制“保存签名”按钮的可用状态:签名区为空时按钮置灰,只要有笔画就即时激活,体验很顺。
3. 让签名体验更像"真的在写字":事件、清空、撤销与双端适配
3.1 用事件驱动业务状态:判断用户是否真的签了名
上面提到@change可以做空态校验,这块我展开细说一下。实际场景里,用户可能在画布上随手点了一下就点保存,生成一张只有一个黑点的“签名图”,这种图发到后端、打到合同上非常难看。所以我在change回调里拿到canvas后,第一步就是检查画布上有没有实际内容:
function handleChange(ctx) { if (!ctx) return const isBlank = isCanvasBlank(ctx) hasSignature.value = !isBlank } function isCanvasBlank(canvas) { const w = canvas.width const h = canvas.height // 读取画布所有像素 const pixelData = canvas.getContext('2d').getImageData(0, 0, w, h).data for (let i = 3; i < pixelData.length; i += 4) { // 只要存在一个alpha值大于阈值的像素,就认为有笔画 if (pixelData[i] > 0) { return false } } return true }这里有个细节:ctx参数在组件的change事件里直接给你的是canvas上下文,但名字容易误导,我一开始以为是Canvas实例,结果调getContext报错,后来才发现它本身就是CanvasRenderingContext2D。判断空白时还要注意,getImageData会受跨域污染影响,如果画布背景用了外部图片资源,这一步可能抛安全问题,所以背景色尽量用纯色或透明,不要塞图片。
3.2 清空与撤销:业务上必加的补救手段
说实话,签名这种操作,用户写歪了、写错了、突然不想写了都是常态,所以清空(clear)几乎是所有签名场景的刚需。如果你只满足于“清空重来”,clear()一行就够了。
但一旦产品经理提出“能不能撤销上一笔”——这个需求几乎一定会来,因为用户往往只写错一个字而不是整张重写。组件虽然内置了undo()方法,但用起来有局限:它只是从内部维护的笔画栈里弹出一张快照,如果你的业务涉及跨设备同步或者多端操作,这个内部栈是拿不到也不可靠的。当时我写了一个更可控的撤销方案:自己存数据快照栈,调用toDataURL()拿到当前画布数据,push进数组,撤销时弹栈、用fromDataURL()恢复。
const snapshotStack = ref([]) function pushSnapshot() { const dataURL = signatureRef.value.toDataURL() snapshotStack.value.push(dataURL) if (snapshotStack.value.length > 20) { snapshotStack.value.shift() // 限制栈深度,防止内存膨胀 } } function handleUndo() { if (!snapshotStack.value.length) { signatureRef.value.clear() return } const last = snapshotStack.value.pop() if (last) { signatureRef.value.fromDataURL(last) } }注意事项有两个:一是fromDataURL在组件里是异步执行的,调用后立刻操作画布可能拿不到完整结果,需要等待渲染完成;二是快照存的base64字符串很占内存,一张全尺寸画布的dataURL动辄几百KB,栈太深会卡顿,所以限制栈深很有必要。我这里限制20步,实际使用里大多数用户的三四次撤销就已经很多了。
3.3 移动端适配:触摸事件和滚动冲突
签名的主要场景在移动端,这也是我强烈建议直接用组件而不是自己写canvas的原因之一。vue3-signature已经绑定好touch事件,并在画布内部正确处理了被动滚动冲突。但我还是碰到一个业务场景:签名区嵌在一个可以上下滚动的弹窗里,用户在签名区书写时,手一斜就会带动整个页面滚动,体验非常割裂。
解决方法是在签名区容器上加touch-action: none,让浏览器不处理该区域的默认触摸行为,把事件的掌控权完全交给组件:
.signature-wrapper { touch-action: none; -webkit-user-select: none; user-select: none; -webkit-tap-highlight-color: transparent; }这里user-select: none也很有必要,不然在部分手机上写字时会弹出文本选择框,或者长按出现系统菜单,那个体验太尴尬了。顺带说一句,如果你需要在小程序WebView里用这个组件,宽度适配要单独处理,因为WebView的布局视口和CSS像素比例跟H5不完全一致。
3.4 响应式尺寸:画布不能把容器撑爆
组件默认按width和height属性渲染画布,这两个值是canvas的实际像素尺寸,不是CSS样式。如果你的布局是固定宽度还好,但像我的业务里签名区要在手机端全宽展示、在PC端居中显示一个合理宽度,问题就来了:容器宽度变化时,画布像素宽度不变,浏览器会把canvas缩放绘制,导致导出图片尺寸和显示尺寸不一致。
我用的方案是监听容器宽度,动态绑定width属性,并配合resizeCanvas():
import { useResizeObserver } from '@vueuse/core' const wrapperRef = ref(null) const canvasWidth = ref(600) useResizeObserver(wrapperRef, (entries) => { const width = Math.floor(entries[0].contentRect.width) if (width > 0) { canvasWidth.value = width nextTick(() => { signatureRef.value?.resizeCanvas() }) } })模板里把width绑定成canvasWidth。这样写的好处是:无论是移动端旋转屏幕还是PC端拖拽窗口,签名区都能跟着容器走,导出图的尺寸也不会因为CSS缩放而变模糊。有一点要提醒:resizeCanvas()在改变尺寸的同时可能会清空原画布内容,如果用户已经写了签名、触发窗口尺寸变化,最好先把旧内容快照存下来再重绘。这个需求比较边缘,但遇到了就是大坑。
4. 签名结果导出与前后端对接全流程
4.1 导出格式怎么选:PNG vs JPEG
组件导出时type参数可选image/png或image/jpeg,这块选择直接影响后面的图片处理。我强烈建议默认用PNG,原因很直接:PNG支持透明通道,JPEG不支持。你做签名图的时候,就算把组件背景配成白色,导出的JPEG边缘也可能出现锯齿感或黑色杂边。而PNG可以把纯黑笔迹和透明背景完美分离,前端可以直接把它当贴纸一样叠到合同、单据的任何位置。
JPEG并非一无是处,当你的下游系统只接受JPG格式、且签名图最终一定会贴在白底文档上时,JPEG能大幅压缩体积,一张签名图能压到十几KB。我的做法是:前端用PNG保存原始签名,后端或文件服务再按需转格式、压缩。原始数据留高保真,业务分发时再瘦身,这个策略比较稳妥。
4.2 透明背景处理:一个容易翻车的细节
默认情况下,组件画布背景是透明的。页面展示时,透明背景叠加在白色容器上视觉效果就是白底,这个没毛病。但当你直接用save()导出时,会发现生成的PNG在某些图片查看器里显示成黑底或者棋盘格——这个不是组件BUG,而是透明像素的展示问题,放到合同上的话底下如果有底色,签名区会透过去,出现脏乱效果。
我的处理方案分两步:第一步,组件属性里先把background设置成不透明的白色,保证用户预览时看到的就是最终效果:
<VueSignature background="rgb(255,255,255)" />第二步,如果业务需要透明底的签名图(比如未来要叠加到带背景色的PDF页面上),导出时不设背景,在业务代码里只保留透明度信息。这里有个权衡:我建议大部分合同类场景直接白底,因为合同纸就是白的,白底最干净;如果你们有彩色封面、深色底图之类需求,再考虑透明导出。判断依据很简单:这份签名最终压到什么颜色的背景下,就导出什么底——压白底就白底,压深色背景就得透明底,千万别无脑统一。
4.3 dataURL、Blob还是File:后端接口怎么设计
save()返回的是dataURL字符串,直接传给后端接口当然可以,但大部分后端框架(尤其是Java/C#)更习惯接收文件流。dataURL在大尺寸时会变得特别长——一张600x300的签名图,base64后大概有几十万字符,走JSON传给后端会有明显的序列化和传输开销。
我实际用的是把dataURL转成Blob再转File,再走multipart/form-data上传:
function dataURLtoFile(dataURL, filename) { const arr = dataURL.split(',') const mime = arr[0].match(/:(.*?);/)[1] const bstr = atob(arr[1]) let n = bstr.length const u8arr = new Uint8Array(n) while (n) { n -= 1 u8arr[n] = bstr.charCodeAt(n) } return new File([u8arr], filename, { type: mime }) } // 导出时 const dataURL = signatureRef.value.save({ type: 'image/png', quality: 1 }) const file = dataURLtoFile(dataURL, `signature_${Date.now()}.png`) const formData = new FormData() formData.append('file', file) formData.append('signId', currentSignId.value) // 上传 await axios.post('/api/signature/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } })后端接口这样定义就够用了(用Node伪代码示意):
// POST /api/signature/upload import multer from 'multer' router.post('/upload', multer().single('file'), (req, res) => { const signId = req.body.signId const file = req.file // 存储到对象存储/本地磁盘,数据库记录 signId -> fileUrl res.json({ code: 0, url: `https://cdn.example.com/${file.originalname}` }) })上传成功后,前端拿到图片URL,就可以回显签名、套打、归档。这套链路不管后端是Java、Go、Python还是Node,都大同小异,核心就是“前端传文件流+业务ID,后端存文件+落库返回URL”。
4.4 把签名图片合成到合同/单据上
很多项目走到“上传签名成功”就结束了,但电子签名的核心价值在于它最终要出现在业务文档上。我们这边的做法是,后端在生成PDF单据时,会预留一个签名占位坐标,拿到前端上传的签名图片URL后,在服务端用PDF库把图片贴到指定坐标。这个能力不需要前端做太多事,前端只要确保两点:一是上传的图片尺寸与实际签名区域比例匹配,别传一个特别小的模糊图;二是图片背景色要跟单据底色一致,上面已经讲过了。
如果说你们是纯前端生成PDF(比如用jsPDF或pdf-lib),那更简单,直接把签名图片dataURL贴到对应坐标就行,数据都不用经过后端。这里有个很实用的心得:签名图在UI上要趁早裁剪,只保留笔迹的包围盒,不要整张画布硬贴。不然明明只写了两个字,画布却占了一大片空白,贴到合同上显得特别业余。我的做法是导出一张完整画布图之后,在前端做一次裁剪:
function cropSignatureCanvas(sourceCanvas) { const ctx = sourceCanvas.getContext('2d') const imageData = ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height) const data = imageData.data let minX = sourceCanvas.width, minY = sourceCanvas.height let maxX = 0, maxY = 0 for (let y = 0; y < sourceCanvas.height; y++) { for (let x = 0; x < sourceCanvas.width; x++) { const alpha = data[(y * sourceCanvas.width + x) * 4 + 3] if (alpha > 0) { minX = Math.min(minX, x) minY = Math.min(minY, y) maxX = Math.max(maxX, x) maxY = Math.max(maxY, y) } } } if (maxX <= minX || maxY <= minY) return null // 空画布 const padding = 10 const cropWidth = maxX - minX + padding * 2 const cropHeight = maxY - minY + padding * 2 const cropCanvas = document.createElement('canvas') cropCanvas.width = cropWidth cropCanvas.height = cropHeight cropCanvas.getContext('2d').drawImage( sourceCanvas, minX - padding, minY - padding, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight ) return cropCanvas.toDataURL('image/png') }这个裁剪函数会把透明边界去掉,只保留笔迹范围,再加上10px的呼吸感。实测下来,合同套打的体验提升非常明显。
5. 我实际踩过的坑和对应的解决方式
5.1 导出图出现白边或黑底,背景色没传对
这个坑我印象最深。项目第一个版本里组件只配了:line-color,没有管background,页面展示时签名区白得干净,导出后却在某些看图软件里变成黑底——后来发现是透明背景在不同解码器里的默认底色不一致。解决方式就是显式配置背景,并且答应自己以后每做一个签名功能都先问清楚“最终压什么底色”。如果你需要白底图,就把background="white"或者说background="rgb(255,255,255)"写死,不要依赖页面容器顺带遮出来的“视觉白”。反过来,如果要透明底,记得确认你的图片服务、PDF组件、下游系统的解码器都支持alpha通道,不然黑底问题会换张脸回来。
5.2 高清屏下笔画发虚,导出图模糊
这个问题属于“不对比没感觉,一对比吓一跳”。同一张签名,在2倍屏的Mac上直接save()导出的图确实比1倍屏的清晰度低。原因是组件的画布像素默认是CSS像素尺寸,在高DPI屏幕上画笔的物理像素只有CSS像素的一半,所以看起来发虚。解决思路是让canvas的实际像素尺寸放大为屏幕像素比(devicePixelRatio)的倍数,这跟普通canvas应用的适配思路完全一样。可以用组件对外暴露的原生canvas方法做手动配置,或者干脆在初始化后调用resizeCanvas()之前把画布的物理宽高乘以window.devicePixelRatio。我的建议是:如果签名功能要经常导出到合同上做高清打印,你就在初始化前先设置canvas.width = cssWidth * dpr、canvas.height = cssHeight * dpr,同时让CSS尺寸保持原始宽高,这样导出图会清晰得多。
5.3 撤销栈无限增长,内存被撑爆
前面提过,完整的撤销功能如果自己实现,最怕的就是图片快照把内存吃掉。一次正常的toDataURL()可能产生几百KB字符串,如果用户边写边自动压栈,连续操作几十次,卡顿是肉眼可见的。我的经验是两个措施双管齐下:一是限制栈深,最多留15到20步,超出就丢弃最老的;二是抽象成“仅在笔画结束时压栈”,不要把每次change都压栈。没有业务会需要用户撤销一百次,20步足够回到最初的空白状态。
5.4 滚动手势和签名书写的冲突
这个也是高频问题:用户写一笔,页面跟着滚,最后签出来的字歪七扭八。前面已经写了针对容器加touch-action: none,这里再补充一点:touch-action: none的作用范围要精确到签名区本身,不要一整个页面上加,否则整个页面都不能滚动了,那就因小失大。如果你们的业务形态是签名区出现在弹窗里,弹窗内部有滚动区,记得只给签名区包一层div,再在那个div上加样式,别图省事给弹窗最外层加。还有一点,在部分安卓WebView里,即使加了touch-action,上下滑动还是会偶尔穿透,此时可以在@start事件里记录一下初始位置,在@end时判断“位移是否过大”,过大则视为误触清空重写,这个方案属于防御性编程,看你业务对误签的容忍度来定。
5.5 配置了background但导出仍然透明?可能你把属性名记错了
最后说一个很隐蔽的坑:vue3-signature的样式类属性和canvas自身的fillStyle背景不是同一套东西。如果你只是给组件的外层div加了白色背景,而组件本身没有设background,那导出的时候div背景跟canvas像素毫无关系,导出图照样透明。这也是为什么反复强调要显式配置组件的background属性,而不是去改容器样式。有一次我就是顺手在wrapper上加了个白色背景,以为完事了,结果导出到后端打印时,签名图叠在合同上把一层白底之外的区域全部透出一个深浅杂色,排查到凌晨两点才定位到是属性没配对。
6. 实战中的一些个人体会和处理细节
把上面所有能力串起来,我这次做出来的签名功能大概是这个形态:签名区嵌入弹窗,支持用户直接手写;写的过程中随时可以清空或撤销最后几笔;写完后点击“保存”,前端裁剪出笔迹包围盒并导出PNG,同时根据业务开关决定是白底图还是透明图;上传到后端,后端存了文件地址和签名ID;后续生成合同时,后端直接调文件服务把该签名贴到指定坐标,一条链路走完。
说几个可能只有做完整条链路才会关注到的细节:
第一,组件实例通过ref拿方法之前,要确保DOM已经渲染完。如果你在onMounted里立即调用resizeCanvas或者clear,可能因为组件内部还没初始化canvas而拿到空引用。稳妥做法是加一个nextTick,或者等用户第一次交互后再调用。我见过有人在onMounted里直接调isEmpty()做初始化判断,结果因为组件未ready,方法调用了却没效果,排查半天。
第二,签名文件的命名最好带业务ID和时间戳,不要用什么signature.png这种固定名字,否则CDN缓存或者对象存储同名覆盖会让你头疼。我这边统一格式是{bizType}_{bizId}_{timestamp}.png,后续按业务ID反查签名图很方便。
第三,保存按钮要做防重复提交。用户签完名手快点了两次保存,就会产生两个签名文件、两条记录,这个坑再小的团队也会遇到。我在按钮click回调里用一个loading状态挡住二次点击,等上传接口返回后重置,必要时再加一个业务幂等号到FormData里,后端按signId去重,双保险。
第四,签名功能一定要有“重新签名”的入口。电子签名跟手写签名不一样,手写签出去改不了,电子签名如果签错了、场景填错了,应该允许重签。所以我在业务表里给签名ID设计了可覆盖字段,新签名上传成功后,之前的URL作废或者走历史版本,这样既合规也方便。
回到vue3-signature本身,它在文档上的API覆盖已经足够支撑90%的业务需求,剩下的10%就是类似裁剪、高清适配、多端同步这些定制工作。我的建议是,想清楚你的合同/单据最终长什么样、签名图压到什么底色、需不需要透明通道,这三个问题想清楚了,组件用起来会非常顺手。如果只想快速跑通,那装上包、写个最简demo、导出图看看效果,半小时以内就能有产出;如果你有更复杂的涂抹、拍板、双人签名场景,也完全可以在这个组件上做二次封装,很多团队就是这么干的。