1. 引言
在 Web 应用中,电子签字功能广泛应用于合同签署、审批确认、表单提交等场景。Canvas 作为 HTML5 提供的绘图接口,可以轻松实现手写签名的采集、展示与导出。本文将从零开始,带你完成一个完整的 Canvas 签字组件,涵盖画布初始化、笔迹绘制、撤销重做、清空以及签名图片导出等核心功能。
2. 基础概念
在动手实现之前,先了解几个关键概念:
- Canvas 坐标系:Canvas 以左上角为原点,x 轴向右,y 轴向下,单位是像素。
- 2D 绘图上下文 :通过
getContext('2d')获取,提供绘制路径、线条、图形等 API。 - Pointer Events:现代浏览器推荐使用指针事件统一处理鼠标和触摸输入,避免分别监听 mouse 和 touch 事件。
- toDataURL:将画布内容导出为图片数据 URL,便于上传或展示。
3. 环境准备
本文示例使用原生 JavaScript 实现,无需任何第三方依赖,只需一个支持 HTML5 的现代浏览器即可。项目结构如下:
text
signature-demo/
├── index.html
├── style.css
└── signature.js
4. 页面结构与样式
首先创建 HTML 页面,包含画布容器、操作按钮和签名预览区域。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas 签字实战</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="signature-container">
<h2>请在此处签名</h2>
<canvas id="signatureCanvas" width="600" height="200"></canvas>
<div class="toolbar">
<button id="undoBtn">撤销</button>
<button id="clearBtn">清空</button>
<button id="saveBtn">保存签名</button>
</div>
<div class="preview">
<p>签名预览:</p>
<img id="previewImg" alt="签名预览">
</div>
</div>
<script src="signature.js"></script>
</body>
</html>
接下来编写样式,让画布有清晰的边框和舒适的书写区域。
css
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Microsoft YaHei", sans-serif;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.signature-container {
background: #fff;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
text-align: center;
}
h2 {
margin-bottom: 16px;
color: #333;
}
canvas {
border: 2px dashed #ccc;
border-radius: 4px;
background: #fafafa;
cursor: crosshair;
touch-action: none;
}
.toolbar {
margin-top: 16px;
display: flex;
gap: 12px;
justify-content: center;
}
button {
padding: 8px 20px;
border: none;
border-radius: 4px;
background: #4a90d9;
color: #fff;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #357abd;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.preview {
margin-top: 20px;
}
.preview img {
max-width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 8px;
}
5. 核心逻辑实现
现在实现签字的核心逻辑。主要分为画布初始化、事件绑定、绘制处理和状态管理四个部分。
5.1 画布初始化与事件绑定
获取画布元素和绘图上下文,并绑定指针事件。使用 Pointer Events 可以同时兼容鼠标和触摸操作。
javascript
const canvas = document.getElementById('signatureCanvas');
const ctx = canvas.getContext('2d');
const undoBtn = document.getElementById('undoBtn');
const clearBtn = document.getElementById('clearBtn');
const saveBtn = document.getElementById('saveBtn');
const previewImg = document.getElementById('previewImg');
let isDrawing = false;
let lastX = 0;
let lastY = 0;
let history = [];
let currentStep = -1;
// 设置画笔样式
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#333';
// 保存初始空白状态
saveState();
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointerleave', onPointerUp);
5.2 绘制处理
按下时开始绘制,移动时画线,松开时结束并保存状态。
javascript
function onPointerDown(e) {
isDrawing = true;
const pos = getPosition(e);
lastX = pos.x;
lastY = pos.y;
canvas.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!isDrawing) return;
const pos = getPosition(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
}
function onPointerUp(e) {
if (!isDrawing) return;
isDrawing = false;
saveState();
updateButtons();
}
function getPosition(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
5.3 状态管理与撤销
每次绘制结束后,将当前画布快照保存到历史栈中,实现撤销功能。
javascript
function saveState() {
// 丢弃撤销分支之后的历史
history = history.slice(0, currentStep + 1);
history.push(canvas.toDataURL());
currentStep++;
if (history.length > 20) {
history.shift();
currentStep--;
}
}
function undo() {
if (currentStep <= 0) return;
currentStep--;
const img = new Image();
img.onload = function () {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = history[currentStep];
updateButtons();
}
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
saveState();
updateButtons();
}
function updateButtons() {
undoBtn.disabled = currentStep <= 0;
clearBtn.disabled = currentStep <= 0;
}
5.4 签名导出
将画布内容导出为 PNG 图片,并显示在预览区域。
javascript
function saveSignature() {
const dataURL = canvas.toDataURL('image/png');
previewImg.src = dataURL;
// 如果需要上传到服务器,可以在这里发送请求
// const link = document.createElement('a');
// link.download = 'signature.png';
// link.href = dataURL;
// link.click();
}
undoBtn.addEventListener('click', undo);
clearBtn.addEventListener('click', clearCanvas);
saveBtn.addEventListener('click', saveSignature);
6. 完整代码整合
将上述代码整合到 signature.js 文件中,即可运行完整的签字组件。完整代码如下:
javascript
const canvas = document.getElementById('signatureCanvas');
const ctx = canvas.getContext('2d');
const undoBtn = document.getElementById('undoBtn');
const clearBtn = document.getElementById('clearBtn');
const saveBtn = document.getElementById('saveBtn');
const previewImg = document.getElementById('previewImg');
let isDrawing = false;
let lastX = 0;
let lastY = 0;
let history = [];
let currentStep = -1;
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#333';
saveState();
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointerleave', onPointerUp);
function onPointerDown(e) {
isDrawing = true;
const pos = getPosition(e);
lastX = pos.x;
lastY = pos.y;
canvas.setPointerCapture(e.pointerId);
}
function onPointerMove(e) {
if (!isDrawing) return;
const pos = getPosition(e);
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
}
function onPointerUp() {
if (!isDrawing) return;
isDrawing = false;
saveState();
updateButtons();
}
function getPosition(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function saveState() {
history = history.slice(0, currentStep + 1);
history.push(canvas.toDataURL());
currentStep++;
if (history.length > 20) {
history.shift();
currentStep--;
}
}
function undo() {
if (currentStep <= 0) return;
currentStep--;
const img = new Image();
img.onload = function () {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = history[currentStep];
updateButtons();
}
function clearCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
saveState();
updateButtons();
}
function saveSignature() {
const dataURL = canvas.toDataURL('image/png');
previewImg.src = dataURL;
}
function updateButtons() {
undoBtn.disabled = currentStep <= 0;
clearBtn.disabled = currentStep <= 0;
}
undoBtn.addEventListener('click', undo);
clearBtn.addEventListener('click', clearCanvas);
saveBtn.addEventListener('click', saveSignature);
7. React 组件封装
如果项目使用 React,可以将上述逻辑封装为一个可复用的签名组件。组件内部管理画布引用、绘制状态和历史栈,并通过 props 暴露导出、清空等回调。
jsx
import React, { useRef, useState, useEffect, useCallback } from 'react';
const SignatureCanvas = ({ onSave, width = 600, height = 200 }) => {
const canvasRef = useRef(null);
const [isDrawing, setIsDrawing] = useState(false);
const [history, setHistory] = useState([]);
const [currentStep, setCurrentStep] = useState(-1);
const lastPoint = useRef({ x: 0, y: 0 });
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#333';
saveState();
}, []);
const saveState = useCallback(() => {
const canvas = canvasRef.current;
setHistory(prev => {
const newHistory = prev.slice(0, currentStep + 1);
newHistory.push(canvas.toDataURL());
if (newHistory.length > 20) newHistory.shift();
return newHistory;
});
setCurrentStep(prev => Math.min(prev + 1, 19));
}, [currentStep]);
const getPosition = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const handlePointerDown = (e) => {
setIsDrawing(true);
const pos = getPosition(e);
lastPoint.current = pos;
canvasRef.current.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e) => {
if (!isDrawing) return;
const ctx = canvasRef.current.getContext('2d');
const pos = getPosition(e);
ctx.beginPath();
ctx.moveTo(lastPoint.current.x, lastPoint.current.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPoint.current = pos;
};
const handlePointerUp = () => {
if (!isDrawing) return;
setIsDrawing(false);
saveState();
};
const undo = () => {
if (currentStep <= 0) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = history[currentStep - 1];
setCurrentStep(prev => prev - 1);
};
const clearCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
saveState();
};
const handleSave = () => {
const dataURL = canvasRef.current.toDataURL('image/png');
onSave && onSave(dataURL);
};
return (
<div className="signature-container">
<canvas
ref={canvasRef}
width={width}
height={height}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
style={{ border: '2px dashed #ccc', cursor: 'crosshair', touchAction: 'none' }}
/>
<div className="toolbar">
<button onClick={undo} disabled={currentStep <= 0}>撤销</button>
<button onClick={clearCanvas} disabled={currentStep <= 0}>清空</button>
<button onClick={handleSave}>保存签名</button>
</div>
</div>
);
};
export default SignatureCanvas;
使用方式:
jsx
import SignatureCanvas from './SignatureCanvas';
function App() {
const handleSave = (dataURL) => {
console.log('签名图片:', dataURL);
// 上传到服务器
};
return <SignatureCanvas onSave={handleSave} width={600} height={200} />;
}
8. Vue 组件封装
在 Vue 项目中,可以使用 Composition API 封装同样的签名组件,逻辑与 React 版本保持一致,但通过 ref 和 reactive 管理状态。
vue
<template>
<div class="signature-container">
<canvas
ref="canvasRef"
:width="width"
:height="height"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
style="border: 2px dashed #ccc; cursor: crosshair; touch-action: none;"
></canvas>
<div class="toolbar">
<button @click="undo" :disabled="currentStep <= 0">撤销</button>
<button @click="clearCanvas" :disabled="currentStep <= 0">清空</button>
<button @click="handleSave">保存签名</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const props = defineProps({
width: { type: Number, default: 600 },
height: { type: Number, default: 200 },
onSave: { type: Function, required: true }
});
const canvasRef = ref(null);
const isDrawing = ref(false);
const history = ref([]);
const currentStep = ref(-1);
const lastPoint = ref({ x: 0, y: 0 });
onMounted(() => {
const ctx = canvasRef.value.getContext('2d');
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#333';
saveState();
});
const saveState = () => {
const canvas = canvasRef.value;
history.value = history.value.slice(0, currentStep.value + 1);
history.value.push(canvas.toDataURL());
if (history.value.length > 20) history.value.shift();
currentStep.value = Math.min(currentStep.value + 1, 19);
};
const getPosition = (e) => {
const rect = canvasRef.value.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const handlePointerDown = (e) => {
isDrawing.value = true;
lastPoint.value = getPosition(e);
canvasRef.value.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e) => {
if (!isDrawing.value) return;
const ctx = canvasRef.value.getContext('2d');
const pos = getPosition(e);
ctx.beginPath();
ctx.moveTo(lastPoint.value.x, lastPoint.value.y);
ctx.lineTo(pos.x, pos.y);
ctx.stroke();
lastPoint.value = pos;
};
const handlePointerUp = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
saveState();
};
const undo = () => {
if (currentStep.value <= 0) return;
const canvas = canvasRef.value;
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = history.value[currentStep.value - 1];
currentStep.value--;
};
const clearCanvas = () => {
const canvas = canvasRef.value;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
saveState();
};
const handleSave = () => {
const dataURL = canvasRef.value.toDataURL('image/png');
props.onSave(dataURL);
};
</script>
使用方式:
vue
<template>
<SignatureCanvas :on-save="handleSave" />
</template>
<script setup>
import SignatureCanvas from './components/SignatureCanvas.vue';
const handleSave = (dataURL) => {
console.log('签名图片:', dataURL);
// 上传到服务器
};
</script>
7. 常见问题与优化建议
7.1 高分辨率屏幕适配
在 Retina 等高清屏上,Canvas 会出现模糊问题。可以通过设置画布的实际像素尺寸为 CSS 尺寸的 devicePixelRatio 倍来解决。
javascript
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
7.2 笔迹平滑
如果需要更平滑的笔迹,可以使用二次贝塞尔曲线替代直线连接,对中间点进行插值处理。
7.3 签名校验
在保存前可以判断画布是否为空,避免提交空白签名。可以通过遍历像素数据检测是否存在非背景色像素。
javascript
function isCanvasEmpty() {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] !== 0) return false;
}
return true;
}
8. 总结
本文实现了一个完整的 Canvas 签字组件,涵盖画布初始化、指针事件绘制、历史状态管理、撤销清空以及图片导出等功能。核心要点包括:
- 使用 Pointer Events 统一处理鼠标和触摸输入,提升兼容性。
- 通过历史快照栈实现撤销功能,并限制历史长度防止内存溢出。
- 利用
toDataURL将签名导出为 PNG 图片,便于上传和展示。 - 针对高清屏和笔迹平滑提供了优化思路。
你可以在此基础上继续扩展,例如增加画笔颜色选择、笔迹粗细调节、签名图片上传到服务器等功能,打造更完善的电子签名解决方案。