简介:面向微信小程序开发者的手写板项目源码包,基于Canvas画布组件实现手写输入与绘制,适用于在线教育、签名确认、绘画创作等场景,也适合希望掌握图形绘制与交互设计的中级开发者研习。压缩包内共21个文件,大小约799KB,主要包含4个JS脚本、4个JSON配置、2个WXSS样式、1个WXML页面结构,以及PNG图标、GIF演示图和Markdown说明文档,目录分区明确,便于对照学习和直接复用。项目完整实现了手写板常用功能:监听触摸开始、移动和结束事件,将触点连成路径,支持颜色和笔触粗细调节,提供保存、回放、撤销与重做等能力,并针对低端设备采用分段绘制等性能优化手段,体现了微信小程序环境下的典型适配思路。已有348人学习浏览,适合需要快速落地手写交互功能的小程序开发者直接使用或进行二次开发。
1. 微信小程序手写板:不是画板 Demo,是一份能直接改的生产级源码
微信小程序手写板这类功能,看着简单,真机一跑就露馅。做过签名、批注、留白题作答的都知道,模拟器里画得挺顺,到了真机上要么导出黑屏,要么笔迹断线,要么手指落点偏移。这份手写板源码,解决的就是这套完整链路:触摸采集、canvas 增量绘制、笔迹数据管理、导出图片、保存相册、撤销与清空,全部用原生小程序实现,不依赖任何第三方库。如果你上一轮跟过 2048 小程序的源码工程,对这个原生工程的结构应该不陌生;如果你是第一次接触,直接从画布初始化和触摸事件入手,也能在半小时内跑起一个能交差的版本。拿到源码后用微信开发者工具导入项目就能运行,不需要配后端,适合正在做签名墙、试卷留白批注、练字打卡这类需求的开发者。
2. 核心绘制链路:从 touch 事件到 canvas 增量绘制
2.1 画布初始化:type="2d" 与节点查询
手写板的第一步不是画线,而是把画布正确初始化。这份源码用的是type="2d"的新版 canvas,不是老式的canvas-id。两者最大的区别是:新版 canvas 可以通过wx.createSelectorQuery拿到真实节点,再用canvas.getContext('2d')获取上下文;老接口在真机和最新基础库上做导出时,经常出现拿不到画布内容的问题,后面避坑章会专门展开。
<!-- pages/board/board.wxml --> <view class="board-wrap" catchtouchmove="noop"> <canvas type="2d" id="boardCanvas" class="board-canvas" bindtouchstart="onTouchStart" bindtouchmove="onTouchMove" bindtouchend="onTouchEnd" bindtouchcancel="onTouchCancel" ></canvas> </view>// pages/board/board.js Page({ data: { canvasWidth: 0, canvasHeight: 0 }, onReady() { this.initCanvas(); }, initCanvas() { wx.createSelectorQuery() .select('#boardCanvas') .fields({ node: true, size: true }) .exec((res) => { if (!res || !res[0]) return; const { node: canvas, width, height } = res[0]; const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const dpr = sys.pixelRatio || 2; canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); this.canvas = canvas; this.ctx = ctx; this.setData({ canvasWidth: width, canvasHeight: height }); }); } });这段代码里的关键参数有三个。fields({ node: true, size: true })是拿 canvas 节点实例和它的 CSS 尺寸,只有type="2d"的 canvas 才支持node字段;dpr是设备像素比,iPhone 上通常为 3,安卓中端机多为 2 或 2.75;ctx.scale(dpr, dpr)把坐标系放大到物理像素,后续所有坐标都按逻辑像素写,不用自己手算乘除法。如果你省略 dpr 缩放,画出来的线条在真机上会明显发虚,尤其细线直接糊成一片。
2.2 触摸事件与增量画线
画布初始化好之后,核心就是 touch 事件三个回调的分工。touchstart标记起点,touchmove持续收集点并画线段,touchend把当前笔画归档。这里最反直觉的一点是:不要在touchmove里把整条笔画重画一遍,只画“上一个点到当前点”这一段就够了。
onTouchStart(e) { const { x, y } = e.touches[0]; const point = this.buildPoint(x, y); this.currentStroke = [point]; this.drawDot(point); }, onTouchMove(e) { const { x, y } = e.touches[0]; const point = this.buildPoint(x, y); const stroke = this.currentStroke; const prev = stroke[stroke.length - 1]; stroke.push(point); this.drawSegment(prev, point); }, onTouchEnd() { if (this.currentStroke && this.currentStroke.length) { this.strokes.push(this.currentStroke); this.currentStroke = null; } }, onTouchCancel() { // 系统打断(来电、弹窗)时同样归档,防止画到一半的笔画丢失 this.onTouchEnd(); }, buildPoint(x, y) { const { canvasWidth } = this.data; const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const scaleX = canvasWidth / sys.windowWidth; return { x: x * scaleX, y, color: this.penColor, width: this.penWidth }; }, drawDot(point) { const ctx = this.ctx; ctx.save(); ctx.beginPath(); ctx.arc(point.x, point.y, point.width / 2, 0, Math.PI * 2); ctx.fillStyle = point.color; ctx.fill(); ctx.restore(); }, drawSegment(prev, point) { const ctx = this.ctx; ctx.save(); ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(point.x, point.y); ctx.strokeStyle = point.color; ctx.lineWidth = point.width; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore(); }buildPoint里做了一个缩放处理:e.touches[0].x是相对页面左上角的坐标,如果 canvas 宽度和windowWidth不一致,需要把 x 按比例归一到画布坐标。这段处理有一个前提——画布左边缘和页面左边缘对齐,如果画布在页面中间或有偏移,就要用boundingClientRect做减法修正,这个坑在第 5 章详细讲。
drawSegment的逻辑是增量绘制的灵魂。它只连上一段线段,lineCap: 'round'让线段两端变成半圆,lineJoin: 'round'处理折角,这两项组合起来才能保证快速滑动时笔迹连续、转角平滑。drawDot则是补上“点一下没拖动”的落笔效果,否则只点不拖时画布上不会有任何痕迹。
2.3 笔迹数据模型:canvas 不存笔迹,数据模型才存
网上很多手写板 demo 画完就丢,canvas 上有什么就导出什么,这种思路做一次性涂鸦没问题,但做签名、留白批注这种需要回看结果的业务,一定会被卡住。canvas 本身只是像素,它不知道“这是一笔”“那是一个字”,撤销、导出高清图、下次进页面恢复现场,全部要从数据重建。
// 笔迹数据模型 this.strokes = []; // 所有已完成笔画,每个元素是一条 stroke this.currentStroke = null; // 正在画的那一笔 // stroke 结构 // [ // { x: 100, y: 200, color: '#333333', width: 4 }, // { x: 104, y: 203, color: '#333333', width: 4 } // ]每个点保存color和width不是冗余,而是为了让撤销、导出、二次编辑都能还原当时画笔的状态。如果不存这两项,导出时所有笔画都会变成当前画笔的颜色和粗细,签名会变得非常奇怪。数据驱动还有一个隐性好处:调试时可以直接在控制台打印this.strokes,看到底是坐标问题还是颜色问题,而不是对着 canvas 像素猜。
3. 导出图片链路:wx.canvasToTempFilePath 的正确用法
3.1 从 canvas 到临时文件
手写板的最终产出是一张图片,导出这一步的 APIwx.canvasToTempFilePath参数非常多,而且新老接口的行为差异在真机上很明显。统一用type="2d"的节点实例去导出,能绕开大部分黑屏问题。
async exportImage() { if (!this.strokes.length) { wx.showToast({ title: '请先书写', icon: 'none' }); return; } const canvas = this.canvas; const res = await new Promise((resolve, reject) => { wx.canvasToTempFilePath({ canvas, // type="2d" 时传节点实例 fileType: 'png', quality: 1, destWidth: canvas.width, // 物理像素 destHeight: canvas.height, success: resolve, fail: reject }); }); this.tempFilePath = res.tempFilePath; return res.tempFilePath; }canvas参数传的是节点实例,这是新版接口的关键;老接口这里要传canvasId字符串,两者混用会出现“开发工具正常、真机黑屏”的经典问题。destWidth和destHeight决定了导出图片的尺寸,不传时默认按 canvas 当前绘制区域导出,传了之后会按目标尺寸重采样。
| 参数 | 取值 | 说明 |
|---|---|---|
| canvas | 节点实例 | type="2d" 传 this.canvas;老接口传 canvasId 字符串 |
| fileType | png / jpg | png 支持透明背景,jpg 固定白底 |
| quality | 0~1 | 仅 jpg 生效,png 传了不报错但无效 |
| destWidth / destHeight | 物理像素 | 决定导出图片分辨率,不等于 CSS 尺寸 |
3.2 保存到相册与授权兜底
导出到临时文件之后,用户真正需要的是“保存到相册”。这里有一个绕不开的授权流程:第一次调wx.saveImageToPhotosAlbum会弹授权框,用户一旦拒绝,之后再调wx.authorize不会二次弹窗,而是直接返回 fail,必须引导用户去设置页手动打开权限。
async saveToAlbum() { const tempFilePath = await this.exportImage(); const auth = await this.checkAlbumAuth(); if (!auth) return; await wx.saveImageToPhotosAlbum({ filePath: tempFilePath }); wx.showToast({ title: '已保存到相册', icon: 'success' }); }, async checkAlbumAuth() { const setting = await wx.getSetting(); if (setting.authSetting['scope.writePhotosAlbum']) return true; const res = await wx.authorize({ scope: 'scope.writePhotosAlbum' }).catch(() => null); if (res) return true; wx.showModal({ title: '需要相册权限', content: '请在设置中开启保存到相册的权限', confirmText: '去设置', success: (r) => { if (r.confirm) wx.openSetting(); } }); return false; }这段代码的关键是getSetting先行判断。第一次进入时authSetting['scope.writePhotosAlbum']是 undefined,需要走authorize;已经授权过就是 true,直接保存;被拒绝过则是 false,authorize必然失败,所以走弹窗引导openSetting。不要省掉getSetting直接调authorize,那是拿用户授权弹窗当测试工具,体验很差,而且真机上会频繁翻车。保存成功之后,记得把tempFilePath留着,后续压缩上传还要用。
3.3 导出尺寸与背景:不是简单截个屏
canvas 的 CSS 尺寸、物理尺寸、导出尺寸是三回事,很多人的导出图模糊就模糊在这。CSS 尺寸是页面布局用的逻辑像素,物理尺寸是canvas.width乘过 dpr 的值,导出尺寸是destWidth/destHeight。正确做法是导出时直接取canvas.width和canvas.height,也就是物理像素。竖屏 375 宽的设备 dpr=3,导出图就是 1125px,打印签名或做证件材料才够用。
canvas 默认是透明背景,导出 png 后透明区域在深色背景上会看不清,签名场景一般需要白底。做法是先铺白底再重绘全部笔迹:
exportImageWithWhiteBg() { const ctx = this.ctx; ctx.save(); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, this.data.canvasWidth, this.data.canvasHeight); this.repaintAll(); ctx.restore(); // 然后再调 wx.canvasToTempFilePath }这一段做完之后,画布会停留在白底状态。如果业务上希望用户导完继续画,就再调一次repaintAll()恢复原来的笔迹显示;如果这是最后一步操作,保持白底也没问题。注意fillRect的白底只存在于画布像素里,不会影响strokes数据模型,所以下次清空画布时clearRect会连白底一起清掉,不需要额外处理。
4. 清空、撤销与画笔设置:把交互做到能上线
4.1 清空画布:clearRect 之后别忘重置数据
清空功能看起来是最简单的,但踩坑率极高。ctx.clearRect清的是像素,如果你只清了画布而没清strokes,一旦触发“导出前重绘”的逻辑,所有笔画又会原封不动地回来。
clearBoard() { const ctx = this.ctx; ctx.clearRect(0, 0, this.data.canvasWidth, this.data.canvasHeight); this.strokes = []; this.currentStroke = null; this.tempFilePath = ''; }clearRect的四个参数是起点 x、y 和宽高,这里直接用画布的逻辑尺寸。strokes必须同时重置,否则导出、撤销、历史恢复都会拿到脏数据。tempFilePath也要清掉,避免用户清空后误点保存,把旧图存进相册。这条链路看着琐碎,但每一项都是真实业务里会被点到的按钮。
4.2 撤销:用快照栈而不是逐点回退
撤销功能的实现策略有两种:逐点回退和整笔撤销。逐点回退需要记录每一笔的完整操作历史,内存开销大且代码复杂;整笔撤销配合全量重绘,是生产环境最常见的做法,笔画数量不多时性能完全够用。
undo() { if (!this.strokes.length) return; this.strokes.pop(); this.repaintAll(); }, repaintAll() { const ctx = this.ctx; ctx.clearRect(0, 0, this.data.canvasWidth, this.data.canvasHeight); this.strokes.forEach((stroke) => { if (!stroke.length) return; ctx.save(); ctx.beginPath(); ctx.moveTo(stroke[0].x, stroke[0].y); stroke.forEach((p) => ctx.lineTo(p.x, p.y)); ctx.strokeStyle = stroke[0].color; ctx.lineWidth = stroke[0].width; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore(); }); }repaintAll是全量重绘,和drawSegment的增量绘制刚好互补:平时画一笔只画一段,撤销、清空、导出白底时才全量重绘。stroke[0].color取的是这条笔画起点的颜色,因为一条笔画内所有点的颜色都是一致的。这里有个容易忽略的细节:touchEnd里strokes.push(this.currentStroke)时,currentStroke已经是一个独立的数组,push 进去是安全的;如果后续还往currentStroke里加元素,就会污染已经归档的历史笔画。这是典型的浅拷贝问题,稍不注意就会变成“撤销之后冒出来半笔”的玄学 bug。
4.3 画笔设置:局部更新,别整页 setData
颜色和线宽的选择器是手写板的基础配置。实现上要克制一个冲动:不要每次改颜色都把整个画布重画一遍,也不要为了刷新一个选中态把整个strokes塞进setData。setData传大数据时页面会卡顿,这在笔画多的场景下尤其明显。
setPenColor(e) { const color = e.currentTarget.dataset.color; this.penColor = color; this.setData({ activeColor: color }); }, setPenWidth(e) { const width = e.currentTarget.dataset.width; this.penWidth = width; this.setData({ activeWidth: width }); }dataset.color和dataset.width是 WXML 里>getOffset() { return new Promise((resolve) => { wx.createSelectorQuery() .select('#boardCanvas') .boundingClientRect((rect) => resolve({ left: rect.left, top: rect.top })) .exec(); }); }
同时在外层容器加catchtouchmove="noop"禁掉页面滚动,保证触摸坐标稳定。手写板尽量不要放在 scroll-view 里,移动端滚动和绘制天然冲突,这是设计问题,不是代码能补的。
5.3 画太快笔画断线
现象:快速滑动时线条断成一截一截的,像虚线。
原因:touchmove事件不是逐像素触发的,手指移动快时两次事件之间可能隔了十几个像素。如果每个点都独立画一个圆点而不连线,中间就断开了。
解决:drawSegment里用lineTo连上一个点,配合lineCap: 'round'和lineJoin: 'round'。round线帽会把线段两端补成半圆,视觉上衔接自然。另外touchstart时先drawDot落一个点,避免快速点一下没画出痕迹。
5.4 底部横条遮挡与顶部导航栏高度
现象:iPhone 上画布最下面一段被底部横条挡住,或者自定义导航时画布从胶囊按钮下面穿过去。
原因:没有适配 safe area。小程序页面默认不会自动避让底部横条,需要手动加env(safe-area-inset-bottom)。顶部导航栏高度写死 44px 也是网上教程常见的坑,胶囊按钮在不同机型上的位置不一样。
解决:画布容器加上:
.board-wrap { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }顶部导航如果一定要自定义,用wx.getMenuButtonBoundingClientRect()拿胶囊位置,再结合系统状态栏高度计算导航栏高度,不要写死。手写板这个场景自定义导航收益不大,省事方案是保持默认导航,让画布从页面顶部开始。
5.5 笔画多了卡顿:别再全量重绘了
现象:画到几十笔之后明显掉帧,松手后页面交互也变慢。
原因:最常见两个:一是每次touchmove都setData更新页面数据,二是每次都在画布上全量重绘。
解决:touchmove里只操作 canvas,不进data。需要更新 UI 的操作(比如撤销按钮可用状态)放在touchstart、touchend、clear、undo这些离散时机去setData。绘制保持增量策略,只有撤销、清空、导出白底时才走repaintAll。如果笔画数上千,还可以对历史strokes做降采样,把间距小于 1px 的点合并,视觉几乎不变,绘制开销能降一半。
6. 进阶用法:签名、裁剪、压缩上传与历史恢复
6.1 签名场景:空白校验与裁剪导出
签名场景必须能判断“有没有写过”。最简单的是看strokes.length,但画了一笔又撤销回空也是空,更稳的做法是遍历所有笔画统计有效点数。配合“裁剪导出”,把笔迹区域的边界算出来,导出时通过x、y、width、height参数指定导出区域,出来的签名图不会带着大片白边,后续贴到合同模板上位置才准。
getStrokeBounds() { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; this.strokes.forEach((stroke) => { stroke.forEach((p) => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); }); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; }6.2 压缩上传:别拿 2MB 的 png 直接传
高 dpr 机型导出的 png 可能到 1~2MB,直接wx.uploadFile很慢,也占服务端存储。常见做法是wx.compressImage转 jpg,压到 0.8 左右体积能小七成。compressImage不会覆盖原临时文件,而是返回新路径,记得拿新路径去上传:
const compressed = await wx.compressImage({ src: tempFilePath, quality: 80, compressedWidth: 750 });quality在 70~85 之间肉眼基本无差异,低于 60 时文字边缘会出现明显锯齿,签名场景尤其明显。
6.3 历史记录:序列化 strokes 恢复现场
strokes本来就是数组,JSON.stringify之后存进 storage 就能实现“下次进入继续画”。恢复时把strokes整体赋值回去,再调一次repaintAll。如果你关心的是微信小程序如何监听用户离开小程序,签批场景里就是onHide和onUnload这两个生命周期,在它们里面存草稿,不要在touchmove里做保存操作。
做这个手写板源码拆解之前,我实际翻过一次车:签名模块上线前测试,真机导出黑屏,查了一下午,最后发现是 wxml 里type="2d"和canvas-id同时存在,导出传参又混用两套。从那以后,我每次做 canvas 功能都强制走一遍“真机导出→相册查看→再次编辑→清空重画”的流程,这个流程至少帮我挡掉过三次类似的问题。这份微信小程序手写板源码就是按这条流程打磨出来的,业务层逻辑可以直接替换,希望帮到你。
本文还有配套的精品资源,点击获取