利用Canvas在Vue中封装一个电子写字板的组件,通常用于电子签名之类的需求,如下图动画效果所示:
新建组件文件components/ETablet.vue
<template>
<div class="e-tablet">
<div class="sig_canvas_container">
<canvas id="signCanvas"></canvas>
<span class="clear_btn" @click="handelClearEl">清除</span>
</div>
</div>
</template>
<script>
export default {
name: 'ETablet',
props: {
height: { // 画布高度
type: String,
default: ''
}
// 注:这里不接收宽度的参数,因为组件中默认宽度100%,若想控制写字板的宽度,只需在父组件中引用此组件时,外面包一层div,通过设置div的宽度来限制写字板的宽度即可。
},
data() {
return {
hasDraw: false // 判断写字板上是否有内容
}
},
methods: {
// 绘制画布并绑定事件
initCanvas() {
// 初始化绘制画布
let rate = 2
let oCanvas = document.getElementById('signCanvas')
oCanvas.width = oCanvas.offsetWidth * rate
oCanvas.height = oCanvas.offsetHeight * rate
let cxt = oCanvas.getContext('2d')
cxt.fillStyle = '#fff' // 背景颜色
cxt.fillRect(0, 0, oCanvas.width, oCanvas.height)
cxt.lineWidth = 2 * rate // 画笔线宽
cxt.strokeStyle = '#101010' // 画笔颜色
let posX = 0
let posY = 0
let parentPosintin = oCanvas.getBoundingClientRect()
// 监听touch事件
oCanvas.addEventListener('touchstart', function(event) {
posX = event.changedTouches[0].clientX
posY = event.changedTouches[0].clientY - parentPosintin.top + 0.5
cxt.beginPath()
cxt.moveTo(posX * rate, posY * rate)
})
oCanvas.addEventListener('touchmove', function(event) {
this.hasDraw = true
optimizedMove(event)
})
let requestAnimationFrame = window.requestAnimationFrame
let optimizedMove = requestAnimationFrame
? function(e) {
requestAnimationFrame(function() {
move(e)
})
}
: move
function move(event) {
posX = event.changedTouches[0].clientX + 0.5
posY = event.changedTouches[0].clientY - parentPosintin.top + 0.5
cxt.lineTo(posX * rate, posY * rate)
cxt.stroke()
}
},
// 清除画布
handelClearEl() {
let oCanvas = document.getElementById('signCanvas')
let cxt = oCanvas.getContext('2d')
cxt.clearRect(0, 0, oCanvas.width, oCanvas.height)
this.hasDraw = false
}
},
mounted() {
if (this.height) {
document.querySelector('.e-tablet .sig_canvas_container').style.height = this.height + 'px'
}
let vm = this
this.$nextTick(() => {
setTimeout(() => {
vm.initCanvas()
}, 100)
})
// 在画板上绘画时,阻止浏览器默认下拉行为
document.querySelector('body').addEventListener(
'touchmove',
function(e) {
if (e.target.id === 'signCanvas') {
e.preventDefault()
}
},
{passive: false}
)
}
}
</script>
<style lang="scss" scoped>
.e-tablet {
.sig_canvas_container {
position: relative;
width: 100vw;
height: 50vh;
overflow: hidden;
#signCanvas {
width: 100%;
height: 100%;
background: #ffffff;
border: none;
box-sizing: border-box;
overflow: hidden;
}
.clear_btn {
position: absolute;
right: 20px;
bottom: 15px;
}
}
}
</style>
在需要使用写字板的页面中引用组件ETablet
<template>
<section class="ETablet">
<e-tablet refs="ETablet" height="500" />
</section>
</template>
export default {
components: {
ETablet: () => import('components/ETablet')
},
data() {
return {}
}
}
这样就在你的页面中呈现出了写字板啦,可以画画,并且一键清除内容。
到第二步为止,你也只能在页面上画画而已,但从业务上来讲,我们最终要的是画完之后生成图片链接。因此,我们要在组件中封装一个生成图片并返回图片链接的方法:
// components/ETablet.vue
methods: {
getSigImage() {
let oCanvas = document.getElementById('signCanvas')
let imgBase64 = oCanvas.toDataURL() // 将当前绘画结果的画布生成base64格式的图片
return imgBase64
}
}
在父组件中通过$refs获取子组件元素并调用此方法
// 父组件中
methods: {
getPic() {
cnsole.log(this.$refs.ETablet.getSigImage())
}
}
但这里拿到的是长长的一串base64图片链接,既不好看,也不符合我们的业务需求,所以这里我们要把这张base64的图片转化成file格式并通过业务接口上传到服务器,最后拿到一个正常的图片地址,如下图链接
【图片链接】https://m.ipipa.cn/web/2020/10/24/22/hljo6gmhctnt6piqc1jozofc/thumb/v1603549434623.jpeg
在此之前,我们要封装一个将base64转化成文件流格式的函数dataURLtoFile:
/**
* @base64转成文件类型
* @param {*} dataurl base64地址
* @param {*} filename 转换后的文件名,默认为 'm+当前时间戳'
*/
export function dataURLtoFile(dataurl, filename = 'm' + +new Date()) {
let arr = dataurl.split(',')
let mime = arr[0].match(/:(.*?);/)[1]
let bstr = atob(arr[1])
let n = bstr.length
let u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
filename = `${filename}.${mime.split('/')[1]}`
return new File([u8arr], filename, { type: mime })
}
然后,我们补充刚才组件中的getSigImage方法:
// components/ETablet.vue
import {dataURLtoFile} from '@/utils/change' // 引入转化方法
methods: {
async getSigImage() {
let oCanvas = document.getElementById('signCanvas')
let imgBase64 = oCanvas.toDataURL() // 将当前绘画结果的画布生成base64格式的图片
let file = dataURLtoFile(imgBase64)
const fd = new FormData()
fd.append('file', file)
const {data} = await this.$http.post('/commons/file/upload', fd) // this.$http:是我的项目对axios请求的二次封装并全局引入了;'/commons/file/upload' 为项目中的上传文件接口,这两个需要各位根据自己的项目进行修改
let url = null
if (data.code === 200) url = data.file.url // 这里的参数接收也要根据自己接口的参数返回进行相应修改
return url
}
}
至此,在父组件中调用getSigImage后就会得到一个正常的图片地址。
最后,再提两点注意事项:
1、从业务严谨性来讲,我们在调用getSigImage获取图片地址之前,应该先判断用户有没有在写字板上绘画,如果没有任何内容,我们获取到的是一张空白的图片。组件中,我们声明了hasDraw变量,并用它来记录了当前画板的绘画状态。因此,我们在获取getSigImage之前,要先通过this.$refs.ETablet.hasDraw
来判断当前写字板是否有内容。
2、需要注意的是,组件中的getSigImage()方法里调用了接口,而接口的调用是异步的,是需要时间的,如果你在父组件中试图直接调用获取图片地址,是拿到不的,如下错误用法示例
// 父组件中
methods: {
getPic() {
let url = this.$refs.ETablet.getSigImage()
console.log(url) // 此时url打印出来的必定是undefined
}
}
正确的用法应该是要异步调用,等getSigImage中的接口调用成功后,我们再执行后面的操作,最简单的异步处理方法就是利用ES7的async/await方法,正确用法如下:
// 父组件中
methods: {
async getPic() {
let url = await this.$refs.ETablet.getSigImage()
console.log(url) // 此时url打印出来的就是正常的图片地址啦
}
}