继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

canvas 小画板问题记录(完整版)

娇娇jojo
关注TA
已关注
手记 21
粉丝 8377
获赞 761

作者:娇娇jojo

时间:2019年7月22日

本篇文章主要记录用 canvas 实现小画板过程中遇到的问题、难点以及如何去解决处理的。

一个完整的小画板包含以下 7 种功能:

改变画笔粗细、改变画笔颜色、橡皮擦、改变画布颜色、撤销、恢复和清空画布。

扩展性功能:回放,而回放一般包括播放、暂停、播放进度等,播放和暂停比较简单,播放进度是需要把总时间和当前时间暴露出去的。

问题汇总:

  1. 坐标点位置偏离

  2. canvas 的 id 值唯一

  3. 模糊问题

  4. 橡皮擦

  5. 生成带背景颜色的图片

  6. 折线问题——贝塞尔曲线

  7. 撤销、恢复

  8. 播放总时间及当前时间

  9. 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;
}

三、模糊问题

image.png

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、实现思路

实现思路有多种:

  1. 用画布颜色当画笔颜色;

  2. 用 clearRect 清除矩形区域;

  3. 用 clip 剪切任意形状和尺寸;

  4. 将 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();

但写出来后发现,当鼠标移动速度很快的时候,擦除的区域就不连贯了,就会出现下面这种效果,这显然不是我们想要的橡皮擦擦除效果。

image.png

既然所有点不连贯,那接下来要做的事就是把这些点连贯起来,如果是实现画图功能的话,就可以直接通过 lineTo 把两点之间连接起来再绘制,但是擦除效果中的剪辑区域要求要是闭合路径,如果是单纯的把两个点连起来就无法形成剪辑区域了。然后就想到用计算的方法,算出两个擦除区域中的矩形四个端点坐标来实现,也就是下图中的红色矩形:

5d359b7a0001db7303430129.jpg

计算方法也很简单,因为可以知道两个剪辑区域连线两个端点的坐标,又知道我们要多宽的线条,矩形的四个端点坐标就变得容易求了,所以就有了下面的代码:

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 属性设置或返回如何将一个源(新的)图像绘制到目标(已有)的图像上。

源图像 = 您打算放置到画布上的绘图。

目标图像 = 您已经放置在画布上的绘图。

image.png

这种方式就很简单了,将 globalCompositeOperation 设置为 destination-out 后,你所进行的一切绘制,都变成了擦除效果。鼠标滑动触发的事件里面代码也少了很多,计算也减少了,性能提升大大滴。

建议使用第 4 种方式,简单且性能好。

五、生成带背景颜色的图片

将 canvas 生成一张图片,首先想到的就是 toDataURL 方法。

canvas.toDataURL('image/png');

确实能生成一张图片,但图片是透明,没有背景色的。

对于画布颜色不需要更改的情况,解决办法很简单,在页面 load 完之后,就将画布颜色设置好,最后生成的图片就是带背景颜色的。

设置画布颜色的代码如下:

ctx.fillStyle = "#f00";
ctx.fillRect(0, 0, canvas.width, canvas.width);

但对于画布颜色可以更改的情况,解决方法就比较复杂了。

  1. 先把当前 canvas 保存成一张图片;

  2. 然后将 globalCompositeOperation 设置为 destination-over,也就是在源图像上方显示目标图像;

  3. 将画布填充成最后那个画布的颜色;

  4. 再将 canvas 保存成一张图片;

  5. 清空画布,将第一次保存的图片画到画布上,再将 globalCompositeOperation 设回默认值;

  6. 此时保存的第二张图片也就是带背景色的图片了。

// 清空画布
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();
}

然而事情并没那么简单,仔细的童鞋也许会发现一个很严重的问题——通过这种方式画出来的线条存在折线,不够平滑,而且你画得越快,折线感越强。表现如下图所示:

image.png

1、出现该现象的原因

我们是以 canvas 的 lineTo 方法连接点的,连接相邻两点的是条直线,非曲线,因此通过这种方式绘制出来的是条折线。受限于浏览器对 toucmove 事件的采集频率,浏览器是每隔一小段时间去采集当前鼠标的坐标的,因此滑动得越快,采集的两个临近点的距离就越远,故“折线感越明显”。

2、解决办法

要画出平滑的曲线,其实也是有方法的,lineTo 靠不住那我们可以采用 canvas 的另一个绘图 API——quadraticCurveTo,它用于绘制二次贝塞尔曲线。

quadraticCurveTo(cp1x, cp1y, x, y)

调用 quadraticCurveTo 方法需要四个参数,cp1x、cp1y 描述的是控制点,而 x、y 则是曲线的终点。

image.png

3、贝塞尔曲线算法

假设我们在一次绘画中共采集到 6 个鼠标坐标,分别是 A, B, C, D, E, F;取前面的 A, B, C 三点,计算出 B 和 C 的中点 B1,以 A 为起点,B 为控制点,B1 为终点,利用 quadraticCurveTo 绘制一条二次贝塞尔曲线线段。

image.png

接下来,计算得出 C 与 D 点的中点 C1,以 B1 为起点、C 为控制点、C1 为终点继续绘制曲线。

image.png

依次类推不断绘制下去,当到最后一个点 F 时,则以 D 和 E 的中点 D1 为起点,以 E 为控制点,F 为终点结束贝塞尔曲线。

image.png

那我们基于该算法再对现有代码进行一次升级改造:

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. 将所有操作存储下来,撤销的时候将数组的最后一条数据删除,然后清空画布,重绘之前所有的操作,包括坐标点、画布颜色等等,恢复时不需要重绘数组,只需要将最新的那个操作加上就可以了;

  2. 将每个操作的快照存储下来,撤销的时候将数组的最后一条数据删除,然后清空画布,将最后一个快照绘制到画布上,恢复操作同理。

和之前一样,我们先分析一下这几种方式的优缺点:

(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 对象),然后举一反三,就差不多啦。


打开App,阅读手记
1人推荐
发表评论
随时随地看视频慕课网APP

热门评论

小姐姐太厉害啦,写的画板功能很全面,文章对我帮助很大,阿里嘎的欧呐桑

查看全部评论