基于 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,我们实现了高内聚低耦合的签名组件。该方案具有以下优势:

  • 清晰的响应式状态管理
  • 良好的触摸事件兼容性
  • 灵活的扩展能力
  • 完整的错误处理机制
相关推荐
天天扭码5 分钟前
前端进阶 | 面试必考—— JavaScript手写定时器
前端·javascript·面试
梦雨生生22 分钟前
拖拉拽效果加点击事件
前端·javascript·css
前端Hardy24 分钟前
HTML&CSS:全网最全的代码时钟效果
javascript·css·html
前端Hardy29 分钟前
HTML&CSS:看这里,动态背景卡片效果
javascript·css·html
前端Hardy29 分钟前
第2课:变量与数据类型——JS的“记忆盒子”
前端·javascript
前端Hardy31 分钟前
第1课:初识JavaScript——让你的网页“动”起来!
javascript
冴羽1 小时前
SvelteKit 最新中文文档教程(23)—— CLI 使用指南
前端·javascript·svelte
jstart千语1 小时前
【SpringBoot】HttpServletRequest获取使用及失效问题(包含@Async异步执行方案)
java·前端·spring boot·后端·spring
徐小夕1 小时前
花了2个月时间,写了一款3D可视化编辑器3D-Tony
前端·javascript·react.js
凕雨1 小时前
Cesium学习笔记——dem/tif地形的分块与加载
前端·javascript·笔记·学习·arcgis·vue