canvas 小畫板問(wèn)題記錄(完整版)
作者:娇娇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 对象),然后举一反三,就差不多啦。
共同學(xué)習(xí),寫下你的評(píng)論
評(píng)論加載中...
作者其他優(yōu)質(zhì)文章








