探究 canvas 绘图中撤销(undo)功能的实现方式详解


Posted in HTML / CSS onMay 17, 2018

最近在做网页版图片处理相关的项目,也算是初入了 canvas 的坑。项目需求中有一个给图片添加水印的功能。我们知道,在浏览器端实现图片添加水印功能,通常的做法就是使用 canvasdrawImage 方法。对于普通的合成(比如一张底图和一张 PNG 水印图片合成)来说,其大致实现原理如下:

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext('2d');

// img: 底图
// watermarkImg: 水印图片
// x, y 是画布上放置 img 的坐标
ctx.drawImage(img, x, y);
ctx.drawImage(watermarkImg, x, y);

直接连续使用 drawImage() 把对应的图片绘制到 canvas 画布上就行。

以上就是背景介绍。但是略麻烦的是添加水印的需求中还有一个需要实现的功能是用户能够切换水印的位置。我们自然会想到能否实现 canvasundo 功能,当用户切换水印位置时,先撤销上一步 drawImage 操作,然后再重新绘制水印图片位置。

restore / save ?

效率最高也是最方便的肯定是查阅 canvas 2D 原生 API 是否有此功能。经过一番搜索, restore / save 这一对 API 进入视线。我们先看一下这两个 API 的描述:

CanvasRenderingContext2D.restore() 是 Canvas 2D API 通过在绘图状态栈中弹出顶端的状态,将 canvas 恢复到最近的保存状态的方法。 如果没有保存状态,此方法不做任何改变。

CanvasRenderingContext2D.save() 是 Canvas 2D API 通过将当前状态放入栈中,保存 canvas 全部状态的方法。

乍看起来可以满足需求。我们看一下官方示例代码:

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

ctx.save(); // 保存默认的状态
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 100, 100);

ctx.restore(); // 还原到上次保存的默认状态
ctx.fillRect(150, 75, 100, 100);

结果如下图所示:

探究 canvas 绘图中撤销(undo)功能的实现方式详解

奇怪,好像和我们预期的结果不太一致。我们想要的结果是 save 方法调用后能够保存当前画布的快照, resolve 方法调用后能够完全回到上一个保存的快照处的状态。

再仔细研究一下 API。原来我们遗漏一个重要概念: drawing state ,也就是绘制状态。保存到栈中的绘制状态包含以下几个部分:

  1. 当前的变换矩阵
  2. 当前的剪切区域
  3. 当前的虚线列表

以下属性当前的值:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled.

好吧, drawImage 操作后对画布的改变根本不存在于绘制状态中。所以,使用 resolve / save 无法实现我们需要的 undo 功能。

模拟栈实现

既然原生的 API 保存绘制状态的栈无法满足需求,那么自然我们会想到自己模拟一个保存操作的栈。随之而来的问题就是:每次绘制操作之后,应该保存什么数据进栈?前面说过,我们想要的是每步绘制操作之后能够保存当前画布的 快照 ,如果能拿到快照数据,同时能利用快照数据恢复画布的话,问题也就迎刃而解了。

幸运的是 canvas 2D 原生提供了获取快照和通过快照恢复画布的 API —— getImageData / putImageData 。以下是 API 说明:

/*
 * @param { Number } sx 将要被提取的图像数据矩形区域的左上角 x 坐标
 * @param { Number } sy 将要被提取的图像数据矩形区域的左上角 y 坐标
 * @param { Number } sw 将要被提取的图像数据矩形区域的宽度
 * @param { Number } sh 将要被提取的图像数据矩形区域的高度
 * @return { Object } ImageData 包含 canvas 给定的矩形图像数据
 */
 ImageData ctx.getImageData(sx, sy, sw, sh);
 
 /*
 * @param { Object } imagedata 包含像素值的对象
 * @param { Number } dx 源图像数据在目标画布中的位置偏移量(x 轴方向的偏移量)
 * @param { Number } dy 源图像数据在目标画布中的位置偏移量(y 轴方向的偏移量)
 */
 void ctx.putImageData(imagedata, dx, dy);

我们来看一个简单的应用方式:

class WrappedCanvas {
    constructor (canvas) {
        this.ctx = canvas.getContext('2d');
        this.width = this.ctx.canvas.width;
        this.height = this.ctx.canvas.height;
        this.imgStack = [];
    }
    drawImage (...params) {
        const imgData = this.ctx.getImageData(0, 0, this.width, this.height);
        this.imgStack.push(imgData);
		this.ctx.drawImage(...params);
    }
    undo () {
        if (this.imgStack.length > 0) {
            const imgData = this.imgStack.pop();
            this.ctx.putImageData(imgData, 0, 0);
        }
    }
}

我们封装了一下 canvasdrawImage 方法,每次调用该方法之前都会保存上一个状态的快照到模拟的栈中。在执行 undo 操作时,从栈中取出最新保存的快照,然后重新绘制画布,即可实现撤销操作。实际测试也符合预期。

性能优化

上一节中我们很粗犷地实现了 canvas 的撤销功能。为什么说粗犷呢?一个很显而易见的原因就是此方案性能不好。我们的方案相当于每次都是重新绘制整个画布。假设操作步骤很多,我们在模拟栈也就是内存中就会保存很多预存的图片数据。此外,在绘制图片过于复杂时, getImageDataputImageData 这两个方法会产生比较严重的性能问题。stackoverflow 上有详细的讨论: Why is putImageData so slow? 。我们还可以从 jsperf 上这个测试用例的数据来验证这一点。淘宝 FED 在Canvas 最佳实践中也提到了尽量“不在动画中使用 putImageData 方法”。另外,文章里还提到一点,“尽可能调用那些渲染开销较低的 API”。我们可以从这里入手思考如何进行优化。

之前说过,我们通过对整个画布保存快照的方式来记录每个操作,换个角度思考,如果我们把每次绘制的动作保存到一个数组中,在每次执行撤销操作时,首先清空画布,然后重绘这个绘图动作数组,也可以实现撤销操作的功能。可行性方面,首先这样可以减少保存到内存的数据量,其次还避免了使用渲染开销较高的 putImageData 。以 drawImage 为比较对象,看 jsperf 上这个测试用例,二者的性能存在数量级的差距。

探究 canvas 绘图中撤销(undo)功能的实现方式详解

因此,我们认为此优化方案是可行的。

改进后的应用方式大致如下:

class WrappedCanvas {
    constructor (canvas) {
        this.ctx = canvas.getContext('2d');
        this.width = this.ctx.canvas.width;
        this.height = this.ctx.canvas.height;
        this.executionArray = [];
    }
    drawImage (...params) {
        this.executionArray.push({
            method: 'drawImage',
            params: params
        });
		this.ctx.drawImage(...params);
    }
    clearCanvas () {
        this.ctx.clearRect(0, 0, this.width, this.height);
    }
    undo () {
        if (this.executionArray.length > 0) {
            // 清空画布
            this.clearCanvas();
            // 删除当前操作
            this.executionArray.pop();
            // 逐个执行绘图动作进行重绘
            for (let exe of this.executionArray) {
                this[exe.method](...exe.params)
            }
        }
    }
}

新人入坑 canvas,如有错误与不足,欢迎指出。以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

HTML / CSS 相关文章推荐
CSS中几个与换行有关的属性简明总结
Apr 15 HTML / CSS
CSS3 Pie工具推荐--让IE6-8支持一些优秀的CSS3特性
Sep 02 HTML / CSS
CSS3属性box-sizing使用指南
Dec 09 HTML / CSS
CSS3 二级导航菜单的制作的示例
Apr 02 HTML / CSS
HTML5里autofocus自动聚焦属性使用介绍
Jun 22 HTML / CSS
HTML5印章绘制电子签章图片(中文英文椭圆章、中文英文椭圆印章)
Jun 03 HTML / CSS
一张图片能隐含千言万语之隐藏你的程序代码
Dec 13 HTML / CSS
x-ua-compatible content=”IE=7, IE=9″意思理解
Jul 22 HTML / CSS
HTML5的download属性详细介绍和使用实例
Apr 23 HTML / CSS
浅析HTML5:'data-'属性的作用
Jan 23 HTML / CSS
移动端开发HTML5页面点击按钮后出现闪烁或黑色背景的解决办法
Sep 19 HTML / CSS
HTML中table表格拆分合并(colspan、rowspan)
Apr 07 HTML / CSS
详解canvas在圆弧周围绘制文本的两种写法
May 22 #HTML / CSS
HTML5 device access 设备访问详解
May 24 #HTML / CSS
html5-canvas中使用clip抠出一个区域的示例代码
May 25 #HTML / CSS
基于 HTML5 的 WebGL 3D 版俄罗斯方块的示例代码
May 28 #HTML / CSS
HTML5探秘:用requestAnimationFrame优化Web动画
Jun 03 #HTML / CSS
html5触摸事件判断滑动方向的实现
Jun 05 #HTML / CSS
使用Canvas操作像素的方法
Jun 14 #HTML / CSS
You might like
php 上传文件类型判断函数(避免上传漏洞 )
2010/06/08 PHP
php性能优化分析工具XDebug 大型网站调试工具
2011/05/22 PHP
基于php 随机数的深入理解
2013/06/05 PHP
ExtJS 学习专题(一) 如何应用ExtJS(附实例)
2010/03/11 Javascript
基于jquery完美拖拽,可返回拖动轨迹
2012/03/29 Javascript
Jquery EasyUI的添加,修改,删除,查询等基本操作介绍
2013/10/11 Javascript
ExtJS判断IE浏览器类型的方法
2014/02/10 Javascript
DOM节点删除函数removeChild()用法实例
2015/01/12 Javascript
JQuery中的事件及动画用法实例
2015/01/26 Javascript
基于jquery实现的自动补全功能
2015/03/12 Javascript
jQuery图片轮播滚动切换代码分享
2020/04/20 Javascript
JavaScript函数学习总结以及相关的编程习惯指南
2015/11/16 Javascript
Node.js 条形码识别程序构建思路详解
2016/02/14 Javascript
jQuery中的each()详细介绍(推荐)
2016/05/25 Javascript
WebSocket+node.js创建即时通信的Web聊天服务器
2016/08/08 Javascript
Angularjs为ng-click事件传递参数
2017/06/15 Javascript
浅谈对Angular中的生命周期钩子的理解
2017/07/31 Javascript
浅谈AngularJS中$http服务的简单用法
2018/05/15 Javascript
javascript数据结构之多叉树经典操作示例【创建、添加、遍历、移除等】
2018/08/01 Javascript
vue实现图片懒加载的方法分析
2020/02/05 Javascript
[02:16]DOTA2英雄基础教程 干扰者
2014/01/15 DOTA
Python3中条件控制、循环与函数的简易教程
2017/11/21 Python
Python线性方程组求解运算示例
2018/01/17 Python
使用Python从零开始撸一个区块链
2018/03/14 Python
python 读入多行数据的实例
2018/04/19 Python
详解pyqt5 动画在QThread线程中无法运行问题
2018/05/05 Python
Python可变参数会自动填充前面的默认同名参数实例
2019/11/18 Python
python代码打印100-999之间的回文数示例
2019/11/24 Python
HTML5添加禁止缩放功能
2017/11/03 HTML / CSS
详解Html5 监听拦截Android返回键方法
2018/04/18 HTML / CSS
纽约和芝加哥当天送花:Ode à la Rose
2019/07/05 全球购物
《母亲的恩情》教学反思
2014/02/13 职场文书
学生安全责任书范本
2014/07/24 职场文书
家庭困难证明
2014/10/12 职场文书
Golang使用Panic与Recover进行错误捕获
2022/03/22 Golang
Windows10安装Apache2.4的方法步骤
2022/06/25 Servers