☰
uni-app手写签名组件开发:canvas跨端绘制与导出全攻略
2026/10/17 1:26:34 网站建设 项目流程

1. 电子签名需求到底该怎么落地

1.1 从业务场景说起

最近后台收到好几个同行留言,都在问uni-app里怎么做电子签名。这需求确实常见,合同签署、确认函、巡检打卡、物业报修单,凡是需要“本人确认”的场景,都逃不开一个签名板。早年大家用H5页面嵌套WebView方案,但在小程序、App、H5多端并存的今天,再用那套老路子就显得笨重了。

电子签名的本质,是把用户在屏幕上用手指或手写笔留下的笔迹,转成一张图片,再和业务单据绑定存到服务器。听着简单,真做起来有不少门道:触摸轨迹怎么采集、笔迹怎么平滑、不同端canvas差异怎么兼容、导出图片格式怎么统一,这些坑我基本都踩过一遍。这篇文章就把我从零实现uni-app电子签名组件的完整过程拆开写,代码可以直接抄,坑也帮你提前排掉。

1.2 自研组件的理由

有人会问,市面上不是有现成的签名插件吗?确实有,但我的建议是核心业务场景尽量自研。第三方签名组件授权费不便宜,而且很多插件对小程序端的支持并不完整,遇到canvas版本差异还得等对方更新。自研一个签名板,核心代码其实不超过两百行,清晰可控,改了不心疼。

这里先解释一个最容易混淆的概念:uni-app里的canvas,在不同端底层实现完全不同。小程序端是原生canvas,H5端是浏览器canvas,App端则要区分vue页面的webview渲染和nvue页面的原生渲染。这直接决定了你写的代码能不能一套跑通。我这次的目标很明确:一套组件,H5、微信小程序、App安卓和iOS全部可用,所以选型上用了canvas 2d接口,而不是已经被标记为废弃的旧版canvas接口。后面会详细讲为什么。

2. 签名板的核心绘制原理

2.1 触摸事件与canvas坐标系

签名板的本质是“指尖在屏幕上移动,画布同步记录轨迹”。这里有两个关键系统要配合:手指触摸事件系统和canvas绘图系统。触摸事件负责告诉程序“手指在哪、动了多少”,canvas负责把轨迹画出来。

在uni-app中,触摸事件通过@touchstart、@touchmove、@touchend三个事件绑定到canvas或者包裹它的view上。每个事件对象里都有一个touches数组,里面存着当前所有触点的坐标信息。我们要取的是e.touches[0].x和e.touches[0].y,注意这里是小写的x和y,并不是canvas坐标系里的坐标。

这就引出了第一个坑:触摸事件给的是页面坐标,canvas绘制需要的是画布内部坐标,两者只有在canvas占据整个屏幕且没有滚动、没有缩放时才能直接画等号。实际项目中签名板通常嵌在表单页面里,页面能滚动,canvas也有自己的位置,所以必须做一次坐标换算。换算公式并不复杂,用touches[0].x减去canvas左上角相对页面左侧的距离,再乘以一个缩放系数。缩放系数是什么?后面在兼容性章节专门说。

2.2 折线连接的致命缺陷

新手最容易犯的错误,是在touchmove里把每个触摸点用lineTo连起来。代码看着没问题,线条也确实画出来了,但放大看全是锯齿,弯折处有明显的“棱角”,笔画一顿一顿的,完全不像人手写的字。

原因在于触摸事件的采样频率不足以覆盖屏幕上的所有像素点,两个相邻采样点之间是一条直线段,大量短线段拼在一起就形成了折线效果。手机屏幕上的采样点间距可能是两三个像素,在快速书写时这个间距会更大,折线感也就更明显。

解决办法是用二次贝塞尔曲线做平滑插值。思路是:不再直接连接两个触摸点,而是取两个点的中点作为贝塞尔曲线的端点,用前一个点作为控制点。这样做的好处是,曲线永远在当前点和下一个点的中点上结束,而控制点让曲线产生了自然的弧度,整体笔迹会圆润很多。这也是业内手写签名组件普遍采用的做法,并不玄学。

2.3 为什么要用canvas 2d接口

uni-app同时存在两套canvas接口:旧版uni.createCanvasContext和新版Canvas 2D。旧版接口在小程序端已明确进入废弃流程,新项目不应该再用 Navigator 遗留接口写新代码。Canvas 2D的用法和浏览器端Web API几乎一致,通过canvas.getContext('2d')拿到上下文,然后用标准的beginPath、moveTo、lineTo、quadraticCurveTo这些方法绘图。

除了标准化的优势,Canvas 2D在性能上也有明显提升。旧版接口每次绘图都是一次完整的canvas渲染指令下发,频繁绘制时会出现掉帧;新版接口支持更流畅的连续绘制,实测在低端安卓机上差距尤其明显。

3. 完整实现一个签名组件

3.1 组件目录与页面结构

我在项目components/sign-board目录下新建了组件,包含sign-board.vue单文件。组件内部划分三个区块:顶部操作栏放“清空”“撤销”“完成”按钮,中间是签名画布,底部是一行提示文字。这样组件本身是封闭的,业务页面只需要引入并监听完成事件,拿到图片路径后自行上传。

签名区域的高度我定为视口宽度的一半,宽度撑满容器。为什么取这个比例?竖屏手机上大约7比5的长宽比,接近真实纸质签名栏的观感,用户书写时也不会因为区域太窄而溢出边界。不过这个比例可根据业务调整,但要注意:canvas的宽高属性是逻辑像素,不是样式像素,两者的关系直接决定了文字和线条的清晰度,这一点必须讲透。

3.2 核心代码逐段解析

先说data部分,组件内部需要维护以下状态:一个数组pointsHistory用来保存每一步的绘制栈,用于实现撤销;一个布尔值isDrawing标记是否正在书写;画布上下文ctx不能放在data里,因为data会被vue做响应式代理,像canvas上下文这样的对象放进去既没必要还可能有性能损耗。

data() { return { pointsHistory: [], currentPath: [], isDrawing: false } }

pointsHistory存储的不是单个点,而是“一次完整笔迹的所有点集合”。也就是说,每画完一道笔画,就把这条笔画的所有点push进pointsHistory,撤销时从栈尾弹出。这样比逐点撤销更符合直觉,也更好管理。

接着是onLoad和onReady阶段初始化canvas。注意uni-app中canvas标签的id是获取上下文的关键:

<canvas id="signCanvas" class="sign-canvas" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd"></canvas>
// 初始化 const query = uni.createSelectorQuery().in(this) query.select('#signCanvas').fields({ node: true, size: true }).exec((res) => { if (!res[0]) return const canvas = res[0].node const ctx = canvas.getContext('2d') // 关键:根据设备像素比dpr放大画布尺寸 const dpr = uni.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) // 设置线条样式 ctx.lineWidth = 3 ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.strokeStyle = '#333333' this.ctx = ctx })

这段代码有个关键操作:ctx.scale(dpr, dpr)。不这么做的话,画布在Retina屏上会模糊,因为逻辑像素的canvas实际要用物理像素来渲染,两者之间的比例就是pixelRatio。把画布内部尺寸乘上dpr,再通过scale把坐标系还原到逻辑像素,这样画出来的线条就是清晰的。这也是之前说的坐标换算中的缩放系数。不过要注意,ctx.scale之后所有绘制坐标仍然按逻辑值写,不需要开发者手动乘dpr,canvas内部已经处理好了。

3.3 触摸绘制:手写笔迹的平滑插值

三个触摸事件的具体实现如下:

onTouchStart(e) { const point = this.getTouchPoint(e) this.isDrawing = true this.currentPath = [point] // 用一个小圆点标记落笔点 this.ctx.beginPath() this.ctx.moveTo(point.x, point.y) this.ctx.arc(point.x, point.y, this.ctx.lineWidth / 2, 0, Math.PI * 2) this.ctx.fill() }, onTouchMove(e) { if (!this.isDrawing) return const point = this.getTouchPoint(e) this.currentPath.push(point) // 一次移动只画当前点和上一个点之间的平滑线段 const len = this.currentPath.length if (len < 3) return const prev = this.currentPath[len - 2] const current = this.currentPath[len - 1] const mid = { x: (prev.x + current.x) / 2, y: (prev.y + current.y) / 2 } this.ctx.beginPath() this.ctx.moveTo(prev.x, prev.y) this.ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y) this.ctx.stroke() }, onTouchEnd() { if (!this.isDrawing) return this.isDrawing = false // 将完整笔迹存入历史栈,用于撤销 this.pointsHistory.push([...this.currentPath]) this.currentPath = [] }

getTouchPoint方法负责坐标换算:

getTouchPoint(e) { const touch = e.touches[0] // 获取canvas在页面中的位置信息 const rect = this.canvasRect return { x: touch.x - rect.left, y: touch.y - rect.top } }

这里canvasRect在初始化canvas时通过createSelectorQuery的boundingClientRect获取。一次查询同时拿到node、size和rect,不需要多次查询,性能也更好。

3.4 撤销和清空:历史栈的妙用

撤销功能对一个签名板来说是刚需,用户写歪了一点,不可能让人家清空重写。我的实现方式是每次touchEnd把整条笔迹压栈。撤销时,从栈里弹出一条,然后清空canvas重绘剩余的笔画。

undo() { if (this.pointsHistory.length === 0) return this.pointsHistory.pop() this.redraw() }, clear() { this.pointsHistory = [] this.currentPath = [] this.redraw() }, redraw() { if (!this.ctx) return const canvas = this.canvasNode this.ctx.clearRect(0, 0, canvas.width, canvas.height) // 重绘所有历史笔迹 this.pointsHistory.forEach(path => { path.forEach((point, index) => { if (index === 0) { this.ctx.beginPath() this.ctx.moveTo(point.x, point.y) this.ctx.arc(point.x, point.y, this.ctx.lineWidth / 2, 0, Math.PI * 2) this.ctx.fill() } else { const prev = path[index - 1] const mid = { x: (prev.x + point.x) / 2, y: (prev.y + point.y) / 2 } this.ctx.beginPath() this.ctx.moveTo(prev.x, prev.y) this.ctx.quadraticCurveTo(prev.x, prev.y, mid.x, mid.y) this.ctx.stroke() } }) }) }

这里有个性能点要提醒:撤销后重绘整个canvas,如果签名笔画很多,会出现短暂空白然后整体重新绘制。对签名这个场景来说笔画数量撑死几十条,完全可以接受。如果你在做画板应用,那得用离屏canvas做增量缓存,那是另一个话题,签名场景不必上过度方案。

3.5 导出签名图片:canvasToTempFilePath的跨端细节

导出是整个组件最关键的一步,也是跨端差异最大的地方。uni-app提供uni.canvasToTempFilePath方法,但它的参数在小程序端和H5端有细微差别。

confirm() { // 签名内容判空 if (this.isEmpty()) { uni.showToast({ title: '请先签名', icon: 'none' }) return } // 导出图片 uni.canvasToTempFilePath({ canvas: this.canvasNode, // 小程序端必须传执行的canvas实例 canvasId: 'signCanvas', // H5端需要canvasId fileType: 'png', quality: 1, success: (res) => { const tempFilePath = res.tempFilePath // 将临时文件路径交给业务层处理 this.$emit('confirm', tempFilePath) }, fail: (err) => { console.error('导出签名失败', err) uni.showToast({ title: '导出失败,请重试', icon: 'none' }) } }) }

这段代码里同时传了canvas实例和canvasId,这样做是因为微信小程序新版Canvas 2D要求传canvas实例,而H5端有些版本的uni-app只看canvasId。两个都传,兼容性最好,实测双端都能正常导出。

导出图片的第二个参数s不要在uni.canvasToTempFilePath外层套this.ctx,这里this.ctx是绘制上下文,canvas节点是已经通过query.select('#signCanvas').fields({node: true})拿到的原生node对象。很多人在这里把canvas和ctx搞混,导出一直报错。

导出的图片默认是白色背景还是透明背景,取决于canvas有没有被fill过白色。我们初始化时设置了线条颜色,但没有对整个画布做白色填充,所以小程序端导出的时透明背景,H5端导出时浏览器会自动为透明区填白。这个差异会导致同一张签名图在不同端呈现不同效果。业务端如果统一把图片放在白色单据上没问题,如果要做透明底叠加的签名,就需要自己控制背景色,在导出前先fillRect涂白或保持透明,两种方案各取所需。我建议统一在导出前强制填充白色背景,因为大多数业务场景签名图是直接展示的,透明背景在部分图像处理链上会有兼容问题。

还有一个细节:fileType建议固定png。签名涉及笔迹,JPG有损压缩会让边缘发虚,法律效力上虽然没有影响,但视觉体验不好。

3.6 判空与二次确认

用户在没写任何内容时点完成,组件要给出友好提示。这里的关键是“如何判断签名区域为空”。有个简单的方案:在touchEnd时并不清空画布,而是标一个hasContent标志。但用户在落笔然后撤销到空,这个标志要同步更新。更稳的做法是再导出前遍历pointsHistory,如果长度为0就是空签名。

isEmpty() { return this.pointsHistory.length === 0 }

二次确认的建议也不能少。签名动作是不可逆的,用户误触“完成”就上传了的话很尴尬。我在组件里加了一个confirmDialog的开关,点“完成”时弹窗提示“确认使用当前签名吗”,确认后才执行导出逻辑。这个交互细节看着小,但真实业务中能减少大量客诉。

4. 多端适配与真机踩坑记录

4.1 触控坐标偏移问题

这是我在实际调试中最头疼的问题,没有之一。在模拟器上一切正常,真机上写出来的签名偏到姥姥家。原因在于e.touches[0].x给出的是相对于整个页面的坐标,canvasRect里的left和top也是相对页面的,两者应满足减法关系。

但有一个隐形变量:页面是否发生了滚动。如果用户在签名前滚动过页面,canvas在页面中的坐标会变,而某些端返回的touches[0].x却始终以“当前可视区域左上角”为原点,两者坐标系根本不一致。解决方法是:在touchstart事件触发的那一刻,重新获取canvas的boundingClientRect,而不是在初始化时获取一次就一劳永逸。

getTouchPoint(e) { const touch = e.touches[0] // 每次落笔前重新获取canvas位置,避免页面滚动导致坐标漂移 const rect = this.canvasRect return { x: touch.x - rect.left, y: touch.y - rect.top } }

然后在下拉刷新、页面resize等事件里把canvasRect清空或刷新。有的同事会写一个全局监听pageScroll,每次滚动都刷新rect,实测对性能影响很小,但必要性取决于业务。我最后的做法是在touchstart时同步刷新rect,简单暴力,效果稳定。

4.2 画布在H5端的层级遮挡

H5端canvas是普通元素,会被某些组件的z-index盖住。最常见的场景是页面底部有固定定位的按钮,弹窗打开时签名区域被遮罩盖住。解决方案有两个:动态调整canvas的z-index,或者在弹窗打开时暂停touch事件。我们项目用的是调整z-index方案,把整个签名组件层级提到弹窗之上,配合position: fixed和合适的z-index值,实测稳定。

4.3 小程序端旧版canvas的兼容性

如果你的项目里还有老代码用了uni.createCanvasContext,在小程序基础库2.9.0之后就应该逐步迁移到Canvas 2D。旧版接口在部分安卓机型上存在绘制延迟、touchend后最后一条线丢失的问题,这类问题在官方文档里没有明确声明,但社区反馈很多。我的经验是:新组件一律用Canvas 2D,老代码如果不好动,就在导出前加一个50ms定时器做缓冲,能规避部分丢失问题,但还是建议尽快迁移。

4.4 App端与nvue的差异

uni-app的App端,vue页面跑在webview中,canvas行为接近H5端,通常问题不大。但如果你用nvue写页面,那canvas就得走uni.createCanvasContext的老路,因为weex原生渲染对Canvas 2D的支持并不完整。这个限制是框架层面的,短期内没有完美的跨端方案。如果业务必须nvue,我的建议是签名页面单独用vue页面实现,不要嵌到nvue页面里,页面间通过uni.navigateTo进行跳转和参数传递。

4.5 导出的图片在部分安卓机上发黑

这是真机上偶发的诡异问题,导出的png图片整体发黑。后来排查发现,是因为canvas的尺寸异常——某些厂商ROM的WebView对canvas.width的异步设置不生效,导致clearRect清除的尺寸和绘制尺寸不一致。解决方法是:初始化canvas时,把canvas.width和canvas.height的赋值显式地放到nextTick里,然后加一层try-catch,在异常时重新初始化画布。代码如下:

this.$nextTick(() => { const query = uni.createSelectorQuery().in(this) query.select('#signCanvas').fields({ node: true, size: true }).exec((res) => { this.resizeCanvas(res) }) })

4.6 触摸笔画断触问题

真机书写时,快速滑动有时会出现笔迹断裂。排查后发现是touchmove事件的频率跟不上手指移动速度造成的。Canvas 2D可以设置ctx.imageSmoothingEnabled,但对解决断触没有实质帮助。真正有效的处理有两个:一是把线条宽度适当调大,让断点不那么明显;二是在touchmove时连续路径的起点不要用上一个采样点,而是用上一个采样点和当前采样点的中点,这样短线段之间衔接更紧密。本质上还是贝塞尔插值在起作用。

5. 组件封装后的业务集成建议

5.1 签名图片上传策略

组件内部不负责上传,只把临时文件路径通过事件抛给业务层,这样职责单一,上传逻辑交由业务页面统一处理。上传时要注意:临时文件路径在小程序端过期时间只有会话生命周期,必须立即上传,不能存入本地缓存后隔段时间再传。

推荐的上传格式是base64字符串还是临时文件?取决于后端接口要求。如果服务器要兼容多端来源图片,建议组件多提供一个exportBase64方法,内部用uni.getFileSystemManager().readFile或H5的canvas.toDataURL获取base64。base64在需要签名预览、回显、缩略图的场景更灵活,缺点是体积大,一张签名图约几十KB,对接口传输来说可以接受。

5.2 签名回显的细节

保存后再次打开页面需要回显已签名图片。回显时注意:如果直接把后端返回的图片路径赋给image标签,在签名区域展示会有拉伸变形。这里推荐的做法是,回显时仍然渲染签名组件,但组件支持src属性,传入后直接以背景图模式显示,不允许再次绘制。这样视觉和编辑模式保持一致的宽高比和缩放表现。

5.3 签名质量的校验

业务上可能会要求“签名不可过于潦草”“书写面积不能过小”。这类规则用canvas可以提前断言:在touchEnd时记录整个笔迹路径的外接矩形宽度和高度,如果小于某个阈值,就提示用户重新签名。阈值需要根据你设定的画布尺寸做百分比计算,不建议用固定像素值,否则不同屏幕大小的设备上测试结果会不一致。

6. 个人经验与最终技巧

写的过程中踩过的坑太多,分享三个实操经验供参考。

第一个经验是关于调试方式的。canvas在手写板这类场景中,模拟器上的表现和真机差异巨大。模拟器里触摸事件用鼠标模拟,采样频率高,笔迹平滑;真机上手指滑动快时采样点稀疏,同样的贝塞尔插值代码效果差距明显。所以做这类组件的第一步就是把真机调试打通,越早越好,不要等全部写完再上真机,那样排查问题成本很高。

第二个经验是笔迹压感模拟。多数手机触摸事件并不携带压力值,所以签名线条默认是均匀粗细。如果你希望笔画更有手写感,可以基于触摸点之间的间距推算“虚拟笔压”:距离大说明速度快、笔提起,线条画细一点;距离小说明笔落得重,线条画粗一点。这个逻辑能显著提升签名观感,但要注意,频繁修改lineWidth会带来额外性能开销,签名笔画数量不大,性能损耗完全可控。

第三个经验是和后端联调时,最好在组件层面就约定好图片的尺寸限制和格式。我遇到过后端接收图片后强制转JPG导致签名边缘出现白色描边,排查半天才发现是转换参数问题。所以要提醒后端,签名的格式和压缩参数不要轻易改,尤其是透明通道和边缘抗锯齿的相关配置。

最后一个小技巧:给签名组件加一个“重新签名”的快捷入口,很多业务方在用户误点完成后,需要回到编辑状态。我在导出成功后没有立即销毁组件,而是保留了一份画布快照,用户点“重签”时先压缩快照给用户确认,确认后清空。这个流程比直接清空重画要稳妥,避免用户误操作后找不到签名内容而产生的投诉。

这套实现上线后,从H5到小程序再到App端,签名清晰度、导出成功率、用户操作体验都达到可用级别。对于需要自己掌控签名流程的业务,完全可以按这套方案落地自己的组件,避开那些已知的坑,效率会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询