canvas 小画板问题记录(完整版)
作者:娇娇jojo
时间:2019年7月22日
本篇文章主要记录用 canvas 实现小画板过程中遇到的问题、难点以及如何去解决处理的。
一个完整的小画板包含以下 7 种功能:
改变画笔粗细、改变画笔颜色、橡皮擦、改变画布颜色、撤销、恢复和清空画布。
扩展性功能:回放,而回放一般包括播放、暂停、播放进度等,播放和暂停比较简单,播放进度是需要把总时间和当前时间暴露出去的。
问题汇总:
坐标点位置偏离
canvas 的 id 值唯一
模糊问题
橡皮擦
生成带背景颜色的图片
折线问题——贝塞尔曲线
撤销、恢复
播放总时间及当前时间
if、else 的优雅写法
一、坐标点位置偏离
1、原因
获取点的坐标是通过 clientX 和 clientY 事件属性,而 clientX、clientY 返回当事件被触发时鼠标指针相对于浏览器页面的水平/垂直坐标。
如果 canvas 相对于视口的位置正好等于 0,就没有偏差;
如果 canvas 相对于视口的位置大于 0,就会出现偏差,偏差距离正好就是 canvas 相对于视口的距离;
2、解决方法
const boundingClientRect = canvas.getBoundingClientRect(); const left = boundingClientRect.left; const top = boundingClientRect.top; return [evt.changedTouches[0].clientX - left, evt.changedTouches[0].clientY - top];
二、canvas 的 id 值唯一
1、原因
多个 canvas 同时存在并且 id 值一样的话,操作的永远是第一个 canvas。
2、解决办法
为每个 canvas 生成唯一的 id 值。
let uniqueId = 1; export default function() { const onlyId = 'canvas_' + (uniqueId++) + '_' + new Date().getTime(); return onlyId; }
三、模糊问题
1、原因
canvas 不是矢量图,而是像图片一样是位图模式的。高 dpi 显示设备意味着每平方英寸有更多的像素。也就是说二倍屏,浏览器就会以 2 个像素点的宽度来渲染一个像素,该 canvas 在 Retina 屏幕下相当于占据了 2 倍的空间,相当于图片被放大了一倍,因此绘制出来的图片文字等会变模糊。
因此,要做 Retina 屏适配,关键是知道当前屏幕的设备像素比,然后将 canvas 放大到该设备像素比来绘制,然后将 canvas 压缩到一倍来展示。
注:
位图[bitmap],也叫做点阵图,像素图,简单的说,就是最小单位由像素构成的图,缩放会失真。
矢量图[vector],也叫做向量图,简单的说,就是缩放不失真的图像格式。矢量图是通过多个对象的组合生成的,对其中的每一个对象的纪录方式,都是以数学函数来实现的,也就是说,矢量图实际上并不是象位图那样纪录画面上每一点的信息,而是记录了元素形状及颜色的算法,当你打开一付矢量图的时候,软件对图形象对应的函数进行运算,将运算结果[图形的形状和颜色]显示给你看。无论显示画面是大还是小,画面上的对象对应的算法是不变的,所以,即使对画面进行倍数相当大的缩放,其显示效果仍然相同[不失真]。
2、解决办法
在浏览器的 window
对象中有一个 devicePixelRatio
的属性,该属性表示了屏幕的设备像素比,即用几个(通常是2个)像素点宽度来渲染1个像素。
举例来说,假设 devicePixelRatio
的值为 2
,一张 100×100 像素大小的图片,在 Retina 屏幕下,会用 2 个像素点的宽度去渲染图片的 1 个像素点,因此该图片在 Retina 屏幕上实际会占据 200×200 像素的空间,相当于图片被放大了一倍,因此图片会变得模糊。
类似的,在 canvas context
中也存在一个 backingStorePixelRatio
的属性,该属性的值决定了浏览器在渲染canvas之前会用几个像素来来存储画布信息。 backingStorePixelRatio
属性在各浏览器厂商的获取方式不一样,所以需要加上浏览器前缀来实现兼容。
那么我们要做的就是,获取像素比,将 Canvas 宽高进行放大,放大比例为:devicePixelRatio / webkitBackingStorePixelRatio
。
function getPixelRatio(context) { var backingStore = context.backingStorePixelRatio || context.webkitBackingStorePixelRatio || context.mozBackingStorePixelRatio || context.msBackingStorePixelRatio || context.oBackingStorePixelRatio || 1; return (window.devicePixelRatio || 1) / backingStore; }
<div style="width:750px; height:750px"> <canvas id="canvas" style="width:100%; height:100%"> </canvas> </div>
const scale = getPixelRatio(context); const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const canvasWidth = canvas.offsetWidth * scale; canvas.width = canvasWidth; canvas.height = canvasWidth; ctx.scale(scale, scale);
四、橡皮擦
1、实现思路
实现思路有多种:
用画布颜色当画笔颜色;
用 clearRect 清除矩形区域;
用 clip 剪切任意形状和尺寸;
将 globalCompositeOperation 的值设为 destination-out,源图像透明,只显示源图像外的目标图像。
先分析一下这几种方式的优缺点:
(1)用背景色当画笔颜色
实现方式很简单,将 ctx.strokeStyle 修改成画布颜色就可以了,但这会存在一个致命的问题,就是切换画布的时候,橡皮擦擦过的地方都会被展示出来,很显然,这并不是我们想要的橡皮擦功能。剩下的 3 种方法都没有这个问题。
(2)用 clearRect 清除矩形区域
用 clearRect 清除我觉得完全没毛病,可是大部分人习惯中的橡皮擦都是圆形的,这个方法差不多也就嗝屁了。下面就出现了另外一个相似但却更强大的剪切功能,也就是 clip 方法。
(3)用 clip 剪切任意形状和尺寸
clip() 方法从原始画布中剪切任意形状和尺寸。
先实现一个圆形路径,然后把这个路径作为剪辑区域,再清除像素就行了。有个注意点就是需要先保存绘图环境,清除完像素后要重置绘图环境,如果不重置的话以后的绘图都是会被限制在那个剪辑区域中。
ctx.save();ctx.beginPath(); ctx.arc(x2, y2, a, 0,2 * Math.PI); ctx.clip(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore();
但写出来后发现,当鼠标移动速度很快的时候,擦除的区域就不连贯了,就会出现下面这种效果,这显然不是我们想要的橡皮擦擦除效果。
既然所有点不连贯,那接下来要做的事就是把这些点连贯起来,如果是实现画图功能的话,就可以直接通过 lineTo 把两点之间连接起来再绘制,但是擦除效果中的剪辑区域要求要是闭合路径,如果是单纯的把两个点连起来就无法形成剪辑区域了。然后就想到用计算的方法,算出两个擦除区域中的矩形四个端点坐标来实现,也就是下图中的红色矩形:
计算方法也很简单,因为可以知道两个剪辑区域连线两个端点的坐标,又知道我们要多宽的线条,矩形的四个端点坐标就变得容易求了,所以就有了下面的代码:
var asin = a * Math.sin(Math.atan((y2 - y1)/(x2 - x1))); var acos = a * Math.cos(Math.atan((y2 - y1)/(x2 - x1))); var x3 = x1 + asin; var y3 = y1 - acos; var x4 = x1 - asin; var y4 = y1 + acos; var x5 = x2 + asin; var y5 = y2 - acos; var x6 = x2 - asin; var y6 = y2 + acos;
x1、y1 和 x2、y2 就是两个端点,从而求出了四个端点的坐标。这样一来,剪辑区域就是圈加矩形。
ctx.save(); ctx.beginPath(); ctx.moveTo(x3,y3); ctx.lineTo(x5,y5); ctx.lineTo(x6,y6); ctx.lineTo(x4,y4); ctx.closePath(); ctx.clip(); ctx.clearRect(0,0,canvas.width,canvas.height); ctx.restore();
这个方法是可以实现橡皮擦的效果的,但计算和代码量还是比较感人了,弃用弃用。
(4)将 globalCompositeOperation 的值设为 destination-out
globalCompositeOperation 属性设置或返回如何将一个源(新的)图像绘制到目标(已有)的图像上。
源图像 = 您打算放置到画布上的绘图。
目标图像 = 您已经放置在画布上的绘图。
这种方式就很简单了,将 globalCompositeOperation 设置为 destination-out 后,你所进行的一切绘制,都变成了擦除效果。鼠标滑动触发的事件里面代码也少了很多,计算也减少了,性能提升大大滴。
建议使用第 4 种方式,简单且性能好。
五、生成带背景颜色的图片
将 canvas 生成一张图片,首先想到的就是 toDataURL 方法。
canvas.toDataURL('image/png');
确实能生成一张图片,但图片是透明,没有背景色的。
对于画布颜色不需要更改的情况,解决办法很简单,在页面 load 完之后,就将画布颜色设置好,最后生成的图片就是带背景颜色的。
设置画布颜色的代码如下:
ctx.fillStyle = "#f00"; ctx.fillRect(0, 0, canvas.width, canvas.width);
但对于画布颜色可以更改的情况,解决方法就比较复杂了。
先把当前 canvas 保存成一张图片;
然后将 globalCompositeOperation 设置为 destination-over,也就是在源图像上方显示目标图像;
将画布填充成最后那个画布的颜色;
再将 canvas 保存成一张图片;
清空画布,将第一次保存的图片画到画布上,再将 globalCompositeOperation 设回默认值;
此时保存的第二张图片也就是带背景色的图片了。
// 清空画布 function clearCanvas() { ctx.clearRect(0, 0, canvas.width, canvas.height); } // 画布生成图片 function canvasToImage() { return canvas.toDataURL('image/png'); } // 画图 function drawImage(imageSrc) { if (!imageSrc) return; const canvasPic = new Image(); canvasPic.src = imageSrc; canvasPic.addEventListener('load', () => { clearCanvas(); ctx.drawImage(canvasPic, 0, 0, canvas.width, canvas.width); }); } const canvasWidth = canvas.width; const compositeOperation = ctx.globalCompositeOperation; const canvasImage = canvasToImage(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = canvas.style.background; ctx.fillRect(0, 0, canvasWidth, canvasWidth); const imageData = canvasToImage(); drawImage(canvasImage); ctx.globalCompositeOperation = compositeOperation; console.log("生成的带背景的图片地址是:" + imageData);
六、折线问题——贝塞尔曲线
canvas 比较熟练的童鞋,实现一个小画板功能应该是手到擒来。html 和 css 代码就不贴了,直接贴 js 代码:
let isDown = false; let beginPoint = null; const canvas = document.querySelector('#canvas'); const ctx = canvas.getContext('2d'); ctx.strokeStyle = 'red'; ctx.lineWidth = 1; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; canvas.addEventListener('touchstart', down, false); canvas.addEventListener('touchmove', move, false); canvas.addEventListener('touchend', up, false); function down(evt) { isDown = true; beginPoint = getPos(evt); } function move(evt) { if (!isDown) return; const endPoint = getPos(evt); drawLine(beginPoint, endPoint); beginPoint = endPoint; } function up(evt) { if (!isDown) return; const endPoint = getPos(evt); drawLine(beginPoint, endPoint); beginPoint = null; isDown = false; } function getPos(evt) { const boundingClientRect = draw.canvas.getBoundingClientRect(); const left = boundingClientRect.left; const top = boundingClientRect.top; return { x: evt.changedTouches[0].clientX - left, y: evt.changedTouches[0].clientY - top } } function drawLine(beginPoint, endPoint) { ctx.beginPath(); ctx.moveTo(beginPoint.x, beginPoint.y); ctx.lineTo(endPoint.x, endPoint.y); ctx.stroke(); ctx.closePath(); }
然而事情并没那么简单,仔细的童鞋也许会发现一个很严重的问题——通过这种方式画出来的线条存在折线,不够平滑,而且你画得越快,折线感越强。表现如下图所示:
1、出现该现象的原因
我们是以 canvas 的 lineTo 方法连接点的,连接相邻两点的是条直线,非曲线,因此通过这种方式绘制出来的是条折线。受限于浏览器对 toucmove 事件的采集频率,浏览器是每隔一小段时间去采集当前鼠标的坐标的,因此滑动得越快,采集的两个临近点的距离就越远,故“折线感越明显”。
2、解决办法
要画出平滑的曲线,其实也是有方法的,lineTo 靠不住那我们可以采用 canvas 的另一个绘图 API——quadraticCurveTo,它用于绘制二次贝塞尔曲线。
quadraticCurveTo(cp1x, cp1y, x, y)
调用 quadraticCurveTo 方法需要四个参数,cp1x、cp1y 描述的是控制点,而 x、y 则是曲线的终点。
3、贝塞尔曲线算法
假设我们在一次绘画中共采集到 6 个鼠标坐标,分别是 A, B, C, D, E, F;取前面的 A, B, C 三点,计算出 B 和 C 的中点 B1,以 A 为起点,B 为控制点,B1 为终点,利用 quadraticCurveTo 绘制一条二次贝塞尔曲线线段。
接下来,计算得出 C 与 D 点的中点 C1,以 B1 为起点、C 为控制点、C1 为终点继续绘制曲线。
依次类推不断绘制下去,当到最后一个点 F 时,则以 D 和 E 的中点 D1 为起点,以 E 为控制点,F 为终点结束贝塞尔曲线。
那我们基于该算法再对现有代码进行一次升级改造:
let isDown = false; let points = []; let beginPoint = null; const canvas = document.querySelector('#canvas'); const ctx = canvas.getContext('2d'); ctx.strokeStyle = 'red'; ctx.lineWidth = 1; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; canvas.addEventListener('touchstart', down, false); canvas.addEventListener('touchmove', move, false); canvas.addEventListener('touchend', up, false); function down(evt) { isDown = true; const { x, y } = getPos(evt); beginPoint = {x, y}; points.push(beginPoint); drawLine(beginPoint); } function move(evt) { if (!isDown) return; const { x, y } = getPos(evt); points.push({x, y}); if (points.length >= 2) { const lastTwoPoints = points.slice(-2); const controlPoint = lastTwoPoints[0]; const endPoint = { x: (lastTwoPoints[0].x + lastTwoPoints[1].x) / 2, y: (lastTwoPoints[0].y + lastTwoPoints[1].y) / 2, } drawLine(beginPoint, controlPoint, endPoint); beginPoint = endPoint; } } function up(evt) { if (!isDown) return; beginPoint = null; isDown = false; points = []; } function getPos(evt) { const boundingClientRect = draw.canvas.getBoundingClientRect(); const left = boundingClientRect.left; const top = boundingClientRect.top; return { x: evt.changedTouches[0].clientX - left, y: evt.changedTouches[0].clientY - top } } function drawLine(beginPoint, controlPoint, endPoint) { ctx.beginPath(); ctx.moveTo(beginPoint.x, beginPoint.y); // 1个点 if (!controlPoint && !endPoint) { ctx.lineTo(beginPoint.x + 0.01, beginPoint.y + 0.01); } // 3个点及以上 if (controlPoint) { ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y); } ctx.stroke(); ctx.closePath(); }
七、撤销、恢复
一个完整的小画板应该包括:普通画笔、橡皮擦、更改画布颜色、撤销、恢复和清除画布,这里就来说说撤销恢复的实现。
1、实现思路
实现思路有两个:
将所有操作存储下来,撤销的时候将数组的最后一条数据删除,然后清空画布,重绘之前所有的操作,包括坐标点、画布颜色等等,恢复时不需要重绘数组,只需要将最新的那个操作加上就可以了;
将每个操作的快照存储下来,撤销的时候将数组的最后一条数据删除,然后清空画布,将最后一个快照绘制到画布上,恢复操作同理。
和之前一样,我们先分析一下这几种方式的优缺点:
(1)重绘操作
当操作足够多的时候,那撤销就很耗性能,比如已经完成了 10000 个操作,那么撤销一下子就要重复前面 9999 个动作。
(2)保存图片
保存图片会很吃内存,如果把图片写到本地的话,频繁撤销会引起短时间内存占用很高,而且增加了设备的 IO。
2、解决办法
基于上面两种方式,撤销可以采取两种方式的折中,撤销的次数应该也有限制,无限撤销哪种操作都不好,比如最多撤销100笔,我们可以第1-100笔保存操作,第101笔保存图片,然后在第102-201笔保存操作,第202笔保存图片,也就是每100笔保存图片,其余保存操作。
当你撤销的时候,截取最近的截图+操作,重绘一遍就可以了;恢复的时候,拿到最近的一笔,绘制上去也就可以了。
具体实现如下:
// 保存操作saveOperation(operation) { this.operationI++; // 每超过100笔存一张图 if (this.operationI > 0 && this.operationI % 101 === 0) { operation.path = this.canvas.toDataURL('image/png'); operation.type = 7; operation.color = this.canvas.style.background; } this.operations.push(operation); this.operationsReplace = this.operations.concat(); } // 撤销undo() { if (this.operations.length === 1) return []; this.operationI--; this.operations.pop(); // 截取最近的截图+操作 const resultOperations = []; for (let i = this.operations.length - 1; i >= 0; i--) { resultOperations.push(this.operations[i]); if (this.operations[i].type === 7) { break; } } const resultOperationsReverse = resultOperations.reverse(); return resultOperationsReverse; } // 恢复 redo() { const operationsLength = this.operations.length; const operationsReplace = this.operationsReplace.length; if (operationsLength === operationsReplace) return this.operationsReplace[operationsReplace - 1]; this.operationI++; const redoData = this.operationsReplace[operationsLength]; this.operations.push(redoData); return redoData; }
八、绘制总时间及当前时间
基于之前说的,一个完整的小画板应该包括:普通画笔、橡皮擦、更改画布颜色、撤销、恢复和清除画布,那么重绘这些操作时需要的时间应该怎么计算?以及当前绘制的进度,也就是当前时间又如何界定?
比如一个完成操作的数据结构如下:
type 代表操作类型,1 和 2 代表普通画笔和橡皮擦,其他操作随意;
path 代表坐标点数组。
{ type: 1, path: [ [1, 2], [4, 5] ] }
更改画布颜色、撤销、恢复和清除画布,这4个操作,1个算1s,普通画笔和橡皮擦则要计算里面的路径绘制时间,而这个又取决于坐标点的个数以及绘制的方法,我们这里说的是用贝塞尔曲线绘制。
1、总时间
那么,绘制的总时间就可以这样计算:
let sumtime = 0; paintInfo.map(item => { if(item.type <= 2){ (item.path.length <= 2) && (sumtime += 1); (item.path.length >= 3) && (sumtime += item.path.length - 1); }else{ sumtime += 1; } }) console.log("总时间是" + sumtime);
paintInfo 是总数据。
type 小于 2 时,为什么会有 path 长度判断后再累加,可以参考之前的贝塞尔曲线绘制的过程,这里就不细说了。
2、当前时间
const paintI = 0; const paintJ = 0; const currentTime = 0; function run(cb) { const next = () => { var paintInfo = paintInfo[paintI]; if (!paintInfo) return; dealPaintData(paintInfo[paintI]); //处理拿到的数据,其中包括 paintI 和 paintJ 的变化 if(paintInfo.type > 2){ paintI++; paintJ = 0; } currentTime++; console.log("当前时间是:" + currentTime); cb && cb(next); }; next(); } run((next) => { setTimeout(() => { next(); }, 100); })
九、if、else 的优雅写法
死亡嵌套不知道大家有没有见过,估计写的人晕乎乎,看的人也晕乎乎。类似于下面这种:
if(){ if(){ if(){ console.log("你知道我在第几层吗,哈哈哈哈"); } else {} } else {} } else {}
举个例子吧:
//type 代表操作类型,1为普通画笔,2为橡皮擦,3为改变画布颜色,4为撤销,5为恢复,6为清除画布 const dealOperation = (type) => { if(type === 1){ brush(); } else if(type === 2){ eraser(); } else if(type === 3){ background(); } else if(type === 4){ undo(); } else if(type === 5){ redo(); } else if(type === 6){ clear(); } }
第一反应修改,估计是 switch 吧:
const dealOperation = (type) => { switch(type){ case 1: brush(); break; case 2: eraser(); break; case 3: background(); break; case 4: undo(); break; case 5: redo(); break; case 6: clear(); break; } }
嗯,这样看起来比 if/else 清晰多了,这时有同学会说,还有更简单的写法:
const actions = { 1: brush, 2: eraser, 3: background, 4: undo, 5: redo, 6: clear } const dealOperation = (type) => { let action = actions[type]; action.call(this); }
这样确实又比 switch 简洁很多,而且扩展起来也很容易,但是还有个终极武器哦:
const actions = new Map([ [1, brush], [2, eraser], [3, background], [4, undo], [5, redo], [6, clear] ]) const dealOperation = (type) => { let action = actions.get(type); action.call(this); }
new Map 的形式其实和上面对象的形式差不多,只不过 Map 里面的 key 可以是很多类型,而不仅仅是数字或者字符串,对象、正则等也都可以。
差不多就这样吧,哈哈哈,文章太长,写不动了==,最后这个方法理解精髓就好(ES6 的 Map 对象),然后举一反三,就差不多啦。
共同学习,写下你的评论
评论加载中...
作者其他优质文章