手把手打造跨平台自由画板组件(UniApp实战)
书接上篇,我们今天继续来挑战来挑战一个稍微复杂一点但很有意思的组件:自由画板。它可以让你在屏幕上随心所欲地绘制线条、形状,甚至签名,还能擦除、清空,并且支持跨平台(H5/小程序/App),附带实现思路和所有源码示例,以及常见避坑指南和开发实践。
此系列文章将带领你从移动端跨平台开发入门到精通,如果你也喜欢关注APP、小程序、公众号、H5等等应用的开发,可以持续关注后续更新,避免错过宝贵的知识分享。
致开发者的忠告: AI编程盛行的今天,我们并不是不需要学习技术,而是更应该专研技术,拥有把控全局的架构设计思维才能在AI盛行的未来有立足之地。
言归正传,咱们今天继续来聊一聊如何实现一个自由画板组件,支持绘制线条、各种常见形状的组件,且可以用于签名和简单的图片绘制功能,本文仅做抛砖引玉,提供基础实现0到1,1到100还得各位看官继续深入研究,再接再厉!
你可能会想:“画板不就是用canvas画图吗?有什么难的?”但等你真正开始写,就会发现:
-
不同平台的canvas API有差异,有的需要
draw,有的直接渲染。 -
触摸事件坐标转换要小心,canvas坐标系和设备像素比搞错就画歪了。
-
想要管理多个图形(矩形、圆形、线条)并支持层级调整,得设计好数据结构。
-
橡皮擦怎么实现?是擦除整个图形还是擦除像素?
-
跨端保存图片时,
canvasToTempFilePath的参数各不相同。
别担心,今天我就带你一步步实现一个功能完整的画板组件,并解决上述所有问题。
一、需求分析:我们要做什么样的画板?
在动手前,先明确功能清单:
-
绘制工具:画笔(自由线条)、矩形、圆形、三角形、扇形、椭圆形。
-
图形管理:每个图形独立对象,支持调整层级(上移/下移/置顶/置底)。
-
签名模式:自由绘制,可以连续画线。
-
擦除模式:类似橡皮擦,擦除笔画(像素级擦除)。
-
清空画板:一键清除所有内容。
-
撤销/重做(可选,但可以后续扩展)。
-
保存图片:将画板内容保存为图片。
跨平台要求:H5、微信小程序、App(iOS/Android)。
二、核心难点与设计思路
2.1 图形对象模型
为了支持独立图形的管理和层级,我们需要为每个绘制的图形创建一个对象,存储其类型、坐标、样式等信息。所有图形按绘制顺序存放在一个数组中,数组索引即层级(后面的在上层)。
interfaceShape {
id: string; // 唯一标识
type: string; // 'line', 'rect', 'circle', 'triangle', 'sector', 'ellipse'
points?: number[]; // 自由线条的点集
x?: number; // 矩形/椭圆/扇形/三角形的起点x
y?: number; // 起点y
width?: number; // 矩形/椭圆的宽
height?: number; // 矩形/椭圆的高
radius?: number; // 圆形半径
startAngle?: number; // 扇形起始角度
endAngle?: number; // 扇形结束角度
style: {
color: string; // 填充/描边颜色
lineWidth: number; // 线宽
fill: boolean; // 是否填充
stroke: boolean; // 是否描边
};
}
绘制时,遍历shapes数组依次绘制。
2.2 橡皮擦如何实现?
考虑两种橡皮擦模式:
-
对象擦除:点击图形选中,删除该对象。适合矢量图形管理。
-
像素擦除:像真正的橡皮擦,擦除画笔痕迹。
为了简单且符合直觉,我们提供两种模式:
-
图形选择模式:点击图形高亮,按删除键删除该图形。
-
橡皮擦模式:以圆形区域擦除像素(通过
globalCompositeOperation = 'destination-out')。
但destination-out在不同平台表现一致,需要注意。擦除后无法恢复,所以最好配合撤销功能。我们将在基础版本中实现像素橡皮擦,后续可以扩展撤销。
2.3 触摸坐标转换
canvas的绘图坐标需要相对于canvas元素,且要考虑设备像素比(dpr)来保证高清显示。
在触摸事件中,我们获取到的是屏幕坐标,需要通过uni.createSelectorQuery()获取canvas的边界,然后转换为canvas相对坐标。
functiongetCanvasCoords(e) {
consttouch=e.touches[0];
returnnewPromise((resolve) => {
uni.createSelectorQuery().in(this).select('#canvas').boundingClientRect(rect=> {
constx= (touch.clientX-rect.left) *dpr;
consty= (touch.clientY-rect.top) *dpr;
resolve({ x, y });
}).exec();
});
}
注意:dpr是设备像素比,canvas的宽度和高度应设置为width * dpr,CSS宽度设置为width,以保证清晰。
2.4 跨平台canvas API差异
-
小程序:通过
uni.createCanvasContext创建上下文,绘图命令需要调用ctx.draw()才会实际渲染。 -
H5/App:直接使用标准canvas API,绘图即实时渲染。
为了统一,我们可以在每次绘制后调用draw,但H5中多余的draw会报错。所以我们采用双模式:如果是小程序环境,绘图后调用ctx.draw();H5环境直接渲染。
更好的办法是抽象渲染层:用一个函数封装绘制操作,内部判断环境。
2.5 图形层级调整
因为图形存储在数组中,数组索引决定了绘制顺序。调整层级只需移动数组元素。
// 上移一层
functionmoveUp(index) {
if (index<shapes.value.length-1) {
consttemp=shapes.value[index];
shapes.value[index] =shapes.value[index+1];
shapes.value[index+1] =temp;
}
}
// 置顶
functionmoveTop(index) {
constitem=shapes.value.splice(index, 1)[0];
shapes.value.push(item);
}
2.6 撤销/重做
可以利用历史栈存储shapes数组的快照。
三、组件设计
3.1 Props
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| width | Number | 300 | canvas宽度(px) |
| height | Number | 300 | canvas高度(px) |
| tool | String | ‘brush’ | 当前工具:brush/rect/circle/triangle/sector/ellipse/eraser |
| color | String | ‘#000000‘ | 画笔颜色 |
| lineWidth | Number | 2 | 线宽 |
| fill | Boolean | false | 是否填充(对矩形/圆形有效) |
3.2 Events
| 事件名 | 说明 |
|---|---|
| change | 画板内容变化时触发 |
| save | 保存图片时返回临时路径 |
3.3 内部状态
constshapes=ref([]) // 图形数组
constcurrentShape=ref(null) // 正在绘制的图形(用于预览)
consthistory=ref([]) // 历史记录
3.4 方法
-
clear(): 清空画板 -
undo(): 撤销 -
redo(): 重做 -
saveAsImage(): 保存图片 -
moveUp(index),moveDown(index),moveTop(index),moveBottom(index): 层级调整
四、完整代码实现
下面给出画板组件的完整代码,已考虑跨平台兼容。
4.1 组件:FreeDraw.vue
<template>
<viewclass="free-draw">
<!-- 画板容器 -->
<viewclass="canvas-container">
<canvas
id="freeCanvas"
class="canvas"
:style="{ width: width + 'px', height: height + 'px' }"
:canvas-id="canvasId"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@error="onError"
></canvas>
</view>
<!-- 工具栏(示例,可根据需要扩展) -->
<viewclass="toolbar">
<button@click="setTool('brush')">画笔</button>
<button@click="setTool('rect')">矩形</button>
<button@click="setTool('circle')">圆形</button>
<button@click="setTool('triangle')">三角形</button>
<button@click="setTool('sector')">扇形</button>
<button@click="setTool('ellipse')">椭圆</button>
<button@click="setTool('eraser')">橡皮擦</button>
<button@click="clear">清空</button>
<button@click="undo">撤销</button>
<button@click="redo">重做</button>
<button@click="save">保存</button>
</view>
</view>
</template>
<scriptsetup>
import { ref, onMounted, watch, nextTick } from'vue'
constprops=defineProps({
width: { type: Number, default: 300 },
height: { type: Number, default: 300 },
tool: { type: String, default: 'brush' },
color: { type: String, default: '#000000' },
lineWidth: { type: Number, default: 2 },
fill: { type: Boolean, default: false }
})
constemit=defineEmits(['change', 'save'])
// canvas相关
constcanvasId=`canvas-${Date.now()}-${Math.random()}`
letctx=null
letdpr=1// 设备像素比
// 画板数据
constshapes=ref([]) // 图形数组
constcurrentShape=ref(null) // 当前正在绘制的图形
consthistory=ref([]) // 历史栈(用于撤销)
consthistoryIndex=ref(-1) // 当前历史位置
// 触摸状态
letisDrawing=false
letlastPoint= { x: 0, y: 0 }
// 初始化canvas
onMounted(async () => {
// 获取设备像素比
constsystemInfo=awaituni.getSystemInfo()
dpr=systemInfo.pixelRatio
// 获取canvas上下文
ctx=uni.createCanvasContext(canvasId, getCurrentInstance().proxy)
// 设置canvas实际尺寸(高清)
constcanvas=awaitgetCanvasNode()
if (canvas) {
canvas.width=props.width*dpr
canvas.height=props.height*dpr
}
// 初始绘制
redraw()
})
// 获取canvas节点(兼容小程序和H5)
functiongetCanvasNode() {
returnnewPromise((resolve) => {
constquery=uni.createSelectorQuery().in(getCurrentInstance().proxy)
query.select(`#${canvasId}`).node(res=> {
if (res&&res.node) {
// H5/App通过node获取
resolve(res.node)
} else {
// 小程序可能不支持node方式,我们通过context设置尺寸
resolve(null)
}
}).exec()
})
}
// 设置工具(由父组件传入或内部调用)
constsetTool= (tool) => {
// 可以通过 emit 通知父组件,或直接修改内部状态
// 这里假设由父组件控制,所以不实现
}
// 监听工具变化,可能需要重置状态
watch(() =>props.tool, () => {
// 切换工具时,可能结束当前绘制
if (currentShape.value) {
// 取消当前绘制
currentShape.value=null
redraw()
}
})
// 触摸事件处理
constonTouchStart=async (e) => {
constpoint=awaitgetCanvasCoords(e)
isDrawing=true
lastPoint=point
if (props.tool==='brush'||props.tool==='eraser') {
// 开始新路径
currentShape.value= {
id: Date.now() +Math.random(),
type: props.tool==='brush'?'path' : 'eraserPath',
points: [point.x, point.y],
style: {
color: props.tool==='eraser'?'#ffffff' : props.color,
lineWidth: props.lineWidth,
fill: false,
stroke: true
}
}
} else {
// 矢量图形:记录起点
currentShape.value= {
id: Date.now() +Math.random(),
type: props.tool,
x: point.x,
y: point.y,
width: 0,
height: 0,
style: {
color: props.color,
lineWidth: props.lineWidth,
fill: props.fill,
stroke: !props.fill
}
}
}
}
constonTouchMove=async (e) => {
if (!isDrawing||!currentShape.value) return
e.preventDefault()
constpoint=awaitgetCanvasCoords(e)
if (currentShape.value.type==='path'||currentShape.value.type==='eraserPath') {
// 路径:添加点
currentShape.value.points.push(point.x, point.y)
// 实时绘制路径
drawTemp()
} else {
// 矢量图形:根据起点和当前点计算尺寸
conststartX=currentShape.value.x
conststartY=currentShape.value.y
currentShape.value.width=point.x-startX
currentShape.value.height=point.y-startY
drawTemp()
}
lastPoint=point
}
constonTouchEnd= () => {
if (!currentShape.value) return
// 完成绘制,将当前图形加入shapes数组
shapes.value.push({ ...currentShape.value })
currentShape.value=null
// 记录历史
pushHistory()
// 重绘所有
redraw()
emit('change', shapes.value)
isDrawing=false
}
// 将触摸点转换为canvas坐标(考虑dpr和边界)
functiongetCanvasCoords(e) {
returnnewPromise((resolve) => {
constquery=uni.createSelectorQuery().in(getCurrentInstance().proxy)
query.select(`#${canvasId}`).boundingClientRect(rect=> {
if (!rect) returnresolve({ x: 0, y: 0 })
consttouch=e.touches[0]
constx= (touch.clientX-rect.left) *dpr
consty= (touch.clientY-rect.top) *dpr
resolve({ x, y })
}).exec()
})
}
// 绘制临时图形(仅当前正在绘制的)
functiondrawTemp() {
redraw(false) // 先重绘所有固定图形
if (currentShape.value) {
drawShape(currentShape.value)
}
drawToCanvas()
}
// 绘制所有图形
functionredraw(includeTemp=true) {
// 清空画布
ctx.clearRect(0, 0, props.width*dpr, props.height*dpr)
// 绘制所有已完成的图形
shapes.value.forEach(shape=>drawShape(shape))
// 如果需要,绘制临时图形
if (includeTemp&¤tShape.value) {
drawShape(currentShape.value)
}
drawToCanvas()
}
// 绘制单个图形
functiondrawShape(shape) {
ctx.save()
ctx.lineWidth=shape.style.lineWidth
ctx.strokeStyle=shape.style.color
ctx.fillStyle=shape.style.fill?shape.style.color : 'transparent'
switch (shape.type) {
case'path':
case'eraserPath':
drawPath(shape)
break
case'rect':
drawRect(shape)
break
case'circle':
drawCircle(shape)
break
case'triangle':
drawTriangle(shape)
break
case'sector':
drawSector(shape)
break
case'ellipse':
drawEllipse(shape)
break
}
ctx.restore()
}
// 绘制路径
functiondrawPath(shape) {
constpoints=shape.points
if (points.length<4) return
ctx.beginPath()
ctx.moveTo(points[0], points[1])
for (leti=2; i<points.length; i+=2) {
ctx.lineTo(points[i], points[i+1])
}
ctx.stroke()
}
// 绘制矩形
functiondrawRect(shape) {
const { x, y, width, height, style } =shape
if (style.fill) {
ctx.fillRect(x, y, width, height)
}
if (style.stroke) {
ctx.strokeRect(x, y, width, height)
}
}
// 绘制圆形
functiondrawCircle(shape) {
const { x, y, width, height } =shape
// 以中心为基点?我们使用起点和当前点计算半径
constradius=Math.hypot(width, height) /2
constcenterX=x+width/2
constcenterY=y+height/2
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, Math.PI*2)
if (shape.style.fill) ctx.fill()
if (shape.style.stroke) ctx.stroke()
}
// 绘制三角形
functiondrawTriangle(shape) {
const { x, y, width, height } =shape
ctx.beginPath()
ctx.moveTo(x+width/2, y)
ctx.lineTo(x+width, y+height)
ctx.lineTo(x, y+height)
ctx.closePath()
if (shape.style.fill) ctx.fill()
if (shape.style.stroke) ctx.stroke()
}
// 绘制扇形(简化:以起点为中心,宽度为半径)
functiondrawSector(shape) {
const { x, y, width, height } =shape
constradius=Math.hypot(width, height)
constcenterX=x
constcenterY=y
conststartAngle=0
constendAngle=Math.PI/2// 简化,实际上可以根据需要设置
ctx.beginPath()
ctx.moveTo(centerX, centerY)
ctx.arc(centerX, centerY, radius, startAngle, endAngle)
ctx.closePath()
if (shape.style.fill) ctx.fill()
if (shape.style.stroke) ctx.stroke()
}
// 绘制椭圆
functiondrawEllipse(shape) {
const { x, y, width, height } =shape
constcenterX=x+width/2
constcenterY=y+height/2
constrx=Math.abs(width) /2
constry=Math.abs(height) /2
ctx.save()
ctx.translate(centerX, centerY)
ctx.scale(1, ry/rx) // 简化,用scale近似椭圆
ctx.beginPath()
ctx.arc(0, 0, rx, 0, 2*Math.PI)
ctx.restore()
if (shape.style.fill) ctx.fill()
if (shape.style.stroke) ctx.stroke()
}
// 将上下文绘制到canvas(小程序需要draw)
functiondrawToCanvas() {
// #ifdef MP-WEIXIN
ctx.draw(false)
// #endif
// #ifndef MP-WEIXIN
// H5/App不需要额外操作,已经实时渲染
// #endif
}
// 历史记录
functionpushHistory() {
constsnapshot=JSON.parse(JSON.stringify(shapes.value))
history.value=history.value.slice(0, historyIndex.value+1)
history.value.push(snapshot)
historyIndex.value++
}
// 撤销
functionundo() {
if (historyIndex.value>0) {
historyIndex.value--
shapes.value=JSON.parse(JSON.stringify(history.value[historyIndex.value]))
redraw()
emit('change', shapes.value)
}
}
// 重做
functionredo() {
if (historyIndex.value<history.value.length-1) {
historyIndex.value++
shapes.value=JSON.parse(JSON.stringify(history.value[historyIndex.value]))
redraw()
emit('change', shapes.value)
}
}
// 清空画板
functionclear() {
shapes.value= []
currentShape.value=null
history.value= []
historyIndex.value=-1
redraw()
emit('change', shapes.value)
}
// 保存图片
functionsave() {
uni.canvasToTempFilePath({
canvasId: canvasId,
success: (res) => {
emit('save', res.tempFilePath)
uni.previewImage({ urls: [res.tempFilePath] })
},
fail: (err) => {
console.error('保存失败', err)
}
}, getCurrentInstance().proxy)
}
// 层级调整
constmoveUp= (index) => {
if (index<shapes.value.length-1) {
consttemp=shapes.value[index]
shapes.value[index] =shapes.value[index+1]
shapes.value[index+1] =temp
pushHistory()
redraw()
}
}
constmoveDown= (index) => {
if (index>0) {
consttemp=shapes.value[index]
shapes.value[index] =shapes.value[index-1]
shapes.value[index-1] =temp
pushHistory()
redraw()
}
}
constmoveTop= (index) => {
constitem=shapes.value.splice(index, 1)[0]
shapes.value.push(item)
pushHistory()
redraw()
}
constmoveBottom= (index) => {
constitem=shapes.value.splice(index, 1)[0]
shapes.value.unshift(item)
pushHistory()
redraw()
}
// 错误处理
functiononError(e) {
console.error('canvas错误', e)
}
// 暴露方法给父组件
defineExpose({
clear,
undo,
redo,
save,
moveUp,
moveDown,
moveTop,
moveBottom
})
</script>
<stylescoped>
.free-draw {
display: flex;
flex-direction: column;
}
.canvas-container {
background-color: #f5f5f5;
border: 1pxsolid#ddd;
position: relative;
}
.canvas {
display: block;
background-color: #fff;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 10px;
}
.toolbarbutton {
padding: 5px10px;
font-size: 12px;
}
</style>
4.2 使用示例
<template>
<view>
<FreeDraw
ref="drawRef"
:width="300"
:height="400"
:tool="currentTool"
:color="color"
:lineWidth="lineWidth"
:fill="fill"
@change="onChange"
@save="onSave"
/>
<viewclass="controls">
<button@click="setTool('brush')">画笔</button>
<button@click="setTool('rect')">矩形</button>
<button@click="setTool('circle')">圆形</button>
<button@click="setTool('eraser')">橡皮擦</button>
<button@click="clear">清空</button>
<button@click="undo">撤销</button>
<button@click="redo">重做</button>
<button@click="save">保存</button>
</view>
</view>
</template>
<scriptsetup>
import { ref } from'vue'
importFreeDrawfrom'@/components/FreeDraw.vue'
constdrawRef=ref(null)
constcurrentTool=ref('brush')
constcolor=ref('#ff0000')
constlineWidth=ref(2)
constfill=ref(false)
constsetTool= (tool) => {
currentTool.value=tool
}
constclear= () => {
drawRef.value?.clear()
}
constundo= () => {
drawRef.value?.undo()
}
constredo= () => {
drawRef.value?.redo()
}
constsave= () => {
drawRef.value?.save()
}
constonChange= (shapes) => {
console.log('画板内容更新', shapes)
}
constonSave= (path) => {
console.log('保存路径', path)
}
</script>
五、常见错误与解决方案
❌ 错误1:画板内容模糊
现象:绘制的线条边缘模糊,像锯齿。原因:canvas实际像素尺寸与CSS尺寸不一致,没有考虑设备像素比。解决方案:设置canvas的width和height属性为物理像素尺寸(CSS宽度 * dpr),绘图时坐标也乘以dpr。
❌ 错误2:触摸点与画线位置偏移
现象:画线时,线条起点不在手指触摸点。原因:触摸坐标没有转换为canvas相对坐标。解决方案:用uni.createSelectorQuery获取canvas边界,减去偏移量。
❌ 错误3:小程序中绘图不显示
现象:所有绘图命令执行后,画布空白。原因:小程序需要调用ctx.draw()才会实际渲染。解决方案:每次绘图后调用ctx.draw(false)。
❌ 错误4:橡皮擦擦出白色背景,但背景不是白色
现象:橡皮擦模式下,擦除后露出黑色或其他颜色。原因:我们设置了画笔颜色为白色,但画板背景是白色,所以看起来像擦除。如果背景有颜色,这种方法不适用。解决方案:使用globalCompositeOperation = 'destination-out'并设置颜色任意,实现真正擦除像素。但需注意不同平台支持。可改为设置合成模式,然后画线条。
ctx.globalCompositeOperation='destination-out';
ctx.strokeStyle='#000000'; // 任意颜色
// 画路径
ctx.stroke();
ctx.globalCompositeOperation='source-over';
❌ 错误5:矢量图形绘制时宽度/高度为负数
现象:从左向右画矩形正常,从右向左画矩形宽度为负数,导致显示异常。原因:计算尺寸时未考虑正负。解决方案:存储时记录起点和终点,绘制时统一转换为正宽高。
❌ 错误6:层级调整后,选中图形混乱
现象:调整层级后,无法正确选中图形。原因:选中依赖索引,层级调整后索引改变。解决方案:存储唯一ID,选中时通过ID查找。
❌ 错误7:小程序中canvasToTempFilePath失败
原因:小程序需要指定canvasId,且需在draw回调后调用。解决方案:在ctx.draw的complete回调中调用,或使用setTimeout延迟执行。
六、总结与扩展
今天我们实现了一个功能丰富的跨平台画板组件。核心收获:
-
图形对象模型:通过数组管理,支持层级和独立属性。
-
触摸坐标转换:统一坐标系,考虑dpr。
-
跨平台适配:通过条件编译和统一的canvas API实现。
-
橡皮擦:利用合成模式实现像素擦除。
-
历史记录:简单的快照栈实现撤销重做。
你可以在这个基础上扩展更多功能,比如:
-
添加文本工具。
-
支持导入图片。
-
增加画笔粗细、透明度调节。
-
导出为SVG或JSON。
如果在实际开发中遇到其他问题,欢迎带着代码来问我。
—— 一个喜欢折腾画板组件的技术老前辈 🎨
加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!


往期相关文章推荐
夜雨聆风
