☰
Canvas半透明涂鸦画板:双层渲染与整笔重绘实践
2026/10/27 7:18:52 网站建设 项目流程

1. 先想清楚一件事:为什么要用两层 canvas

“canvas 跟随鼠标移动画带透明度的线”这个需求,看着像新手练手题,真动手写就会发现坑比想象的多——尤其是那个透明度。我前后给三类不同的项目做过类似的涂鸦功能:一个在线协作白板、一个产品原型标注工具、还有一个是给儿童做的电子画板。三次代码结构完全不同,原因就在于对“半透明”这三个字的理解不一样。第一次我图省事,一层 canvas 直接画,结果画到第二笔就发现了问题:同一笔画内部颜色深浅不一,两个笔画交叉的地方颜色直接翻倍。

这篇文章讲的就是怎么把这件事做干净:鼠标按下开始画、移动时实时出线、抬起结束,线条带透明度,像马克笔涂鸦的感觉。适合已经会一点 canvas 基础 API、但真正动手做涂鸦功能时卡在“半透明线段拼接”这一步的人,也适合想把白板/画板模块拆开重写的人。默认你了解getContext('2d')、beginPath、stroke这些基本用法,不熟也没关系,关键的地方我会把为什么讲透。

1.1 需求拆成四件事,别一上来就写代码

动手前我习惯把标题拆成可验证的小目标,这个需求拆完是这样几块:

  • 坐标采集:鼠标在画布上按下、移动、抬起,这一串事件要转成画布内部的坐标点。
  • 实时反馈:移动过程中要看到线在跟着长,不能等抬起才出现。
  • 透明度控制:线的整体 alpha 是固定的,并且同一条线不能因为画法问题出现深浅断层。
  • 状态管理:一笔画完了要能存下来、能撤销、能清空、能导出。

前两条是“能用”,后两条是“好用”。绝大多数踩坑都发生在第三条——透明度。因为 canvas 的globalAlpha是个状态量,它作用于每一次绘制指令,而不是作用于“一条线”这个逻辑概念。你在同一个位置描两遍边,alpha 就叠两遍,这不是 bug,这是 canvas 的正常合成规则。

我见过不少实现是这么写的:每来一个mousemove,就moveTo上一个点、lineTo当前点、stroke一次。这样写线条确实跟着鼠标走,但每一小段都是一次独立的描边,相邻小段在接头处必然重叠(尤其是lineCap = 'round'的时候,圆头直接叠出一个圆点),重叠区域的 alpha 变成1-(1-a)²。设a = 0.4,重叠处就是 0.64,肉眼一眼就能看出来那一串“深色珠子”。

1.2 双层结构:背景层存历史,实时层画当前

我的做法是把画布分成两层,用两个<canvas>绝对定位叠在一起:

层职责清空策略内容变化频率
背景层(base)存放所有已经画完的笔画只在撤销/清空时整体重绘低,一笔一次
实时层(live)只画“当前正在画的这一笔”每帧clearRect后整笔重绘高,跟着鼠标走

这么分的好处很直接:实时层每帧都会被清空重画,所以它上面的内容永远是“这一笔的完整路径”,透明度均匀;抬起鼠标的瞬间,把这一笔重新在背景层画一遍,然后清空实时层,交接完成。背景层只增不改,不需要每帧刷新,性能压力小得多。

注意:交接的时候不要图省事用baseCtx.drawImage(liveCanvas, 0, 0)。虽然在抬起那一刻实时层上确实只有当前这一笔,drawImage的结果也基本正确,但一旦你后面要加“笔刷纹理”“橡皮擦”“图层混合模式”这些扩展,drawImage的合成路径会和直接重绘不一致,容易埋雷。用同一份点数组在背景层重绘,行为最可控。

这个结构还有一个隐性收益:当前笔画的点数可以独立管理。画完一笔就把它压进strokes数组,实时层的点数组直接清空,不会出现“画了十分钟数组里存了十万个点”的情况。

1.3 半透明线段的本质:是叠加模型,不是“半透明画笔”

很多人脑子里的模型是“我拿了一支透明度 40% 的马克笔”。真实的马克笔在纸上划过,重叠处确实会变深,因为颜料叠了两层。canvas 的globalAlpha完全是同一套物理模型:它模拟的是颜料叠加,不是“这条线整体变淡”。

所以你要先决定自己要哪种效果:

  • 要马克笔效果(重叠变深):那就保持每次stroke独立合成,重叠加深是预期行为,甚至可以利用它做“叠色涂鸦”。
  • 要荧光笔效果(同一笔内部均匀,不同笔画之间才叠加):那就必须保证“同一笔”只被合成一次,也就是本节说的整笔重绘 + 一次性stroke。

第二种是大多数白板、标注工具的需求。判断标准很简单:你自己画一个圈,首尾相接的地方如果明显深一块,那就是第一种。

这里补一个实测细节:把整条路径组织成一个path然后只调用一次stroke,路径自交的地方(比如画个“8”字)在主流浏览器上不会出现 alpha 二次叠加。我理解的原因是引擎会先把路径转成描边区域再统一填充,而不是逐段合成。不同引擎实现细节可能有差异,但我在几个常见浏览器上测下来是一致的。这也是“整笔重绘”方案比“逐段描边”更稳的根本原因。

2. 核心细节:坐标、事件与透明度参数

结构定了,接下来是那些“写错一行就全盘皆错”的细节。这一章基本都是我这几年反复踩、反复改出来的东西,有些看起来很小,但省不掉。

2.1 坐标换算:别直接信 offsetX

canvas 有两套尺寸,这是新手最容易混的地方:

  • CSS 尺寸:canvas.style.width / height或getBoundingClientRect()拿到的,是它在页面上占多大。
  • 位图尺寸:canvas.width / height属性,是内部像素网格有多大。

如果两者相等,在普通屏上勉强能跑;一旦屏幕是 2 倍或 3 倍像素密度,位图尺寸跟不上,浏览器会把画布内容拉伸,线就糊了。所以标准做法是让位图尺寸 = CSS 尺寸 ×devicePixelRatio,再用ctx.scale把坐标系缩回 CSS 像素,这样你写代码时全部按 CSS 像素思考,不用到处乘 dpr。

关于取坐标,event.offsetX / offsetY看着最方便,但它依赖于事件目标的边界计算,在存在 CSStransform: scale()、zoom、或者 canvas 被别的元素遮挡时会出现偏差,而且不同浏览器在缩放场景下表现不完全一致。我现在的统一写法是用clientX / clientY减掉getBoundingClientRect():

function toCanvasPoint(canvas, evt) { const rect = canvas.getBoundingClientRect(); return { x: evt.clientX - rect.left, y: evt.clientY - rect.top }; }

getBoundingClientRect()返回的宽高已经包含了 CSS 缩放,所以即使你在页面上把 canvas 缩放到 80%,减去rect.left之后的坐标依然是正确的。这个写法唯一的成本是每次事件都调一次布局查询,高频事件下可能有微小开销,可以缓存 rect,但要在resize和滚动时失效。我一般先不缓存,测出来有瓶颈再说——通常瓶颈不在这里。

提示:如果你的画布支持缩放和平移(比如白板场景),坐标还要再叠加一层视图变换的逆矩阵。这时候建议统一走DOMMatrix管理视图状态,别自己手写加减乘除,很容易在多次缩放后累积误差。

2.2 事件选型:只监听 mousemove 是不够的

鼠标事件mousedown / mousemove / mouseup能跑通桌面端,但会漏三类设备:触屏、手写笔、以及带触摸的笔记本。用 Pointer Events 一套吃掉最省事:

canvas.addEventListener('pointerdown', onDown); canvas.addEventListener('pointermove', onMove); canvas.addEventListener('pointerup', onUp); canvas.addEventListener('pointercancel', onUp);

三个容易被忽略的点:

第一,pointercancel必须处理。触屏设备上手指滑动被浏览器判定为滚动手势时,会直接抛一个pointercancel,如果你只监听pointerup,状态会卡在“正在画”,下一次点击就变成“接着上一笔画”,线会从上一个位置拉一条长直线过来。这个 bug 在真机上出现率极高,模拟器上基本测不出来。

第二,setPointerCapture要加。鼠标画到画布边缘、光标移出画布时,pointerup会发生在画布外面,你的监听器收不到,状态又卡住了。在pointerdown里调canvas.setPointerCapture(evt.pointerId),后续事件会继续投递到画布上,直到抬起。

第三,CSS 里必须写touch-action: none。不写的话,移动端浏览器的默认滚动和缩放手势会抢走触摸事件,pointermove只零星触发几下,画出来的线是断的。同时顺便加上user-select: none和-webkit-tap-highlight-color: transparent,避免拖动时选中文字、出现点击高亮。

另外pointermove的触发频率是跟着设备走的,普通鼠标 60~125Hz,高刷屏和手写笔能到 240Hz 以上,而屏幕刷新率一般只有 60Hz 或 120Hz。这意味着你在一帧里可能收到好几个移动点。想要线条更顺滑,可以用evt.getCoalescedEvents()拿到这一帧内被合并掉的中间点,把它们全部压进点数组,路径的弧度会细腻很多,尤其是在快速画圈的时候。

2.3 透明度和线宽,参数怎么定

这两个参数没有绝对正确答案,但有可以推算的起点。我一般的取值逻辑:

设目标观感是“能看清,但能透出底下的参考线”,那么globalAlpha落在 0.3~0.5;如果是纯涂鸦玩具,0.6~0.9 更合适。线宽跟画布尺寸挂钩而不是写死:以画布短边为基准,lineWidth = 短边 × 0.006左右。比如短边 800px,线宽约 4.8px;短边 400px(手机上),线宽约 2.4px。这样在手机和大屏上看起来粗细比例一致,不会出现手机上线粗得像马克笔、桌面上细得像头发丝。

如果你想让“重叠加深”这件事可控,可以反推:假设同一位置最多重叠两次,希望最深处的最终 alpha 不超过 0.7,那么:

1 - (1 - a)² ≤ 0.7→(1-a)² ≥ 0.3→1 - a ≥ 0.548→a ≤ 0.452

也就是单层 alpha 控制在 0.45 以内,两层叠加最深也只到 0.7。这个算法在需要多笔画叠加的涂鸦场景很实用,反推一下就能定参数,不用来回试。

至于线宽,还有一个必须知道的限制:lineWidth为奇数且坐标为整数时,线条会跨像素边界,视觉上发虚。因为 1px 的线要占据 0 到 1 之间的半个像素,浏览器会用抗锯齿摊到相邻两个像素上,看起来就是浅一个色号。两个解法:要么线宽用偶数,要么坐标加 0.5 偏移。做了 DPR 适配之后这个问题会大幅缓解(2x 屏上 1 CSS 像素对应 2 个物理像素,边界基本对得上),所以我一般靠 DPR 适配解决,不额外做半像素对齐。

2.4 线段拼接:lineCap 和 lineJoin 决定了线的“长相”

三个属性,随手写和认真调的效果差很多:

  • lineCap = 'round':线头是圆的,适合涂鸦笔画,看起来像笔尖。
  • lineJoin = 'round':拐角圆润,鼠标急转弯时不会出现尖角毛刺。
  • miterLimit:只在lineJoin = 'miter'时生效,用圆角就不用管。

除了这三项,还有一个视觉细节:当一笔只有一个点(点一下就抬起)时,stroke画不出任何东西,因为路径长度为 0。这时候要单独处理,用arc画一个半径lineWidth/2的实心圆,否则用户点一下什么都没发生,会以为功能坏了。

同理,路径的起点也应该补一个圆点。当 lineWidth 较大、alpha 较高时,起点是平的(lineCap只作用于两个端点,起点其实是端点之一,会正常显示圆头),但如果你的路径是从第二个点开始moveTo,起点就会缺一块。我在实现里统一用“单点时画圆,多点时正常描边”的策略,简单可靠。

3. 完整实现:从零到能用的涂鸦画板

前面讲原理,这章直接上能跑的代码。整套约 200 行,我把关键的几段拆开讲,原理和代码对应着看会更清楚。

3.1 页面骨架与样式

HTML 结构很简单,两层 canvas 叠放,父容器负责尺寸和圆角:

<div class="board" id="board"> <canvas class="layer base" id="base"></canvas> <canvas class="layer live" id="live"></canvas> </div>
.board { position: relative; width: 100%; height: 480px; background: #fafafa; border-radius: 10px; overflow: hidden; touch-action: none; user-select: none; -webkit-tap-highlight-color: transparent; } .layer { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; } .live { pointer-events: none; }

关键点在于.live要pointer-events: none,让所有指针事件都落到背景层(或者落在.board上统一处理)。如果两层都接收事件,你在实时层上按下的坐标和背景层的坐标可能会有极小的取整差异,导致“抬起后线条位置跳一下”。

touch-action: none必须写在容器和画布上,写在<body>上不行,因为手势的判定是从事件目标向上找的。

3.2 初始化与 DPR 适配

初始化函数做三件事:算位图尺寸、设变换矩阵、处理尺寸变化。

function setupCanvas(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr); const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; return ctx; }

用setTransform(dpr, 0, 0, dpr, 0, 0)而不是scale(dpr, dpr),是因为后者会在多次 resize 后不断累积缩放(除非你每次都先resetTransform)。setTransform是绝对设置,重复调用安全。

注意:给canvas.width赋值这个动作本身会清空整张画布并把所有上下文状态重置为默认值,包括你设的lineCap、globalAlpha、变换矩阵。所以 resize 之后必须重新跑一遍初始化,并且要把已有的笔画重新绘制一遍。我在第一次实现时就漏了重绘,用户拖着窗口大小一变,画好的内容全没了,被吐槽了很久。

尺寸变化用一个ResizeObserver监听容器最稳妥,比window.resize靠谱(容器撑开但窗口没变的情况也能捕捉到):

let resizeTimer = null; const ro = new ResizeObserver(() => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { baseCtx = setupCanvas(baseCanvas); liveCtx = setupCanvas(liveCanvas); repaintAll(); }, 120); }); ro.observe(boardEl);

加了 120ms 防抖,是因为拖动窗口时会连续触发几十次,每次都重绘所有笔画在笔画多的时候会明显卡顿。这个数字没什么讲究,能做到“松手后基本立刻响应”就行。

3.3 跟随鼠标画线的核心循环

先定义状态。我用一个strokes数组存所有历史笔画,每笔是一个点数组;current存当前笔画的点。

const strokes = []; let current = null; let drawing = false; let rafId = 0;

按下时开始新的一笔,同时把第一个点压进去:

function onDown(evt) { if (evt.button !== undefined && evt.button !== 0) return; canvas.setPointerCapture(evt.pointerId); drawing = true; current = [toCanvasPoint(canvas, evt)]; scheduleDraw(); }

移动时追加点,但不是每个点都收。这里要做一个距离采样:新点与上一个点的距离小于 2px 就丢掉。原因有两个:一是减少点数,二是鼠标静止时的微小抖动会产生一堆几乎重合的点,会让转角处出现不自然的“鼓包”。

const MIN_DIST = 2; function onMove(evt) { if (!drawing || !current) return; const events = evt.getCoalescedEvents ? evt.getCoalescedEvents() : [evt]; for (const e of events) { const p = toCanvasPoint(canvas, e); const last = current[current.length - 1]; const dx = p.x - last.x; const dy = p.y - last.y; if (dx * dx + dy * dy < MIN_DIST * MIN_DIST) continue; current.push(p); } scheduleDraw(); }

用平方距离比较,省掉一次Math.sqrt。采样阈值 2px 是个经验值:小于 1px 点太密,路径点数暴涨;大于 4px 快速移动时拐角会明显变成折线。

绘制用requestAnimationFrame合批,避免一次pointermove就画一帧:

function scheduleDraw() { if (rafId) return; rafId = requestAnimationFrame(() => { rafId = 0; renderLive(); }); } function renderLive() { if (!current) return; liveCtx.clearRect(0, 0, liveCanvas.width, liveCanvas.height); drawStroke(liveCtx, current); }

clearRect的参数用的是位图尺寸,但因为我们设了setTransform(dpr,...),所以这里得传 CSS 尺寸才对——这是个很容易写错的地方。我习惯包一层clearAll(ctx, canvas),内部用getBoundingClientRect()的宽高,避免记混:

function clearAll(ctx, canvas) { const rect = canvas.getBoundingClientRect(); ctx.clearRect(0, 0, rect.width, rect.height); }

核心的描边函数,用中点二次贝塞尔做平滑,这是让线条“像笔画的”关键:

function drawStroke(ctx, pts) { if (pts.length === 0) return; ctx.save(); ctx.globalAlpha = ALPHA; ctx.strokeStyle = COLOR; ctx.fillStyle = COLOR; ctx.lineWidth = LINE_WIDTH; if (pts.length === 1) { ctx.beginPath(); ctx.arc(pts[0].x, pts[0].y, LINE_WIDTH / 2, 0, Math.PI * 2); ctx.fill(); ctx.restore(); return; } ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length - 1; i++) { const mx = (pts[i].x + pts[i + 1].x) / 2; const my = (pts[i].y + pts[i + 1].y) / 2; ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my); } const last = pts[pts.length - 1]; ctx.lineTo(last.x, last.y); ctx.stroke(); ctx.restore(); }

中点贝塞尔的思路是:把相邻两点的中点当作目标锚点,原始点当作控制点。这样曲线会“穿过中点、被原始点牵引”,效果比直接lineTo顺滑得多,而且不会像 Catmull-Rom 那样在急转弯处过冲。它还有一个好处:曲线始终在原始点的凸包附近,不会出现为了平滑而甩出去的怪异弯钩。

抬起时做交接:在背景层重画一遍这一笔,清空实时层,把这一笔存进历史。

function onUp(evt) { if (!drawing) return; drawing = false; if (current && current.length) { drawStroke(baseCtx, current); strokes.push(current); } current = null; liveCtx.clearRect(0, 0, liveCanvas.width, liveCanvas.height); if (evt && canvas.hasPointerCapture(evt.pointerId)) { canvas.releasePointerCapture(evt.pointerId); } }

注意抬起时背景层重绘用的是同一份点数组和同一个drawStroke,这样实时层上看到的和落到背景层上的像素级一致,不会出现“抬笔瞬间线条变细/变淡”的闪烁。

3.4 撤销、清空与导出

撤销用重放方案,别用快照。原因算一下就知道:一张 1920×1080 的画布,getImageData一次是1920 × 1080 × 4 ≈ 7.9MB,撤销栈存 30 步就是 240MB,浏览器标签页直接顶到内存红线。重放方案只存点数组,一笔 500 个点也就 8KB 左右,差三个数量级。

function undo() { strokes.pop(); repaintAll(); } function repaintAll() { clearAll(baseCtx, baseCanvas); for (const s of strokes) drawStroke(baseCtx, s); clearAll(liveCtx, liveCanvas); if (current) drawStroke(liveCtx, current); }

代价是笔画多了以后撤销会变慢(要重画全部)。但这个代价是可以量化的:一条笔画大概几百个点,100 条笔画也就几万个点,现代浏览器画这些点耗时在十几毫秒量级,用户感知不到。真正需要优化的是上千条笔画的情况,那时候再考虑“分段快照”或者“把早期笔画烘焙成图片”,属于另一个话题了。

清空就是strokes.length = 0; current = null; repaintAll();,一行搞定。

导出要注意透明度问题。canvas 本身是透明背景,toDataURL()导出的 PNG 在白底页面上看着正常,丢到某些看图软件或者聊天窗口里,透明区域会变成黑色或者棋盘格。想要干净的图片,导出前先铺一层白底:

function exportPNG() { const rect = baseCanvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const out = document.createElement('canvas'); out.width = Math.round(rect.width * dpr); out.height = Math.round(rect.height * dpr); const ctx = out.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, out.width, out.height); ctx.drawImage(baseCanvas, 0, 0); return out.toDataURL('image/png'); }

这里把背景层画到新画布上,而不是在原画布上填白——原画布一旦填白,后续擦除就擦不掉了,等于把画板毁了。

3.5 性能:什么时候需要“烘焙”

前面说每题重绘全路径,在点数量少的时候完全没问题。但有一类场景会翻车:一笔画很久。用户按住不放,在画布上绕了三十圈,点数到两三万,这时候每帧都要重新走一遍完整的路径生成 + 描边,帧率会从 60 掉到个位数。

解法是“烘焙”:当当前笔画的点数超过阈值(我设的是 1500),就把前面一段(比如前 1000 个点)先在实时层上画出来,然后把实时层的这部分内容搬到背景层,同时把这批点从current里删掉。剩下的一小段继续走实时层,帧率就回来了。

这里有个细节必须注意:烘焙到背景层的那部分和后续部分之间,在拼接点处会有一次 alpha 重叠,因为它们是两次独立的stroke。如果 alpha 是 0.4,拼接点会出现一个 0.64 的小深点。我的处理办法是在烘焙时把拼接点的前一个点保留在current里,让重绘区间和已烘焙区间有 1 个点的重叠,视觉上几乎看不出来。要彻底消除得引入笔画级的离屏合成,成本太高,涂鸦场景不值当。

4. 踩坑实录:那些文档里不会写的问题

这一章是我这些年最值钱的部分——每个问题都是真机上炸出来的,模拟器上一个都复现不了。

4.1 线条发虚、变粗、位置偏移

这三个症状经常一起出现,根因基本都是同一个:坐标系没统一。

  • 线条发虚:没做 DPR 适配,或者做了但是setTransform之后又在某些地方手动乘了 dpr,导致描边落在半像素上。
  • 线条变粗:canvas.width设成了 CSS 宽度的 dpr 倍,但没设ctx.scale,于是你按 CSS 像素算的线宽 4px 实际被拉伸成 8 物理像素,视觉上粗了一倍。
  • 位置偏移:用了offsetX但在有 CSS 变换的容器里;或者 canvas 有border、padding而getBoundingClientRect包含 border 但offsetX不包含。

排查顺序我固定这样走:先在pointerdown里console.log一个点,看按下时打印的坐标和实际鼠标位置差多少;再看canvas.width和getBoundingClientRect().width的比值是不是等于 dpr;最后看一眼有没有祖先元素带transform。三步之内基本能定位。

4.2 半透明线段的“深色接头”

这是最经典的坑,前面已经讲了原理,这里补一个我踩过的变体:用了整笔重绘,但笔画之间还是出现了深色区域。

当时排查了很久,最后发现是“橡皮擦”功能在捣鬼。橡皮擦我是用globalCompositeOperation = 'destination-out'实现的,擦完之后画布上留下的是半透明像素。后续用globalAlpha画线时,新线会和这些残留像素做合成,但因为残留像素的 alpha 不是 0 也不是 1,合成出来的结果既不是“纯色”也不是“透明”,看起来就是一块脏色。

处理办法是:擦除时用lineWidth稍大的圆角描边,并且擦除完成后用getImageData做一次 alpha 阈值清理(a < 10直接置 0),或者干脆不用真擦除,改用“画白色/背景色线”的假擦除——涂鸦场景里假擦除反而更常用,因为可以撤销,而且不会有残留像素问题。

4.3 移动端和高分屏的典型问题

问题一:手机上画出来的线和手指位置差十几像素。90% 是页面没加<meta name="viewport">,浏览器做了 980px 的默认视口缩放,clientX和实际物理位置对不上。这个坑我在三个项目里各踩过一次,每次都是同一个原因。

问题二:手指一滑整个页面跟着滚。touch-action: none没写,或者写在了错误的元素上。判断方法:在手机上画一条竖线,如果页面跟着滚动了一下,就是这个问题。

问题三:Safari 上画到一半断掉。iOS Safari 对pointermove的处理在某些版本里会在手指移动过快时丢点,而且getCoalescedEvents不一定可用。我的保底策略是:如果检测到相邻两点的距离超过阈值(比如 30px),就在中间线性插值补几个点。插值虽然不如原始点准确,但总比断成两截强。这个阈值别设太小,否则正常快速画线也会被插值成不自然的直线。

问题四:画布内容在高 DPR 屏幕上锯齿明显。除了 DPR 适配,还可以在 CSS 上给 canvas 加image-rendering: auto(默认值,别改成 pixelated),并确保 canvas 的 CSS 尺寸是整数像素。小数尺寸会导致浏览器做亚像素级缩放,产生模糊。

4.4 问题排查速查表

我把这些年遇到的问题整理成一张表,遇到症状直接对号入座:

现象最可能的原因处理方式
线条模糊、边缘发虚未做 DPR 适配位图尺寸 × dpr,setTransform回缩
线条粗细和设定值不符缺ctx.scale或缺 DPR检查位图/CSS 尺寸比值
坐标和鼠标位置错位用 offsetX + 有变换/边框改用 clientX 减 rect
一抬笔线就消失实时层清空后没合并到背景层抬起时在背景层重绘一次
线条接头一圈圈发深逐段 stroke + globalAlpha改整笔重绘,一次 stroke
只点一下没反应单点路径长度为 0单点时 arc 画实心圆
触屏画不动 / 页面滚动缺touch-action: none加在容器和画布上
画到画布外松开,状态卡住未处理捕获或 pointercancelsetPointerCapture+ 监听 cancel
改变窗口大小内容消失赋值 width 清空画布resize 后重绘全部笔画
画久了卡顿点数组无限增长距离采样 + 分段烘焙
导出图片透明区变黑PNG 透明背景导出前铺白底
撤销后内存暴涨用了 getImageData 快照改存点数组重放

5. 参数调优与场景延展

代码能跑只是起点,真正决定体验的是参数和场景适配。这部分我给几个可以直接抄的组合,再说一个能明显提升手感的扩展。

5.1 不同涂鸦风格对应的参数组合

同样是“带透明度的线”,不同用途的参数差很远。下面这套是我实际项目里验证过、投产使用的:

场景lineWidthglobalAlpha采样距离平滑说明
白板标注3~40.92px是要看清,几乎不透明
荧光笔标记14~180.352px是宽笔触,重叠加深可接受
儿童涂鸦8~120.753px否关平滑更“有笔触感”
手写签名2~31.01.5px是要锐利,可接压感
连笔速写50.52.5px是采样放宽,减少点量

“儿童涂鸦”那一行我特意关了平滑,因为小朋友画画本来就歪歪扭扭,加了平滑反而显得不真实,而且平滑会让快速的短笔画变成圆团。这是做完一轮用户测试才改的,一开始我全场景都开了平滑,反馈是“像用橡皮泥画的”。

手写笔场景还多一个维度:event.pressure。支持压感的设备会返回 0~1 的压力值,可以直接映射到lineWidth:

const w = baseWidth * (0.4 + evt.pressure * 1.2);

注意0.4这个下限是必要的,压力值可能是 0,否则线宽直接归零画不出来。另外要判断evt.pointerType === 'pen',鼠标事件的pressure要么是 0.5 要么是 0,直接用会让鼠标画的线粗细乱跳。

5.2 再往前一步:让墨迹慢慢淡去

有个小效果我很喜欢用在“临时标注”场景:画完的线过几秒自己淡掉,不需要手动清空。实现思路是在背景层上每隔一小段时间做一次极低 alpha 的“全局擦除”:

function fadeTick() { baseCtx.save(); baseCtx.globalCompositeOperation = 'destination-out'; baseCtx.globalAlpha = 0.02; baseCtx.fillStyle = '#000'; const rect = baseCanvas.getBoundingClientRect(); baseCtx.fillRect(0, 0, rect.width, rect.height); baseCtx.restore(); } setInterval(fadeTick, 120);

destination-out加上极低的 alpha,每次擦掉一点点透明度,视觉上就是缓慢消失。参数要小心:0.02配合 120ms 间隔,大概 20 秒左右完全消失;想快一点就把 alpha 提到 0.05,大约 8 秒。这个效果不可逆,一旦淡到看不见就找不回来了,所以只适合临时标注,不适合需要留存的场景。

注意:这个方案和“撤销”是冲突的——淡出改变了画布像素,但strokes数组没变,撤销重放会把淡出的线又完整画回来。要么在淡出场景下禁用撤销,要么改成基于笔画透明度的淡出(每笔带一个life值,逐帧递减,重放时按当前life画),后者干净但复杂度上去了。

我个人的取舍是:临时标注场景本来就不需要撤销,直接禁用按钮,用户心理预期也能接受。做产品有句话挺对,功能不是越多越好,是越符合场景越好。

5.3 一个容易被低估的优化:别在 pointermove 里做重活

最后说个性能上的习惯。pointermove的触发频率跟着设备走,某些高刷屏上能到 240Hz,而屏幕只刷新 60Hz。如果你在事件回调里直接stroke(),等于每帧画了四次,其中三次是无效绘制,还白白吃掉了主线程时间,导致真正的帧渲染被推后,手感发滞。

统一用requestAnimationFrame合批,事件里只做“收集数据 + 打个标记”,绘制全部放到帧回调里。这个改动通常只要十几行,但在低端设备上的手感提升很明显。我在一个千元安卓平板上测过,改之前画快速曲线有明显的“跟不上手”,改之后基本跟手。同一份代码,同一个设备,差别只在有没有合批。

另外,getCoalescedEvents拿到的那批中间点,也建议只在“需要高精度”的场景(手写、速写)打开,普通涂鸦场景用它会让点数翻好几倍,收益不明显。参数和功能一样,够用就好,多出来的部分都是成本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询