微信小程序手写板:基于Canvas 2D的签名组件实现与避坑指南

发布时间:2026/10/18 7:50:42
微信小程序手写板:基于Canvas 2D的签名组件实现与避坑指南
简介这是一份微信小程序手写板项目的完整源码压缩包适合想学习Canvas绘图与触摸交互的中初级开发者也可直接用于手写签名、在线教育、绘画创作等小程序场景。包内共21个文件以js逻辑、json配置、wxss/wxml页面文件为主另含png图标、演示动图与README说明整体仅799KB结构干净便于对照学习。项目核心围绕手写板功能展开完整覆盖Canvas画笔绘制、触摸事件监听、笔迹路径拼接、颜色与笔触粗细切换、笔迹保存与回放并涉及撤销重做与绘制性能优化等进阶处理。通过查看源码可以快速理解微信小程序图形绘制与交互设计的实现链路也能把抽出的手写模块直接移植到自己的项目里。目前已有348人学习下载是入门小程序Canvas开发的实用参考。1. 微信小程序手写板一个从落笔到导出图片都封装好的签名组件某公司做内部审批流的时候移动端一直缺一个能收签名的地方键盘输入太冷冰冰截图留痕又没法证明是本人操作。把这款微信小程序手写板接进审批页面后员工在手机上直接落笔签字笔迹实时渲染签完一键导出成图片挂到审批单里整个过程再没碰过键盘。这个项目本质是一个以 Canvas 2D 为核心的手写签名板包含笔迹绘制、平滑处理、撤销重做、清空画布、导出图片等完整功能全部逻辑都在前端完成不需要后端服务。适合两类人一是在小程序里做电子签名、手写批注、涂鸦功能的业务开发二是想搞懂小程序画布交互底层逻辑的初学者。整个项目导入微信开发者工具就能跑代码量不大但把 touch 事件、坐标换算、像素比适配这些容易翻车的细节都踩平了。2. 技术选型与目录结构原生 Canvas 2D 的轻量组合2.1 为什么不用第三方绘图库很多开发者拿到「手写板」需求的第一反应是去找绘图库比如引入一个 canvas 工具库。但小程序里的 canvas 上下文本身已经提供了 beginPath、lineTo、quadraticCurveTo、stroke 这些基础 API手写板的核心需求只是「采集轨迹 绘制路径」自己封装不过一两百行代码。引入第三方库反而要额外处理包体积、基础库版本兼容、canvas 节点绑定方式差异这些问题对一个签名场景来说完全是负收益。这个项目走的是原生实现路线我个人非常认同。签名动作对延迟很敏感从手指落下到笔迹显示最好控制在 100ms 以内原生 canvas 的 2D 上下文足够轻松应对而绘图库的大部分能力图形变换、动画、事件命中检测在手写板场景里根本用不上。项目里唯一用到的「高级」能力是二次贝塞尔曲线这也是原生 API 自带的。结论是手写板这种功能复杂度从来不在绘图 API而在事件处理、坐标换算和历史栈管理这些靠库是解决不了的。2.2 工程目录每个文件负责什么解压后整个工程的结构很清爽没有多余的依赖目录├── app.js # 小程序入口注册全局生命周期 ├── app.json # 页面路由与全局窗口配置 ├── app.wxss # 全局样式 ├── project.config.json # 微信开发者工具的项目配置 ├── pages │ └── pad │ ├── pad.js # 手写板页面逻辑事件绑定与画布操作全在这 │ ├── pad.json # 页面级配置设置导航栏标题 │ ├── pad.wxml # 页面结构canvas 节点 工具栏按钮 │ └── pad.wxss # 画布尺寸、工具栏布局样式 └── utils └── stroke.js # 笔迹工具函数平滑绘制、笔迹抽稀pages/pad 是核心目录四个文件对应小程序页面的标准四件套。utils/stroke.js 里放的是独立于页面的笔迹处理函数比如中点贝塞尔平滑算法和笔迹抽稀逻辑抽出来放公共目录是为了后续做多个页面复用时不用复制粘贴。整体没有使用任何构建工具也没有 npm 依赖属于典型的「打开就能编译」的原生小程序工程。2.3 从落笔到出图一次签名的完整数据流搞清楚数据流比看懂任何一段代码都重要。这个项目的数据流可以拆成五步手指按下触发 touchstart记录笔迹起点初始化画笔状态手指移动触发 touchmove把坐标点追加进当前笔迹数组同时在 canvas 上画出一段线段手指抬起触发 touchend把完整的一条笔迹压入 strokeStack 历史栈点击撤销时从 strokeStack 弹出一条笔迹然后清空画布按剩余笔迹重绘点击导出时调用 wx.canvasToTempFilePath 把 canvas 内容转成临时图片再保存到相册。整个链路里历史栈是撤销功能的核心数据结构canvas 2D 上下文是唯一的「画笔」touch 事件是唯一的输入源。后面第三章的代码拆解就是按这五步逐一展开的。3. 核心模块拆解一次落笔从 touchstart 到生成图片的完整链路3.1 画布初始化devicePixelRatio 适配是第一步新版小程序 Canvas 2D 接口基础库 2.9.0使用 type2d 声明 canvas 节点然后在页面 onReady 之后通过 SelectorQuery 拿到 canvas 节点和尺寸。这里第一件要做的事就是适配设备像素比否则真机上笔画会发虚。代码里是这样处理的// pages/pad/pad.js 片段 Page({ onReady() { this.initCanvas() }, initCanvas() { const query wx.createSelectorQuery() query.select(#handPad) .fields({ node: true, size: true }) .exec(res { const canvas res[0].node const ctx canvas.getContext(2d) const dpr wx.getWindowInfo().pixelRatio // canvas 的物理尺寸按像素比放大绘制时再把坐标系缩放回去 canvas.width res[0].width * dpr canvas.height res[0].height * dpr ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx this.drawing false this.strokeStack [] // 每条笔迹一个元素用于撤销重绘 this.currentStroke [] // 正在绘制的笔迹点集 }) } })这里有几个关键点。res[0].width 是 canvas 节点在页面上的逻辑尺寸单位 px乘以 dpr 之后才是物理像素否则在高分屏上 canvas 的实际分辨率不足导出图片也会偏糊。ctx.scale(dpr, dpr) 的作用是把后续所有绘制坐标统一成逻辑坐标这样 touch 事件里拿到的坐标可以直接传给绘图 API不用自己再乘一遍像素比。strokeStack 和 currentStroke 两个数组的分工在后面的撤销和重绘逻辑里会反复用到。3.2 笔迹采集touchstart / touchmove / touchend 三件套WXML 里 canvas 节点绑定了三个事件分别是按下、移动、抬起。在 Canvas 2D 接口下事件对象里 e.touches[0].x 和 e.touches[0].y 是相对于 canvas 节点左上角的坐标这一点比旧版 canvas 省事得多旧版需要手动减去 canvas 的 offsetTop、offsetLeft。// pages/pad/pad.js 片段 onTouchStart(e) { const touch e.touches[0] this.drawing true this.currentStroke [{ x: touch.x, y: touch.y }] this.lastPoint { x: touch.x, y: touch.y } this.ctx.beginPath() this.ctx.lineWidth this.data.penSize this.ctx.strokeStyle this.data.penColor this.ctx.lineCap round // 圆头端点笔画首尾不会出现方角 this.ctx.lineJoin round }, onTouchMove(e) { if (!this.drawing) return const touch e.touches[0] const point { x: touch.x, y: touch.y } this.currentStroke.push(point) // 只画上一段到当前点的线段避免整条路径反复描边 this.ctx.beginPath() this.ctx.moveTo(this.lastPoint.x, this.lastPoint.y) this.ctx.lineTo(point.x, point.y) this.ctx.stroke() this.lastPoint point }, onTouchEnd() { if (!this.drawing) return this.drawing false this.strokeStack.push(this.currentStroke) this.currentStroke [] }onTouchMove 里有个很多人会写错的细节不要在每次 move 里把整条 stroke 路径从头 re-stroke 一遍。如果按「beginPath 一次然后 lineTo 累积」的写法touchmove 频繁触发时 canvas 会在每一帧重画整条路径笔画一长就会卡顿。这里每帧只画上一点到当前点的一条短线段绘制开销恒定是签名场景下更稳的做法。3.3 笔迹平滑用中点做二次贝塞尔曲线直接 lineTo 连出来的笔迹在快速书写时会呈现明显的折线感尤其斜向笔画。项目在 utils/stroke.js 里封装了一个基于中点二次贝塞尔曲线的平滑绘制函数重绘和导出时统一走它// utils/stroke.js function drawSmoothStroke(ctx, points) { if (points.length 2) return ctx.beginPath() ctx.moveTo(points[0].x, points[0].y) // 相邻两点的中点作为贝塞尔终点原有点作为控制点 for (let i 1; i points.length - 1; i) { const midX (points[i].x points[i 1].x) / 2 const midY (points[i].y points[i 1].y) / 2 ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY) } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y) ctx.stroke() } module.exports { drawSmoothStroke }原理不复杂把相邻两个原始点连线的中点作为一段曲线的终点原始点当作控制点。这样曲线始终经过中点不会出现 overshoot 的毛刺同时又比直线段圆滑。实时手写阶段用的是 3.2 里的线段增量画法追求跟手撤销重绘和导出阶段才使用这个平滑函数追求最终笔迹质量。两套画法并存是这个项目在「手感」和「效果」之间做的取舍。3.4 撤销重做与清空历史栈 全量重绘撤销的实现思路是「栈弹出 全量重绘」。每次手指抬起时把整条笔迹点数组压入 strokeStack撤销时弹出最后一条然后清空画布把栈里剩余笔迹全部重新画一遍// pages/pad/pad.js 片段 onUndo() { if (this.strokeStack.length 0) return this.strokeStack.pop() this.redrawAll() }, redrawAll() { const ctx this.ctx ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) const { drawSmoothStroke } require(../../utils/stroke.js) this.strokeStack.forEach(stroke { drawSmoothStroke(ctx, stroke) }) }, onClear() { this.strokeStack [] this.redrawAll() }这里要注意 clearRect 的尺寸参数。因为初始化时 canvas.width 已经被设置成了物理像素逻辑尺寸 × dpr如果 clearRect 用逻辑尺寸去清画布下半部分会残留上一帧的内容。稳妥写法是直接用 this.canvas.width 和 this.canvas.height它们本身就是物理像素值。撤销粒度是「一条笔迹」不是「一个点」这是为了符合签名场景的操作直觉——用户不会想一点一点地撤销。3.5 导出图片canvasToTempFilePath 的两个关键参数导出的实现集中在页面底部工具栏的「导出」按钮上核心调用是 wx.canvasToTempFilePath// pages/pad/pad.js 片段 onExport() { wx.canvasToTempFilePath({ canvas: this.canvas, // 新版 2D 接口必须传 canvas 节点 destWidth: this.canvas.width, destHeight: this.canvas.height, success(res) { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, fail() { // 用户拒绝过相册权限时引导去设置页打开 wx.showToast({ title: 请先授权相册权限, icon: none }) } }) } }) }这个 API 在旧版里要传 canvasId新版 Canvas 2D 接口要传 canvas 节点对象两者混用是导出白屏的头号原因。destWidth 和 destHeight 如果不传默认按 canvas 当前像素尺寸导出这里显式传一遍是为了让导出分辨率与画布一致避免某些机型上出现导出图被缩放的情况。wx.saveImageToPhotosAlbum 在用户第一次调用时会自动拉起授权弹窗如果用户之前点过拒绝授权弹窗不会再出现需要在 fail 回调里引导用户通过右上角菜单的设置页手动打开相册权限。4. 跑通项目从导入工程到真机签名的完整步骤4.1 准备工作工具与基础库跑这个项目只需要微信开发者工具和一个小程序账号没有账号可以用测试号。开发者工具安装过程没有特殊选项装完用微信扫码登录即可。打开工具后在项目配置里确认基础库版本。Canvas 2D 接口要求基础库不低于 2.9.0代码里用到的 wx.getWindowInfo 更是较新版本才有的 API。如果编译时报错提示 xxx is not a function优先去「详情 → 本地设置 → 调试基础库」里把版本切到 2.30.0 以上的稳定版本绝大多数兼容问题都能解决。4.2 导入工程目录、AppID 与编译第一步解压 zip 得到一个完整的小程序工程目录确认根目录下有 app.json 和 project.config.json。第二步打开微信开发者工具点「导入项目」选择解压后的目录。第三步AppID 一栏选择「测试号」测试号不需要注册小程序也不占用个人主体名额canvas 相关能力在测试号下都能正常使用。导入完成后先看一眼 app.json确认页面路由指向 pages/pad/pad{ pages: [pages/pad/pad], window: { navigationBarTitleText: 手写板, navigationBarBackgroundColor: #f7f7f7 } }确认无误后点工具栏的「编译」按钮。如果页面正常渲染出带工具栏的白色画布区域说明工程本身没有问题。此时可以直接用鼠标在模拟器上写写画画体验一下笔迹显示和撤销重做。模拟器上鼠标拖动触发的是 touch 事件和真机手指操作走的是同一条事件链路所以第一轮功能验证在模拟器里就能完成。4.3 真机预览二维码调试与画布表现模拟器验证通过后点击工具栏的「预览」按钮开发者工具会生成一个二维码用微信扫码即可在真机上打开。这里要重点检查三件事一是笔迹是否跟手真机 touchmove 的触发频率比模拟器低如果快速书写出现断线优先怀疑 3.2 里说的增量绘制逻辑有没有生效二是导出图片的清晰度真机上 dpr 通常是 2 或 3如果导出图发虚回到 3.1 检查像素比适配三是画布上方的工具栏是否遮挡了 canvas 的触摸区域。小程序 Canvas 2D 是同层渲染不再像旧版 canvas 那样有盖不住原生组件的层级问题但页面布局里如果 toolbar 和 canvas 有重叠触摸事件可能被 toolbar 拦截检查一下 wxml 里两者是不是上下排列、没有负 margin。编译过程遇到报错时优先看 console 面板的红色日志。这个项目没有网络请求所有报错都集中在事件处理和 canvas API 调用上点开报错堆栈能直接定位到具体行号。真机上的调试用「真机调试」按钮而非「预览」前者会拉起 vConsole可以在手机上直接看 console 输出和 touch 事件的坐标值排查偏移类问题效率高很多。5. 避坑手册手写板开发里最常踩的四个坑5.1 真机笔迹发虚模拟器却一切正常现象工程在开发者工具模拟器里笔画清晰扫码到真机上笔迹明显发虚边缘有毛刺。原因canvas 的默认物理尺寸等于逻辑尺寸没有按设备像素比放大。模拟器的 dpr 通常为 1真机 dpr 为 2 或 3分辨率不足导致笔画被拉伸这是新手最容易漏掉的一步。解决在 initCanvas 里用 wx.getWindowInfo().pixelRatio 获取 dpr将 canvas.width 设为 res[0].width * dpr并调用 ctx.scale(dpr, dpr) 让绘制坐标保持逻辑值。改完后真机笔画边缘会明显锐利。这个适配代码应该作为 canvas 初始化的固定模板不管画什么内容都应该先写上。5.2 导出图片全白或只导出一半现象点击导出后生成的图片是空白或者只有画布左上角一部分内容。原因两个常见根因。一是 wx.canvasToTempFilePath 在旧版接口里传的是 canvasId而新版 Canvas 2D 必须传 canvas 节点对象传错参数后 API 静默失败返回空白图。二是导出前画布被 display: none 隐藏或 clearRect 用了逻辑尺寸导致内容不在预期区域内。解决调用时显式传 canvas: this.canvas清屏时用 this.canvas.width 和 this.canvas.height 作为 clearRect 的宽高。导出动作放在绘制完成之后不要在 touchmove 尚未结束时触发导出。如果导出仍然异常可以先调用 wx.canvasToTempFilePath 的 fail 回调打印错误信息大多数情况下错误码会直接说明参数问题。5.3 手指写出来的笔迹位置偏了现象真机上写「一」字显示出来的线条在手指下方一段距离或者整体向左偏移。原因使用了 e.touches[0].clientX 和 clientY 这类视口坐标。旧版 canvas 需要手动减去 canvas 相对页面的偏移量如果页面里有自定义导航栏或 padding偏移量就不是 0笔迹自然错位。不少从旧版迁移过来的代码习惯性保留了这个减法反而把坐标算偏了。解决Canvas 2D 接口下直接使用 e.touches[0].x 和 e.touches[0].y这两个值是相对 canvas 节点左上角的坐标不受页面滚动和导航栏影响。如果项目里同时存在多个 canvas记得在事件回调里确认拿到的 touch 对象属于当前绑定的 canvas不要混用。5.4 快速连续书写时出现断笔现象写得快时笔画中间出现缺口尤其是斜向快速划过时最明显。原因touchmove 的触发频率受设备能力和主线程负载影响手指移动速度过快时相邻两个事件点的距离可能超过十几个像素。事件点之间如果用直线连接视觉上会显得生硬如果 touchmove 回调里还同时做了大量计算比如每次都重绘整条路径主线程卡顿会进一步放大丢点。解决事件采集用增量线段绘制把每帧开销压到最低重绘阶段用 utils/stroke.js 里的中点贝塞尔平滑弥补事件点稀疏造成的折线感。如果还是不够可以在 touchmove 里对超过阈值距离的两点之间做线性插值补点代价是笔迹数组变大需要配合抽稀来平衡存储和性能。6. 二次开发思路把签名板升级成能用的电子签名组件6.1 从页面到自定义组件当前手写板是一个 Page接入业务时更合理的形态是 Component。把 pad.js 里的 data 迁移到 properties把 onTouchStart 等事件迁移到 methods对外暴露 penColor、penSize 两个属性触发 bindchange 事件把导出图片的临时路径传给父级。这样审批页、确认页、物流签收页都能复用同一块画布不用每个页面复制一遍事件逻辑。6.2 笔迹序列化与上传strokeStack 本身就是可序列化的数据。把每条笔迹的点数组转成 JSON就能实现签名上传、留痕、回放。建议在存储前做一次抽稀相邻两点距离小于 2px 的直接丢弃签名笔迹一般几百个点就够体积可以压到几 KB。回放时用 drawSmoothStroke 按时间戳逐段绘制就能还原书写过程这个能力用在「查看谁签的、怎么签的」场景里很实用。6.3 导出的清晰度与图片裁剪wx.canvasToTempFilePath 的 destWidth、destHeight 参数可以调大导出分辨率比如导出 2 倍尺寸的签名图用于存档。另外可以计算所有笔迹点的包围盒用 canvas 的 drawImage 把签名区域单独裁剪出来去掉上下大片留白这样生成的签名图更干净也方便后续直接贴到单据的签名栏里。这份工程解压即用拿到手之后建议先按第四章的步骤完整跑一遍再照着本章的思路动手改。从那以后我每次接到带 canvas 的需求都会先确认「像素比适配做了没有、导出参数传的是节点还是 id、clearRect 用的哪套尺寸」这三件事再动手省掉了大量返工。希望帮到你。本文还有配套的精品资源点击获取