
字节笔记本
2026年10月7日 · 约 11 分钟读完
uni-app Canvas 2D:从竖式算式到触控手写板
在浏览器里写一块画布,getContext('2d') 一行就能开工。换到 uni-app,这件事要同时面对小程序、App 与 H5 三套运行环境:画布怎么拿、尺寸怎么定、事件怎么接,每一步都藏着平台差异。本文沿一条完整的实现路径展开:在全屏画布上绘制带间隔的竖式算式,再让用户用手指在算式上打草稿,写错了随时擦掉。这是口算练习、答题批注类页面的典型形态,也恰好集齐了 uni-app Canvas 2D 接口几乎全部的关键细节。
先分清两代 Canvas 接口
老一代小程序 canvas 只认 canvas-id,通过 uni.createCanvasContext('myCanvas') 拿到一个简化版上下文:方法集不全,getImageData 这类位级操作不可用,性能也一般。
新一代接口在 <canvas> 上加 type="2d",画布从此真正暴露节点,走标准 2D 上下文:
<canvas type="2d" id="myCanvas" class="my-canvas"></canvas>两代接口的写法分界要记牢:旧 API 认 canvas-id,新 API 认 id。沿用旧 API 拿到的仍是旧上下文,type="2d" 只有配合节点查询才有意义。选型标准很直接:需要 getImageData、需要完整方法集、在意渲染性能,就上 type="2d"。
另一个隐性差别在层级。旧版 canvas 是原生组件,悬浮在普通元素之上,想在画布附近放按钮,只能用 cover-view 迁就;type="2d" 支持同层渲染,画布可以像普通元素一样参与排版与遮挡。后面要做的画笔、橡皮擦工具栏正是受益者——一组普通 view 直接与画布同层排布即可,不必再绕任何特殊组件。
初始化:节点是查出来的
H5 里 document.getElementById 直来直去;uni-app 组件跨端编译,并不存在真实 DOM,节点必须通过选择器查询获取:
onReady() {
const query = uni.createSelectorQuery().in(this);
query.select('#myCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const ctx = canvas.getContext('2d');
});
}三个细节值得标注:in(this) 不能省,否则在自定义组件里查不到节点;时机放在 onReady 而不是 onLoad,此时节点才完成挂载,查询过早只会拿到 null;fields({ node: true }) 是拿 node 的前提,只写 select 得到的只有布局信息。
全屏画布:CSS 尺寸不等于绘图缓冲尺寸
这是最容易踩的坑。给 canvas 设 width: 100vw; height: 100vh 只改变元素的显示大小,绘图缓冲区——真正可用的像素矩阵——仍是画布默认值(浏览器规范里是 300×150)。结果就是内容被拉伸、模糊或错位。
正确做法是把屏幕尺寸写进画布属性:
const sys = uni.getSystemInfoSync();
canvas.width = sys.windowWidth;
canvas.height = sys.windowHeight;CSS 决定元素占多大屏幕,canvas.width/height 决定缓冲区有多大,两者要分别设置且保持一致。顺带一提,按 CSS 像素设置缓冲在高清屏上会偏模糊,追求清晰度可以乘以 devicePixelRatio 后再整体缩放绘图坐标,这是标准画布的通用做法,type="2d" 同样适用。
竖式算式:为什么逐位绘制
画 1234 + 5678 的竖式,直觉是用一整串字符串 fillText。但小学竖式要求个位对个位、十位对十位,整体绘制做不到列对齐,除非依赖等宽数字字体,而各端可用字体并不可控。
可靠的做法是逐位绘制:把数字转成字符串,从右往左每一位单独 fillText,位间加固定间隔:
drawSpacedNumber(numStr, startX, startY, maxLength, spacing) {
const digitWidth = parseInt(ctx.font) * 0.6; // 单个数字近似宽度
for (let i = 0; i < maxLength; i++) {
const digit = numStr[numStr.length - 1 - i] || ' ';
const x = startX + (maxLength - 1 - i) * (digitWidth + spacing);
ctx.textAlign = 'center';
ctx.fillText(digit, x + digitWidth / 2, startY);
}
}短数的高位用空格占位,运算符、横线与三行数字便落在同一网格上。总宽度等于「位数 × 单字宽 + (位数 − 1) × 间隔」,据此计算起点即可水平居中。系数 0.6 是经验值,更精确可用 ctx.measureText 实测,等宽数字场景下两者相差不大。
布局参数上,示例取字号 40px、位间隔 10px,行距以字号为基准倍数:第一行是被加数,第二行是加数,运算符落在列左侧;横线画在第二行下方约 1.5 倍字号处,用 beginPath/moveTo/lineTo/stroke 描出,宽度盖住整个竖式网格;结果行再往下多让出一倍行高,留出呼吸空间。整个竖式的起点取画布中心略偏上,三行内容便近似居中。

复盘一次「画布上什么都不显示」
功能逐个叠加后,最经典的故障是画布全白:上一步还正常,加了位间隔逻辑,屏幕上就什么都不剩了。此时最忌讳在业务代码里来回猜,排查有一套固定动作:
- 先画一个最简测试图形(比如红色方块)验证链路:能看到,说明初始化没问题,问题出在业务绘制逻辑;看不到,问题在画布本身。
- 检查节点查询结果:
res[0]是否为空、node 是否拿到、宽高是否为 0。宽高为 0 多半是时机太早或 CSS 未生效。 - 确认画布没有被其他元素遮挡,页面上的工具栏等浮层要预留高度。
- 真机验证。模拟器对 canvas 的渲染一直是可靠性最低的一环,不少「模拟器白屏」到了真机自然消失。
- 查看控制台报错。
把调试状态直接渲染到页面上的一个半透明 debug 视图里,比反复打断点更快——画布的很多错误是静默的,控制台什么都不打印。
触摸手写与橡皮擦
在已绘制的算式上叠加批注,靠三个触摸事件:@touchstart、@touchmove、@touchend。自由手写的本质,是把手指轨迹切成大量短线段,每段独立描边:
onTouchStart(e) {
const touch = e.touches[0];
this.isDrawing = true;
this.lastX = touch.x;
this.lastY = touch.y;
},
onTouchMove(e) {
if (!this.isDrawing) return;
const touch = e.touches[0];
ctx.beginPath();
ctx.moveTo(this.lastX, this.lastY);
ctx.lineTo(touch.x, touch.y);
ctx.strokeStyle = this.penColor;
ctx.lineCap = 'round';
ctx.stroke();
this.lastX = touch.x;
this.lastY = touch.y;
},
onTouchEnd() {
this.isDrawing = false;
}lineCap: 'round' 用来消除短线段拼接处的折角。快速滑动时触摸事件采样稀疏,笔迹会有折线感,用二次贝塞尔曲线做平滑可以明显改善观感。
逐段描边的策略有个好处:画完一段就提交一段,不必把整条轨迹存在内存里反复重绘,绘制成本只与手指移动距离线性相关,与已画内容的多少无关。代价是画布变成「一次性」的——想撤销上一步,就得靠 getImageData 快照栈来补救。再把 strokeStyle 与 lineWidth 挂到 data 上,配一个简单工具栏(画笔/橡皮切换、颜色选择器、粗细滑块),就是一块完整的批注板。
橡皮擦是整套功能里最「魔法」的一行:globalCompositeOperation = 'destination-out'。它让后续笔画不再叠加颜色,而是把目标区域已有像素抠掉,于是「擦除」只作用于画布内容本身;恢复作画时切回默认的 'source-over'。若想保留底稿、支持一键恢复,在底图绘制完成后用 ctx.getImageData(0, 0, w, h) 存一份快照,需要时 putImageData 还原——这也是撤销与重做功能的基础。

延伸:从算式板到通用白板
这套「全屏画布 + 底图 + 手写层 + 橡皮擦」的骨架,适用的远不止竖式算术:口算答题、试卷与合同批注、签名板、简单涂鸦互动,结构完全一致,差异只在底图来源(程序绘制还是图片)与笔迹记录方式。
三个值得提前规划的点。其一,destination-out 会把底图一起擦掉,若要求「只擦批注不擦题目」,把底图与笔迹拆成两层 canvas 叠放是更稳的结构。其二,触摸事件在不同端返回的坐标对象略有差异,封装一层统一的取坐标函数,能省掉大量跨端适配。其三,getSystemInfoSync 在新版本里已被拆分成 getWindowInfo 等更细粒度的 API,新项目建议直接采用。若要把答题结果存档,配套的 uni.canvasToTempFilePath 能把画布导出为临时图片文件,再走常规上传即可。最后重申多端开发里永远成立的一条:canvas 的渲染差异是跨端项目最大的不确定性来源,任何绘制逻辑上线前,都要在全部目标平台的真机上过一遍。



