手写签名是很多业务系统里绕不开的一环,从电子合同到报销审批,都要求用户在页面上留下笔迹。过去常见的做法是引入第三方签名库,但增加依赖的同时也带来了体积和定制上的负担。其实利用 HTML5 的 Canvas 元素,结合 Vue 3 的组合式 API,完全可以自己实现一个轻量、可控的签名组件。核心思路并不复杂:监听指针事件收集坐标,用 Canvas 的 2D 上下文绘制路径,最后通过 toDataURL 方法导出为 base64 字符串。接下来就从初始化画布开始,一步步把整个流程走通。

初始化 Canvas 并处理设备像素比
直接给 canvas 设置 CSS 宽高再在上面画线,在高分屏上经常会出现模糊的情况。这是因为 CSS 像素和物理像素并不总是 1:1 对应,比如一台 devicePixelRatio 为 2 的显示器,一个 CSS 像素实际占用 2x2 个物理像素。如果画布内部宽度与 CSS 宽度相同,浏览器会把画布内容拉伸到物理像素上,导致线条发虚。解决办法是把画布的实际宽高乘以 devicePixelRatio,然后用 scale 把上下文缩放回去,这样绘制时依然使用 CSS 坐标,但渲染精度匹配了屏幕。
在 Vue 3 的组件里,可以通过 ref 拿到 canvas 元素,在 onMounted 生命周期中执行初始化。下面这段代码展示了完整的初始化逻辑,包括设置宽高、缩放上下文以及设置默认的线条样式。这里把签名背景设成白色,是为了后续导出 JPEG 时避免透明区域变成黑色。
<template>
<div class="signature-pad">
<canvas ref="canvasRef" class="signature-canvas"></canvas>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const canvasRef = ref(null);
let ctx = null;
let isDrawing = false;
let lastX = 0;
let lastY = 0;
function initCanvas() {
const canvas = canvasRef.value;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
// 实际像素尺寸 = CSS 尺寸 * 设备像素比
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
// 填充白色背景
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, rect.width, rect.height);
// 设置笔触样式
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#000000';
}
onMounted(() => {
initCanvas();
});
</script>
<style scoped>
.signature-canvas {
width: 100%;
height: 300px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: crosshair;
}
</style>
这里有几个细节需要留意。首先是在 onMounted 中调用 initCanvas,因为此时 DOM 元素已经挂载,可以准确获取到 getBoundingClientRect 的尺寸。其次,填充白色背景这一步是必要的,否则在透明背景上签名后导出为 JPEG 会出现黑色底。最后,lineCap 和 lineJoin 设为 round 能让笔迹的转折处更自然,适合手写场景。
如果画布尺寸在窗口缩放时会变化,还需要监听 resize 事件重新初始化,或者使用 ResizeObserver。不过对于大多数签名场景,画布尺寸是固定的,按需调整即可。另外要注意,重新初始化会清空已有签名,所以通常在画布尺寸变化后需要提示用户重新签名。
利用 Pointer Events 采集并绘制轨迹
传统实现里通常会分别监听 mousedown / mousemove / mouseup 和 touchstart / touchmove / touchend 来处理鼠标和触摸输入。但 Pointer Events 统一了这两类输入源,现代浏览器对它的支持已经非常完善,只需要监听 pointerdown、pointermove 和 pointerup 三个事件就够了。Pointer Events 还提供了 pointerType 属性,可以区分鼠标、触摸笔或手指,方便做差异化处理。
绘制签名时,不能简单地用 lineTo 连接每个点,那样会产生明显的折线感。更好的做法是用二次贝塞尔曲线平滑连接,取相邻两点的中点作为控制点。每次 pointermove 时记录当前点和上一个点,计算中点,然后使用 quadraticCurveTo 完成一段平滑曲线。pointerdown 时先移动到起点,pointerup 时结束当前路径。下面这段代码展示了具体的事件绑定逻辑。
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const canvasRef = ref(null);
let ctx = null;
let isDrawing = false;
let lastX = 0;
let lastY = 0;
let strokes = []; // 存储每次笔画的起点、终点等用于撤销(此处简化)
function getCanvasPos(e) {
const canvas = canvasRef.value;
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}
function startDrawing(e) {
isDrawing = true;
const pos = getCanvasPos(e);
lastX = pos.x;
lastY = pos.y;
ctx.beginPath();
ctx.moveTo(lastX, lastY);
canvasRef.value.setPointerCapture(e.pointerId);
}
function draw(e) {
if (!isDrawing) return;
e.preventDefault();
const pos = getCanvasPos(e);
const midX = (lastX + pos.x) / 2;
const midY = (lastY + pos.y) / 2;
ctx.quadraticCurveTo(lastX, lastY, midX, midY);
ctx.stroke();
lastX = pos.x;
lastY = pos.y;
}
function endDrawing(e) {
if (!isDrawing) return;
isDrawing = false;
ctx.closePath();
canvasRef.value.releasePointerCapture(e.pointerId);
}
function bindEvents() {
const canvas = canvasRef.value;
canvas.addEventListener('pointerdown', startDrawing);
canvas.addEventListener('pointermove', draw);
canvas.addEventListener('pointerup', endDrawing);
canvas.addEventListener('pointercancel', endDrawing);
// 阻止移动端默认滚动
canvas.style.touchAction = 'none';
}
onMounted(() => {
initCanvas();
bindEvents();
});
onBeforeUnmount(() => {
const canvas = canvasRef.value;
canvas.removeEventListener('pointerdown', startDrawing);
canvas.removeEventListener('pointermove', draw);
canvas.removeEventListener('pointerup', endDrawing);
canvas.removeEventListener('pointercancel', endDrawing);
});
</script>
上面这段代码中,setPointerCapture 和 releasePointerCapture 的作用是让指针事件始终被当前画布捕获,即使手指或鼠标移出了画布区域,也能继续绘制,避免中断笔迹。这在移动端尤其重要,因为用户签名时很容易滑出边界。另外,设置 canvas.style.touchAction = 'none' 可以禁止浏览器在触摸画布时触发滚动或缩放,保证绘制流畅。
如果需要支持撤销功能,可以在每次 pointerdown 时保存当前画布的快照,或者记录笔画路径数据。保存快照的方式比较简单,用 getImageData 或 toDataURL 暂存,但内存消耗较大;记录笔画数据则更高效,撤销时清空画布并重放其余笔画。对于简单的签名应用,保存快照已经足够。下面是一个清空和撤销的示例函数。
function clearCanvas() {
const canvas = canvasRef.value;
const rect = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, rect.width, rect.height);
}
// 简化撤销:每次开始新笔画前保存快照
let snapshot = null;
function saveSnapshot() {
snapshot = ctx.getImageData(0, 0, canvasRef.value.width, canvasRef.value.height);
}
function undo() {
if (snapshot) {
ctx.putImageData(snapshot, 0, 0);
}
}
// 在 pointerdown 中调用 saveSnapshot()
function startDrawing(e) {
saveSnapshot();
isDrawing = true;
// ... 其余逻辑
}
注意 getImageData 使用的坐标是基于实际像素的,所以这里直接传入 0 到 canvas.width 的范围,不需要再结合 dpr。撤销时用 putImageData 恢复即可。如果你希望撤销只回退一步,这种快照方式就足够了;如果需要多步撤销,可以维护一个快照数组,但要注意内存占用。
导出签名并保存为 base64 字符串
绘制完成后,通常需要把签名提交给服务器或者显示在预览区域。Canvas 提供了 toDataURL 方法,可以直接把画布内容转换成一个数据 URL,格式类似于 data:image/png;base64,xxxxxx。这个方法默认导出 PNG 格式,也可以指定为 image/jpeg 并传入质量参数。PNG 是无损压缩,适合线条细节;JPEG 体积更小,但背景必须是不透明的,否则透明区域会变成黑色。
在 Vue 组件中,可以定义一个导出函数,在用户点击“保存”按钮时调用。由于前面初始化时已经填充了白色背景,直接调用 toDataURL 即可得到带白色背景的图片。如果希望保留透明背景,可以把背景填充去掉,导出为 PNG。下面这段代码展示了如何获取 base64 字符串,并处理可能的异常。
<template>
<div>
<canvas ref="canvasRef"></canvas>
<button @click="exportSignature">导出签名</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const canvasRef = ref(null);
const signatureDataUrl = ref('');
function exportSignature() {
const canvas = canvasRef.value;
if (!canvas) return;
// 导出为 PNG,保留白色背景
const dataUrl = canvas.toDataURL('image/png');
signatureDataUrl.value = dataUrl;
// 如果需要 JPEG 且不希望透明变黑,已在初始化时填充白色
// const jpegDataUrl = canvas.toDataURL('image/jpeg', 0.9);
console.log('签名 base64:', dataUrl);
}
</script>
导出的 dataUrl 可以直接作为 img 标签的 src 显示,也可以把 base64 部分(去掉前缀 data:image/png;base64,)发送给后端接口。如果后端需要文件上传,可以先把 dataUrl 转成 Blob 再包装成 File 对象。转换方法大致如下:用 atob 解码 base64 字符串,构建 Uint8Array,再 new Blob 和 new File。这里给出一个通用转换函数,方便在不同场景复用。
function dataUrlToFile(dataUrl, filename) {
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 File([u8arr], filename, { type: mime });
}
另外要注意,toDataURL 对画布内容有同源限制,如果画布上绘制了跨域图片,调用 toDataURL 会抛出 SecurityError。签名场景中通常不会涉及跨域图片,但如果你的组件里加载了外部水印或背景图,就需要给图片设置 crossOrigin 属性并通过 CORS 验证。此外,对于特别大的画布,导出的 base64 字符串可能很长,直接放在状态里会导致组件响应变慢,可以考虑先压缩尺寸或降低质量。
优化交互细节与常见问题
移动端签名时,手指在画布上滑动很容易触发页面的滚动或下拉刷新,即使设置了 touch-action: none,有时候因为事件冒泡或页面整体滚动行为仍然会有干扰。比较稳妥的做法是在画布上再包一层容器,给容器设置 touch-action: none,并且阻止 touchmove 的默认行为。另外,在 iOS Safari 上,pointer events 的支持从 13 版本才开始,如果还需要兼容更老的设备,可以同时监听 touch 事件作为降级方案。
画布尺寸的自适应也是一个常见需求。签名区域通常要跟随屏幕宽度变化,如果直接用 window.resize 重新初始化画布,会丢失已经签好的内容。一个折中方案是保存当前的 base64,重新初始化后用 drawImage 把旧签名绘制回新画布。但这样做在频繁 resize 时会有性能问题,可以考虑使用 CSS 保持画布比例,或者在 resize 结束后再重建。对于多数签名场景,固定画布高度、宽度设为百分比即可,没必要过度复杂化。
性能方面,pointermove 触发频率很高,如果每次事件都直接进行路径绘制并 stroke,在一些低端设备上可能会卡顿。可以使用 requestAnimationFrame 对绘制操作进行节流,把坐标变更累积起来,下一帧统一绘制。或者把绘制逻辑放到 worker 中进行,但那样需要离屏 Canvas,复杂度较高。对于签名这种短时间、低频的交互,通常不需要做 rAF 优化,真正影响体验的是笔迹的平滑算法,所以优先保证曲线插值质量更重要。