新的 HTML5 规范旨在帮助开发人员更轻松的编写出各类 Web 应用,以顺应当前 SaaS,云计算以及 RIA 等技术的最新趋势。在 HTML5 得以广泛推广之前,开发人员通常使用 SVG,VML 等技术进行 Web 绘图操作,但这些基于 XML 的绘图语言声明式的绘图方式并不能满足复杂绘图操作在性能上的需求,比如 Web 游戏所需要的像素级别的绘图能力。HTML5 canvas 元素的出现填补了这种不足,开发人员可以使用 JavaScript 脚本语言在 canvas 中进行一系列基于命令的图形绘制操作,本文将通过讲解如何使用 canvas 元素进行基本绘图操作,以及完成简单的动画和用户交互任务,阐明 canvas 在帮助构建 Web 图形类应用时所能够提供的能力。

林 林, 软件工程师, IBM

2010 年 12 月 30 日

  • +内容

背景介绍

HTML5 中新引入的 canvas 元素使得 Web 开发人员在无须借助任何第三方插件(如 Flash,Silverlight)的情况下,可以直接使用 JavaScript 脚本在 Web 页面进行绘图。它首次由苹果公司的 Webkit 框架引入实现,并成功运用在 Safari 浏览器中,读者在 这里可以体验到基于 canvas 的精彩示例。目前,canvas 已成为 HTML5 规范中的事实性标准,并且已经被 Firefox 3.0+, Safari 3.0+, Chrome 3.0+, Opera10.0+ 等浏览器所支持。最近(本文撰写之时),IE 也正式宣称将在其 9.0 版本之后,开始对 canvas 元素进行支持。

基于 canvas 的绘图填补了 SVG 绘图的在复杂绘图操作,特别是性能方面的不足,可广泛应用于 Dashboard,2D/3D Game 等 Web 应用中。

基本绘图 API

在了解了什么是 canvas 元素之后,是时候使用 canvas 在 Web 页面上真正进行的绘图操作了。实际上,单独的一个 canvas 标记只是在页面中定义了一块矩形区域,并无特别之处,开发人员只有配合使用 JavaScript 脚本,才能够完成各种图形,线条,以及复杂的图形变换操作,与基于 SVG 来实现同样绘图效果来比较,canvas 绘图是一种像素级别的位图绘图技术,而 SVG 则是一种矢量绘图技术。正鉴于这种本质机理的不同,如何更快速高效的进行 canvas 渲染成为各主流 JavaScript 执行引擎性能比拼的重要指标之一。目前,Chrome 的 V8, Firefox 的 SpiderMonkey 以及 Safari 的 Nitro 等引擎都已经能够很好的满足二维绘图所需的必要性能指标,虽然在运行一些基于 canvas 的游戏时 CPU 占用率还是相对较高,但我们有理由相信随着 NVIDIA 和 AMD 等一系列硬件厂商的参与,硬件加速技术将大大提升 Web 应用的性能。

在开始绘图之前,我们需要首先创建一个指定大小的 canvas,并为其指定一个 id,方便在 JavaScript 脚本中获取该 DOM 实例对象。声明一个 canvas 节点的方式如下所示。

 <canvas id="canvas" width="300" height="200"> 
 Fallback content, in case the browser does not support Canvas. 
 </canvas>

需要指明的是,由于无法保证所有用户使用的浏览器都能够支持 canvas 元素,所以在目前开发基于 canvas 的 Web 应用中需要增加“Fallback content”,以提示用户他们无法正常体验此功能的原因或建议他们去下载最新的浏览器。

这里,好奇的读者可能会问,既然这是一个普通的 DOM 节点,那么便意味着可以通过直接改变其 width 或 height 属性值来改变 canvas 的大小?确实如此,但是,正如之前提到的 canvas 是一种像素级别的绘图方法,因而,一旦动态调整 canvas 的大小,canvas 将被“重置”到一个新的初始状态,即便是如下这种操作,也会将 canvas 内的位图清除并将所有相关属性恢复到初始值的状态。当然,我们也可以把这当作重置 canvas 的小技巧来使用。

 document.getElementById("canvas").width = document.getElementById("canvas").width;

简单图形绘制

基于 canvas 的绘图并不是直接在 canvas 标记所创建的绘图画面上进行各种绘图操作,而是依赖画面所提供的 渲染上下文(Rendering Context),所有的绘图命令和属性都定义在渲染上下文当中。在通过 canvas id 获取相应的 DOM 对象之后首先要做的事情就是获取渲染上下文对象。 渲染上下文与 canvas 一一对应,无论对同一 canvas 对象调用几次 getContext() 方法,都将返回同一个上下文对象。目前,所有支持 canvas 标签的浏览器都支持 2D 渲染上下文,可以使用如下的代码来获取该对象。

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

除此之外,在不久的将来,开发人员还会能够得到基于 OpenGL 的 3D 渲染上下文以在 canvas 中进行 3D 绘图。

与 SVG 不同,canvas 原生支持的基本图形只有矩形一种,至于其他的圆形,多边形等图形则都由路径来负责绘制实现。清单 1 展示了如何使用渲染上下文中的矩形绘图方法完成了图 1 所示图形。

图 1. 清单 1 对应的示例图形
图 1. 清单 1 对应的示例图形
清单 1. 绘制 canvas 矩形
 function drawRect(){ 
 var canvas = document.getElementById('canvas'); 
 if (canvas.getContext){ 
 var ctx = canvas.getContext('2d');  // 获取 2D 渲染上下文
		
 ctx.clearRect(0,0,300,200)  ;// 清除以(0,0)为左上坐标原点,300*200 矩形区域内所有像素
 ctx.fillStyle = '#00f';   // 设置矩形的填充属性,#00f 代表蓝色
 ctx.strokeStyle = '#f00';  // 设置矩形的线条颜色,#f00 代表红色
 ctx.fillRect(50,25,150,80); // 使用 fillStyle 填充一个 150*80 大小的矩形
 ctx.strokeRect(45,20, 160, 90);  // 以 strokeStype 属性为边的颜色绘制一个无填充矩形
     } 
 }

绘制路径

在开始动手绘制路径之前,首先需要明确的是:矩形绘制 API 是一种即时性的 API,他会在相应的绘图函数执行完毕之后,将图形即时的渲染在画面上。然而路径绘制 API 并非如此,完整的路径绘制过程大致可以分为如下两个阶段:

  • 定义路径轮廓:

在每个 canvas 实例对象中都拥有一个 path 对象,创建自定义图形的过程就是不断对 path 对象操作的过程。每当开始一次新的图形绘制任务,都需要先使用 beginPath() 方法来重置 path 对象至初始状态,进而通过一系列对 moveTo/lineTo 等画线方法的调用,绘制期望的路径,其中 moveTo(x, y) 方法设置绘图起始坐标,而 lineTo(x,y) 等画线方法可以从当前起点绘制直线,圆弧以及曲线到目标位置。最后一步,也是可选的步骤,是调用 closePath() 方法将自定义图形进行闭合,该方法将自动创建一条从当前坐标到起始坐标的直线。

  • 绘制路径

定义完路径的轮廓,此时 canvas 画面中没有显示任何路径,开发人员还可以对路径进行修改。一旦确定完成,则需要继续调用 stroke()/fill() 函数来完成将路径渲染到画面的最后一步。路径的轮廓颜色和填充颜色由 strokeStyle 和 fillStyle 属性决定。

清单 2 绘制一个图 2 所示半圆弧,并通过 closePath() 方法完成图形的闭合。

图 2. 清单 2 对应的示例图形
图 2. 清单 2 对应的示例图形
清单 2. 绘制 canvas 路径
 function draw(){ 
 var canvas = document.getElementById('canvas'); 
	 if (canvas.getContext){ 
		 var ctx = canvas.getContext('2d'); 
 ctx.fillStyle = '#00f'; 
		 ctx.strokeStyle = '#f00'; 
		 ctx.beginPath(); 
 ctx.arc(75,75,30,0,Math.PI, false);  // 绘制一条半圆弧线
 ctx.closePath();    // 自动绘制一条直线来关闭弧线。若不调用此方法,将仅仅显示一条半圆弧
 ctx.fill();      // 可以尝试注释掉 fill 或者 stroke 函数,观察图形的变化
 ctx.stroke();  
	 } 
 }

二维变形

Canvas 绘图中另一个重要的概念是 绘画状态(Drawing State),绘画状态反映了渲染上下文当前的瞬时状态,开发人员可以通过对绘画状态的保存 / 恢复操作而快速的回到之前使用的各种属性和变形操作。绘画状态主要由以下三个部分构成:

  • 当前的变形矩阵(transformation matrix)
  • 当前的裁剪区域(clipping region)
  • 当前上下文中的属性,比如 strokeStyle, fillType, globalAlpha, font 等等。

需要指出的是,当前路径对象以及当前的位图都不包含在绘画状态之中,路径是持续性的对象,如前文所讲,只有通过 beginPath() 操作才会进行重置,而位图则是 canvas 的属性,并非属于渲染上下文的。

开发人员可以使用 save 和 restore 两种方法来保存和恢复 canvas 状态,每调用 save 方法,都会将当前状态压入堆栈中,而相应的 restore 方法则会从堆栈中弹出一个状态,并将当前画面恢复至该状态。绘画状态在 canvas 图形变形操作中应用极为广泛,也非常重要,因为调用一个 restore 方法远比手动恢复先前状态要简单许多,因而,一个较好的习惯是在做变形操作之前先保存 canvas 状态。

二维绘图的常用变形操作在 canvas 中都可到了很好的支持,包括平移(Translate),旋转(Rotate),伸缩(Scale)等等。由于所有的变形操作都基于变形矩阵,因而开发人员始终需要记住一点的就是,一旦没有使用 save/restore 操作保持住原来的绘图状态,那么后续的绘图操作,都会在当前所应用的变形状态下完成。清单 3 使用平移和旋转方法绘制了如下所示画面。

图 3. 清单 3 所示示例图形
图 3. 清单 3 所示示例图形
清单 3. 使用平移 / 旋转变形方法绘制复杂位图
 function drawPointCircle(){  
 var canvas = document.getElementById('canvas');  
	 if (canvas.getContext){ 
		 var ctx = canvas.getContext('2d');  
 ctx.translate(150,150);   // 将 canvas 的原点从 (0,0) 平移至(150,150)
 for (i=1;i<=2;i++){        // 绘制内外 2 层
 if ((i % 2) == 1) {ctx.fillStyle = '#00f';} 
 else{ ctx.fillStyle = '#f00'; } 
 ctx.save();             // 保持开始绘制每一层时的状态一致
 for (j=0;j<=i*6;j++){   // 每层生成点的数量
 ctx.rotate(Math.PI/(3*i));  // 绕当前原点将坐标系顺时针旋转 Math.Pi/(3*i) 度
				 ctx.beginPath(); 
				 ctx.arc(0,20*i,5,0,Math.PI*2,true); 
 ctx.fill();         // 使用 fillType 值填充每个点
 } 
 ctx.restore();   
		 } 
	 } 
 }

像素级绘图

像素级别的绘图操作是 canvas 绘图区别于 SVG,VML 等绘图技术的最为明显特征之一,渲染上下文提供了 createImageData, getImageData, 和 putImageData 三种方法来进行针对像素的操作,所基于的对象都是 imageData 对象。imageData 对象包含 width、height 和 data 三个属性,其中 data 包含了 width × height × 4 个像素值,之所以乘以 4,在于每个像素都有 RGB 值和透明度 alpha 值。

清单 4 中所示代码为上一节中示例图形增添了简单的颜色反转滤镜效果,通过调用 getImageData(x,y,width,height) 方法获取以(x,y)为左上坐标的矩形区域内所有像素,而后对所有像素的 RGB 值做取反操作,最后通过 putImageData(imageData, x, y)将修改后的像素值重新绘制到在 canvas 上。

图 4. 清单 4 所示示例图形
图 4. 清单 4 所示示例图形
清单 4. 实现简单滤镜效果
 function revertImage(){ 
 var canvas = document.getElementById('canvas'); 
 if (canvas.getContext){ 
 var context = canvas.getContext('2d'); 
 // 从指定的矩形区域获取 canvas 像素数组
 var imgdata = context.getImageData(100, 100, 100, 100); 
 var pixels = imgdata.data; 

 // 遍历每个像素并对 RGB 值进行取反
 for (var i=0, n=pixels.length; i<n; i+= 4){ 
      pixels[i] = 255-pixels[i]; 
       pixels[i+1] = 255-pixels[i+1]; 
       pixels[i+2] = 255-pixels[i+2]; 
 } 
 // 在指定位置进行像素重绘
 context.putImageData(imgdata, 100, 100); 
	 } 
 }

实现动画效果

Canvas 并非为了制作动画而出现,自然没有动画制作中帧的概念。因而,使用定时器不断的重绘 canvas 画面成为了实现动画效果的通用解决方式。Javascript 中的 setInterval(code,millisec) 方法可以按照指定的时间间隔 millisec 来反复调用 code 所指向的函数或代码串,这样,通过将绘图函数作为第一个参数传给 setInterval 方法,在每次被调用的过程中移动画面中图形的位置,来最终达到一种动画的体验。需要注意的一点是,虽然 setinterval 方法的第二个参数允许开发人员对绘图函数的调用频率进行设定,但这始终都是一种最为理想的情况,由于这种绘图频率很大程度上取决于支持 canvas 的底层 JavaScript 引擎的渲染速度以及相应绘图函数的复杂性,因而实际运行的结果往往都是要慢于指定绘图频率的。

清单 5 显示了一个小弹力球动画效果,在球没有到达四周边界时,绘图方法不断的移动所绘小球的横纵坐标。并且,在每次重绘之前,都是用 clear 方法将之前的画面清除。

清单 5. 实现小弹力球动画
 <script type="text/javascript"> 
 var x=0,y=0,dx=2,dy=3,context2D;   // 小球从(0,0)开始移动,横向步长为 2,纵向步长为 3 

 function draw(){ 
 context2D.clearRect(0, 0, canvas.width, canvas.height);   // 清除整个 canvas 画面
 drawCircle(x, y);         // 使用自定义的画圆方法,在当前(x,y)坐标出画一个圆
	
 // 判断边界值,调整 dx/dy 以改变 x/y 坐标变化方向。
 if (x + dx > canvas.width || x + dx < 0) dx = -dx; 
 if (y + dy > canvas.height || y + dy < 0) dy = -dy; 
 x += dx; 
 y += dy; 
 } 

 window.onload = function (){ 
 var canvas = document.getElementById('canvas'); 
 context2D = canvas.getContext('2d'); 
 setInterval(draw, 20);     // 设置绘图周期为 20 毫秒
 } 
 </script>

提高可访问性

一款优秀的 Web 应用必须要做到的就是提供给用户很好的可访问性,这包括对鼠标,键盘以及快捷键等操作的响应,canvas 画面的本质仍是一个 DOM 节点,因而开发人员可以通过常规的方法来处理响应。这里,与基于 SVG 的绘图不同,由于 SVG 是一种基于 XML 的声明式的绘图方式,因而,SVG 中任何的图形都可以作为一个独立的 DOM 节点去接收并响应特定事件,而 canvas 由于其像素绘图的本质,则只可以在 canvas 元素节点去处理。

图 5 所示示例代码,当鼠标在 canvas 中移动时,鼠标当前相对于 canvas 中的横纵坐标将实时输出到上方提示信息区域;当用户在 canvas 中单击鼠标左键,将在相应位置创建一个蓝色小球,而后用户可以通过键盘上的左 / 右方向键对蓝色小球进行控制,使其进行横向的移动。示例代码如清单 6 所示。

图 5. 清单 6 所示示例展现
清单 6 所示示例展现
清单 6. 实现 canvas 对方向键和鼠标点击事件的响应
 <script type="text/javascript"> 
 var g_x,g_y;    // 鼠标当前的坐标
 var g_pointx, g_pointy;   // 蓝色小球当前的坐标
	 var canvas; 
	
 function drawCircle(x,y){    // 以鼠标当前位置为原点绘制一个蓝色小球
 var ctx = canvas.getContext('2d'); 
		 ctx.clearRect(0,0,300,300); 
		 ctx.fillStyle = '#00f'; 
		 ctx.beginPath(); 
		 ctx.arc(x,y,20,0,Math.PI*2,true); 
		 ctx.fill(); 
			
 g_pointx = x; 
		 g_pointy = y 
 } 
	
	 function onMouseMove(evt) { 
 // 获取鼠标在 canvas 中的坐标位置
 if (evt.layerX || evt.layerX == 0) { // FireFox 
 g_x = evt.layerX; 
 g_y = evt.layerY; 
 } 
		 document.getElementById("xinfo").innerHTML = g_x; 
		 document.getElementById("yinfo").innerHTML = g_y; 
	 } 

	 function onKeyPress(evt) { 
 var dx = 3;  // 横向平移步长
		 var kbinfo = document.getElementById("kbinfo"); 
		
 if (evt.keyCode == 39){   
			 kbinfo.innerHTML="right"; 
 if (g_x<300-dx) drawCircle(g_pointx+dx,g_pointy); 
 document.getElementById("xinfo").innerHTML = g_pointx; 
		 }else if (evt.keyCode == 37){ 
 kbinfo.innerHTML = "left"; 
			 if (g_x>dx) drawCircle(g_pointx-dx,g_pointy); 
 document.getElementById("xinfo").innerHTML = g_pointx; 
 } 
 } 
			
 window.onload = function(){ 
 canvas = document.getElementById('canvas'); 
 // 增加 canvas 节点对鼠标单击,移动以及键盘事件的响应函数
 canvas.addEventListener('click', function(evt){drawCircle(g_x, g_y);} , false);
 canvas.addEventListener('mousemove', onMouseMove, false); 
 canvas.addEventListener('keypress', onKeyPress, false); 
 canvas.focus();  // 获得焦点之后,才能够对键盘事件进行捕获
 } 
 </script>

这里我们对鼠标的移动,单击操作进行响应,在实际应用中可以视特定应用的需求,增加对鼠标摁下,松开或双击等更为丰富操作的响应,增强应用的可访问性。

细心的读者可能发现,在通过不断重绘画面以达到动画效果的过程中,我们的重绘方法首先做的事情都是调用 clearRect(x, y, width, height) 方法将原画面清空,这种销毁而后重绘的方式丢失了之前的画面,使得开发人员不得不重绘整幅画面,这在性能上是难以接受的,一种可行的做法是通过多个 canvas 叠加的方式,根据不同 canvas 上的不同刷新频率,分别完成各自的重绘任务。这种多 canvas 技巧,在处理绘图类应用中最为常见的“撤销”操作时也非常有效,所有的绘图都发生在上层 canvas,只有被用户确认的画面,才会被绘制到底层 canvas 上。鉴于本文所讨论技术范围,这里不做过多讲解,有兴趣的读者可以通过本文参考文献所列资源,进行进一步的深入学习。

总结

本文对 HTML5 新引入的 canvas 元素在 Web 绘图中所扮演的角色和所发挥的作用做了最基本的介绍,其中包括使用 canvas 完成基本的 Web 绘图,动画和交互任务,虽然 Flash,Silverlight 也都可以完成相同的任务,甚至在性能上更胜一筹,但是作为一种不依赖任何插件的标准 Web 像素级绘图技术,我们有理由相信随着各大浏览器厂商的加入,canvas 将会更加成熟完善,也会有更多基于 canvas 的绘图类应用不断涌现。

声明

本人所发表的内容仅为个人观点,不代表 IBM 公司立场、战略和观点。

参考资料

学习

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐