
电子手写签名实战:新手避坑指南,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 和 mouse
if (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 uuid
app = 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'}), 400
file = 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 可缩放但移动端兼容性坑多。评论区交流你的实战经验,或者贴出你的避坑技巧,帮更多人少踩雷。