在电子合同、审批流程或笔记类应用中,手写签名是一个高频需求。Uniapp 作为跨端框架,要同时兼容微信小程序、H5 和 App 的 Canvas 实现,往往需要处理不同平台的 API 差异、像素比适配和触摸事件兼容。本文将从一个完整的签名组件案例出发,逐步解决这些难题,并给出可直接使用的代码与最佳实践。
一、需求分析与技术选型
一个合格的手写签名组件需要支持:
- 手指/鼠标滑动绘制,笔迹流畅、无锯齿。
- 跨平台:微信小程序使用 Canvas 2D API(新版),H5 使用标准 Canvas,App 使用 Webview Canvas。
- 高清屏适配(Retina 屏 2x/3x 物理像素)。
- 清空、撤销、保存为图片的功能。
在 Uniapp 中,我们可以统一使用 <canvas> 标签,并在不同平台通过条件编译调用对应的 Canvas 上下文 API。相对于社区中的签名插件,自己封装能够完全控制笔迹样式和导出逻辑,避免依赖冲突。
二、项目结构与基础组件
新建一个 Vue3 的 Uniapp 项目,在 components 目录下创建 SignaturePad.vue。
<!-- components/SignaturePad.vue -->
<template>
<view class="signature-container">
<canvas
id="signCanvas"
class="sign-canvas"
:width="canvasWidth"
:height="canvasHeight"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@mousedown="onTouchStart"
@mousemove="onTouchMove"
@mouseup="onTouchEnd"
disable-scroll="true"
></canvas>
<view class="btn-group">
<button @click="clearCanvas">清空</button>
<button @click="saveImage">保存签名</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue'
const canvasWidth = ref(640)
const canvasHeight = ref(400)
let ctx = null
let drawing = false
// 平台判断
const isMP = uni.getSystemInfoSync().platform !== 'windows' && uni.getSystemInfoSync().platform !== 'mac'
onMounted(async () => {
await nextTick()
initCanvas()
})
function initCanvas() {
// #ifdef MP-WEIXIN
// 小程序使用 Canvas 2D 新接口
const query = uni.createSelectorQuery().in(this)
query.select('#signCanvas').fields({ node: true, size: true }).exec((res) => {
const canvas = res[0].node
const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = canvasWidth.value * dpr
canvas.height = canvasHeight.value * dpr
ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
// 设置画布样式
ctx.lineWidth = 3
ctx.strokeStyle = '#000'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
})
// #endif
// #ifndef MP-WEIXIN
const canvas = document.getElementById('signCanvas')
const dpr = window.devicePixelRatio || 1
canvas.width = canvasWidth.value * dpr
canvas.height = canvasHeight.value * dpr
canvas.style.width = canvasWidth.value + 'px'
canvas.style.height = canvasHeight.value + 'px'
ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
ctx.lineWidth = 3
ctx.strokeStyle = '#000'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
// #endif
}
三、触摸与绘制逻辑
为了兼容触摸和鼠标事件,我们统一使用事件处理函数,并提取坐标。
function getPoint(e) {
// #ifdef MP-WEIXIN
const touch = e.touches[0] || e.changedTouches[0]
return { x: touch.x, y: touch.y }
// #endif
// #ifndef MP-WEIXIN
const rect = e.target.getBoundingClientRect()
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY
return {
x: clientX - rect.left,
y: clientY - rect.top
}
// #endif
}
function onTouchStart(e) {
drawing = true
const point = getPoint(e)
ctx.beginPath()
ctx.moveTo(point.x, point.y)
}
function onTouchMove(e) {
if (!drawing || !ctx) return
const point = getPoint(e)
ctx.lineTo(point.x, point.y)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(point.x, point.y) // 保证笔触连续
}
function onTouchEnd(e) {
drawing = false
ctx.closePath()
}
function clearCanvas() {
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
}
function saveImage() {
// #ifdef MP-WEIXIN
uni.canvasToTempFilePath({
canvasId: 'signCanvas',
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => uni.showToast({ title: '保存成功' })
})
}
})
// #endif
// #ifndef MP-WEIXIN
const canvas = document.getElementById('signCanvas')
const dataURL = canvas.toDataURL('image/png')
// H5 下载或展示
const link = document.createElement('a')
link.download = 'signature.png'
link.href = dataURL
link.click()
uni.showToast({ title: '图片已下载' })
// #endif
}
四、跨端适配细节点
- 高清屏适配:Canvas 的 CSS 尺寸与物理像素不一致时,必须按
devicePixelRatio缩放画布和 context,否则签名会模糊。上文中已将 canvas 逻辑宽度设为 640/400,物理尺寸乘以 dpr。 - 小程序 Canvas 2D:微信小程序基础库 2.9.0 起支持 Canvas 2D,需使用
query.select()获取 node,不能直接使用uni.createCanvasContext(那是旧版接口,性能差且不支持高清缩放)。 - App 端:App 内跑的是 Webview,与 H5 一致,但部分旧系统可能需要开启硬件加速或调整交互。
- 事件穿透:在小程序中,canvas 默认会拦截页面滚动,添加
disable-scroll="true"可阻止页面跟随移动。
五、进阶优化:笔锋效果与性能
真实的签名往往有粗细变化。可以通过计算两点的移动速度动态调整 lineWidth。在 onTouchMove 中:
let lastTime = 0
let lastPoint = null
function onTouchMove(e) {
if (!drawing || !ctx) return
const point = getPoint(e)
const curTime = Date.now()
if (lastPoint) {
const distance = Math.sqrt((point.x - lastPoint.x) ** 2 + (point.y - lastPoint.y) ** 2)
const velocity = distance / (curTime - lastTime) // 像素/毫秒
// 根据速度调整线宽:速度越快越细
const lineWidth = Math.max(1, 3 - velocity * 0.8)
ctx.lineWidth = lineWidth
}
ctx.lineTo(point.x, point.y)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(point.x, point.y)
lastPoint = point
lastTime = curTime
}
此外,为提高性能,应尽量减少 stroke() 调用频率。可以在 touchmove 中收集点,在 requestAnimationFrame 中批量绘制(H5/App 可用),但小程序中不支持 rAF,可以适当降低采集密度。
六、完整代码与使用
以上所有片段组合后,即可得到一个跨端手写签名组件。在页面中直接引用:
<template>
<SignaturePad />
</template>
<script setup>
import SignaturePad from '@/components/SignaturePad.vue'
</script>
运行到微信小程序、H5 或 App,均能获得一致的签名体验。该组件还可继续封装为 uni_modules 插件,方便多处复用。
七、常见问题与解决
Q:小程序真机上 Canvas 不显示或位置偏移?
A:确保使用 Canvas 2D 方式(node 接口),并给 canvas 设置固定的宽高。偏移通常是父容器的 padding 或自身样式未重置导致,建议设置 box-sizing: border-box 并在组件内限制尺寸。
Q:H5 端签名图片保存后背景是黑色?
A:Canvas 默认透明背景转为图片时可能变黑。在初始化时先绘制白色背景:ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h)。
八、总结
从触摸坐标的捕获、跨端 Canvas API 的对齐,到高清屏适配和笔锋优化,本文完整展示了在 Uniapp 中自建一个手写签名组件的全过程。相比直接使用第三方插件,自主封装不仅更灵活,而且能深入掌握各端 Canvas 渲染的差异。希望该案例能帮助你更自信地在 Uniapp 中处理图形交互需求。

