Vue3 电子签名实战指南:从选型到避坑

5 阅读12分钟

一、电子签名组件生态概览

Vue3 生态中有多款成熟的电子签名组件可供选择,各有特色:

组件名称特点适用场景
vue3-signature-padVue3 专用、轻量无依赖、CSS变量主题适配快速集成、原生 Vue3 项目
signature_pad原生 JS 库、成熟的 DPR 适配方案需要精细控制、高分辨率屏幕兼容
sign-canvas支持二次封装、配置丰富需要深度定制、Element UI 集成
vue-esign支持裁剪空白区域、自适应屏幕变化需导出干净图片、移动端适配

这些组件均基于 HTML5 Canvas 实现,核心原理相似:通过监听鼠标或触摸事件,将用户手写轨迹实时绘制到画布上,最终支持导出为 PNG/JPEG/SVG 等图片格式。

二、快速集成:四大方案详解

2.1 vue3-signature-pad(推荐 Vue3 项目首选)

这是专为 Vue3 打造的签名组件,API 设计简洁,与 Vue3 响应式系统天然融合。

安装:

npm install vue3-signature-pad --save

全局注册(main.js):

import { createApp } from 'vue'
import App from './App.vue'
import VueSignaturePad from 'vue3-signature-pad'

const app = createApp(App)
app.use(VueSignaturePad)
app.mount('#app')

页面使用:

<template>
  <div>
    <vue3-signature-pad
      :width="500"
      :height="300"
      pen-color="#000000"
      :pen-width="2"
      border-color="#333"
      @end="onSignEnd"
    />
    <button @click="clearSign">清空</button>
    <button @click="saveSign">保存</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const signaturePad = ref(null)

const clearSign = () => {
  signaturePad.value.clear()
}

const saveSign = () => {
  const { isEmpty, data } = signaturePad.value.save()
  if (isEmpty) {
    alert('请先签名')
    return
  }
  console.log('签名数据:', data) // base64 图片
}

const onSignEnd = () => {
  console.log('签名完成')
}
</script>

核心配置:

属性类型默认值说明
widthString/Number100%画布宽度
heightString/Number100%画布高度
pen-colorString#000笔触颜色
pen-widthNumber2笔触粗细
border-colorString#eee边框颜色
disabledBooleanfalse是否禁用

核心方法:

  • clear() - 清空画布
  • save() - 返回 { isEmpty, data },data 为 base64 图片
  • isEmpty() - 判断是否为空签名
  • fromDataURL(url) - 回显已有签名

2.2 signature_pad(原生方案,DPR 适配最优)

如果你需要更好的跨框架兼容性或在高分辨率设备上遇到签名偏移问题,推荐使用这个原生库。它提供了完善的 DPR(Device Pixel Ratio)适配方案。

安装:

npm install signature_pad

封装为 Vue3 组件:

<template>
  <div class="signature-container" ref="containerRef">
    <canvas
      ref="canvasRef"
      @mousedown="onStart"
      @mousemove="onMove"
      @mouseup="onEnd"
      @touchstart.prevent="onStart"
      @touchmove.prevent="onMove"
      @touchend="onEnd"
    />
    <div class="actions">
      <button @click="clear">清空</button>
      <button @click="save">保存</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import SignaturePad from 'signature_pad'

const canvasRef = ref(null)
const containerRef = ref(null)
let signaturePad = null

// 初始化签名板
const initPad = () => {
  const canvas = canvasRef.value
  const container = containerRef.value
  const dpr = window.devicePixelRatio || 1
  
  // 设置画布实际尺寸(考虑 DPR)
  canvas.width = container.offsetWidth * dpr
  canvas.height = container.offsetHeight * dpr
  canvas.style.width = `${container.offsetWidth}px`
  canvas.style.height = `${container.offsetHeight}px`
  
  const ctx = canvas.getContext('2d')
  ctx.scale(dpr, dpr) // 坐标系缩放
  
  signaturePad = new SignaturePad(canvas, {
    minWidth: 1,
    maxWidth: 3,
    penColor: '#000'
  })
}

const clear = () => signaturePad?.clear()
const save = () => {
  if (signaturePad.isEmpty()) {
    alert('请先签名')
    return
  }
  return signaturePad.toDataURL() // base64
}

onMounted(initPad)
onUnmounted(() => signaturePad?.off())
</script>

2.3 sign-canvas(适合 Element UI 生态)

如果你在项目中使用 Element UI,需要更丰富的配置选项,sign-canvas 是很好的选择。

安装:

npm install sign-canvas --save

全局注册:

import SignCanvas from 'sign-canvas'
import { createApp } from 'vue'

const app = createApp(App)
app.use(SignCanvas)

基础使用:

<template>
  <el-dialog title="签名" v-model="visible" width="600px">
    <sign-canvas
      ref="SignCanvas"
      :options="options"
      v-model:image="signImage"
    />
    <template #footer>
      <el-button @click="clear">清空</el-button>
      <el-button type="primary" @click="save">确认</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref } from 'vue'

const visible = ref(false)
const signImage = ref('')
const SignCanvas = ref(null)

const options = {
  writeWidth: 5,      // 笔迹宽度
  writeColor: '#101010', // 笔迹颜色
  bgColor: '#ffffff',  // 背景色
  lineCap: 'round',   // 线条边缘
  lineJoin: 'round',  // 线条交汇
  canvasWidth: 500,
  canvasHeight: 300,
  isSign: true,        // 签名模式(无边框)
  imgType: 'png'
}

const clear = () => SignCanvas.value?.canvasClear()
const save = () => {
  const base64 = SignCanvas.value.saveAsImg()
  console.log('签名图片:', base64)
}
</script>

2.4 vue-esign(支持裁剪与自适应)

vue-esign 的最大特色是支持裁剪签名周围的空白区域,以及画布自适应屏幕变化(窗口缩放、屏幕旋转时自动校正坐标)。

安装:

npm install vue-esign --save

使用:

<template>
  <vue-esign
    ref="esign"
    :width="800"
    :height="300"
    :line-width="4"
    line-color="#000000"
    bg-color="#ffffff"
    :is-crop="true"
    @result="onSignResult"
  />
  <button @click="reset">清空</button>
  <button @click="generate">生成</button>
</template>

<script setup>
import { ref } from 'vue'

const esign = ref(null)

const reset = () => esign.value.reset()

const generate = async () => {
  const base64 = await esign.value.generate()
  if (!base64) {
    alert('请签名后再生成')
    return
  }
  console.log('签名图片:', base64)
}

const onSignResult = (data) => {
  console.log('签名结果:', data)
}
</script>

核心配置:

属性类型默认值说明
widthNumber800画布宽度
heightNumber300画布高度
lineWidthNumber4画笔粗细
lineColorString#000000画笔颜色
bgColorString-画布背景色
isCropBooleanfalse是否裁剪空白区域
isClearBgColorBooleanfalse清空时是否清空背景色

三、高分辨率屏幕签名偏移问题详解

在 DPR > 1 的屏幕上(如 Retina 显示屏),如果直接使用默认配置,会出现签名轨迹与实际触点位置不匹配的问题。这是 Canvas 开发中的经典问题。

3.1 问题根源

  1. Canvas 尺寸不匹配:CSS 设置的显示尺寸与 Canvas 内部缓冲区的实际尺寸不一致
  2. 设备像素比(DPR):CSS 像素与设备物理像素的比例不再是 1:1
  3. 坐标系未校正:事件坐标直接映射到未缩放的 Canvas 坐标系

3.2 解决方案(六步法)

const initCanvas = () => {
  const canvas = document.getElementById('signature-canvas')
  const dpr = window.devicePixelRatio || 1
  const cssWidth = 500
  const cssHeight = 300
  
  // 1. 获取 DPR
  // 2. 设置 Canvas 实际尺寸(乘以 DPR)
  canvas.width = cssWidth * dpr
  canvas.height = cssHeight * dpr
  
  // 3. 设置 CSS 尺寸(保持视觉大小)
  canvas.style.width = `${cssWidth}px`
  canvas.style.height = `${cssHeight}px`
  
  const ctx = canvas.getContext('2d')
  
  // 4. 坐标系缩放(关键步骤)
  ctx.scale(dpr, dpr)
  
  // 5. 调整笔迹粗细(高分辨率下需要更细)
  // 可根据 DPR 动态调整 minWidth 和 maxWidth
  
  // 6. 触摸支持:禁用默认滚动
  canvas.addEventListener('touchstart', (e) => {
    e.preventDefault()
  }, { passive: false })
}

3.3 响应式处理

窗口大小变化时需要重新初始化:

import { ref, onMounted, onUnmounted } from 'vue'

const handleResize = () => {
  // 重新初始化签名板
  initCanvas()
}

onMounted(() => {
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
})

四、移动端签名支持最优方案

移动端签名是电子签名最具挑战性的场景——触摸事件处理、页面滚动穿透、DPR 适配、笔锋效果、横竖屏切换等问题需要逐一解决。以下是经过验证的最优实践方案。

4.1 移动端核心问题清单

问题影响严重程度
触摸事件与页面滚动冲突签名时页面跟随滚动,无法正常书写🔴 严重
DPR 高分辨率偏移签名轨迹与触点位置不匹配🔴 严重
横竖屏切换后画布异常旋转屏幕后签名丢失或坐标错乱🟡 中等
笔迹生硬无压感书写体验差,签名不自然🟡 中等
Canvas 尺寸未随容器自适应小屏设备签名区域过大或过小🟡 中等
导出图片模糊高 DPR 设备导出低分辨率图片🟡 中等

4.2 方案一:smooth-signature(移动端最优推荐)

smooth-signature 是专为移动端签名场景设计的库,内置贝塞尔曲线笔锋算法、自动 DPR 适配和分层绘制策略,是当前移动端体验最优的选择。

安装:

npm install smooth-signature --save

封装为 Vue3 组件:

<template>
  <div class="sign-container">
    <canvas ref="canvasRef" class="sign-canvas" />
    <div class="action-buttons">
      <button @click="handleClear">清除</button>
      <button @click="handleUndo">撤销</button>
      <button @click="handleSave">保存</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import SmoothSignature from 'smooth-signature'

const canvasRef = ref(null)
let signature = null

onMounted(() => {
  const canvas = canvasRef.value
  signature = new SmoothSignature(canvas, {
    width: window.innerWidth - 40,   // 适配屏幕宽度
    height: 300,
    minWidth: 3,                      // 最小笔画宽度
    maxWidth: 8,                      // 最大笔画宽度
    color: '#000000',                 // 笔画颜色
    bgColor: '#ffffff',               // 背景色
    scale: window.devicePixelRatio,   // 自动 DPR 适配
    openSmooth: true                  // 开启笔锋效果
  })
})

const handleClear = () => signature?.clear()
const handleUndo = () => signature?.undo()
const handleSave = () => {
  if (signature?.isEmpty()) {
    alert('请先签名')
    return
  }
  const base64 = signature?.toDataURL()
  console.log('签名图片:', base64)
}
</script>

<style scoped>
.sign-canvas {
  width: 100%;
  touch-action: none; /* 阻止浏览器默认手势 */
}
</style>

核心优势:

特性说明
笔锋效果贝塞尔曲线算法还原真实书写轨迹,线条粗细随书写速度动态变化
自动触摸适配内部自动处理 touch 事件,无需手动绑定
Retina 适配导出图片无锯齿,适配高清屏幕
分层绘制避免频繁全画布重绘,性能优异
撤销功能内置撤销支持,无需自行实现历史栈

4.3 方案二:原生 Canvas 手写封装(完全可控)

对于需要深度定制或不愿引入额外依赖的场景,可基于原生 Canvas 手写移动端签名组件。以下是经过验证的完整方案:

完整组件实现:

<template>
  <div class="signature-wrapper" ref="wrapperRef">
    <canvas
      ref="canvasRef"
      @mousedown="onStart"
      @mousemove="onMove"
      @mouseup="onEnd"
      @mouseleave="onEnd"
      @touchstart.prevent="onTouchStart"
      @touchmove.prevent="onTouchMove"
      @touchend.prevent="onTouchEnd"
    />
    <div class="toolbar">
      <button @click="undo">撤销</button>
      <button @click="clear">清空</button>
      <button @click="save">保存</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const canvasRef = ref(null)
const wrapperRef = ref(null)
const isDrawing = ref(false)
const history = [] // 撤销历史栈
let ctx = null

// ========== 初始化 ==========
const initCanvas = () => {
  const canvas = canvasRef.value
  const wrapper = wrapperRef.value
  const dpr = window.devicePixelRatio || 1

  // 1. 根据容器设置 CSS 尺寸
  const cssWidth = wrapper.offsetWidth
  const cssHeight = wrapper.offsetHeight - 50 // 减去工具栏高度

  // 2. 设置 Canvas 缓冲区尺寸(乘以 DPR)
  canvas.width = cssWidth * dpr
  canvas.height = cssHeight * dpr

  // 3. 设置 CSS 显示尺寸
  canvas.style.width = `${cssWidth}px`
  canvas.style.height = `${cssHeight}px`

  // 4. 缩放绘图上下文
  ctx = canvas.getContext('2d')
  ctx.scale(dpr, dpr)

  // 5. 配置笔触
  ctx.strokeStyle = '#000'
  ctx.lineWidth = 3
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'

  // 6. 保存初始状态
  saveHistory()
}

// ========== 坐标转换(兼容 PC / 移动端)==========
const getPos = (e) => {
  const canvas = canvasRef.value
  const rect = canvas.getBoundingClientRect()
  const clientX = e.touches ? e.touches[0].clientX : e.clientX
  const clientY = e.touches ? e.touches[0].clientY : e.clientY
  return {
    x: clientX - rect.left,
    y: clientY - rect.top
  }
}

// ========== PC 端事件 ==========
let lastPos = null

const onStart = (e) => {
  isDrawing.value = true
  lastPos = getPos(e)
  ctx.beginPath()
  ctx.moveTo(lastPos.x, lastPos.y)
}

const onMove = (e) => {
  if (!isDrawing.value) return
  const pos = getPos(e)
  ctx.lineTo(pos.x, pos.y)
  ctx.stroke()
  lastPos = pos
}

const onEnd = () => {
  if (isDrawing.value) {
    isDrawing.value = false
    ctx.closePath()
    saveHistory()
  }
}

// ========== 移动端事件(额外处理滚动穿透)==========
const onTouchStart = (e) => {
  // 通知父组件禁止页面滚动
  document.body.style.overflow = 'hidden'
  onStart(e)
}

const onTouchMove = (e) => {
  onMove(e)
}

const onTouchEnd = (e) => {
  // 恢复页面滚动
  document.body.style.overflow = ''
  onEnd()
}

// ========== 历史记录(撤销功能)==========
const saveHistory = () => {
  const canvas = canvasRef.value
  if (!canvas) return
  history.push(canvas.toDataURL())
  // 限制历史栈大小,避免内存溢出
  if (history.length > 30) history.shift()
}

const undo = () => {
  if (history.length <= 1) return
  history.pop() // 移除当前状态
  const prev = history[history.length - 1]
  const img = new Image()
  img.onload = () => {
    const dpr = window.devicePixelRatio || 1
    ctx.clearRect(0, 0, canvasRef.value.width / dpr, canvasRef.value.height / dpr)
    ctx.drawImage(img, 0, 0, canvasRef.value.width / dpr, canvasRef.value.height / dpr)
  }
  img.src = prev
}

// ========== 清空与保存 ==========
const clear = () => {
  const dpr = window.devicePixelRatio || 1
  ctx.clearRect(0, 0, canvasRef.value.width / dpr, canvasRef.value.height / dpr)
  history.length = 0
  saveHistory()
}

const isEmpty = () => {
  const canvas = canvasRef.value
  const blank = document.createElement('canvas')
  blank.width = canvas.width
  blank.height = canvas.height
  return canvas.toDataURL() === blank.toDataURL()
}

const save = () => {
  if (isEmpty()) {
    alert('请先签名')
    return
  }
  return canvasRef.value.toDataURL('image/png')
}

// ========== 横竖屏切换处理 ==========
const handleResize = () => {
  const dataURL = canvasRef.value.toDataURL()
  initCanvas()
  // 重新绘制之前的内容
  const img = new Image()
  img.onload = () => {
    const dpr = window.devicePixelRatio || 1
    ctx.drawImage(img, 0, 0, canvasRef.value.width / dpr, canvasRef.value.height / dpr)
  }
  img.src = dataURL
}

// ========== 屏幕方向监听 ==========
const handleOrientationChange = () => {
  setTimeout(handleResize, 300) // 延迟等待布局完成
}

onMounted(() => {
  initCanvas()
  window.addEventListener('resize', handleResize)
  window.addEventListener('orientationchange', handleOrientationChange)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
  window.removeEventListener('orientationchange', handleOrientationChange)
  document.body.style.overflow = '' // 确保恢复滚动
})
</script>

<style scoped>
.signature-wrapper {
  width: 100%;
  height: 100%;
  position: relative;
}

canvas {
  display: block;
  border: 1px solid #ddd;
  border-radius: 4px;
  touch-action: none; /* 关键:阻止浏览器默认手势 */
  -webkit-touch-callout: none; /* 禁止长按弹窗 */
  -webkit-user-select: none; /* 禁止选中 */
}

.toolbar {
  display: flex;
  gap: 8px;
  padding: 8px 0;
}
</style>

4.4 移动端签名六项关键处理

1. 触摸事件与滚动穿透

这是移动端签名的第一道坎。签名时手指滑动会触发页面滚动,必须阻止:

// 方案 A:Vue 模板中使用 .prevent 修饰符
@touchstart.prevent="onTouchStart"
@touchmove.prevent="onTouchMove"

// 方案 B:CSS 一行搞定(推荐)
canvas { touch-action: none; }

// 方案 C:JS 动态控制(签名时禁止,非签名时恢复)
document.body.style.overflow = 'hidden'  // 禁止滚动
document.body.style.overflow = ''         // 恢复滚动

推荐:CSS touch-action: none 最简洁有效,但只作用于 Canvas 元素;如果页面整体需要锁定滚动,需配合方案 C。

2. DPR 适配(消除高分辨率偏移)

const dpr = window.devicePixelRatio || 1
canvas.width = cssWidth * dpr    // 缓冲区放大
canvas.height = cssHeight * dpr
canvas.style.width = `${cssWidth}px`   // CSS 尺寸不变
canvas.style.height = `${cssHeight}px`
ctx.scale(dpr, dpr)             // 坐标系缩放,对齐触点

3. 坐标转换(PC / 移动端统一)

const getPos = (e) => {
  const rect = canvas.getBoundingClientRect()
  // 移动端用 touches[0],PC 端直接用 e
  const clientX = e.touches ? e.touches[0].clientX : e.clientX
  const clientY = e.touches ? e.touches[0].clientY : e.clientY
  return {
    x: clientX - rect.left,
    y: clientY - rect.top
  }
}

4. 横竖屏切换自适应

// 监听屏幕方向变化
window.addEventListener('orientationchange', () => {
  setTimeout(() => {
    // 保存当前签名 → 重新初始化画布 → 恢复签名
    const snapshot = canvas.toDataURL()
    initCanvas()
    restoreFromDataURL(snapshot)
  }, 300) // 延迟等待布局完成
})

// 监听窗口大小变化(处理 soft keyboard 弹出等场景)
window.addEventListener('resize', debounce(handleResize, 200))

5. 笔锋效果

原生 Canvas 实现笔锋需要根据书写速度动态调整线宽:

// 简易笔锋:根据两点距离计算速度,映射到线宽
const calcLineWidth = (lastPos, currentPos) => {
  const distance = Math.sqrt(
    Math.pow(currentPos.x - lastPos.x, 2) +
    Math.pow(currentPos.y - lastPos.y, 2)
  )
  const speed = distance // 距离越大速度越快
  const minWidth = 1
  const maxWidth = 6
  // 速度越快线越细,速度越慢线越粗
  return Math.max(minWidth, maxWidth - speed * 0.3)
}

更优方案:使用 smooth-signature 的 openSmooth: true 配置,内置贝塞尔曲线算法,效果远超手动实现。

6. 导出高清图片

// 确保 Canvas 缓冲区尺寸已乘以 DPR,导出时使用原始尺寸
const save = () => {
  return canvas.toDataURL('image/png', 1.0) // quality 设为最高
}

// 如需裁剪空白区域(签名通常只占画布一小部分)
const cropSignature = (canvas) => {
  const ctx = canvas.getContext('2d')
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  const { data, width, height } = imageData

  // 扫描有效像素边界
  let top = height, left = width, right = 0, bottom = 0
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const alpha = data[(y * width + x) * 4 + 3]
      if (alpha > 0) {
        top = Math.min(top, y)
        left = Math.min(left, x)
        right = Math.max(right, x)
        bottom = Math.max(bottom, y)
      }
    }
  }

  // 添加边距后裁剪
  const padding = 20
  const cropCanvas = document.createElement('canvas')
  cropCanvas.width = right - left + padding * 2
  cropCanvas.height = bottom - top + padding * 2
  const cropCtx = cropCanvas.getContext('2d')
  cropCtx.fillStyle = '#fff'
  cropCtx.fillRect(0, 0, cropCanvas.width, cropCanvas.height)
  cropCtx.drawImage(canvas,
    left, top, right - left, bottom - top,
    padding, padding, right - left, bottom - top
  )
  return cropCanvas.toDataURL('image/png')
}

4.5 移动端方案对比

维度smooth-signature原生 Canvas 封装vue-esignvue3-signature-pad
笔锋效果✅ 贝塞尔曲线,最优⚠️ 需手动实现❌ 无❌ 无
DPR 适配✅ 内置✅ 需手动编码✅ 内置✅ 内置
触摸滚动穿透✅ 自动处理⚠️ 需手动处理⚠️ 需手动处理⚠️ 需手动处理
横竖屏自适应⚠️ 需补充⚠️ 需手动实现✅ 内置❌ 不支持
裁剪空白区域❌ 不支持✅ 可自定义✅ 内置❌ 不支持
撤销功能✅ 内置✅ 需手动实现❌ 无❌ 无
包体积~8KB0~5KB~3KB
定制灵活度🟡 中等🟢 最高🟡 中等🟡 中等
上手难度🟢 低🔴 高🟢 低🟢 低

选型建议:

  • 追求体验优先smooth-signature(笔锋效果 + 自动适配,移动端体验最佳)
  • 需要完全掌控 → 原生 Canvas 封装(零依赖,灵活度最高,但开发成本大)
  • 快速上线vue-esign(支持裁剪和横竖屏适配,开箱即用)

五、组件选型指南

根据不同业务场景,推荐选择方案如下:

场景推荐组件理由
Vue3 新项目,追求简单vue3-signature-pad专为 Vue3 设计,API 简洁,零依赖
高分辨率屏幕 / 跨框架signature_pad + 自定义封装成熟方案,DPR 适配完善
Element UI 项目sign-canvas配置丰富,兼容 Element 生态
需要导出干净图片vue-esign支持裁剪空白区域
移动端 H5(体验优先)smooth-signature笔锋效果最优,内置 DPR 适配和触摸处理
移动端 H5(快速上线)vue-esign支持裁剪和横竖屏自适应,开箱即用
移动端 H5(完全定制)原生 Canvas 封装零依赖,灵活度最高

六、常见问题与解决方案

Q1:签名导出后是空白怎么办? A:确保在调用保存方法前,签名板不为空。可调用 isEmpty() 方法进行判断。

Q2:移动端签名不流畅? A:检查是否添加了 touch-action: none CSS 样式,并确保正确处理了 preventDefault() 防止页面滚动。

Q3:导出图片有黑色背景? A:部分组件默认背景为透明,导出前可先通过 CSS 或配置设置白色背景。

Q4:如何实现签名回显? A:使用组件的 fromDataURL()generate(data) 方法,将之前保存的 base64 数据重新渲染到画布。