
微信小程序手写签名功能完整开发方案
一、方案概述
1.1 开发背景
在微信小程序表单场景中,常需实现手写签名功能(如合同签署、审批流程等),需满足"点击唤起签名→手绘签名→保存上传→预览/删除签名"的完整交互流程,同时保证交互流畅、无样式残留等问题。
1.2 功能目标
- 表单式签名入口:点击虚线框占位区域唤起签名弹窗;
- 手绘签名:支持Canvas 2D流畅绘制,适配设备像素比避免模糊;
- 签名管理:保存签名后自动更新表单显示,支持删除已签内容;
- 交互优化:弹窗显隐无残留,点击响应及时,操作反馈清晰。
1.3 技术栈
- 框架:微信小程序原生框架(WXML/WXSS/JS);
- 核心:Canvas 2D API、wx.uploadFile 文件上传、小程序事件系统。
二、核心开发实现
2.1 页面结构设计(WXML)
xml
<view class="page-root">
<!-- 表单式签名区域 -->
<view class="form-item signature-item">
<view class="signature-wrap" bindtap="openSignModal" data-type="user_sign_a">
<image wx:if="{{markerInfo.user_sign_a}}" class="signature-img" src="{{markerInfo.user_sign_a}}" mode="aspectFit"></image>
<view wx:else class="signature-placeholder">点击此处进行签字</view>
<view wx:if="{{markerInfo.user_sign_a}}" class="signature-delete" catchtap="deleteSignature" data-type="user_sign_a"></view>
</view>
</view>
<!-- 签名弹窗 -->
<view class="sign-modal {{isModalShow ? 'modal-show' : ''}}" bindtap="closeSignModal">
<view class="modal-content" catchtap="stopPropagation">
<!-- 弹窗头部 -->
<view class="modal-header">
<text class="modal-title">手写签名</text>
<text class="close-icon" bindtap="closeSignModal">×</text>
</view>
<!-- 签名画布 -->
<view class="sign-container">
<canvas type="2d" id="signCanvas" class="sign-canvas"
bindtouchstart="handleTouchStart"
bindtouchmove="handleTouchMove"
bindtouchend="handleTouchEnd"
disable-scroll="true">
</canvas>
</view>
<!-- 操作按钮 -->
<view class="btn-group">
<button bindtap="clearSign" class="common-btn">清空签名</button>
<button bindtap="saveAndUploadSign" class="primary-btn" type="primary">保存签名</button>
</view>
</view>
</view>
</view>
2.2 样式设计(WXSS)
css
/* 页面根容器 */
.page-root {
padding: 40rpx;
background-color: #f5f5f5;
min-height: 100vh;
box-sizing: border-box;
}
/* 表单项基础样式 */
.form-item {
width: 100%;
margin-bottom: 30rpx;
}
/* 签名区域核心样式 */
.signature-item {
align-items: flex-start;
padding-top: 10rpx;
}
.signature-wrap {
flex: 1;
position: relative;
border: 1rpx dashed #ccc;
border-radius: 8rpx;
min-height: 150rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 10rpx;
cursor: pointer;
touch-action: manipulation;
background-color: #fff;
}
.signature-placeholder {
font-size: 26rpx;
color: #999;
text-align: center;
width: 100%;
height: 100%;
line-height: 150rpx;
}
.signature-img {
width: 100%;
height: 150rpx;
border-radius: 8rpx;
}
.signature-delete {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 40rpx;
height: 40rpx;
background-color: #000;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0;
line-height: 1;
z-index: 99;
touch-action: manipulation;
}
.signature-delete::after {
content: '×';
color: #ffffff;
font-size: 28rpx;
font-weight: bold;
text-align: center;
}
/* 弹窗样式(彻底隐藏无残留) */
.sign-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
z-index: 999;
display: none;
transition: none;
}
.modal-show {
display: flex;
}
.modal-content {
width: 90vw;
max-width: 600rpx;
background-color: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-sizing: border-box;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.close-icon {
font-size: 40rpx;
color: #999;
width: 60rpx;
height: 60rpx;
line-height: 60rpx;
text-align: center;
}
.sign-container {
width: 100%;
margin-bottom: 30rpx;
}
.sign-canvas {
width: 100%;
height: 300rpx;
border: 1rpx solid #eee;
background-color: #fff;
box-sizing: border-box;
}
.btn-group {
display: flex;
gap: 20rpx;
justify-content: space-between;
}
.common-btn, .primary-btn {
flex: 1;
padding: 0;
font-size: 26rpx;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
box-sizing: border-box;
}
2.3 核心逻辑(JS)
javascript
Page({
data: {
isModalShow: false, // 弹窗显示状态
canvasCtx: null, // Canvas 2D上下文
canvasNode: null, // Canvas节点实例
isDrawing: false, // 是否正在绘制
hasDrawn: false, // 是否有签名内容
markerInfo: { // 签名数据存储
user_sign_a: ''
},
currentSignType: '' // 当前签名类型(支持多签名扩展)
},
/**
* 打开签名弹窗(核心:确保点击响应)
*/
openSignModal(e) {
console.log('触发签名弹窗', e);
const signType = e.currentTarget.dataset.type || 'user_sign_a';
this.setData({
isModalShow: true,
currentSignType: signType
}, () => {
// 延迟初始化Canvas,避免阻塞事件响应
setTimeout(() => {
this.initCanvas();
}, 100);
});
wx.showToast({
title: '正在打开签名面板',
icon: 'none',
duration: 800
});
},
/**
* 关闭签名弹窗(彻底清空状态)
*/
closeSignModal() {
const { canvasCtx, canvasNode } = this.data;
// 强制清空Canvas内容,避免透传残留
if (canvasCtx && canvasNode) {
const dpr = wx.getSystemInfoSync().pixelRatio || 1;
canvasCtx.fillStyle = '#fff';
canvasCtx.fillRect(0, 0, canvasNode.width / dpr, canvasNode.height / dpr);
}
// 重置所有状态
this.setData({
isModalShow: false,
hasDrawn: false,
isDrawing: false
});
},
/**
* 阻止弹窗内容区点击穿透
*/
stopPropagation() {},
/**
* 初始化Canvas(适配像素比,解决模糊)
*/
initCanvas() {
try {
const query = wx.createSelectorQuery().in(this);
query.select('#signCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0] || !res[0].node) return;
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
const dpr = wx.getSystemInfoSync().pixelRatio || 1;
// 适配设备像素比
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 = '#000';
// 填充白色背景,避免透明底问题
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, res[0].width, res[0].height);
this.setData({ canvasCtx: ctx, canvasNode: canvas });
});
} catch (e) {
console.error('Canvas初始化失败', e);
wx.showToast({ title: '画布初始化失败', icon: 'none' });
}
},
/**
* 触摸开始:开始绘制签名
*/
handleTouchStart(e) {
const { canvasCtx } = this.data;
if (!canvasCtx) return;
this.setData({ isDrawing: true });
const x = e.touches[0].x;
const y = e.touches[0].y;
canvasCtx.beginPath();
canvasCtx.moveTo(x, y);
},
/**
* 触摸移动:绘制签名轨迹
*/
handleTouchMove(e) {
const { canvasCtx, isDrawing } = this.data;
if (!canvasCtx || !isDrawing) return;
const x = e.touches[0].x;
const y = e.touches[0].y;
canvasCtx.lineTo(x, y);
canvasCtx.stroke();
this.setData({ hasDrawn: true });
},
/**
* 触摸结束:停止绘制
*/
handleTouchEnd() {
this.setData({ isDrawing: false });
},
/**
* 清空签名画布
*/
clearSign() {
const { canvasCtx, canvasNode } = this.data;
if (!canvasCtx || !canvasNode) return;
const dpr = wx.getSystemInfoSync().pixelRatio || 1;
canvasCtx.fillStyle = '#fff';
canvasCtx.fillRect(0, 0, canvasNode.width / dpr, canvasNode.height / dpr);
this.setData({ hasDrawn: false });
},
/**
* 保存并上传签名(核心:更新表单显示)
*/
saveAndUploadSign() {
const { hasDrawn, canvasNode, currentSignType } = this.data;
// 校验签名内容
if (!hasDrawn) {
wx.showToast({ title: '请先完成手写签名', icon: 'none' });
return;
}
if (!canvasNode) {
wx.showToast({ title: '画布初始化失败', icon: 'none' });
return;
}
// 将Canvas转为临时文件
wx.canvasToTempFilePath({
canvas: canvasNode,
fileType: 'png',
quality: 0.8,
success: (res) => {
// 上传签名文件到后端(替换为实际接口)
wx.uploadFile({
url: 'https://your-domain.com/api/upload-sign', // 后端接口地址
filePath: res.tempFilePath,
name: 'sign_img',
formData: {
user_id: '1001', // 业务参数:用户ID
order_id: 'OD20260101',// 业务参数:订单ID
sign_type: currentSignType // 签名类型
},
success: (uploadRes) => {
try {
const result = JSON.parse(uploadRes.data);
if (result.code === 200) {
wx.showToast({ title: '签名保存成功', icon: 'success' });
// 更新表单签名显示
const updateData = {};
updateData[`markerInfo.${currentSignType}`] = result.data.sign_url;
this.setData(updateData, () => {
// 延迟关闭弹窗,确保样式渲染完成
setTimeout(() => {
this.closeSignModal();
}, 100);
});
} else {
wx.showToast({ title: result.msg || '签名保存失败', icon: 'none' });
}
} catch (e) {
console.error('解析后端数据失败', e);
wx.showToast({ title: '数据解析失败', icon: 'none' });
}
},
fail: (err) => {
console.error('上传失败', err);
wx.showToast({ title: '网络错误,上传失败', icon: 'none' });
}
});
},
fail: (err) => {
console.error('Canvas转文件失败', err);
wx.showToast({ title: '签名生成失败', icon: 'none' });
}
}, this);
},
/**
* 删除已保存的签名
*/
deleteSignature(e) {
const signType = e.currentTarget.dataset.type;
wx.showModal({
title: '提示',
content: '确定删除该签名吗?',
success: (res) => {
if (res.confirm) {
const updateData = {};
updateData[`markerInfo.${signType}`] = '';
this.setData(updateData);
}
}
});
}
});
三、常见问题及解决方案
3.1 点击签名区域无响应
问题原因
- 样式缺失导致点击区域不可交互;
- Canvas初始化逻辑阻塞事件响应;
- 元素层级覆盖导致点击穿透。
解决方案
- 给
signature-wrap添加cursor: pointer和touch-action: manipulation; - 延迟初始化Canvas(
setTimeout),优先执行弹窗显示逻辑; - 补充
page-root根容器,避免点击区域被挤压/隐藏。
3.2 签名后弹窗未彻底隐藏
问题原因
- 弹窗使用
opacity仅透明化,元素仍残留; - Canvas绘制内容透传至页面;
- 弹窗关闭时机过早,样式未渲染完成。
解决方案
- 弹窗显隐切换为
display: none/flex,彻底移除元素; - 关闭弹窗时强制清空Canvas内容;
- 保存签名后延迟100ms关闭弹窗。
3.3 签名图片模糊
问题原因
- 未适配设备像素比(dpr),Canvas实际分辨率不足。
解决方案
- 初始化Canvas时,将宽高乘以设备像素比;
- 通过
ctx.scale(dpr, dpr)缩放画布,保证绘制精度。
3.4 多签名场景扩展
- 复制签名区域WXML,修改
data-type(如user_sign_b); - JS无需修改,
currentSignType自动适配不同签名类型; - 后端接口通过
sign_type区分存储不同签名。
四、后端接口适配建议
4.1 接口地址
POST https://your-domain.com/api/upload-sign
4.2 请求参数
| 参数名 | 类型 | 说明 |
|---|---|---|
| sign_img | File | 签名图片文件 |
| user_id | String | 用户ID(业务参数) |
| order_id | String | 订单ID(业务参数) |
| sign_type | String | 签名类型(如user_sign_a) |
4.3 返回格式
json
{
"code": 200,
"msg": "上传成功",
"data": {
"sign_url": "https://your-domain.com/sign/xxx.png" // 签名图片访问地址
}
}
五、优化与扩展建议
5.1 体验优化
- 增加笔迹粗细/颜色配置;
- 签名弹窗添加"重签"按钮,简化操作流程;
- 加载状态提示(如上传时显示loading)。
5.2 功能扩展
- 支持签名撤销(记录绘制轨迹,分步回退);
- 签名图片压缩(调整
quality参数,平衡清晰度与大小); - 横屏签名适配(调整Canvas宽高比例)。
5.3 兼容性
- 适配小程序基础库版本≥2.10.0;
- 针对低版本库,降级使用Canvas 1.0 API。
六、总结
本方案实现了微信小程序表单式手写签名的完整功能,解决了"点击无响应、弹窗残留、图片模糊"等核心问题,代码结构清晰、可扩展性强。通过Canvas 2D适配设备像素比保证绘制精度,通过样式和逻辑优化确保交互流畅,同时支持多签名场景扩展,可直接应用于合同签署、审批等业务场景。