微信小程序画板实战:canvas触摸事件与性能优化全解析

发布时间:2026/9/15 0:42:27
微信小程序画板实战:canvas触摸事件与性能优化全解析 简介这是一份微信小程序绘图项目实例面向小程序初学者与前端开发者完整演示了白板自由绘画、从本地相册选图涂鸦、动态调整画笔宽度与颜色以及将最终画作保存到本地的实现思路。压缩包共25个文件体积仅45KB包含7个JSON配置文件、6个JS逻辑文件、5个WXSS样式文件、4个WXML页面文件及3个PNG图片素材配置与代码划分清晰非常适合按页面逐一拆解学习资源虽小但页面切换、绘图交互、数据保存等环节一应俱全。项目覆盖画布绘图、页面跳转、自定义工具函数等小程序常用能力能帮助读者理解触摸事件处理、画布导出和本地存储等关键API的配合方式也可作为课程设计或毕业设计的起步模板。目前已有1459人学习下载是一个轻量而完整的实战范例可以在此基础上继续丰富画笔样式与分享能力。1. 我有一支画笔一个值得手写一遍的微信小程序画板实例很多人找微信小程序项目实例时第一反应是商城、记账本、新闻列表但我更推荐先做一块画板。“我有一支画笔”这个题目看起来很玩具它真正练的是小程序里最容易翻车的三件事canvas 的坐标换算、触摸事件的高频处理、图片导出与授权。你做完会明白画线不跟手通常不是 setData 性能问题而是坐标算错了保存到相册失败也不是代码问题而是授权流程没兜住。这个实例适合刚学完生命周期、想用完整项目巩固基础的新手也适合已经上线过几个页面、准备碰 canvas 和多媒体能力的开发者。2. 先搭最小画板canvas 2d、触摸事件与坐标换算2.1 为什么这一版优先用 canvas type2d微信小程序里 canvas 有两种主流写法旧版用wx.createCanvasContext配合组件上的canvas-id新版用canvas type2d通过wx.createSelectorQuery拿到 canvas 节点再调用canvas.getContext(2d)。新写法拿到的 context 和浏览器里的 2d context 高度一致能做createImage()、drawImage()、getImageData()画板要做的橡皮擦、撤销、导出都会省很多事。我一般会直接放弃旧 API理由不是“旧的会废弃”而是旧版没法在不刷新整块画布的情况下拿到清晰位图做撤销栈时到处是兼容补丁。组件声明也很简单canvas idpaintCanvas type2d classpaint bindtouchstartonTouchStart bindtouchmoveonTouchMove bindtouchendonTouchEnd catchtouchcancelonTouchEnd styletouch-action: none; width: 100vw; height: 60vh; /canvas事件上bindtouchstart、bindtouchmove、bindtouchend是画板核心catchtouchcancel很多人会漏实际在真机上突然来电、页面被系统弹窗打断时touchcancel 不处理会导致下一笔和上一笔连在一起。样式里写touch-action: none是告诉浏览器这块区域不响应默认手势避免在苹果手机上手指一画页面就跟着滚。2.2 初始化画布从 SelectorQuery 拿到 node 和 ctxtype2d的 canvas 不会自动给你上下文必须在页面onReady之后用wx.createSelectorQuery找到节点。下面这段是画板初始化的骨架onReady() { this.initCanvas(); }, initCanvas() { const query wx.createSelectorQuery(); query.select(#paintCanvas).fields({ node: true, size: true }); query.select(#paintCanvas).boundingClientRect(); query.exec((res) { const canvasInfo res[0]; const rect res[1]; if (!canvasInfo || !canvasInfo.node) return; const canvas canvasInfo.node; const ctx canvas.getContext(2d); const systemInfo wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const dpr systemInfo.pixelRatio || 2; this.cssWidth canvasInfo.width; this.cssHeight canvasInfo.height; this.canvasX rect.left; this.canvasY rect.top; canvas.width canvasInfo.width * dpr; canvas.height canvasInfo.height * dpr; ctx.scale(dpr, dpr); ctx.lineCap round; ctx.lineJoin round; this.canvas canvas; this.ctx ctx; }); }这里有两个关键参数。fields({ node: true, size: true })里的size返回的是 canvas 的 CSS 宽高不是物理像素dpr是设备像素比iPhone 上常见是 2 或 3。把canvas.width设成CSS 宽 * dpr再调用ctx.scale(dpr, dpr)后续所有绘制就都可以继续用 CSS 像素思考画出来的线也不会发虚。同一个 SelectorQuery 里又追加了一次boundingClientRect()是为了拿到 canvas 左上角相对视口的位置。执行结果res[0]和res[1]的顺序跟你调用查询的先后顺序一致很多人在这里只取一个返回值结果坐标换算永远是错的。2.3 把触摸点换算成画布坐标别让画笔“漂移”touch 事件里的clientX、clientY是相对视口的而 canvas 的坐标系原点在画布左上角。如果画布恰好贴满屏幕可以直接用但只要页面有标题栏、导航栏或工具栏就一定要减掉 canvas 的left、top否则笔迹和手指之间会有一个固定的偏移看起来就是“画笔漂移”。coordinate(e) { const touch e.touches[0] || e.changedTouches[0]; return { x: touch.clientX - this.canvasX, y: touch.clientY - this.canvasY }; }, onTouchStart(e) { if (!this.ctx) return; const pos this.coordinate(e); this.drawing true; this.lastX pos.x; this.lastY pos.y; this.ctx.beginPath(); this.ctx.moveTo(pos.x, pos.y); }, onTouchMove(e) { if (!this.drawing || !this.ctx) return; const pos this.coordinate(e); const dist Math.sqrt((pos.x - this.lastX) ** 2 (pos.y - this.lastY) ** 2); if (dist 1.5) return; this.ctx.lineTo(pos.x, pos.y); this.ctx.stroke(); this.lastX pos.x; this.lastY pos.y; }touchstart里beginPath()加moveTo()是为了让每一笔都从手指落下的位置重新开始move里用lineTo()加stroke()画的是从上一个点到当前点的一小段直线连起来就是一条平滑曲线。Math.sqrt算的是当前点和上次绘制点之间的距离小于 1.5 就跳过这是为了让慢速移动时不会在同一位置反复画点既省 CPU 也让笔画重量更均匀。2.4 bindtouchmove 与 catchtouchmove 的选择触摸事件的绑定方式直接影响页面会不会跟着手指滚动。下表是画板场景下我这边的选择绑定方式行为画板里的用法bindtouchmove事件冒泡页面仍可滚动画布外元素需要监听触摸时用catchtouchmove阻止冒泡拦截默认滚动画布上直接用防止画画变成滚屏catchtouchcancel处理触摸被打断来电、弹窗时收笔执行快照在onReady初始化之后如果发现真机上手指一放上去页面就滑动优先检查是不是把catchtouchmove写成了bindtouchmove。touch-action: none是更好的兜底但真正要阻止页面滚动还是要靠catch系列事件。3. 让画线跟得上手连续 stroke、rAF 节流与多点触控3.1 每帧 setData 画点为什么卡渲染层与逻辑层的代价新手最常见的画板实现是维护一个points数组每次touchmove都this.setData({ points: ... })再在 WXML 里用wx:for渲染点或者canvas-id老接口重绘。这个方案在小程序里会非常卡原因是setData要把数据从逻辑层序列化传到渲染层再触发视图更新而手指移动一秒钟会产生几十个甚至上百个触摸事件等于让小程序每帧都在做一次全量数据同步。canvas 2d 节点模式的优点就在这里ctx操作直接发生在渲染层不需要把坐标点回传逻辑层。画线的数据流是“触摸事件 → 坐标换算 → 直接画”中间没有 setData这是画板性能的基本盘。3.2 连续 lineTo stroke 的最小绘制循环第 2 章代码里touchmove中每次只画一个小线段没有清空整个画布。这是画板类应用最经典的增量绘制方式不清屏、不重绘历史笔迹只需要保留当前路径的上下文。lineCap设置成round笔画两端是圆的lineJoin设置成round折线转角不会出现尖刺。在使用这套绘制循环时touchend一定要把drawing置为falseonTouchEnd() { this.drawing false; this.ctx.closePath(); }closePath()会把路径的终点和起点连起来对于自由画线不是必须的但我习惯加上如果后续要扩展成画笔工具形状封闭的闭合语义会更完整。真正重要的是drawing false否则下一次手指落下时上一次笔迹的路径还会留在 context 里新笔画会出现一条从上一笔末尾连过来的线。3.3 用 requestAnimationFrame 合并同帧的多余 move即使已经用增量绘制touchmove触发频率仍然可能高于屏幕刷新率。一帧内多次stroke()前几次的绘制结果根本来不及上屏这属于无效计算。我会用一个requestAnimationFrame把同一帧内的多次移动合并成一次绘制onTouchMove(e) { if (!this.drawing || !this.ctx) return; const pos this.coordinate(e); const dist Math.sqrt((pos.x - this.lastX) ** 2 (pos.y - this.lastY) ** 2); if (dist 1.5) return; this.pendingPoint pos; if (this.rafId) return; this.rafId requestAnimationFrame(() { this.rafId null; if (!this.pendingPoint) return; this.ctx.lineTo(this.pendingPoint.x, this.pendingPoint.y); this.ctx.stroke(); this.lastX this.pendingPoint.x; this.lastY this.pendingPoint.y; this.pendingPoint null; }); }rafId用来标记当前帧是否已经安排了绘制如果这一帧里又来了新的touchmove我们只更新pendingPoint不新增绘制任务。等下一帧开始时拿到的就是这一帧最后那个手指坐标中间略过的点丢失几个像素无关紧要笔画连续性靠 1.5 像素距离阈值兜底。这里有个细节lineTo的起点是上一次绘制的终点而我们是把最后一点作为终点。lineTo本身会自动从当前路径的最后一个点连接到新点所以只要lastX、lastY不更新下一帧画出来的线段就是连续的不会出现断裂。3.4 touches 和 changedTouches多点触控时的落笔选择真机上画画经常会碰到第二根手指不小心搭在屏幕上。e.touches是所有仍在屏幕上的手指列表e.changedTouches是本次事件中发生变化的触点。画板通常只要跟踪第一根手指coordinate(e) { const touch e.touches.length 0 ? e.touches[0] : e.changedTouches[0]; return { x: touch.clientX - this.canvasX, y: touch.clientY - this.canvasY }; }这样第二根手指落下时touches[0]仍然是第一根手指笔画不会被第二根手指的坐标突然拽走。如果你希望支持“双指松手后继续画第二笔”那需要给每根手指分配独立路径复杂度会高很多画板实例通常不值得做。真机上发现笔画“跳线”先检查是不是在touchmove里用了touches[e.touches.length - 1]那个取到的是最后一根手指和落笔那根不是同一根。3.5 画笔手感参数表lineCap、lineJoin、lineWidth、strokeStyle参数推荐值作用lineCapround线段端点圆头笔画自然lineJoinround折线转角圆滑不扎手lineWidth4 ~ 12画笔粗细单位是 CSS 像素strokeStyle#333333画笔颜色支持渐变和透明色globalCompositeOperationsource-over正常画线橡皮擦时改为destination-out这些参数放在touchstart里设置比放在onReady里设置更合适用户在画板上切换颜色和粗细之后下一笔开始前才应用新值如果统一在onReady设置会出现“改了颜色但这笔没变”的滞后感。4. 给画板补齐常用功能橡皮擦、撤销重做与保存相册4.1 工具栏的数据驱动颜色与线宽用>selectColor(e) { const color e.currentTarget.dataset.color; this.brush.color color; this.setData({ activeColor: color }); }, selectSize(e) { const size Number(e.currentTarget.dataset.size); this.brush.size size; this.setData({ activeSize: size }); }>if (this.brush.mode eraser) { this.ctx.globalCompositeOperation destination-out; } else { this.ctx.globalCompositeOperation source-over; }很多项目偷懒用白色画笔当橡皮擦这在白底画板上看起来没有区别但一旦用户选了深色背景或做渐变底色白色画笔会留下一层“白漆”而destination-out是真正清掉像素不存在这个问题。4.3 撤销重做用 canvasToTempFilePath 做快照栈小程序的 2d canvas 没有浏览器里那种可以反复调用的历史栈常见做法是自己维护一个撤销栈。我的方案是每一笔touchend后把当前画布导出成一张临时图片存进undoStack撤销时把上一张图重新画回 canvas。onLoad() { this.undoStack []; this.redoStack []; this.maxUndo 10; }, onTouchEnd() { if (!this.drawing) return; this.drawing false; this.pushHistory(); }, async pushHistory() { const res await wx.canvasToTempFilePath({ canvas: this.canvas, fileType: png }); this.undoStack.push(res.tempFilePath); if (this.undoStack.length this.maxUndo) { this.undoStack.shift(); } this.redoStack.length 0; }, async undo() { if (!this.undoStack.length) return; const frame await this.currentFrame(); this.redoStack.push(frame); const target this.undoStack.pop(); await this.restoreFrame(target); }wx.canvasToTempFilePath在 2d canvas 模式下必须显式传入canvas: this.canvas否则会一直拿不到内容。fileType用png而不是jpg因为画布里有透明区域jpg 会把这些区域压缩成黑色。restoreFrame的核心是canvas.createImage()配合drawImagerestoreFrame(src) { const img this.canvas.createImage(); return new Promise((resolve, reject) { img.onload () { this.ctx.clearRect(0, 0, this.cssWidth, this.cssHeight); this.ctx.drawImage(img, 0, 0, this.cssWidth, this.cssHeight); resolve(); }; img.onerror reject; img.src src; }); }createImage是 canvas 节点自带的方法不能用wx.getImageInfo替代否则 drawImage 拿到的不是 canvas 期望的图像对象。clearRect清空的是整个画布的逻辑尺寸也就是this.cssWidth和this.cssHeight因为前面已经ctx.scale(dpr, dpr)所以这里不能用canvas.width否则会超出可视区域。这个方案每撤一步都会读写一两次临时文件在笔数特别多时会有一点延迟。如果画板尺寸不大也可以改成ctx.getImageData保存像素数组内存占用高一些但速度更快。我选择临时文件路径因为不用处理像素数组的深拷贝也不怕数据量把内存撑爆。4.4 保存到系统相册canvasToTempFilePath saveImageToPhotosAlbum保存到相册的完整链路是先把 canvas 导出成临时文件再用wx.saveImageToPhotosAlbum写入相册。代码如下async saveToAlbum() { const res await wx.canvasToTempFilePath({ canvas: this.canvas, fileType: png, destWidth: this.cssWidth, destHeight: this.cssHeight }); wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success() { wx.showToast({ title: 已保存, icon: success }); }, fail(err) { if (err.errMsg err.errMsg.indexOf(auth) -1) { wx.showModal({ title: 需要相册权限, confirmText: 去设置, success(r) { if (r.confirm) { wx.openSetting(); } } }); } else { wx.showToast({ title: 保存失败, icon: none }); } } }); }destWidth和destHeight如果不传导出图片默认使用 canvas 的物理像素尺寸也就是CSS 宽 * dpr在部分安卓机型上会导致导出的图片比画布显示区域大很多。传 CSS 宽高后导出的图片尺寸稳定。saveImageToPhotosAlbum第一次调用会触发授权弹窗用户点了拒绝后后面再调用会直接走fail所以判断errMsg里带不带auth是必须的避免只弹一个“保存失败”让用户不知道去哪开权限。4.5 画布与工具栏的相对布局画布高度不要用固定100vh要给工具栏、标题栏留出空间。工具栏尽量覆盖在画布底部不要用绝对定位把画布盖住如果工具栏按钮是在 canvas 上方要用catchtap而不是bindtap否则按钮点击一次会同时触发画布触摸事件导致误画一笔。这个细节在用户调整颜色时最明显选完颜色画笔落下去屏幕边缘莫名其妙多了一点颜色。5. 真机验证三板斧导出图片、同层渲染、事件穿透5.1 导出黑底的 1 个补救技巧canvas 默认是透明背景canvasToTempFilePath导出的 PNG 在部分真机上会表现出黑色底。原因是透明像素在解码链路里可能被转成了黑色尤其是 iOS 老版本。解决办法是在导出前把透明部分填成白色this.ctx.save(); this.ctx.globalCompositeOperation destination-over; this.ctx.fillStyle #ffffff; this.ctx.fillRect(0, 0, this.cssWidth, this.cssHeight); this.ctx.restore();destination-over让白色画在已有像素的下面所以历史笔迹不会被覆盖。这个填充一旦执行就会永久留在画布上如果产品需要导出透明背景就要在导出完成后用白色重新填充或者准备一块离屏 canvas 来做导出不要在主画布上反复切换。5.2 iOS 真机与开发者工具的 canvas 渲染差异开发者工具里的 canvas 渲染结果和真机不是同一个渲染栈最容易出现差异的是canvasToTempFilePath的时序。真机上经常遇到导出图片少最后一笔、导出时画布闪烁、撤销后图片加载慢。我在真机验证时会做两步第一画完一笔后用setTimeout延迟一帧再导出确认最后的状态已经提交第二连续快速画 20 笔观察内存和滚动是否卡顿。如果撤销后图片变黑优先怀疑createImage的src指向的临时文件已被自动清理。临时文件路径在当次启动内有效不要把undoStack里的路径持久化到本地存储。5.3 用模块变量承载高频状态最后一个实用技巧把drawing、rafId、pendingPoint这类随触摸事件高频变化的状态放在this上不要放进data。this.drawing true; // 不触发视图更新 this.rafId null; // rAF 句柄 this.pendingPoint null; // 待绘制的临时点data里只需要放颜色选中态、线宽选中态、撤销栈长度这些需要 UI 反馈的值。画板项目里常见的“画几笔开始卡顿、切后台回来延迟很高”多半不是绘制代码的问题而是把太多和界面无关的状态塞进了setData。这个习惯在后续做白板、签名板、图片标注组件时同样适用。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询