项目有个“服务确认”环节,需要用户用手指在屏幕上签个名字,保存成图片附件。原以为是调用个现成插件就行,结果公司审批流程不允许随便引入第三方原生SDK。索性自己用 uni-app 写了个纯前端签字组件。原本以为 canvas 画个线能有多难,实际在手机上跑通以后才发现,跨端问题比想象的多。这篇文章拆解我实现签字组件的全部经过,包括画布坐标修正、触摸事件兼容以及跨端导出图片的坑。
先看最终用法
我把签字功能封装成了一个 SvgSign 组件(其实是 Canvas),页面里只需要引入和调用:
<template>
<view class="sign-page">
<signature-board ref="signRef" @confirm="onConfirm"></signature-board>
<button type="primary" @click="saveSign">保存签名</button>
</view>
</template>
<script setup>
import SignatureBoard from '@/components/signature-board.vue';
const signRef = ref(null);
function saveSign() {
signRef.value.exportImage()
.then(res => console.log('生成图片', res.tempFilePath))
.catch(err => console.error('签名失败或没画内容', err));
}
</script>
注意这个组件不能做成全屏遮罩那种,最好嵌入在页面里,否则容易和弹层滚动冲突。我这边就是普通 view 内放置 canvas,然后组件内部自适应宽度。
组件模板与基础 canvas
<template>
<view class="sign-wrap">
<canvas
canvas-id="signCanvas"
id="signCanvas"
class="sign-canvas"
disable-scroll
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
></canvas>
<view class="clear-btn" @click="clear">重签</view>
</view>
</template>
有几点必须提前注意:
- canvas-id 和 id 同时设置,小程序端给 canvas 取 id 用 canvas- 前缀,这里保持一致。
- disable-scroll 属性可以避免手指绘图时页面跟着滚动。
- H5 端的 canvas 用 canvas-id 后期通过 uni.createCanvasContext 操作,和小程序通用,所以不用兼容 createSelectorQuery 的方式。
初始化上下文和坐标系转换
组件 mounted 后需要获取 canvas 的尺寸。麻烦的是实际绘制区域是在 CSS 像素与真实设备像素之间存在缩放。
import { ref, onMounted, getCurrentInstance } from 'vue';
const ctx = ref(null);
const canvasW = ref(0);
const canvasH = ref(0);
onMounted(() => {
// 获得渲染实例
const instance = getCurrentInstance();
// 使用 uni.createSelectorQuery 获取元素布局信息
const query = uni.createSelectorQuery().in(instance.proxy);
query.select('.sign-canvas').boundingClientRect(rect => {
if (!rect) return;
canvasW.value = rect.width;
canvasH.value = rect.height;
// 创建画布上下文
ctx.value = uni.createCanvasContext('signCanvas', instance.proxy);
// 关键一步:绘制区域高清适配
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
// canvas 元素的实际渲染尺寸是 rect.width, height
// 但物理像素是 dpr 倍,我们需要设置 canvas 的 width / height 属性
// 小程序支持设置 canvas 的 width 与 height
// 但在 H5 端,canvas 的 width/height 就是画布像素宽度
// 所以我统一在 mounted 后调整尺寸
// 这里使用 uni.createCanvasContext 后再设置
// 经过多次试验,小程序端要直接操作 canvas 节点才行
// 因此采用创建 CanvasContext 后绘制前动态设置
// 但是 setCanvasWidth 不存在,直接修改 canvas 对象的属性?
// 换个方式,直接给 canvas-id 所在节点设置样式宽高,并用 ctx.scale(dpr, dpr) 解决,
// 但 canvas 自身宽度与样式的宽度比例关系在小程序里需要通过 select 拿到 node。
// 索性拆开处理。
});
});
为了少绕弯,我用了常规但稳妥的途径:在 mounted 后取出 canvas 的底层 node,然后设置它的宽高。对于微信小程序端支持 <canvas type=”2d”>,但 uni-app 如果也要支持 App 和 H5,最好还是定义两种模式。 我这边项目主要编译到微信小程序和 H5,所以采用了两套逻辑处理。
canvas 尺寸在不同端的处理
经过整理,我写了以下初始化方法:
function initCanvas() {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
query.select('.sign-canvas').fields({ node: true, size: true }, (res) => {
if (res && res.node) {
// 小程序或App-vue支持 node 方式
const canvas = res.node;
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
canvas.width = res.width * dpr;
canvas.height = res.height * dpr;
canvasContext = canvas.getContext('2d');
canvasContext.scale(dpr, dpr);
canvasW.value = res.width;
canvasH.value = res.height;
isCanvas2D = true;
resolve();
} else {
// H5 和其它渠道,使用旧版 CanvasContext
canvasContext = uni.createCanvasContext('signCanvas', getCurrentInstance().proxy);
canvasW.value = res.width;
canvasH.value = res.height;
isCanvas2D = false;
resolve();
}
}).exec();
});
}
为什么这么麻烦?因为小程序里 canvas 成功绘制需要 node 方式,如果直接用 uni.createCanvasContext,会发现线条清晰度差或者画布显示空白。而在 H5 端不太容易出现 node 方式获取,只好退回到旧版 API。
绘图时的坐标修正
触摸事件中的 e.touches 里 x 和 y 是屏幕坐标,但在画布中要减去 canvas 左上角的偏移量,否则签名位置会偏移。这里注意页面滚动和组件位置的影响。
function getDrawPosition(e) {
const touch = e.touches ? e.touches[0] : e.changedTouches[0];
// 普通事件拿不到,直接使用触摸点相对于 canvas 的x/y(uni-app已处理过)
// 在小程序中,touch.x 会自动基于 canvas 坐标吗?不会!
// 所以需要手工偏移,用CanvasBoundingRect记录初始位置。
return {
x: touch.x - canvasLeft.value,
y: touch.y - canvasTop.value
};
}
我在触摸开始的时候,使用 SelectorQuery 查询当前 canvas 的 left/top,并存在两个变量里:
let canvasLeft = 0;
let canvasTop = 0;
function refreshRect() {
const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
query.select('.sign-canvas').boundingClientRect(rect => {
if (rect) {
canvasLeft = rect.left;
canvasTop = rect.top;
}
}).exec();
}
绘制逻辑很简单:记录上一坐标点,并画一条线。难点在于如果直接修改 touch 坐标,会发现画出的线和手指轨迹有偏差。这时候需要对比 canvas 的 CSS 尺寸和物理尺寸。如果用 isCanvas2D 方式,则上下文已经设置了 scale(dpr, dpr),那么传入的坐标应该按 CSS 像素来。这样就对了。对于旧版 CanvasContext,uni-app 内部也会处理好像素比,只需要用相对坐标即可。
完整绘图代码
let drawing = false;
let lastPoint = null;
function onTouchStart(e) {
drawing = true;
refreshRect();
const pos = getDrawPosition(e);
lastPoint = pos;
}
function onTouchMove(e) {
if (!drawing) return;
const curPoint = getDrawPosition(e);
// 防止短时间内移动过快线条断开,画线走直线连接
if (isCanvas2D) {
canvasContext.beginPath();
canvasContext.moveTo(lastPoint.x, lastPoint.y);
canvasContext.lineTo(curPoint.x, curPoint.y);
canvasContext.strokeStyle = '#333';
canvasContext.lineWidth = 2;
canvasContext.lineCap = 'round';
canvasContext.lineJoin = 'round';
canvasContext.stroke();
} else {
// 旧版
canvasContext.beginPath();
canvasContext.moveTo(lastPoint.x, lastPoint.y);
canvasContext.lineTo(curPoint.x, curPoint.y);
canvasContext.setStrokeStyle('#333');
canvasContext.setLineWidth(2);
canvasContext.setLineCap('round');
canvasContext.setLineJoin('round');
canvasContext.stroke();
}
canvasContext.draw && canvasContext.draw();
lastPoint = curPoint;
}
function onTouchEnd() {
drawing = false;
}
在这里犯过一个错误:在旧版 CanvasContext 中,每次画线后必须调用 draw(),否则不会渲染出来。而在新版 node 方式中不能调用 draw(),它需要显式调用 canvas.requestAnimationFrame? 不需要,因为直接基于 context2d 绘制,绘图是同步的。所以需要根据 isCanvas2D 判断。
导出图片与保存相册
签名完成后,最重要的一步是导出为图片。在不同端导出方式有差异。
对于旧版 CanvasContext(H5或非 node 的小程序基础库),uni.canvasToTempFilePath() 可以直接把 canvas-id 内容转成临时图片:
function exportImageFromLegacy() {
return new Promise((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId: 'signCanvas',
success: res => resolve(res),
fail: err => reject(err)
});
});
}
但新版 canvas 2D 结构不同,你需要传入 canvas 节点,参数有点古怪:
function exportImageFrom2D() {
return new Promise((resolve) => {
const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
query.select('.sign-canvas').fields({ node: true, size: true }, (res) => {
if (res && res.node) {
uni.canvasToTempFilePath({
canvas: res.node,
success: res2 => resolve(res2),
fail: (e) => reject(e)
});
} else {
// fallback
resolve(exportImageFromLegacy());
}
}).exec();
});
}
为了两个端都能用,我在组件里暴露出 exportImage 方法:
function exportImage() {
return new Promise((resolve, reject) => {
// 如果画布没有任何像素点,但不主动判断空
// 简单判断是否画过
if (!hasContent) {
reject(new Error('没有签名内容'));
return;
}
const query = uni.createSelectorQuery().in(getCurrentInstance().proxy);
query.select('.sign-canvas').fields({ node: true, size: true }, (res) => {
let param = {
success: res => resolve(res),
fail: err => reject(err)
};
if (res && res.node) {
param.canvas = res.node;
} else {
param.canvasId = 'signCanvas';
}
uni.canvasToTempFilePath(param);
}).exec();
});
}
但是还有个大坑:App端或者小程序端 canvas 如果css里设了宽高是百分比,比如 width: 100%,然后再用 node 方式获取的尺寸可能为0。所以我在组件内用了 CSS 固定高度,宽度使用百分比并顺带用 rect.width 获取。
“hasContent” 判断
用户如果没有签字就点保存,最好给个提示。我通过 canvas 上下文的相关方法判断像素不行,因为不同端支持不同,于是采用在 touchmove 时设置 hasContent = true。
function onTouchMove(e) {
// ...
hasContent = true;
}
function clear() {
hasContent = false;
if (isCanvas2D) {
canvasContext.clearRect(0, 0, canvasW.value, canvasH.value);
} else {
canvasContext.clearRect(0, 0, canvasW.value, canvasH.value);
canvasContext.draw();
}
}
保存到系统相册又是另一个坑
导出得到临时文件路径后,下一步是保存到相册或者上传服务器。保存相册需要先获得授权。我在组件外处理,组件只负责导出临时路径,另外走下载上传。
function confirmSign() {
signRef.value.exportImage().then(res => {
// 保存相册前需要权限提示
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => console.log('保存成功'),
fail: (e) => console.log('保存失败可能是没授权', e)
});
}).catch(err => {
uni.showToast({
title: err.message || '请先签名',
icon: 'none'
});
});
}
注意在小程序端如果用户拒绝过授权,再次调用保存不会弹窗,直接 fail。需要在 fail 里引导去设置页。这个被拒的逻辑大家都很熟,不浪费篇幅。
还要处理多端差异:H5 的导出路径
在 H5 端,uni.canvasToTempFilePath 返回的 tempFilePath 是 base64 图片地址,可以直接用于预览。但如果要上传到服务器,得先把 base64 转成 blob 再上传。我的项目里后端只接受 multipart 文件,所以我写了个上传函数检测 H5 和 APP:
function uploadSignImage(imagePath, failCallback) {
// #ifdef H5
// 将base64转成File对象比较麻烦,简单用uni.uploadFile虽然也行?
// 实测uni.uploadFile在H5端会接受tempFilePath作为文件
uni.uploadFile({
url: 'https://api.yourserver.com/upload',
filePath: imagePath,
name: 'file',
success: (res) => console.log(res.data)
});
// #endif
// #ifndef H5
uni.uploadFile({
url: 'https://api.yourserver.com/upload',
filePath: imagePath,
name: 'file',
success: function (res) {
console.log(res.data);
}
});
// #endif
}
参数:清场与按钮样式
如果你把组件的 clear 按钮设计在画布内部,点击的时候有可能会意外触发触摸移动。我给 clear 按钮绑定 @touchstart.stop 与 @click.stop,防止事件冒泡到画布。
<view class="clear-btn" @touchstart.stop @click.stop="clear">重签</view>
完整组件代码不足100行?
下面提供组件源码压缩概括(已去掉注释)。
<template>
<view class="sig-wrapper">
<canvas canvas-id="signCanvas" id="signCanvas" disable-scroll @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" class="sig-canvas"></canvas>
<view class="sig-toolbar">
<text class="sig-placeholder" v-if="!hasContent">// 原生文字,不用样式搞复杂</text>
</view>
</view>
</template>
主要难点都在脚本里,模板就这些。算下来实现一个基础可用的手写签名组件,所需代码不到300行。有定制需求可以在 Canvas 上增加背景色、笔锋粗细,核心思路一致。
如果还要考虑 App 端
App-vue 默认使用的是 canvas 组件 type=2d? 官方推荐使用 plus html5webview ?但 uni-app 提供的 canvas 在 App 端表现历来一般。我的项目主要运行在微信小程序和 H5,所以没有对 App 进行真机深挖。如果你打算在 App 上同样使用,建议使用 vue 页面里的 canvas 并且通过 renderjs 或 wxs 处理触摸轨迹来提升性能。方案会复杂很多,等下次有空我再写写那一版。
签名组件做完后,用户反馈“挺好用”、“没觉得有延迟”。其实手写签名本身就没有非常高的帧率要求,只要线条轨迹正常即可。
最后罗列我踩过的坑排行榜
- 用 canvas 2D 的 node 方式绘图后,必须检查坐标系放大倍率,否则导出的图片清晰但显示变形。
- 不要用 CSS transform scale 对 canvas 做缩放,否则触摸坐标错位到怀疑人生。
- 单次 draw() 在部分小程序里会闪屏,使用 requestAnimationFrame 批量绘制更丝滑,但代码复杂度大幅上升。
- canvas 的 type 属性在小程序里有时默认旧版,要手动加上 type=”2d” 但 uni-app 的编译又有自己的规矩,最终使用 fields({ node: true }) 后 iOS 挺正常。
手写签名,其实是个很老的功能,但放到跨端框架里就变得细节缠身。希望这篇经验记录能帮你少走一点弯路。

