Canvas 签字实战:从画布绘制到签名导出的完整实现

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 图片,便于上传和展示。
  • 针对高清屏和笔迹平滑提供了优化思路。

你可以在此基础上继续扩展,例如增加画笔颜色选择、笔迹粗细调节、签名图片上传到服务器等功能,打造更完善的电子签名解决方案。

相关推荐
前端·柱子22 天前
q-floodfill白边锯齿?一招搞定抗锯齿边缘问题
前端·html·canva可画
笑霸final1 个月前
不用上传服务器!用 Vue3 + ONNX Runtime Web 在浏览器本地实现智能抠图
运维·前端·图像处理·ai·onnx·canva可画·web性能优化
只要微微辣5 个月前
Uniapp 微信小程序 Canvas画框标注:拖拽缩放全攻略
前端·微信小程序·uni-app·canvas·canva可画
山楂树の5 个月前
图像标注大坑:img图片 + Canvas 叠加标注,同步放大后标注位置偏移、对不齐?详解修复方案及亚像素处理原理
前端·css·学习·canva可画
帆张芳显6 个月前
智表ZCELL产品V3.6 版发布,新增系统预置右键菜单操作、页签栏操作等功能
前端·canva可画·excel插件
yikegis7 个月前
TOP 5 在线音频剪辑软件 免费(2026)
人工智能·ffmpeg·音视频·语音识别·学习方法·canva可画
帆张芳显9 个月前
智表zcell产品V3.5 版发布,新增行列选中操作等功能
前端·javascript·excel·插件·canva可画
山楂树の10 个月前
ImageBitmap 将图像数据转换为GPU可用的纹理
性能优化·图形渲染·canva可画
by__csdn10 个月前
Vue2纯前端图形验证码实现详解+源码
前端·javascript·typescript·vue·状态模式·css3·canva可画