一、电子签名组件生态概览
Vue3 生态中有多款成熟的电子签名组件可供选择,各有特色:
| 组件名称 | 特点 | 适用场景 |
|---|---|---|
| vue3-signature-pad | Vue3 专用、轻量无依赖、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>
核心配置:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
width | String/Number | 100% | 画布宽度 |
height | String/Number | 100% | 画布高度 |
pen-color | String | #000 | 笔触颜色 |
pen-width | Number | 2 | 笔触粗细 |
border-color | String | #eee | 边框颜色 |
disabled | Boolean | false | 是否禁用 |
核心方法:
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>
核心配置:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
width | Number | 800 | 画布宽度 |
height | Number | 300 | 画布高度 |
lineWidth | Number | 4 | 画笔粗细 |
lineColor | String | #000000 | 画笔颜色 |
bgColor | String | - | 画布背景色 |
isCrop | Boolean | false | 是否裁剪空白区域 |
isClearBgColor | Boolean | false | 清空时是否清空背景色 |
三、高分辨率屏幕签名偏移问题详解
在 DPR > 1 的屏幕上(如 Retina 显示屏),如果直接使用默认配置,会出现签名轨迹与实际触点位置不匹配的问题。这是 Canvas 开发中的经典问题。
3.1 问题根源
- Canvas 尺寸不匹配:CSS 设置的显示尺寸与 Canvas 内部缓冲区的实际尺寸不一致
- 设备像素比(DPR):CSS 像素与设备物理像素的比例不再是 1:1
- 坐标系未校正:事件坐标直接映射到未缩放的 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-esign | vue3-signature-pad |
|---|---|---|---|---|
| 笔锋效果 | ✅ 贝塞尔曲线,最优 | ⚠️ 需手动实现 | ❌ 无 | ❌ 无 |
| DPR 适配 | ✅ 内置 | ✅ 需手动编码 | ✅ 内置 | ✅ 内置 |
| 触摸滚动穿透 | ✅ 自动处理 | ⚠️ 需手动处理 | ⚠️ 需手动处理 | ⚠️ 需手动处理 |
| 横竖屏自适应 | ⚠️ 需补充 | ⚠️ 需手动实现 | ✅ 内置 | ❌ 不支持 |
| 裁剪空白区域 | ❌ 不支持 | ✅ 可自定义 | ✅ 内置 | ❌ 不支持 |
| 撤销功能 | ✅ 内置 | ✅ 需手动实现 | ❌ 无 | ❌ 无 |
| 包体积 | ~8KB | 0 | ~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 数据重新渲染到画布。