基于 Vue3 与 Canvas 、Ali-oss实现移动端手写签名上传功能

引言

在移动端应用中,手写签名功能常见于电子合同、审批流程等场景。本文将通过 Vue3 Composition API 结合 HTML5 Canvas 技术,实现一个高性能的移动端手写签名组件,支持触摸绘制、图片生成、OSS 上传等核心功能。


技术实现要点

1. 核心实现逻辑

(1) Canvas 初始化

vue 复制代码
<template>
  <div ref="canvasBox" class="canvas-box">
    <canvas 
      ref="board"
      @touchstart.prevent="handleTouchStart"
      @touchmove.prevent="handleTouchMove"
      @touchend.prevent="handleTouchEnd"
    />
    <van-icon class="fullscreen-icon" @click="emit('fullscreen')" name="expand" />
  </div>
</template>

(2) 响应式状态管理(Composition API)

javascript 复制代码
import { ref, reactive, onMounted } from 'vue'
import AliOSS from 'ali-oss'

const props = defineProps({
  id: { type: [Number, String], default: '' },
  parentName: { type: String, default: '' }
})

const emit = defineEmits(['stopScroll', 'fullscreen'])

// 响应式状态
const canvasBox = ref(null)
const board = ref(null)
const ctx = ref(null)
const isDrawing = ref(false)
const showHintText = ref(true)

const coordinates = reactive({ x: 0, y: 0 })

(3) 画布初始化(生命周期处理)

javascript 复制代码
onMounted(() => {
  initCanvas()
  initOSS()
})

const initCanvas = () => {
  const { offsetWidth: width, offsetHeight: height } = canvasBox.value
  board.value.width = width
  board.value.height = height
  
  ctx.value = board.value.getContext('2d')
  ctx.value.strokeStyle = '#000'
  ctx.value.lineWidth = 3
  drawHintText()
}

const drawHintText = () => {
  ctx.value.font = '30px Arial'
  ctx.value.fillStyle = '#ccc'
  ctx.value.textAlign = 'center'
  ctx.value.fillText('签 名 区', board.value.width/2, board.value.height/2)
}

2. 核心交互实现

(1) 触摸事件处理

javascript 复制代码
const getCanvasPosition = (clientX, clientY) => {
  const rect = canvasBox.value.getBoundingClientRect()
  return {
    x: clientX - rect.left,
    y: clientY - rect.top
  }
}

const handleTouchStart = (e) => {
  emit('stopScroll', true)
  if(showHintText.value) clearCanvas()
  
  const { x, y } = getCanvasPosition(
    e.touches[0].clientX,
    e.touches[0].clientY
  )
  
  coordinates.x = x
  coordinates.y = y
  ctx.value.beginPath()
  isDrawing.value = true
}

const handleTouchMove = (e) => {
  if(!isDrawing.value) return
  
  const { x, y } = getCanvasPosition(
    e.touches[0].clientX,
    e.touches[0].clientY
  )
  
  ctx.value.moveTo(coordinates.x, coordinates.y)
  ctx.value.lineTo(x, y)
  ctx.value.stroke()
  
  coordinates.x = x
  coordinates.y = y
}

const handleTouchEnd = () => {
  emit('stopScroll', false)
  isDrawing.value && ctx.value.closePath()
  isDrawing.value = false
}

(2) 画布操作方法

javascript 复制代码
const clearCanvas = () => {
  ctx.value.clearRect(0, 0, board.value.width, board.value.height)
  showHintText.value = false
}

const convertToImage = async () => {
  if(showHintText.value || isCanvasEmpty()) {
    showToast('请先完成签名')
    return null
  }
  
  return new Promise(resolve => {
    board.value.toBlob(blob => {
      const file = new File([blob], 'signature.png', {
        type: 'image/png',
        lastModified: Date.now()
      })
      resolve(file)
    }, 'image/png')
  })
}

3. 进阶功能实现

(1) 画布空白检测

javascript 复制代码
const isCanvasEmpty = () => {
  const blankCanvas = document.createElement('canvas')
  blankCanvas.width = board.value.width
  blankCanvas.height = board.value.height
  return board.value.toDataURL() === blankCanvas.toDataURL()
}

(2) OSS 上传处理

javascript 复制代码
let ossInstance = null

const initOSS = () => {
  // 此处得换成自己的Ali-oss配置
  ossInstance = new AliOSS({
    region: 'oss-cn-shanghai',
    stsURL: `${import.meta.env.VITE_API_COMMON}/sts/security`,
    bucket: 'test01'
  })
}

const uploadSignature = async (file) => {
  try {
    const { url } = await ossInstance.put(file.name,file)
    await submitSignResult(url)
  } catch(error) {
    console.error('上传失败:', error)
  }
}

4.完整组件代码

vue 复制代码
<script setup name="CanvasSign">
import { ref, reactive, onMounted } from 'vue'
import { Toast } from 'vant'
import AliOSS from 'ali-oss'

const props = defineProps({
  id: { type: [Number, String], default: '' },
  parentName: { type: String, default: '' }
})

const emit = defineEmits(['stopScroll', 'fullscreen'])

// Canvas 相关引用
const canvasBox = ref(null)
const board = ref(null)
const ctx = ref(null)

// 响应式状态
const isDrawing = ref(false)
const showHintText = ref(true)
const coordinates = reactive({ x: 0, y: 0 })
let ossInstance = null

// 初始化画布
onMounted(() => {
  initCanvas()
  initOSS()
})

const initCanvas = () => {
  const { offsetWidth, offsetHeight } = canvasBox.value
  board.value.width = offsetWidth
  board.value.height = offsetHeight
  
  ctx.value = board.value.getContext('2d')
  ctx.value.strokeStyle = '#000'
  ctx.value.lineWidth = 3
  ctx.value.lineCap = 'round'
  
  drawHintText()
}

const drawHintText = () => {
  ctx.value.font = '30px Arial'
  ctx.value.fillStyle = '#ccc'
  ctx.value.textAlign = 'center'
  ctx.value.textBaseline = 'middle'
  ctx.value.fillText(
    '签 名 区',
    board.value.width / 2,
    board.value.height / 2
  )
}

// 触摸事件处理
const getCanvasPosition = (clientX, clientY) => {
  const rect = canvasBox.value.getBoundingClientRect()
  return {
    x: clientX - rect.left,
    y: clientY - rect.top
  }
}

const handleTouchStart = (e) => {
  emit('stopScroll', true)
  if (showHintText.value) clearCanvas()

  const pos = getCanvasPosition(
    e.touches[0].clientX,
    e.touches[0].clientY
  )
  
  coordinates.x = pos.x
  coordinates.y = pos.y
  ctx.value.beginPath()
  isDrawing.value = true
}

const handleTouchMove = (e) => {
  if (!isDrawing.value) return

  const pos = getCanvasPosition(
    e.touches[0].clientX,
    e.touches[0].clientY
  )
  
  ctx.value.moveTo(coordinates.x, coordinates.y)
  ctx.value.lineTo(pos.x, pos.y)
  ctx.value.stroke()
  
  coordinates.x = pos.x
  coordinates.y = pos.y
}

const handleTouchEnd = () => {
  emit('stopScroll', false)
  if (isDrawing.value) {
    ctx.value.closePath()
    isDrawing.value = false
  }
}

// 画布操作
const clearCanvas = () => {
  ctx.value.clearRect(0, 0, board.value.width, board.value.height)
  showHintText.value = false
}

// 图片生成
const generateSignatureImage = async () => {
  if (showHintText.value || isCanvasEmpty()) {
    Toast('请先完成签名')
    return null
  }

  return new Promise(resolve => {
    board.value.toBlob(blob => {
      const file = new File([blob], 'signature.png', {
        type: 'image/png',
        lastModified: Date.now()
      })
      resolve(file)
    }, 'image/png', 0.9)
  })
}

// OSS 上传
const initOSS = () => {
  // 此处需自行配置ali-oss 直接去官方文档跟着配置就好,然后使用这个ali-oss的包就好了
  ossInstance = new AliOSS({
    region: 'oss-cn-shanghai',
    stsURL: `${import.meta.env.VITE_API_COMMON}/sts/security`,
    bucket: 'rdts',
    path: 'rdts-admin/'
  })
}

const uploadSignature = async () => {
  try {
    const file = await generateSignatureImage()
    if (!file) return

    const { url } = await ossInstance.put(file.name,file)
    await submitSignResult(url)
    Toast.success('上传成功')
  } catch (error) {
    Toast.fail('上传失败')
    console.error('Upload error:', error)
  }
}

// 辅助方法
const isCanvasEmpty = () => {
  const blankCanvas = document.createElement('canvas')
  blankCanvas.width = board.value.width
  blankCanvas.height = board.value.height
  return board.value.toDataURL() === blankCanvas.toDataURL()
}

const submitSignResult = async (signUrl) => {
  // 调用API提交签名结果
  // const res = await createSign({ signUrl, id: props.id })
  // 处理业务逻辑...
}
</script>


<template>
  <div ref="canvasBox" class="canvas-box">
    <canvas
      ref="board"
      @touchstart.prevent="handleTouchStart"
      @touchmove.prevent="handleTouchMove"
      @touchend.prevent="handleTouchEnd"
    />
    <van-icon 
      class="fullscreen-icon" 
      @click="emit('fullscreen')"
      name="expand"
    />
  </div>
</template>


<style lang="less" scoped>
.canvas-box {
  width: 343px;
  height: 210px;
  position: relative;
  background: #ebedf0;
  border-radius: 8px;
}

canvas {
  width: 100%;
  height: 100%;
}

.fullscreen-icon {
  position: absolute;
  top: 8px;
  right: 8px;
  font-size: 18px;
  color: #666;
  z-index: 1;
}
</style>

优化实践建议

  1. 性能优化 :使用 requestAnimationFrame 优化绘制过程
  2. 跨端适配:添加鼠标事件支持实现 PC 端兼容
  3. 笔锋效果:通过速度计算动态调整线条宽度
  4. 撤销功能:使用历史记录栈实现多步撤销

结语

通过 Vue3 的组合式 API,我们实现了高内聚低耦合的签名组件。该方案具有以下优势:

  • 清晰的响应式状态管理
  • 良好的触摸事件兼容性
  • 灵活的扩展能力
  • 完整的错误处理机制
相关推荐
irises18 分钟前
tabby-vscode代码补全的一些阅读笔记
前端·javascript
2501_9068014818 分钟前
BY组态-低代码web可视化组件
前端·物联网·低代码·数学建模·编辑器·web
hang_bro20 分钟前
element-plus e-tabs与pinia 一起使用的问题
前端·vue.js
VitStratUp22 分钟前
antdvue+tree+transfer+vue3 实现树形带搜索穿梭框
前端·vue.js
千野竹之卫22 分钟前
2025最新云渲染网渲100渲染农场使用方法,渲染100邀请码1a12
开发语言·前端·javascript·数码相机·3d·3dsmax
前端提桶人25 分钟前
Win11 安装 Sentry 监控
linux·前端
南茗啊25 分钟前
echarts地图轮播markpoint-自用记录📝
前端·echarts
__不想说话__29 分钟前
面试官问我React Router原理,我掏出了平底锅…
前端·javascript·react.js
yzzzz31 分钟前
面试官:聊聊数组扁平化
javascript·面试
头发秃头小宝贝36 分钟前
JavaScript 高级之手写Promise
前端·javascript·面试