上周组长甩过来一个需求:“客户要在合同图片上签电子签名,还得能拖着签名调整位置” 虽然是真的懵逼但也还是挤出一句 “好的,我看看”。查了一圈资料,发现 fabric.js 这玩意儿简直是为这种 Canvas 交互量身定做的,空谈理论不如上手实践,今天就把实现过程掰开揉碎了讲给大家。
先搭个基础架子
任何前端功能实现的第一步都是搭建基础环境。练习就直接用 CDN 引入 fabric.js了,毕竟不是每个项目都有闲工夫搞 npm 那套复杂流程。在 HTML 里放个 Canvas 元素,再配个签名板和几个按钮,基本布局就有了:
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
<div class="container">
<h3 style="padding-bottom: 5px;">合同图片签名区</h3>
<canvas id="canvas" width="800" height="400"></canvas>
<div class="signature-area">
<h4>电子签名</h4>
<p class="tips">在下方画布签名,完成后点击保存签名</p>
<canvas id="signaturePad" width="800" height="150"></canvas>
<!-- 新增签名操作区 -->
<div class="signature-actions">
<button id="clearCurrentSignature">清空当前签名</button>
<button id="saveSignature">保存签名到画布</button>
<button id="uploadImage">上传合同图片</button>
<input type="file" id="fileInput" accept="image/*" style="display:none">
<button id="removeSelected">删除选中签名</button>
<button id="clearCanvas">清空画布</button>
</div>
</div>
</div>
CSS 部分简单美化一下,让签名板和主画布分开显示,按钮排整齐点,别让用户觉得我们的界面太潦草。
初始化 fabric 画布
这一步是核心中的核心。初始化 fabric.Canvas 实例时,最好加上 preserveObjectStacking 属性,不然拖拽元素时层级会乱得让人头大。我吃过这方面的亏,大家一定要注意:
const canvas = new fabric.Canvas('canvas', {
preserveObjectStacking: true,
backgroundColor: '#fff'
});
初始化后可以先放张默认图片,让用户有个直观感受。用 fabric.Image.fromURL 方法加载图片,记得设置 selectable 为 true,这样才能拖拽调整。
实现电子签名功能
签名功能用原生 Canvas 实现更灵活。给签名板 Canvas 绑定 mousedown、mousemove 和 mouseup 事件,在 mousemove 时用 lineTo 绘制线条。这里有个小技巧,设置 lineCap 为 round 可以让签名更流畅自然。
保存签名时,把签名板 Canvas 转换成图片对象,然后添加到 fabric 画布中:
document.getElementById('saveSignature').addEventListener('click', () => {
// 检查签名是否为空
const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
const data = imageData.data;
let hasContent = false;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
hasContent = true;
break;
}
}
if (!hasContent) {
alert('请先完成签名再保存');
return;
}
const signatureImg = new Image();
signatureImg.src = signaturePad.toDataURL('image/png');
signatureImg.onload = function() {
const fabricImg = new fabric.Image(signatureImg, {
left: 100,
top: 100,
opacity: 1,
// 启用缩放功能
lockScalingX: false,
lockScalingY: false,
lockUniScaling: false,
// 可选设置
minScaleLimit: 0.3, // 最小缩放比例
maxScaleLimit: 3, // 最大缩放比例
isSignature: true
});
canvas.add(fabricImg);
canvas.setActiveObject(fabricImg);
sigCtx.clearRect(0, 0, signaturePad.width, signaturePad.height);
};
});
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电子签名合成工具</title>
<!-- 引入fabric.js -->
<script src="https://cdn.jsdelivr.net/npm/fabric@5.3.0/dist/fabric.min.js"></script>
<style>
*{
margin:0;
padding:0;
box-sizing: border-box;
}
body{
display:flex;
justify-content: center;
align-items: center;
}
.container {
width: 90%;
margin: 20px auto;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
display:flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
#canvas {
border: 1px dashed #ccc;
background-color: #f9f9f9;
}
.signature-area {
margin: 20px 0;
}
#signaturePad {
border: 1px solid #333;
background-color: white;
}
.signature-actions {
margin-top: 10px;
display: flex;
gap: 10px;
}
.btn-group {
margin-top: 15px;
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #3367d6;
}
.tips {
color: #666;
font-size: 14px;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h3 style="padding-bottom: 5px;">合同图片签名区</h3>
<canvas id="canvas" width="800" height="400"></canvas>
<div class="signature-area">
<h4>电子签名</h4>
<p class="tips">在下方画布签名,完成后点击保存签名</p>
<canvas id="signaturePad" width="800" height="150"></canvas>
<!-- 新增签名操作区 -->
<div class="signature-actions">
<button id="clearCurrentSignature">清空当前签名</button>
<button id="saveSignature">保存签名到画布</button>
<button id="uploadImage">上传合同图片</button>
<input type="file" id="fileInput" accept="image/*" style="display:none">
<button id="removeSelected">删除选中签名</button>
<button id="clearCanvas">清空画布</button>
</div>
</div>
</div>
<script>
// 初始化主画布
const canvas = new fabric.Canvas('canvas', {
preserveObjectStacking: true,
backgroundColor: '#fff',
enableTouchEvents: true
});
// 初始化签名板
const signaturePad = document.getElementById('signaturePad');
const sigCtx = signaturePad.getContext('2d');
let isDrawing = false;
// 设置签名线条样式
sigCtx.lineWidth = 2;
sigCtx.lineCap = 'round';
sigCtx.strokeStyle = '#000';
// 签名板事件绑定
signaturePad.addEventListener('mousedown', startDrawing);
signaturePad.addEventListener('mousemove', draw);
signaturePad.addEventListener('mouseup', stopDrawing);
signaturePad.addEventListener('mouseout', stopDrawing);
// 移动端触摸事件
signaturePad.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
const mouseEvent = new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY
});
signaturePad.dispatchEvent(mouseEvent);
});
signaturePad.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY
});
signaturePad.dispatchEvent(mouseEvent);
});
signaturePad.addEventListener('touchend', () => {
const mouseEvent = new MouseEvent('mouseup');
signaturePad.dispatchEvent(mouseEvent);
});
// 签名绘制函数
function startDrawing(e) {
isDrawing = true;
const rect = signaturePad.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
sigCtx.beginPath();
sigCtx.moveTo(x, y);
}
function draw(e) {
if (!isDrawing) return;
const rect = signaturePad.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
sigCtx.lineTo(x, y);
sigCtx.stroke();
}
function stopDrawing() {
isDrawing = false;
}
// 核心功能:清空当前签名
document.getElementById('clearCurrentSignature').addEventListener('click', () => {
// 先判断是否有签名内容
const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
const data = imageData.data;
let hasContent = false;
// 检查画布是否有内容(跳过全透明像素)
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) { // alpha通道大于0
hasContent = true;
break;
}
}
if (hasContent) {
sigCtx.clearRect(0, 0, signaturePad.width, signaturePad.height);
} else {
// 可以省略提示,也可以保留
// alert('签名区为空,无需清空');
}
});
// 上传图片功能
document.getElementById('uploadImage').addEventListener('click', () => {
document.getElementById('fileInput').click();
});
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
fabric.Image.fromURL(event.target.result, (img) => {
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height,
1
);
img.scale(scale);
img.set({
left: (canvas.width - img.width * scale) / 2,
top: (canvas.height - img.height * scale) / 2,
selectable: false
});
canvas.add(img);
canvas.sendToBack(img);
});
};
reader.readAsDataURL(file);
});
// 保存签名到主画布
document.getElementById('saveSignature').addEventListener('click', () => {
// 检查签名是否为空
const imageData = sigCtx.getImageData(0, 0, signaturePad.width, signaturePad.height);
const data = imageData.data;
let hasContent = false;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
hasContent = true;
break;
}
}
if (!hasContent) {
alert('请先完成签名再保存');
return;
}
const signatureImg = new Image();
signatureImg.src = signaturePad.toDataURL('image/png');
signatureImg.onload = function() {
const fabricImg = new fabric.Image(signatureImg, {
left: 100,
top: 100,
opacity: 1,
// 启用缩放功能
lockScalingX: false,
lockScalingY: false,
lockUniScaling: false,
// 可选设置
minScaleLimit: 0.3, // 最小缩放比例
maxScaleLimit: 3, // 最大缩放比例
isSignature: true
});
canvas.add(fabricImg);
canvas.setActiveObject(fabricImg);
sigCtx.clearRect(0, 0, signaturePad.width, signaturePad.height);
};
});
// 删除画布中选中的签名
document.getElementById('removeSelected').addEventListener('click', () => {
const activeObj = canvas.getActiveObject();
if (activeObj) {
canvas.remove(activeObj);
} else {
alert('请先点击选中要删除的签名');
}
});
// 清空画布
document.getElementById('clearCanvas').addEventListener('click', () => {
if (canvas.getObjects().length > 0 && confirm('确定要清空所有内容吗?')) {
canvas.clear();
}
});
// 初始化时加载示例图片
fabric.Image.fromURL('https://picsum.photos/800/600', (img) => {
img.set({
left: 0,
top: 0,
selectable: false
});
canvas.add(img);
canvas.sendToBack(img);
});
</script>
</body>
</html>
踩过的坑和优化建议
测试时发现,签名图片添加到画布后有时会盖住背景图片。解决办法是在添加背景图片后调用 canvas.sendToBack (),把它放到最底层。另外,移动端适配是个大问题。需要给 Canvas 添加 touch 事件监听,好在 fabric.js 有专门的 touch 事件处理,稍微配置一下就能用。如果用户签名失误,最好加个 “清除签名” 按钮,调用 canvas.remove (activeObject) 就能删除当前选中的签名。
最后想说,作为程序员,我们每天都在和各种奇葩需求打交道。与其抱怨,不如像这次用 fabric.js 实现签名合成功能一样,把每次需求都当成提升自己的机会。毕竟,解决问题的能力才是我们安身立命的根本。
jym有其他方法欢迎评论区讨论