HTML5基本Canvas绘图

Published on 2016 - 09 - 20

HTML5的目标之一就是让网页中的富应用实现起来更简单。当然,这里所谓的“富”指的可不是你银行账户中的钱。富应用的含义包括漂亮的图片、人机互动功能,以及眩目的动画效果。

实现富应用最重要的新工具就是Canvas,这块“画布”能够把你内心深处的“毕加索”释放出来。与其他HTML元素相比,<canvas>独特的地方是需要JavaScript来操作。不使用JavaScript,就无法绘制图形,也不能画出图画。这也就意味着<canvas>是一个编程工具,而这已然超出了Web基于文档的设计初衷。

表面上看,使用<canvas>似乎就是把简化版的Windows“画图”程序硬塞到了网页里。但深入之后,你会发现这个元素是一切高级图形应用的核心所在。利用它,可以开发出很多你梦寐以求的东西(比如游戏、地图和动态图表),也可以开发出你从未想过的东西(比如音乐灯光秀、物理模拟器)。在不远的过去,要开发出这些东西,如果没有Flash等插件是极其困难的。而今天,有了<canvas>,这一扇门终于敞开了。只要你愿意,这些作品就可以从你的手中创造出来。

本文,我们学习如何在页面中添加<canvas>,并在其中绘制线条、曲线和简单图形。然后,学以致用,开发一个简单的绘图程序。另外,大概也是最重要的,我们会讨论怎么让包含<canvas>的页面在不支持HTML5的旧浏览器中正常运作。

注意 <canvas>对某些开发人员来说是不可或缺的,而对另一些人来说可能只是一种消遣。(还有一些人对<canvas>感兴趣,但他们会觉得与使用Flash等成熟的编程平台相比,学习这门新技术有点麻烦。)不过有一件事是肯定的:这个直观的绘图界面对百无聊赖的程序员而言,绝非一个玩具那么简单。

Canvas起步

<canvas>元素就是一块画布,就是你提笔挥洒写意的地方。从标记的角度看,它简单明了,只要给它指定三个属性即可:id、width和height。

<canvas id="drawingCanvas" width="500" height="300"></canvas>

其中,id属性是一个唯一的名字,JavaScript脚本可以利用它找到这块“画布”。相应地,width和height属性指定的就是这块“画布”的宽度和高度,单位是像素。

注意 一定要通过width和height属性设置<canvas>的宽和高,而不要在样式表中设置其宽度和高度。

开始的时候,<canvas>在页面上会显示一块空白、无边框的矩形(意思就是你看不到它)。为了让它在页面显现出轮廓,可以通过一条样式规则为它应用不同的背景颜色或者边框:

canvas {
  border: 1px dashed black;
}

图1展示了这块空白的画布。

每个<canvas>一开始就是一个空白的矩形。哪怕要在上面画一条直线,你都得编写JavaScript代码

开始绘图之前,需要JavaScript执行两步操作。首先,利用document.getElementById()方法取得<canvas>对象:

var canvas = document.getElementById("drawingCanvas");

这没有什么需要解释的,当你需要从当前页面中取得某个元素时,就要使用document.getElementById()方法。

其次,必须调用<canvas>对象的getContext()方法,取得二维绘图上下文:

var context = canvas.getContext("2d");

什么是绘图上下文?你可以把它想象成一个超级强大的绘图工具,它可以帮你完成所有绘图任务,比如绘制矩形、输出文本、嵌入图像……总之,所有绘图操作都是通过它来完成的。

注意 这里的上下文明确地称为“二维上下文”(在代码中用"2d"表示),可能就会有读者想问:那有没有三维绘图上下文呢?答案是目前还没有,但HTML5的制定者正在考虑,将来就会有的。

取得了上下文对象之后,任何时候都可以进行绘图了。比如,可以在页面加载完毕后、用户单击了按钮时,等等。刚开始接触<canvas>的读者,心里可能会想:要是能有一个直观的练习页面就好了。好吧,下面就是那么一个模板页面:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Canvas Test</title>

  <style>
canvas {
  border: 1px dashed black;
}
  </style>

  <script>
window.onload = function() {
  var canvas = document.getElementById("drawingCanvas");
  var context = canvas.getContext("2d");

  //(把你自己的绘图代码写在这里)
};
  </script>
</head>

<body>
  <canvas id="drawingCanvas" width="500" height="300"></canvas>
</body>
</html>

代码中的<style>元素为<canvas>加了边框,以便它在页面中显示出轮廓来。而<script>部分则主要处理window.onload事件,这个事件是在浏览器加载完页面时触发的。然后,代码取得了<canvas>对象,并创建了绘图上下文,为下一步绘图作好准备。就这样了,接下来你就可以用这个页面作为起点开始试验了。

注意 当然,如果是在真实的网站中使用<canvas>,应该把JavaScript代码挪到一个外部文件中,这样才能保持页面的清晰(相关内容请参见附录B)。不过就目前来讲,把所有东西都放在一个页面里可以让试验更方便。

画直线

现在一切准备就绪,可以绘图了。等一等,在我们涂鸦之前,还得先了解一个基本知识点:画布的坐标系。图2展示了<canvas>中坐标系的概念。

图2:与其他HTML元素一样,<canvas>坐标的左上角是坐标原点(0,0)。向右移动,x值增大,向下移动,y值增大。对于500像素×300像素的<canvas>元素来说,其右下角坐标就是(500,300)

最简单的绘图操作就是画一条实心直线。为此,需要通过绘图上下文执行三个操作。首先,使用moveTo()方法找到直线的起点。其次,使用lineTo()方法在起点和终点之间建立联系。最后,调用stroke()方法,把直线实际地绘制出来:

context.moveTo(10,10);
context.lineTo(400,40);
context.stroke();

如果你觉得不好理解,也可以这么想:首先,拿起画笔把笔头放在画布上的某一点(使用moveTo方法),然后在画布上画直线(使用lineTo方法),最后让直线显现出来(使用stroke方法)。结果就是一条起点为(10,10),终点为(400,40)的1像素宽的黑色直线。

不止如此,要是你还有什么创意,也是可以美化直线的。在调用stroke()方法把直线实际地绘制出来之前,你可以在任何时候设置绘图上下文的3个属性:lineWidth、strokeStyle和lineCap。这几个属性会一直影响后面的绘图操作,除非再修改它们的值。

顾名思义,使用lineWidth可以设置线条宽度,单位是像素。比如,要绘制10像素粗的线条,就要这样设置:

context.lineWidth = 10;

而strokeStyle用于设置线条的颜色。设置颜色可以使用HTML颜色名、HTML颜色编码或CSS中的rgb()函数。其中,使用rgb()函数可以直接指定红、绿、蓝三个分量的比例。(很多绘图和平面处理软件都使用rgb()颜色表示法。)无论你使用哪种方式,都需要把颜色值放在一对引号内,比如:

//使用HTML颜色编码设置颜色(砖红色)
context.strokeStyle = "#cd2828";

//使用rgb()函数设置颜色(砖红色)
context.strokeStyle = "rgb(205,40,40)";

注意 之所以把这个属性命名为strokeStyle而不是strokeColor,是因为通过它不仅仅可以设置颜色。通过它还可以设置叫做渐变的混合颜色和基于图像的图案。

最后,使用lineCap可以设置线条两端的形状,即线头类型。默认值是butt,即方头。另外,还可以使用round(圆头)或square(效果与butt类似,也是方头,但会在线条的两头各增加一半线宽的长度,因此可以叫“加长方头”。)

以下就是我们要绘制的三条不同线头水平线的全部脚本代码(结果如图3所示)。要试验这些代码,可以把它们包装到一个函数里。然后,在前面介绍的window.onload事件处理函数中调用它即可:

上面的线使用标准的方头,而下面的线则使用了加长的线头(一个加长圆头,一个加长方头),即在线条的两头各增加一半线宽的长度

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

//设置线条宽度和颜色(适用于所有线条)
context.lineWidth = 20;
context.strokeStyle = "rgb(205,40,40)";

//绘制第一条直线,使用默认的方头
context.moveTo(10,50);
context.lineTo(400,50);
context.lineCap = "butt";
context.stroke();

//绘制第二条直线,使用圆头
context.beginPath();
context.moveTo(10,120);
context.lineTo(400,120);
context.lineCap = "round";
context.stroke();

//绘制第三条直线,使用加长方头
context.beginPath();
context.moveTo(10,190);
context.lineTo(400,190);
context.lineCap = "square";
context.stroke();

这个例子中又介绍了一个新的特性:绘图上下文的beginPath()方法。每次调用beginPath()方法,都重新开始一个新线段的绘制。如果没有这一步,那么每次调用stroke(),都会把画布上原有的线段再重新绘制一遍。(在修改了其他上下文属性的情况下,这个问题会比较明显。就以上面的代码为例,如果不调用beginPath()的话,那么就会发生在原有直线上以新颜色、新宽度或新线头形状重新绘制的问题。)

注意 尽管开始绘制新线段时要调用beginPath(),但结束绘制线段则不一定要做什么。每次开始新路径时,原来的路径就会自动“完成”。

路径与形状

为了确保三条直线各自独立,上一个例子将每条直线都按照新路径来绘制。这样可以为不同的直线分别应用不同的颜色(以及不同的线宽和线头)。实际上,路径本身也是很有用的,因为可以通过路径来填充自定义的形状。例如,以下代码可以绘制出红色的空心三角形:

context.moveTo(250,50);
context.lineTo(50,250);
context.lineTo(450,250);
context.lineTo(250,50);

context.lineWidth = 10;
context.strokeStyle = "red";
context.stroke();

不过,如果想给这个三角形填上颜色,那么stroke()方法是无能为力的。此时,应该先调用closePath()来明确地关闭路径,然后再把fillStyle属性设置为想要填充的颜色,最后再调用fill()方法完成填充操作:

context.closePath();
context.fillStyle = "blue";
context.fill();

这个例子还有两个地方有必要调整一下。首先,如果知道最后会关闭路径,那实际上就不必再绘制最后一条线段了,因为closePath()会自动在最后一个绘制点与绘制起点间绘制一条线。其次,最好是先填充形状,然后再绘制其轮廓。否则,形状的轮廓线会有一部分被填充色覆盖掉。

好了,下面就是绘制三角形的完整代码:

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

context.moveTo(250,50);
context.lineTo(50,250);
context.lineTo(450,250);
context.closePath();

//填充内部
context.fillStyle = "blue";
context.fill();

//绘制轮廓
context.lineWidth = 10;
context.strokeStyle = "red";
context.stroke();

有读者可能已经注意到了,这个例子并没有调用beginPath()方法。这是因为<canvas>在开始的时候,会自动开始一段新路径。如果你想重新开始另一段路径,那就要调用beginPath()。重新开始新路径意味你可能重新设置了线条的样式,或者准备绘制另外一个新的形状。图4展示了以上代码的结果。

要创建一个类似这个三角形的封闭形状,使用moveTo()方法定位起点,使用lineTo()方法绘制每一条线段,然后用closePath()补充完成路径。然后再调用fill()填充,调用stroke()描边

注意 在绘制前后相连的线段时(比如上面例子中的三角形),可以通过设置绘图上下文的lineJoin属性指定线段交点的形状。这个属性的默认值是mitre(锐角斜接),另外两个值是round(圆头)和bevel(平头斜接)。

多数情况下,如果想要绘制复杂的形态,你都需要自己逐个线段地绘制。但有一个例外,那就是绘制矩形。可以使用fillRect()方法直接填充一个矩形区域。只要为它提供矩形区域左上角的坐标、宽度和高度即可。

例如,要在(0,10)点放置一个100像素×200像素的矩形,可以使用以下代码:

fillRect(0,10,100,200);

与fill()方法一样,fillRect()也是从绘图上下文的fillStyle属性取得颜色。

类似地,还有一个strokeRect()方法,用于直接绘制一个矩形框:

strokeRect(0,10,100,200);

绘制矩形框时,strokeRect()的宽度取自lineWidth属性,而边框宽度和颜色则取自strokeStyle属性,与stroke()方法一样。

绘制曲线

要是除了矩形和直线之外,你还想弄点别的更有意思的(谁不想呢),那就得理解绘制曲线的四个方法:arc()、artTo()、bezierCurveTo()和quadraticCurveTo()。使用这几个方法分别能够以不同的方式绘制曲线,但共同点是它们都要求你做一点简单的数学计算(有时候计算量还是蛮大的)。

这四个方法里面,arc()是最简单的,它可以绘制一段圆弧。在画圆弧之前,你可以先闭上眼睛,想象有那么一个圆,而你想绘制的圆弧就是这个圆上的一部分(如图5所示)。然后,你就可以对要传给arc()方法的参数做到胸有成竹了。

圆弧看起来简单,但要描述它得需要多方面的信息。首先,需要确定一个想象中的圆形。而确定圆形就必须有圆心的坐标(#1)和表示大小的半径(#2)。然后,为了描述圆弧的长度,必须知道其起点的角度(#3)和终点的角度(#4)。这两个角度都要用弧度表示,即常量pi的倍数(1pi是半圆,2pi是整个圆形)

想通了所有细节之后,接下来就是调用arc()方法:

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

//创建变量,保存圆弧的各方面信息
var centerX = 150;
var centerY = 300;
var radius = 100;
var startingAngle = 1.25 * Math.PI;
var endingAngle = 1.75 * Math.PI;

//使用确定的信息绘制圆弧
context.arc(centerX, centerY, radius, startingAngle, endingAngle);
context.stroke();

如果在调用stroke()之前调用closePath(),就会在圆弧的起点和终点之间绘制一条直线。于是,就可以得到一个封闭的小半圆。

实际上,圆形也就是这么个圆弧继续向两端伸展构成的。因此如果想画一个整圆,可以这样设置:

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

var centerX = 150;
var centerY = 300;
var radius = 100;
var startingAngle = 0;
var endingAngle = 2 * Math.PI;

context.arc(centerX, centerY, radius, startingAngle, endingAngle);
context.stroke();

注意 使用arc()方法画不了椭圆(扁圆)。要画椭圆,要么使用接下来我们会介绍的更复杂的绘制曲线的方法,要么使用变换把普通的圆拉伸成椭圆。

接下来要介绍的三个方法(artTo()、bezierCurveTo()和quadraticCurveTo())需要你承受一些几何计算方面的挑战。这三个方法要用到同一个概念:控制点。控制点本身并不包含在最终的曲线里,但能够影响曲线最终的形状。最好的例子就是贝塞尔曲线,几乎任何插图软件中都会用到它。贝塞尔曲线之所以那么流行,就是因为这种曲线能够保证平滑,哪怕再小、再大的弧度都可以。图6展示了贝塞尔曲线的控制点。

一条贝塞尔曲线有两个控制点。曲线的起点切线连接第一个控制点,终点切线连接第二个控制点。两条连接线之间就是曲线。曲线的弯曲程度(曲率)由控制点与起点和终点的距离决定。距离越远,弯曲度越大。这有点像引力,只不过越远力越大

以下就是用于创建图6所示曲线的代码:

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

//把笔移动到起点位置
context.moveTo(62, 242);

//创建变量,保存两个控制点及曲线终点信息
var control1_x = 187;
var control1_y = 32;
var control2_x = 429;
var control2_y = 480;
var endPointX = 365;
var endPointY = 133;

//绘制曲线
context.bezierCurveTo(control1_x, control1_y, control2_x, control2_y,
 endPointX, endPointY);
context.stroke();

复杂而自然的形状通常需要多个圆弧和曲线拼接而成。完成之后,可以调用closePath()以便填充,或者显示出完成的轮廓。学习绘制曲线的最好方式,就是自己动手编写代码。

变换

变换,就是一种通过变化<canvas>坐标系达到绘制目的的技术。例如,假设你想在三个地方绘制相同的正方形。为此,可以调用三次rect(),每次都传入不同的起点位置:

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

//在三个地方绘制同样大小(30×30)的正方形
context.rect(0, 0, 30, 30);
context.rect(50, 50, 30, 30);
context.rect(100, 100, 30, 30);

context.stroke();

或者,也可以在同一个地方调用三次rect(),但每次都移动一下坐标系,最终也能达到在三个不同位置绘制正方形的目的,比如:

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

//在(0,0)点绘制正方形
context.rect(0, 0, 30, 30);

//把坐标系向下、向右各移动50像素
context.translate(50, 50);
context.rect(0, 0, 30, 30);

//把坐标系再向下移一点;变换是可以累积的
//因此现在(0,0)点实际上将被平移到(100,100)
context.translate(50, 50);
context.rect(0, 0, 30, 30);

context.stroke();

以上两段代码得到的结果都一样:在三个不同位置绘制三个相同的正方形。

表面上看,变换把一些复杂的绘图任务变得更加复杂了。但在处理一些棘手问题的场合,使用变换却能收到神奇的效果。例如,假设你有一个函数,负责绘制一系列复杂的图形,最终再将它们组合成一幅鸟的图片。现在,你准备让鸟动起来,在<canvas>区域里飞翔。

如果没有变换,要实现这个目标必须在每次绘制鸟的时候调整一次坐标。而有了变换,绘图代码可以不变,只要反复修改坐标系的位置就好了。

使用变换有几种不同的方式。在前面的例子中,我们使用平移(translate)变换移动了坐标系的原点——也就是(0,0)点,默认位置在<canvas>的左上角。除了平移变换之外,还有缩放(scale)变换、旋转(rotate)变换和矩阵(matrix)变换。缩放变换可以把本来要绘制的形状放大或缩小,旋转变换可以旋转坐标系。矩阵变换更复杂一些,但可以在任意方向拉伸和扭曲坐标系,要求你必须理解复杂的矩阵计算,只有这样才能实现自己想要的视觉效果。

变换是累积的。比如,下面这个例子先使用translate()方法把坐标系从(0,0)平移到(100,100),然后又在新位置使用rotate()方法把坐标系旋转了几次。每旋转一次,都会绘制一个新的正方形,从而得到如图8所示的图形。

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

//移动(0,0)点。这一步很重要
//因为接下来要围绕新原点旋转
context.translate(100, 100);

//绘制10个正方形
var copies = 10;
for (var i=1; i<copies; i++) {
  //绘制正方形之前,先旋转坐标系

  //旋转一周是2*Math.PI,因此每个正方形的旋转角度取决于要绘制的总数
  context.rotate(2 * Math.PI * 1/(copies-1));
//绘制正方形
  context.rect(0, 0, 60, 60);
}
context.stroke();

提示 调用绘图上下文的save()方法可以保存坐标系当前的状态。然后,再调用restore()方法可以返回保存过的前一个状态。如果要保存坐标系的状态,必须在应用任何变换之前调用save(),这样再调用restore()才能把坐标系恢复到正常状态。而在多步操作绘制复杂图形时,往往都需要多次保存坐标系状态。这些状态就如同浏览器中的历史记录一样。每次调用restore(),坐标系就会恢复到前一个最近的状态。

透明度

到现在为止,我们一直都在使用实心颜色。实际上,<canvas>支持使用半透明的颜色,从而实现多个形状叠加透视的效果。有两种创建透明图形的方式,第一种就是使用rgba()函数设置透明颜色(即设置fillStyle和strokeStyle属性),而不是使用rgb()函数。注意,rgba()函数接收4个参数:红、绿、蓝颜色分量(0~255)和颜色的不透明度值。最后一个参数(alpha)值为1,表示完全不透明,值为0表示完全不可见。位于0和1之间的值(比如0.5),表示颜色部分透明,即透过它能看到下方的形状。

注意 哪些内容在下,哪些内容在上,完全取决于绘制操作的先后顺序。比如,先画一个圆形,再在相同位置上画一个正方形,则正方形会叠加在圆形上面。

下面这个例子绘制了一个圆形和一个三角形。使用的颜色相同,但三角形的不透明度值被设置为0.5,因此是半透明的:

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

//设置填充及描边颜色

context.fillStyle = "rgb(100,150,185)";
context.lineWidth = 10;
context.strokeStyle = "red";

//绘制圆形
context.arc(110, 120, 100, 0, 2*Math.PI);
context.fill();
context.stroke();

//别忘了调用beginPath(),然后再绘制新形状
//否则,两个形状的路径会意外地连在一起
context.beginPath();

//用半透明的颜色填充三角形
context.fillStyle = "rgba(100,150,185,0.5)";

//好了,绘制三角形
context.moveTo(215,50);
context.lineTo(15,250);
context.lineTo(315,250);
context.closePath();
context.fill();
context.stroke();

图9是这个例子的结果。

左:两个实心、叠加在一起的图形。右:底下的圆形是实心的,上面的三角形是半透明的。半透明的形状看起来较明亮(因为透过它们能看到白色背景),透过它们可以看到下方的内容。注意这个例子中的三角形是半透明的,但三角形的边框则使用了实色

第二种创建透明图形的方式是设置绘图上下文的globalAlpha属性:

context.globalAlpha = 0.5;

//此时,再设置的颜色不透明度值都将是0.5
context.fillStyle = "rgb(100,150,185)";

这样一来,后续所有绘图操作都会取得相同的不透明度值,也就是会有相同的透明度(直至再次修改globalAlpha属性)。包括描边颜色和填充颜色。

哪种方式更好一些呢?如果你只需要一种透明的颜色,使用rgba()就好了。如果你需要使用不同的颜色绘制很多形状,但每个形状的透明度不一样,可以使用globalAlpha。另外,如果你想在<canvas>上绘制半透明的图像,也要用到globalAlpha属性。

合成操作

到现在一直假设在绘制多个图形时,后绘制的图形会位于先绘制的图形上方,并遮住先绘制的图形。使用<canvas>绘图时,多数情况下都是这样的。然而,<canvas>也支持更复杂的合成操作。

所谓合成操作,就是告诉<canvas>怎么显示两个重叠的图形。默认的合成操作是source-over,即后绘制的图形会位于先绘制的图形上方。但除此之外,还有其他很多种合成方式。例如xor,告诉<canvas>不显示两个图形相互重叠的部分。图10展示了不同合成操作的结果。

这里是12种可能的合成操作及其在Firefox浏览器中的效果。IE9和Opera对copy操作的理解不同,而Chrome和Safari在source-in、source-out、destination-in和destination-atop这几个操作上也持不同看法

要改变<canvas>当前使用的合成操作方式,只要像下面这样设置绘图上下文的globalCompositeOperation属性即可:

context.globalCompositeOperation = "xor";

只要运用得当,利用合成操作可以迅速实现一些特定的绘图任务。可惜的是,不同浏览器对合成操作的结果并没有达成一致。因此,同一种合成操作在不同浏览器中可能会出现不同的结果。

构建基本的画图程序

要介绍的<canvas>的功能还有很多。不过,经过本章到现在的学习,我们已经有足够的基础知识构建一个基于<canvas>的画图程序了。图11就是本节我们要构建的基本的画图程序。

实现这个程序的JavaScript代码比我们前面看到的所有代码都要长,但实际上却仍然非常好理解。接下来几小节,我们就逐一分析每一段代码。

准备工作

首先,当页面加载后,脚本代码会取得<canvas>对象,为它添加一些处理函数,以便处理不同鼠标操作导致的JavaScript事件:onMouseDown、onMouseUp、onMouseOut和onMouseMove。(稍后你就会看到,我们正是通过这些事件来控制绘图过程的。)与此同时,代码也把<canvas>保存在了一个全局变量中(变量名为canvas),把绘图上下文保存在了另一个全局变量中(变量名为context)。任何位置的代码都能轻易访问到全局变量:

var canvas;
var context;

window.onload = function() {
  //取得<canvas>和绘图上下文
  canvas = document.getElementById("drawingCanvas");
  context = canvas.getContext("2d");

  //添加用于实现绘图操作的事件处理程序
  canvas.onmousedown = startDrawing;
  canvas.onmouseup = stopDrawing;
  canvas.onmouseout = stopDrawing;
  canvas.onmousemove = draw;
};

要想开始画图,首先要从窗口顶部的两个工具栏中选择笔画颜色和笔画粗细。这两个工具栏就是两个<div>元素,通过样式给它们添加了熟悉的铁青色背景,还有边框。工具栏中包含一些可以点击的图像(<img>元素)。例如,以下就是供用户选择3种颜色的工具栏的标记:

<div class="Toolbar">
  - Pen Color -<br>
  <img id="redPen" src="pen_red.gif" alt="Red Pen"
   onclick="changeColor('rgb(212,21,29)', this)">
  <img id="greenPen" src="pen_green.gif" alt="Green Pen"
   onclick="changeColor('rgb(131,190,61)', this)">
  <img id="bluePen" src="pen_blue.gif" alt="Blue Pen"
   onclick="changeColor('rgb(0,86,166)', this)">
</div>

以上标记中的重点在于每个<img>元素的onclick属性。访客单击每幅图像时,都会调用与<img>元素绑定的changeColor()函数。这个函数接收两个参数:与图标颜色匹配的新颜色和对被单击的<img>元素本身的引用。changeColor()函数的代码如下:

//记录此前为选择颜色而被单击过的<img>元素
var previousColorElement;

function changeColor(color, imgElement) {
  //重新设置当前绘图要使用的颜色
  context.strokeStyle = color;
  //为刚被单击的<img>元素应用一个新样式
  imgElement.className = "Selected";

  //恢复上一次被单击的<img>元素的样式
  if (previousColorElement != null) previousColorElement.className = "";
  previousColorElement = imgElement;
}

这个changeColor()函数负责完成两项任务:首先,将绘图上下文的strokeStyle属性设置为新的颜色值。这只需一行代码就够了。其次,改变被单击的<img>元素的样式,即添加实心边框,以便明确显示当前绘图所使用的颜色。这个任务用一行代码就不行了。因此不仅要记录上一次选择的图像(颜色),而且还要去掉该图像的边框。

接下来的changeThickness()函数几乎与changeColor()完全相同,唯一的区别就是它要修改绘图上下文的lineWidth属性,以保证绘图以适当粗细的笔画进行。

//记录此前为选择粗细而被单击过的<img>元素
var previousThicknessElement;

function changeThickness(thickness, imgElement) {
  //重新设置当前绘图要使用的粗细
  context.lineWidth = thickness;

  //为刚被单击的<img>元素应用一个新样式
  imgElement.className = "Selected";
  //恢复上一次被单击的<img>元素的样式
  if (previousThicknessElement != null) {
    previousThicknessElement.className = "";
  }
  previousThicknessElement = imgElement;
}

没错,这些代码没有执行任何实际的绘图操作,我们的例子还没有做完。接下来的(最后)一步,就是添加实际绘图的代码。

在画布上绘图

绘图操作从用户在画布上按下鼠标时开始。我们这个画图程序使用了一个名为isDrawing的全局变量,记录绘图什么时候开始,以方便其他代码知悉是否该通过绘图上下文进行绘制。

在前面的代码中,我们看到onMouseDown事件是与startDrawing()函数绑定的。这个函数首先将isDrawing变量设置为true,然后创建新路径,找到起点位置,并作好绘制准备。

var isDrawing = false;

function startDrawing(e) {
  //开始绘图了
  isDrawing = true;
  //创建新路径(使用当前设置好的描边颜色和线条粗细)
  context.beginPath();

//把画笔放到鼠标当前所在位置
  context.moveTo(e.pageX - canvas.offsetLeft, e.pageY - canvas.offsetTop);
}

为了让画图程序正确运行,应该在鼠标当前所在位置开始绘制。鼠标当前所在位置,就是用户在画布上单击鼠标的位置。不过,取得这个位置的坐标还要费点小周折。

onMouseDown事件本身提供了坐标(如代码所示,通过事件对象的pageX和pageY属性),但这两个坐标值是相对于整个页面的。而我们需要的是相对画布左上角的坐标值,所以需要再减去浏览器左上角到画布左上角的距离。

实际的绘图操作要等到用户移动鼠标的时候再开始。用户每次移动鼠标,哪怕只移动了一个像素,都会触发onMouseMove事件并执行draw()函数。此时,如果isDrawing的值为true,draw()函数就会计算当前画布坐标(即鼠标最新位置的坐标),然后调用lineTo()在画布上绘制极小的一段线段,最后再调用stroke()把线条实际地绘制出来:

function draw(e) {
  if (isDrawing == true) {
    //找到鼠标的新位置
    var x = e.pageX - canvas.offsetLeft;
    var y = e.pageY - canvas.offsetTop;

    //画一条到新位置的线
    context.lineTo(x, y);
    context.stroke();
  }
}

用户继续移动鼠标,draw()函数就会再次被调用,再绘制一小段线段。这条线段非常短,恐怕只有一两个像素,因此累积起来不会是一条笔直的线。

最后,用户释放鼠标时,或者把光标移动到画布外面时,就会触发onMouseUp或onMouseOut事件。这两个事件都会触发同一个函数:stopDrawing(),这个函数告诉程序停止绘图:

function stopDrawing() {
  isDrawing = false;
}

关于这个简单的画图程序的代码,到这里已经差不多介绍完了。所剩的就是画布下方的那两个按钮,一个按钮用于保存当前作品,另一个按钮用于清除画布。单击清除按钮,clearCanvas()函数会清空整个画布,使用的是绘图上下文的clearRect()方法:

function clearCanvas() {
  context.clearRect(0, 0, canvas.width, canvas.height);
}

而保存操作更有意思,因此下一节我们就讨论如何实现保存为图像的操作。

将画布保存为图像

说到把画布保存为图像的方法,那可是太多了。选择什么方法,首先取决于你怎么取得相应的数据。<canvas>元素提供了三个基本的选项。

  • 使用数据URL。这样会把画布转换为一幅图像文件,然后将图像数据转换为字符序列并编码为URL形式。这种方式生成的数据URL非常适合传输图像(例如,可以将数据URL作为<img>元素的src属性值,或者也可以将其发送到Web服务器)。我们的画图程序就使用这种方法。

    • 使用getImageData()方法。这样会取得原始的像素数据,然后可以继续根据需要操作这些数据。
    • 保存一组“步骤”。比如,可以把在画布上绘制的每一条线都保存到一个数组中。然后,保存这个数组,以便将来根据该数组重新绘制图像。这个方法不占空间,而且更具灵活性,方便以后编辑图像。

如果这些方法已经让你感觉头晕目眩了,请再坚持一小会儿,我们还没结束。确定了要保存什么之后,接下来还要决定保存到哪儿。而这又有三种选择。

  • 保存为图像文件。例如,可以让用户把画布以PNG或JPEG图像格式保存在自己的硬盘上。这也是我们选择的方式。

    • 保存在本地存储系统中
    • 保存在Web服务器上。在把数据发送到Web服务器后,服务器端程序可以把它保存到文件里,也可以保存到数据库中。这样,当用户下次访问相同页面时,还可以读取到以前的绘图记录。

为了给画图程序增加保存功能,我们使用数据URL的方案。要取得当前数据的URL,必须通过画布对象调用toDataURL()方法:

var url = canvas.toDataURL();

在调用toDataURL()方法时如果不提供参数,得到的将是一个PNG图片。如果你想要其他格式的图片,可以传入相应的MIME类型:

var url = canvas.toDataURL("image/jpeg");

不过,假如浏览器不支持你想要的格式,它仍然会发给你一个PNG文件,一个转换后的长长的字符串。

数据URL到底是什么?从技术角度讲,数据URL就是一个以data:image/png;base64开头的base-64编码的字符串。这个字符串很长,不过不要紧,因为数据URL是要给计算机程序(如浏览器)看的。以下就是当前画布图片的数据URL:

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAfQAAAEsCAYAAAA1u0HIAAAAAXNSR
0IArs4c6QAAAARnQU1BAACxjwv8YQUAACqRSURBVHhe7Z1bkB1Hecdn5uxFFzA2FWOnsEEGiiew
nZgKsrWLrZXMRU9JgZQKhoSHVK...gAAEIQAACEIBAiAT+HxAYpeqDfKieAAAAAElFTkSuQmCC

为了节省版面,这里代码的中间省略了大量字符(注意省略号)。

注意 Base-64编码是一种将图像数据转换成长字符串的编码方法,长字符串由字符、数字及少量特殊字符组成。由于编码后的字符串不包含标点符号和所有专用扩展字符,所以结果可以安全地用在网页中(例如,作为隐藏输入字段的value或<img>元素的src属性值)。

总之,很容易把画布转换为数据URL形式的图像数据。但有了数据URL之后,又能用它来做什么呢?一种处理方式是将它发送给Web服务器长期保存。

如果你只想把数据保存在客户端,那方法并不太多。有些浏览器支持直接访问数据URL,也就是可以使用下面这样的代码直接打开图像:

window.location = canvas.toDataURL();

而更可靠的方法则是把数据URL交给一个<img>元素。下面就是我们画图程序的处理代码(见图12):

function saveCanvas() {
  //找到<img>元素
  var imageCopy = document.getElementById("savedImageCopy");

  //在图像中显示画布数据
  imageCopy.src = canvas.toDataURL();

  //显示包含<img>元素的<div>,以便把图像显示出来
  var imageContainer = document.getElementById("savedCopyContainer");
  imageContainer.style.display = "block";
}

在此,我们使用数据URL把画布中的信息传递给了<img>元素。我们把<img>元素的尺寸设置得比较小,以区别于画布。如果想把图像保存为.png文件,只要在图像上单击右键,选择“图片另存为”即可——与保存网页中的其他图像一样

以上代码并没有真的“保存”图像数据,因为图像不会长期保存(比如保存为一个文件)。然而,对于显示在网页中的图像,只要简单操作两下就可以保存下来了。这个大家都知道,在图像上单击右键,选择“图片另存为”就好了。虽然这样不如下载文件或弹出“保存”对话框方便,但却是在所有浏览器中都能可靠使用的唯一一个客户端方案。

注意 如果你是在本地计算机硬盘上运行自己的试验页面,那么数据URL功能会失效——<canvas>的其他几项功能也是如此。为了避免出现这个问题,需要把试验页面上传到Web服务器后再打开。

浏览器对Canvas的支持情况

关于Canvas,我们已经介绍了很多东西了。现在该面对现实,回答那个关系到每个HTML5新功能的问题了——什么时候可以放心使用?

我们很幸运,Canvas是目前得到较好支持的HTML5功能。主流浏览器的所有最新版本都支持它,如表1所示。当然,浏览器版本越高,支持得就越好。而且,新版本浏览器还进一步提高了绘图速度,修复了一些偶尔出现的小问题。

说明 IE Firefox Chrome Safari Opera Safari iOS Android
最低版本 9 3.5 3 4 10 1 1

除了IE之外,很少有人会使用其他浏览器的旧版本。而这正是今天Canvas用户最关心的问题:怎样才能既在网页中使用<canvas>,又能保证不把IE7和IE8这两款依然健在的浏览器排除在外?

与许多其他HTML5功能一样,为了保证兼容性,我们有两个选择。第一个选择是检测浏览器是否支持新功能,不支持则提供后备内容。第二个选择是利用第三方工具来模仿HTML5的<canvas>,做到同一个页面也能在旧版本浏览器中运行。

Canvas后备及功能检测

<audio><video>元素一样,<canvas>元素本身也支持后备内容。比如,下列代码表示可以在浏览器支持的情况下使用<canvas>,而在不支持的情况下显示图像:

<canvas id="logoCreator" width="500" height="300">
  <p>The canvas isn't supported on your computer, so you can't use our
  dynamic logo creator.</p>
  <img src="logo.png" alt="Standard Company Logo">
</canvas>

这种方案当然是聊胜于无。多数时候,我们会利用<canvas>绘制一些动态图像,或者创建一些基于图形的交互应用,在这种情况下只显示一幅静态图像,显然于事无补。为此,更好的办法是在<canvas>元素中嵌入Flash应用。这个办法对已经有了Flash版,又想迁移到<canvas>以适应未来发展的应用非常合适。这样,既有满足老版本IE浏览器的Flash应用,又可以让其他人安心使用无插件的<canvas>版。

如果你使用Modernizr,还可以在JavaScript代码中检测浏览器是否支持<canvas>。为此,只要检测Modernizr.canvas属性即可。而要检测浏览器是否支持文本绘制功能(后来才添加的Canvas绘图功能),可以检测Modernizr.canvastext属性。

参考文档