电子手写签名实战:新手避坑指南,3步搞定配置不卡顿
刚接手劳务系统开发时,我被“电子手写签名”这个需求坑惨了。前端画布闪烁、后端存储报错、移动端适配崩盘,折腾三天没搞定,差点被甲方骂退。
别慌,这套方案我在 5 个项目中复用,零配置冲突,新手避坑全靠这篇。核心痛点就一个:配置环境就卡半天。今天直接从零搭建,Python + Flask + Canvas 实现,代码全贴,注释逐行讲,保证你看完能跑通。
项目目标
先明确我们要做什么:用户用手指/鼠标在网页上画签名,前端采集轨迹,后端生成 PNG 图片并关联到劳务合同/考勤记录。
为什么不用现成 SaaS? 劳务行业数据敏感,合同签名必须本地化存储,SaaS 接口延迟高、费用贵,且无法离线部署。自研方案可控性强,核心依赖仅 NPM/PyPI 官方包,无第三方黑盒。
技术选型:
- 前端:原生 Canvas API(无 jQuery 依赖,兼容 Chrome/Safari/微信内置浏览器)
- 后端:Flask(轻量,部署快)
- 存储:SQLite + 文件系统(小项目够用,后期可换 MySQL/OSS)
- 签名格式:PNG(透明背景,叠加到 PDF 合同上不突兀)
验收标准:
- 平板/手机/PC 三端绘制流畅,无断线
- 签名图片分辨率 ≥ 800x300px,边缘清晰
- 后端 100ms 内返回签名 URL
- 支持撤销、清空、重签
目录结构
保持极简,新手复制即可跑:
sign-demo/
├── app.py # Flask 主入口
├── requirements.txt # 依赖清单
├── templates/
│ └── index.html # 前端页面
├── static/
│ ├── css/style.css # 样式
│ └── js/sign.js # 签名核心逻辑
└── uploads/ # 签名图片存储(自动创建)
requirements.txt 内容:
flask==2.3.3
pillow==10.0.0
避坑点:Pillow 必须锁版本。10.x 起移除部分兼容接口,旧教程用 9.x 会报错。NPM 前端无依赖,纯原生实现,避免 CDN 加载失败。
核心代码实现
前端:Canvas 签名采集
static/js/sign.js 是核心,逐行讲:
// 1. 获取画布上下文
const canvas = document.getElementById('signatureCanvas');
const ctx = canvas.getContext('2d');
let isDrawing = false;
let lastX = 0, lastY = 0;// 2. 设置画布尺寸(关键:避免模糊)
function resizeCanvas() {const rect = canvas.getBoundingClientRect();// 乘以 devicePixelRatio 适配高分屏const dpr = window.devicePixelRatio || 1;canvas.width = rect.width * dpr;canvas.height = rect.height * dpr;ctx.scale(dpr, dpr);// 设置描边样式ctx.strokeStyle = '#000';ctx.lineWidth = 2;ctx.lineCap = 'round'; // 圆头线条,更自然ctx.lineJoin = 'round';
}// 3. 触摸/鼠标事件统一处理
function getPos(e) {const rect = canvas.getBoundingClientRect();// 兼容 touch 和 mouseif (e.touches) {return {x: e.touches[0].clientX - rect.left,y: e.touches[0].clientY - rect.top};}return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}function startDraw(e) {e.preventDefault(); // 防止移动端滚动isDrawing = true;const pos = getPos(e);lastX = pos.x;lastY = pos.y;// 画第一个点ctx.beginPath();ctx.moveTo(lastX, lastY);
}function draw(e) {if (!isDrawing) return;e.preventDefault();const pos = getPos(e);ctx.lineTo(pos.x, pos.y);ctx.stroke();lastX = pos.x;lastY = pos.y;
}function endDraw() {isDrawing = false;
}// 4. 绑定事件(兼容移动端)
canvas.addEventListener('mousedown', startDraw);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDraw);
canvas.addEventListener('mouseleave', endDraw);
canvas.addEventListener('touchstart', startDraw, { passive: false });
canvas.addEventListener('touchmove', draw, { passive: false });
canvas.addEventListener('touchend', endDraw);// 5. 清空按钮
document.getElementById('clearBtn').onclick = () => {ctx.clearRect(0, 0, canvas.width, canvas.height);
};// 6. 提交签名
document.getElementById('submitBtn').onclick = async () => {if (ctx.getImageData(0, 0, canvas.width, canvas.height).data.some(v => v !== 0)) {const dataUrl = canvas.toDataURL('image/png');// 转 Blob 上传const blob = dataURLtoBlob(dataUrl);const formData = new FormData();formData.append('signature', blob, 'signature.png');const res = await fetch('/api/signature', {method: 'POST',body: formData});const data = await res.json();alert('签名保存成功,ID: ' + data.id);} else {alert('请先绘制签名');}
};// dataURL 转 Blob 工具函数
function dataURLtoBlob(dataurl) {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--) {u8arr[n] = bstr.charCodeAt(n);}return new Blob([u8arr], { type: mime });
}window.addEventListener('load', resizeCanvas);
window.addEventListener('resize', resizeCanvas);
避坑详解:
- 高分屏模糊:必须用
devicePixelRatio缩放,否则 iPhone/4K 屏签名线条发虚 - 移动端滚动:
touchmove必须加{ passive: false }+e.preventDefault(),否则画着画着页面就滚走了 - 空签名判断:
getImageData检查是否有非透明像素,防止用户误触提交空白图
后端:Flask 接收与存储
app.py 实现签名接收、验证、存储:
from flask import Flask, request, jsonify, send_from_directory
from werkzeug.utils import secure_filename
from datetime import datetime
import os
import uuidapp = Flask(__name__)
UPLOAD_FOLDER = 'uploads'
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB 限制# 确保上传目录存在
os.makedirs(UPLOAD_FOLDER, exist_ok=True)@app.route('/')
def index():return app.send_static_file('index.html') # 实际应 render_template@app.route('/api/signature', methods=['POST'])
def upload_signature():# 1. 检查是否有文件if 'signature' not in request.files:return jsonify({'error': 'No file part'}), 400file = request.files['signature']if file.filename == '':return jsonify({'error': 'No selected file'}), 400# 2. 安全文件名(防目录穿越)filename = secure_filename(file.filename)if not filename.endswith('.png'):return jsonify({'error': 'Only PNG allowed'}), 400# 3. 生成唯一 ID 避免覆盖sig_id = str(uuid.uuid4())safe_name = f"{sig_id}_{filename}"save_path = os.path.join(UPLOAD_FOLDER, safe_name)# 4. 保存文件file.save(save_path)# 5. 可选:记录到数据库(简化版用文件)metadata = {'id': sig_id,'timestamp': datetime.now().isoformat(),'path': safe_name,'ip': request.remote_addr}# 实际项目建议写入 SQLite# import sqlite3# conn = sqlite3.connect('signatures.db')# conn.execute('INSERT INTO signatures VALUES (?,?,?,?)', # (sig_id, metadata['timestamp'], safe_name, metadata['ip']))# conn.commit()# conn.close()return jsonify({'id': sig_id,'message': 'Signature saved','url': f'/uploads/{safe_name}'}), 200@app.route('/uploads/<filename>')
def uploaded_file(filename):return send_from_directory(UPLOAD_FOLDER, filename)if __name__ == '__main__':app.run(debug=True, host='0.0.0.0', port=5000)
避坑详解:
- secure_filename:必须用!直接拼用户文件名会有
../../etc/passwd风险 - UUID 命名:避免多人同时签名覆盖,
uuid4足够随机 - 文件大小限制:
MAX_CONTENT_LENGTH防止恶意大文件撑爆磁盘 - IP 记录:劳务场景需审计,谁签的、何时签、从哪签,全留痕
前端页面模板
templates/index.html:
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>电子手写签名</title><link rel="stylesheet" href="/static/css/style.css">
</head>
<body><div class="container"><h2>请在此区域签名</h2><canvas id="signatureCanvas" width="600" height="200"></canvas><div class="btn-group"><button id="clearBtn">清空</button><button id="submitBtn">提交签名</button></div><div id="result" class="result"></div></div><script src="/static/js/sign.js"></script>
</body>
</html>
static/css/style.css:
.container {max-width: 650px;margin: 50px auto;padding: 20px;font-family: Arial, sans-serif;
}canvas {border: 2px dashed #ccc;border-radius: 8px;touch-action: none; /* 关键:禁用默认触摸行为 */background: #fff;
}.btn-group {margin-top: 15px;display: flex;gap: 10px;
}button {padding: 10px 20px;font-size: 16px;cursor: pointer;border: none;border-radius: 4px;
}#clearBtn { background: #f44336; color: white; }
#submitBtn { background: #4CAF50; color: white; }
运行与测试
环境搭建
# 1. 创建虚拟环境(强烈建议)
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate# 2. 安装依赖
pip install -r requirements.txt# 3. 启动服务
python app.py
访问 http://localhost:5000,看到签名框即成功。
三端测试清单
| 设备 | 测试项 | 通过标准 |
|---|---|---|
| iPhone 12 | 手指绘制 | 无断线,不触发页面滚动 |
| 华为平板 | 手写笔绘制 | 压感不生效但轨迹连续,线条粗细一致 |
| Windows Chrome | 鼠标绘制 | 高分屏(150% 缩放)签名清晰 |
| 微信内置浏览器 | 触摸绘制 | 兼容正常,无白屏 |
常见问题排查:
画布空白,无法绘制
- 检查
touch-action: none是否生效 - 控制台看是否有
Uncaught TypeError: Cannot read properties of null,通常是 Canvas ID 不匹配
- 检查
签名上传 413 错误
- 检查
MAX_CONTENT_LENGTH,Canvas 生成的 PNG 通常 < 200KB,5MB 足够 - Nginx 部署时记得加
client_max_body_size 5m;
- 检查
移动端签名偏上/偏左
getBoundingClientRect返回的是 CSS 像素,如果画布有transform或父元素有 padding,需修正坐标- 简单方案:Canvas 直接铺满容器,无内边距
高清屏签名模糊
- 确认
resizeCanvas在load事件后执行 - 检查
ctx.scale(dpr, dpr)是否调用,且只调用一次
- 确认
优化扩展
性能优化
- 前端防抖:
touchmove事件高频触发,可加requestAnimationFrame节流 - 后端压缩:上传后用 Pillow 二次压缩,
image.save(path, optimize=True, quality=85) - CDN 加速:签名图片放 OSS,URL 带签名有效期,防盗链
安全加固
- HTTPS 强制:签名涉及合同效力,必须 HTTPS,否则浏览器拦截 Canvas
toDataURL - 文件类型校验:不仅查扩展名,还要用
python-magic检查 MIME - 访问控制:签名 URL 加 token,
/uploads/{id}?token=xxx,过期失效 - 防重放:前端生成 nonce,后端校验一次有效
法律合规
劳务场景签名需具备法律效力,建议:
- 时间戳:后端记录 UTC 时间,前端显示本地时区
- IP + User-Agent:完整记录签署环境
- 哈希值:对 PNG 文件算 SHA256,存库,防篡改
- 第三方存证:对接 e 签宝、法大大等,生成可信时间戳证书
重要提醒:纯 Canvas 签名在司法实践中可能被质疑真实性。劳务合同建议叠加“短信验证码 + 签名”双因子,或接入合规电子签平台。
扩展功能
- 签名预览:提交前弹出 Modal 确认
- 多次签名:支持保存历史签名,复用
- PDF 叠加:后端用
reportlab将 PNG 嵌入 PDF 合同 - OCR 识别:对接阿里云 OCR,签名转文本存档
小结
这套电子手写签名方案,我在 5 个劳务/外包项目中落地,稳定运行 2 年+。新手避坑核心就三点:
- 高分屏适配:
devicePixelRatio不能省,否则签名发虚 - 移动端事件:
touch-action: none+preventDefault,否则画着画着就滚走 - 安全文件名:
secure_filename+ UUID,防目录穿越
部署建议:
- 开发:Flask debug 模式
- 测试:Gunicorn + Nginx
- 生产:Gunicorn + Nginx + HTTPS + 对象存储
成本估算:
- 服务器:2 核 4G 阿里云轻量,约 100 元/月
- 域名 + SSL:约 100 元/年
- 存储:100 万张签名 ≈ 50GB,OSS 约 5 元/月
总成本可控,比 SaaS 年费省 90%。
你更常用 Canvas 还是 SVG 实现签名?Canvas 性能好但难矢量放大,SVG 可缩放但移动端兼容性坑多。评论区交流你的实战经验,或者贴出你的避坑技巧,帮更多人少踩雷。