从零实现一个Vue Canvas画板组件:支持画笔、橡皮擦和保存图片 从零构建Vue Canvas画板组件实战画笔、橡皮擦与图片导出在当今前端开发领域交互式绘图功能已成为许多Web应用的标配需求。无论是在线教育平台的批注工具、电子签名采集系统还是创意设计类应用Canvas技术的灵活运用都能带来出色的用户体验。本文将带您深入探索如何基于Vue 3的Composition API打造一个功能完备的Canvas画板组件实现以下核心功能多工具支持自由切换画笔、直线、矩形、圆形和橡皮擦响应式设计完美适配鼠标和触摸屏操作状态管理采用Pinia集中管理绘图状态图像导出一键保存画作为PNG格式组件封装构建可复用的单文件组件(SFC)1. 项目初始化与Canvas基础搭建首先创建一个新的Vue 3项目本文使用Vite作为构建工具npm create vitelatest vue-canvas-drawing --template vue cd vue-canvas-drawing npm install pinia接下来我们创建基础的Canvas组件结构。在src/components目录下新建CanvasDrawer.vue文件template div classcanvas-container canvas refcanvasRef mousedownstartDrawing mousemovedraw mouseupstopDrawing mouseleavestopDrawing touchstartstartDrawing touchmovedraw touchendstopDrawing /canvas /div /template script setup import { ref, onMounted } from vue const canvasRef ref(null) const isDrawing ref(false) const ctx ref(null) onMounted(() { const canvas canvasRef.value canvas.width canvas.offsetWidth canvas.height canvas.offsetHeight ctx.value canvas.getContext(2d) }) /script style scoped .canvas-container { width: 100%; height: 600px; border: 1px solid #eee; } canvas { width: 100%; height: 100%; background-color: white; touch-action: none; } /style这段代码建立了Canvas的基本交互框架需要注意几个关键点响应式尺寸处理在onMounted钩子中同步Canvas元素与显示尺寸跨设备支持同时监听鼠标和触摸事件触摸优化touch-action: none防止浏览器默认的触摸行为干扰2. 实现绘图工具系统2.1 工具状态管理我们使用Pinia来管理绘图工具的状态。创建src/stores/drawing.jsimport { defineStore } from pinia export const useDrawingStore defineStore(drawing, { state: () ({ currentTool: pen, color: #000000, lineWidth: 5, eraserSize: 20, isDrawing: false, startX: 0, startY: 0 }), actions: { setTool(tool) { this.currentTool tool }, // 其他状态更新方法... } })2.2 核心绘图逻辑实现扩展CanvasDrawer组件的绘图功能script setup import { useDrawingStore } from /stores/drawing const drawingStore useDrawingStore() const startDrawing (e) { drawingStore.isDrawing true const { offsetX, offsetY } getCoordinates(e) drawingStore.startX offsetX drawingStore.startY offsetY if (drawingStore.currentTool pen) { ctx.value.beginPath() ctx.value.moveTo(offsetX, offsetY) } } const draw (e) { if (!drawingStore.isDrawing) return const { offsetX, offsetY } getCoordinates(e) switch (drawingStore.currentTool) { case pen: ctx.value.lineTo(offsetX, offsetY) ctx.value.strokeStyle drawingStore.color ctx.value.lineWidth drawingStore.lineWidth ctx.value.stroke() break case eraser: ctx.value.save() ctx.value.globalCompositeOperation destination-out ctx.value.beginPath() ctx.value.arc(offsetX, offsetY, drawingStore.eraserSize, 0, Math.PI * 2) ctx.value.fill() ctx.value.restore() break // 其他工具实现... } } const getCoordinates (e) { const rect canvasRef.value.getBoundingClientRect() return { offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top } } /script2.3 形状工具实现添加对基本形状的支持// 在draw方法中继续扩展switch语句 case line: redrawCanvas() ctx.value.beginPath() ctx.value.moveTo(drawingStore.startX, drawingStore.startY) ctx.value.lineTo(offsetX, offsetY) ctx.value.strokeStyle drawingStore.color ctx.value.lineWidth drawingStore.lineWidth ctx.value.stroke() break case rectangle: redrawCanvas() ctx.value.strokeStyle drawingStore.color ctx.value.lineWidth drawingStore.lineWidth ctx.value.strokeRect( drawingStore.startX, drawingStore.startY, offsetX - drawingStore.startX, offsetY - drawingStore.startY ) break case circle: redrawCanvas() const radius Math.sqrt( Math.pow(offsetX - drawingStore.startX, 2) Math.pow(offsetY - drawingStore.startY, 2) ) ctx.value.beginPath() ctx.value.arc(drawingStore.startX, drawingStore.startY, radius, 0, Math.PI * 2) ctx.value.strokeStyle drawingStore.color ctx.value.lineWidth drawingStore.lineWidth ctx.value.stroke() break // 辅助方法重绘Canvas用于预览形状 const redrawCanvas () { ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height) // 这里应该重绘所有已完成的图形 // 实际项目中需要维护一个图形列表 }3. 高级功能实现3.1 撤销/重做功能实现绘图历史记录// 在drawing store中添加状态 history: [], historyIndex: -1, // 添加action方法 addToHistory() { // 限制历史记录数量 if (this.historyIndex this.history.length - 1) { this.history this.history.slice(0, this.historyIndex 1) } this.history.push(canvasRef.value.toDataURL()) this.historyIndex this.history.length - 1 }, undo() { if (this.historyIndex 0) { this.historyIndex-- restoreCanvasFromHistory() } }, redo() { if (this.historyIndex this.history.length - 1) { this.historyIndex restoreCanvasFromHistory() } } // 在组件中添加恢复方法 const restoreCanvasFromHistory () { const image new Image() image.onload () { ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height) ctx.value.drawImage(image, 0, 0) } image.src drawingStore.history[drawingStore.historyIndex] }3.2 图片导出功能实现画布内容导出为PNGtemplate button clickexportImage导出图片/button /template script setup const exportImage () { const link document.createElement(a) link.download drawing.png link.href canvasRef.value.toDataURL(image/png) link.click() } /script3.3 响应式调整与性能优化添加窗口大小变化的响应处理import { onMounted, onUnmounted } from vue onMounted(() { window.addEventListener(resize, handleResize) }) onUnmounted(() { window.removeEventListener(resize, handleResize) }) const handleResize () { const canvas canvasRef.value const tempImage canvas.toDataURL() canvas.width canvas.offsetWidth canvas.height canvas.offsetHeight const image new Image() image.onload () { ctx.value.drawImage(image, 0, 0) } image.src tempImage }4. 完整组件封装与API设计4.1 组件Props设计为增强组件的复用性我们定义以下propsscript setup const props defineProps({ width: { type: [Number, String], default: 100% }, height: { type: [Number, String], default: 600px }, backgroundColor: { type: String, default: #FFFFFF }, defaultColor: { type: String, default: #000000 }, defaultLineWidth: { type: Number, default: 5 } }) /script4.2 暴露组件方法通过defineExpose暴露实用方法defineExpose({ clearCanvas: () { ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height) }, getImageData: () { return canvasRef.value.toDataURL(image/png) }, setImageData: (dataURL) { const image new Image() image.onload () { ctx.value.drawImage(image, 0, 0) } image.src dataURL } })4.3 完整的使用示例template div classdrawing-app div classtoolbar button v-fortool in tools :keytool clickdrawingStore.setTool(tool) :class{ active: drawingStore.currentTool tool } {{ tool }} /button input typecolor v-modeldrawingStore.color input typerange v-modeldrawingStore.lineWidth min1 max50 button clickundo撤销/button button clickredo重做/button button clickexportImage导出/button /div CanvasDrawer refcanvasRef / /div /template script setup import { ref } from vue import CanvasDrawer from ./CanvasDrawer.vue import { useDrawingStore } from /stores/drawing const drawingStore useDrawingStore() const canvasRef ref(null) const tools [pen, line, rectangle, circle, eraser] const undo () drawingStore.undo() const redo () drawingStore.redo() const exportImage () canvasRef.value.exportImage() /script5. 性能优化与进阶技巧5.1 离屏Canvas优化对于复杂绘图操作可以使用离屏Canvas提升性能const offscreenCanvas document.createElement(canvas) const offscreenCtx offscreenCanvas.getContext(2d) // 在需要时绘制到离屏Canvas function drawComplexShape() { offscreenCanvas.width canvasRef.value.width offscreenCanvas.height canvasRef.value.height // ...复杂绘制操作 ctx.value.drawImage(offscreenCanvas, 0, 0) }5.2 压力敏感支持添加对压感笔的支持const handlePressure (e) { if (e.pressure) { ctx.value.lineWidth drawingStore.lineWidth * e.pressure * 2 } } // 在draw方法中调用 ctx.value.lineWidth e.pressure ? drawingStore.lineWidth * e.pressure * 2 : drawingStore.lineWidth5.3 Web Worker处理复杂计算将耗时的图形计算移入Web Worker// worker.js self.onmessage function(e) { const { type, data } e.data if (type calculatePath) { const result complexPathCalculation(data) self.postMessage({ type: pathResult, result }) } } // 在主线程中 const worker new Worker(./worker.js) worker.onmessage (e) { if (e.data.type pathResult) { drawPath(e.data.result) } }