Vue2,实现电子签名(web、移动端)功能
一、简述
现如今,电子签名与手写签名一样具有法律效应。越来越多的项目开发中会有电子签名的需求,自己最近的项目也会频繁出现该需求。一般开发时会用到现有的npm
依赖包vue-signature-pad
,但是自己所处的开发环境不能连接外网,所以打算自己研究和总结实现电子签名功能。
实现电子签名功能,需要用到html5
中一个重要级别的辅助标签——canvas
。
二、canvas介绍
什么是canvas
?
HTML5
的<canvas>
用于图形的绘制。它只是一个图形容器,不提供任何绘制对象的信息。画布的内容并不像html
那样具有语义并能暴露出来。
它的图形绘制,通常是使用javascript
来完成的,可以通过多种方法来绘制路径、盒、圆、字符以及添加图像等。
如何实现canvas
- 创建
canvas
元素 - 获取
canvas
元素 - 创建
context
对象
Vue2(2.6.11)
<template>
<div class="ml_sign">
<!-- 创建canvas元素(标签) -->
<canvas ref="signature" id="signature"></canvas>
</div>
</template>
<script>
export default {
name: 'Signatrue',
data () {
return {
ctx: null
}
},
mounted () {
// 获取canvas实例
const canvas = this.$refs.signature
// 创建context对象
this.ctx = canvas.getContext('2d')
}
}
</script>
canvas
给我们提供了很多的Api
,供我们使用。
getContext('2d')
对象是内建的HTML5
对象,拥有多种绘制路径、矩形、圆形、字符、以及添加图像的方法。
在这里需要先添加两个按钮,分别是取消和保存,后续会用到。
<canvas ref="signature" id="signature"></canvas>
<div class="btn-wrapper">
<button>取消</button>
<button>保存</button>
</div>
三、签名实现
实现步骤:
- 配置基础内容
- 获取
canvas
实例- 基础内容设置
- 设备兼容 - 绑定事件
- 开始绘制
- 绘制
- 结束绘制
- 取消功能/清空画布
- 保存功能 - 图片显示、本地下载、上传后端存储
1.配置基础内容
- 定义宽、高、线条颜色、线条宽度等基础内容;
<script>
export default {
...,
data () {
return {
canvas: null, // 存储canvas节点
ctx: null, // 存储canvas的context上下文
config: {
width: 400, // 宽度
height: 200, // 高度
strokeStyle: 'red', // 线条颜色
lineWidth: 4, // 线条宽度
lineCap: 'round', // 设置线条两端圆角
lineJoin: 'round' // 线条交汇处圆角
},
client: {
offsetX: 0, // 偏移量
offsetY: 0,
endX: 0, // 坐标
endY: 0
},
points: [] // 记录坐标 用来判断是否有签名的
}
}
}
</script>
2.获取canvas
实例
<script>
export default {
...,
// 注意:习惯使用created生命周期的童鞋,将无法获取到canvas节点。
mounted () {
// 初始化
this.init()
},
methods: {
// 初始化
init () {
const canvas = this.$refs.signature
// 存储canvas节点
this.canvas = canvas
// 创建context对象
this.ctx = canvas.getContext('2d')
}
}
}
</script>
3.基础内容设置
- 设置
canvas
的宽、高等基础配置;- 注意:这里需要注意的是,
canvas
的默认宽高是 width: 300 height: 150,若是style设置width和height,可能会出现拉伸问题。所以尽量使用canvas
内置属性设置width和height,不然会有bug。
<script>
export default {
...,
methods: {
// 初始化
init () {
const canvas = this.$refs.signature
canvas.width = this.config.width // 设置canvas的宽
canvas.height = this.config.height // 设置canvas的高
// 设置一个边框
canvas.style.border = '1px solid #000'
// 存储canvas节点
this.canvas = canvas
// 创建context对象
this.ctx = canvas.getContext('2d')
// 设置相应配置
this.ctx.fillStyle = 'transparent'
this.ctx.lineWidth = this.config.lineWidth
this.ctx.strokeStyle = this.config.strokeStyle
this.ctx.lineCap = this.config.lineCap
this.ctx.lineJoin = this.config.lineJoin
// 绘制填充矩形
this.ctx.fillRect(
0, // x 轴起始绘制位置
0, // y 轴起始绘制位置
this.config.width, // 宽度
this.config.height // 高度
)
}
}
}
</script>
4.设备兼容 - 绑定事件
- 定义计算属性,判断是否为移动端;
- 监听
canvas
鼠标/手势按下 和 鼠标/手势 弹起/离开 事件;
<script>
export default {
...,
***puted: {
// 判断是否为移动端
mobileStatus () {
return (/Mobile|Android|iPhone/i.test(navigator.userAgent))
}
},
methods: {
// 初始化
init () {
...
// 创建鼠标/手势按下监听器
canvas.addEventListener(this.mobileStatus ? 'touchstart' : 'mousedown', this.startDraw)
// 创建鼠标/手势 弹起/离开 监听器
canvas.addEventListener(this.mobileStatus ? 'touchend' : 'mouseup', this.cloaseDraw)
}
}
}
</script>
5.开始绘制
- 鼠标/手势按下后,获取偏移量及坐标并存储;
- 清除以上一次
beginPath
之后的所有路径,进行绘制;moveTo
设置画线起始点位;- 监听 鼠标移动或手势移动;
<script>
export default {
...,
methods: {
// 初始化
init () { ... },
// 开始绘制
startDraw (event) {
// 获取偏移量及坐标
const { offsetX, offsetY, pageX, pageY } = this.mobileStatus ? event.changedTouches[0] : event
// 修改上次的偏移量及坐标
this.client.offsetX = offsetX
this.client.offsetY = offsetY
this.client.endX = pageX
this.client.endY = pageY
// 清除以上一次 beginPath 之后的所有路径,进行绘制
this.ctx.beginPath()
// 设置画线起始点位
this.ctx.moveTo(this.client.endX, this.client.endY)
// 监听 鼠标移动或手势移动
this.canvas.addEventListener(this.mobileStatus ? 'touchmove' : 'mousemove', this.draw)
}
}
}
</script>
6.绘制
- 获取当前坐标点位;
li***o
根据坐标点位移动添加线条;stroke
绘制;- 记录坐标;
<script>
export default {
...,
methods: {
// 初始化
init () { ... },
// 开始绘制
startDraw () { ... },
// 绘制
draw (event) {
// 获取当前坐标点位
const { pageX, pageY } = this.mobileStatus ? event.changedTouches[0] : event
// 修改最后一次绘制的坐标点
this.client.endX = pageX
this.client.endY = pageY
const obj = {
x: pageX,
y: pageY
}
// 根据坐标点位移动添加线条
this.ctx.li***o(pageX, pageY)
// 绘制
this.ctx.stroke()
// 记录坐标
this.points.push(obj)
}
}
}
</script>
7.结束绘制
closePath
结束绘制;- 移除 鼠标移动或手势移动 监听器;
<script>
export default {
...,
methods: {
// 初始化
init () { ... },
// 开始绘制
startDraw () { ... },
// 绘制
draw () { ... },
// 结束绘制
cloaseDraw () {
// 结束绘制
this.ctx.closePath()
// 移除鼠标移动或手势移动监听器
this.canvas.removeEventListener('mousemove', this.draw)
}
}
}
</script>
8.取消功能/清空画布
- 绑定 取消功能/清空画布 事件;
- 清空当前画布上的所有绘制内容;
- 清空坐标;
<div class="btn-wrapper">
<!-- 添加点击事件 -->
<button @click="clear">取消</button>
<button>保存</button>
</div>
<script>
export default {
...,
methods: {
// 初始化
init () { ... },
// 开始绘制
startDraw () { ... },
// 绘制
draw () { ... },
// 结束绘制
cloaseDraw () { ... },
// 取消/清空画布
clear () {
// 清空当前画布上的所有绘制内容
this.ctx.clearRect(0, 0, this.config.width, this.config.height)
// 清空坐标
this.points = []
}
}
}
</script>
9.保存功能 - 图片显示、本地下载、上传后端存储
保存功能做了三个项目业务场景常用的方法,供大家参考或使用
图片显示:将签名转成
base64
,并放在img
路径上,进行签名的图片展示;本地下载:将签名转成
blob
流,并下载至本地(默认png格式图片);上传后端存储:将签名转成
base64
,然后将base64
转成File文件对象,再上传后端;
基础保存设置
- 绑定保存事件;
- 签名判空;
- 操作事件;
<div class="btn-wrapper">
<!-- 添加点击事件 -->
<button @click="clear">取消</button>
<button @click="save">保存</button>
</div>
<script>
export default {
...,
methods: {
// 初始化
init () { ... },
// 开始绘制
startDraw () { ... },
// 绘制
draw () { ... },
// 结束绘制
cloaseDraw () { ... },
// 取消/清空画布
clear () { ... },
// 保存
save () {
// 判断至少有20个坐标 才算有签名
if (this.points.length < 20) {
alert('签名不能为空!')
return
}
// 操作事件
...
}
}
}
</script>
显示图片
- 创建
img
标签,并绑定路径;- 定义操作事件;
- 将
canvas
内容转成base64
,并赋值img
绑定路径;
<template>
<div class="ml_sign">
...
<!-- 创建img标签, 绑定路径 -->
<img :src="imgurl">
</div>
</template>
<script>
export default {
...,
data () {
...,
imgurl: '' // img图片路径
},
methods: {
...,
// 保存
save () {
// 判断至少有20个坐标 才算有签名
if (this.points.length < 20) {
alert('签名不能为空!')
return
}
// 操作事件
this.dataToImg()
},
// img显示签名
dataToImg () {
// 转成base64
const baseFile = this.canvas.toDataURL() // 默认转成png格式的图片编码
this.imgurl = baseFile
}
}
}
</script>
本地下载
- 定义操作事件;
- 将
canvas
内容转成blob
流;- 通过a标签进行下载;
<script>
export default {
...,
methods: {
...,
// 保存
save () {
// 判断至少有20个坐标 才算有签名
if (this.points.length < 20) {
alert('签名不能为空!')
return
}
// 操作事件
this.dataUrlToPng()
},
// 将签名生成png图片
dataUrlToPng () {
// 将canvas内容转成blob流
this.canvas.toBlob(blob => {
// 获取当前时间并转成字符串,用来当做文件名
const date = Date.now().toString()
// 创建一个 a 标签
const a = document.createElement('a')
// 设置 a 标签的下载文件名
a.download = `${date}.png`
// 设置 a 标签的跳转路径为 文件流地址
a.href = URL.createObjectURL(blob)
// 手动触发 a 标签的点击事件
a.click()
// 移除 a 标签
a.remove()
})
}
}
}
</script>
上传后端存储
- 将
canvas
内容转成base64
,自定义文件名;- 将
base64
转成File文件对象;- 上传签名;
<script>
export default {
...,
methods: {
...,
// 保存
save () {
// 判断至少有20个坐标 才算有签名
if (this.points.length < 20) {
alert('签名不能为空!')
return
}
// 操作事件
const baseFile = this.canvas.toDataURL() // 转成base64,默认转成png格式的图片编码
const filename = `${Date.now()}.png` // 文件名字
const file = this.dataURLToFile(baseFile, filename) // 图片文件形式 传给后端存储即可
this.uploadSignatrue(file)
},
// 将base64转成File文件对象
dataURLToFile (dataURL, filename) {
const arr = dataURL.split(',')
// 获取图片格式
const imgType = arr[0].match(/:(.*?);/)[1]
// atob() 方法用于解码使用 base-64 编码的字符串
const dec = atob(arr[1])
let n = dec.length
const u8arr = new Uint8Array(n)
while (n--) {
// 转成ASCII码
u8arr[n] = dec.charCodeAt(n)
}
return new File([u8arr], filename, { type: imgType })
},
// 上传签名
uploadSignatrue (file) {
const formData = new FormData()
formData.append('file', file)
formData.append('paramsOne', paramsOne)
...
// 上传接口 这里就不赘述了
uploadFile(formData, ...)
},
}
}
</script>
四、完整代码
<template>
<div class="ml_sign">
<canvas ref="signature" id="signature"></canvas>
<div class="btn-wrapper">
<button @click="clear">取消</button>
<button @click="save">保存</button>
</div>
<img :src="imgurl">
</div>
</template>
<script>
export default {
name: 'Signatrue',
data () {
return {
canvas: null, // 存储canvas节点
ctx: null, // 存储canvas的context上下文
config: {
width: 400, // 宽度
height: 200, // 高度
strokeStyle: 'red', // 线条颜色
lineWidth: 4, // 线条宽度
lineCap: 'round', // 设置线条两端圆角
lineJoin: 'round' // 线条交汇处圆角
},
points: [], // 记录坐标 用来判断是否有签名的
client: {
offsetX: 0, // 偏移量
offsetY: 0,
endX: 0, // 坐标
endY: 0
},
imgurl: ''
}
},
***puted: {
// 判断是否为移动端
mobileStatus () {
return (/Mobile|Android|iPhone/i.test(navigator.userAgent))
}
},
mounted () {
this.init()
},
methods: {
// 初始化
init () {
const canvas = this.$refs.signature
canvas.width = this.config.width // 设置canvas的宽
canvas.height = this.config.height // 设置canvas的高
// 设置一个边框
canvas.style.border = '1px solid #000'
// 存储canvas节点
this.canvas = canvas
// 创建context对象
this.ctx = canvas.getContext('2d')
// 设置相应配置
this.ctx.fillStyle = 'transparent'
this.ctx.lineWidth = this.config.lineWidth
this.ctx.strokeStyle = this.config.strokeStyle
this.ctx.lineCap = this.config.lineCap
this.ctx.lineJoin = this.config.lineJoin
// 绘制填充矩形
this.ctx.fillRect(
0, // x 轴起始绘制位置
0, // y 轴起始绘制位置
this.config.width, // 宽度
this.config.height // 高度
)
// 创建鼠标/手势按下监听器
canvas.addEventListener(this.mobileStatus ? 'touchstart' : 'mousedown', this.startDraw)
// 创建鼠标/手势 弹起/离开 监听器
canvas.addEventListener(this.mobileStatus ? 'touchend' : 'mouseup', this.cloaseDraw)
},
// 开始绘制
startDraw (event) {
// 获取偏移量及坐标
const { offsetX, offsetY, pageX, pageY } = this.mobileStatus ? event.changedTouches[0] : event
// 修改上次的偏移量及坐标
this.client.offsetX = offsetX
this.client.offsetY = offsetY
this.client.endX = pageX
this.client.endY = pageY
// 清除以上一次 beginPath 之后的所有路径,进行绘制
this.ctx.beginPath()
// 设置画线起始点位
this.ctx.moveTo(this.client.endX, this.client.endY)
// 监听 鼠标移动或手势移动
this.canvas.addEventListener(this.mobileStatus ? 'touchmove' : 'mousemove', this.draw)
},
// 绘制
draw (event) {
// 获取当前坐标点位
const { pageX, pageY } = this.mobileStatus ? event.changedTouches[0] : event
// 修改最后一次绘制的坐标点
this.client.endX = pageX
this.client.endY = pageY
const obj = {
x: pageX,
y: pageY
}
// 根据坐标点位移动添加线条
this.ctx.li***o(pageX, pageY)
// 绘制
this.ctx.stroke()
// 记录坐标
this.points.push(obj)
},
// 结束绘制
cloaseDraw () {
// 结束绘制
this.ctx.closePath()
// 移除鼠标移动或手势移动监听器
this.canvas.removeEventListener('mousemove', this.draw)
},
// 取消/清空画布
clear () {
// 清空当前画布上的所有绘制内容
this.ctx.clearRect(0, 0, this.config.width, this.config.height)
// 清空坐标
this.points = []
},
// 保存
save () {
// 判断至少有20个坐标 才算有签名
if (this.points.length < 20) {
alert('签名不能为空!')
return
}
// 操作事件
const baseFile = this.canvas.toDataURL() // 转成base64,默认转成png格式的图片编码
const filename = `${Date.now()}.png` // 文件名字
const file = this.dataURLToFile(baseFile, filename) // 图片文件形式 传给后端存储即可
this.uploadSignatrue(file)
// this.dataUrlToPng()
// this.dataToImg()
},
// img显示签名
dataToImg () {
// 转成base64
const baseFile = this.canvas.toDataURL() // 默认转成png格式的图片编码
this.imgurl = baseFile
},
// 将签名生成png图片
dataUrlToPng () {
// 将canvas上的内容转成blob流
this.canvas.toBlob(blob => {
// 获取当前时间并转成字符串,用来当做文件名
const date = Date.now().toString()
// 创建一个 a 标签
const a = document.createElement('a')
// 设置 a 标签的下载文件名
a.download = `${date}.png`
// 设置 a 标签的跳转路径为 文件流地址
a.href = URL.createObjectURL(blob)
// 手动触发 a 标签的点击事件
a.click()
// 移除 a 标签
a.remove()
})
},
// 将base64转成File文件对象
dataURLToFile (dataURL, filename) {
const arr = dataURL.split(',')
// 获取图片格式
const imgType = arr[0].match(/:(.*?);/)[1]
// atob() 方法用于解码使用 base-64 编码的字符串
const dec = atob(arr[1])
let n = dec.length
const u8arr = new Uint8Array(n)
while (n--) {
// 转成ASCII码
u8arr[n] = dec.charCodeAt(n)
}
return new File([u8arr], filename, { type: imgType })
},
// 上传签名
uploadSignatrue (file) {
const formData = new FormData()
formData.append('file', file)
// formData.append('paramsOne', paramsOne)
// ...
console.log(formData)
// 上传接口 这里就不赘述了
// uploadFile(params, ...)
}
}
}
</script>