动画与 Canvas 图形
图形和动画已经日益成为浏览器中现代 Web 应用程序的必备功能,但实现起来仍然比较困难。视觉上复杂的功能要求性能调优和硬件加速,不能拖慢浏览器。目前已经有一套日趋完善的 API 和工具可以用来开发此类功能。
毋庸置疑,<canvas>
是 HTML5 最受欢迎的新特性。这个元素会占据一块页面区域,让 JavaScript 可以动态在上面绘制图片。<canvas>
最早是苹果公司提出并准备用在控制面板中的,随着其他浏览器的迅速跟进,HTML5 将其纳入标准。目前所有主流浏览器都在某种程度上支持 <canvas>
元素。
与浏览器环境中的其他部分一样,<canvas>
自身提供了一些 API,但并非所有浏览器都支持这些 API,其中包括支持基础绘图能力的 2D 上下文和被称为 WebGL 的 3D 上下文。支持的浏览器的最新版本现在都支持 2D 上下文和 WebGL。
使用 requestAnimationFrame
很长时间以来,计时器和定时执行都是 JavaScript 动画最先进的工具。虽然 CSS 过渡和动画方便了 Web 开发者实现某些动画,但 JavaScript 动画领域多年来进展甚微。Firefox 4 率先在浏览器中为 JavaScript 动画增加了一个名为 mozRequestAnimationFrame()方法的 API。这个方法会告诉浏览器要执行动画了,于是浏览器可以通过最优方式确定重绘的时序。自从出现之后,这个 API 被广泛采用,现在作为 requestAnimationFrame()
方法已经得到各大浏览器的支持。
早期定时动画
以前,在 JavaScript 中创建动画基本上就是使用 setInterval()
来控制动画的执行。下面的例子展示了使用 setInterval()
的基本模式:
(function () {
function updateAnimations() {
doAnimation1();
doAnimation2(); // 其他任务
}
setInterval(updateAnimations, 100);
})();
这种定时动画的问题在于无法准确知晓循环之间的延时。定时间隔必须足够短,这样才能让不同的动画类型都能平滑顺畅,但又要足够长,以便产生浏览器可以渲染出来的变化。一般计算机显示器的屏幕刷新率都是 60Hz,基本上意味着每秒需要重绘 60 次。大多数浏览器会限制重绘频率,使其不超出屏幕的刷新率,这是因为超过刷新率,用户也感知不到。
因此,实现平滑动画最佳的重绘间隔为 1000 毫秒/60,大约 17 毫秒。以这个速度重绘可以实现最平滑的动画,因为这已经是浏览器的极限了。如果同时运行多个动画,可能需要加以限流,以免 17 毫秒的重绘间隔过快,导致动画过早运行完。
虽然使用 setInterval()的定时动画比使用多个 setTimeout()实现循环效率更高,但也不是没有问题。无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。简单来讲,这里毫秒延时并不是说何时这些代码会执行,而只是说到时候会把回调加到任务队列。如果添加到队列后,主线程还被其他 任务占用,比如正在处理用户操作,那么回调就不会马上执行。
时间间隔的问题
知道何时绘制下一帧是创造平滑动画的关键。直到几年前,都没有办法确切保证何时能让浏览器把下一帧绘制出来。随着 <canvas>
的流行和 HTML5 游戏的兴起,开发者发现 setInterval()
和 setTimeout()
的不精确是个大问题。
浏览器自身计时器的精度让这个问题雪上加霜。浏览器的计时器精度不足毫秒。以下是几个浏览器计时器的精度情况:
- IE8 及更早版本的计时器精度为 15.625 毫秒;
- IE9 及更晚版本的计时器精度为 4 毫秒;
- Firefox 和 Safari 的计时器精度为约 10 毫秒;
- Chrome 的计时器精度为 4 毫秒。
IE9 之前版本的计时器精度是 15.625 毫秒,意味着 0~15 范围内的任何值最终要么是 0,要么是 15, 不可能是别的数。IE9 把计时器精度改进为 4 毫秒,但这对于动画而言还是不够精确。Chrome 计时器精度是 4 毫秒,而 Firefox 和 Safari 是 10 毫秒。更麻烦的是,浏览器又开始对切换到后台或不活跃标签页中的计时器执行限流。因此即使将时间间隔设定为最优,也免不了只能得到近似的结果。
requestAnimationFrame
Mozilla 的 Robert O’Callahan 一直在思考这个问题,并提出了一个独特的方案。他指出,浏览器知道 CSS 过渡和动画应该什么时候开始,并据此计 算出正确的时间间隔,到时间就去刷新用户界面。但对于 JavaScript 动画,浏览器不知道动画什么时候开始。他给出的方案是创造一个名为 mozRequestAnimationFrame()
的新方法,用以通知浏览器某些 JavaScript 代码要执行动画了。这样浏览器就可以在运行某些代码后进行适当的优化。目前所有浏览器都支持这个方法不带前缀的版本,即 requestAnimationFrame()
。
requestAnimationFrame()
方法接收一个参数,此参数是一个要在重绘屏幕前调用的函数。这个函数就是修改 DOM 样式以反映下一次重绘有什么变化的地方。为了实现动画循环,可以把多个 requestAnimationFrame()
调用串联起来,就像以前使用 setTimeout()
时一样:
function updateProgress() {
var div = document.getElementById('status');
div.style.width = parseInt(div.style.width, 10) + 5 + '%';
if (div.style.left != '100%') {
requestAnimationFrame(updateProgress);
}
}
requestAnimationFrame(updateProgress);
因为 requestAnimationFrame()
只会调用一次传入的函数,所以每次更新用户界面时需要再手动调用它一次。同样,也需要控制动画何时停止。结果就会得到非常平滑的动画。
目前为止,requestAnimationFrame()
已经解决了浏览器不知道 JavaScript 动画何时开始的问题, 以及最佳间隔是多少的问题,但是,不知道自己的代码何时实际执行的问题呢?这个方案同样也给出了解决方法。
传给 requestAnimationFrame()
的函数实际上可以接收一个参数,此参数是一个 DOMHighRes- TimeStamp 的实例(比如 performance.now()返回的值),表示下次重绘的时间。这一点非常重要: requestAnimationFrame()
实际上把重绘任务安排在了未来一个已知的时间点上,而且通过这个参数告诉了开发者。基于这个参数,就可以更好地决定如何调优动画了。
cancelAnimationFrame
与 setTimeout()
类似,requestAnimationFrame()
也返回一个请求 ID,可以用于通过另一个方法 cancelAnimationFrame()
来取消重绘任务。下面的例子展示了刚把一个任务加入队列又立即将其取消:
let requestID = window.requestAnimationFrame(() => {
console.log('Repaint!');
window.cancelAnimationFrame(requestID);
});
通过 requestAnimationFrame 节流
requestAnimationFrame
这个名字有时候会让人误解,因为看不出来它跟排期任务有关。支持这个方法的浏览器实际上会暴露出作为钩子的回调队列。所谓钩子(hook),就是浏览器在执行下一次重绘之前的一个点。这个回调队列是一个可修改的函数列表,包含应该在重绘之前调用的函数。每次调用 requestAnimationFrame()
都会在队列上推入一个回调函数,队列的长度没有限制。
这个回调队列的行为不一定跟动画有关。不过,通过 requestAnimationFrame()
递归地向队列中加入回调函数,可以保证每次重绘最多只调用一次回调函数。这是一个非常好的节流工具。在频繁执行影响页面外观的代码时(比如滚动事件监听器),可以利用这个回调队列进行节流。
先来看一个原生实现,其中的滚动事件监听器每次触发都会调用名为 expensiveOperation() (耗时操作)的函数。当向下滚动网页时,这个事件很快就会被触发并执行成百上千次:
function expensiveOperation() {
console.log('Invoked at', Date.now());
}
window.addEventListener('scroll', () => {
expensiveOperation();
});
如果想把事件处理程序的调用限制在每次重绘前发生,那么可以像这样下面把它封装到 requestAnimationFrame()
调用中:
function expensiveOperation() {
console.log('Invoked at', Date.now());
}
window.addEventListener('scroll', () => {
window.requestAnimationFrame(expensiveOperation);
});
这样会把所有回调的执行集中在重绘钩子,但不会过滤掉每次重绘的多余调用。此时,定义一个标志变量,由回调设置其开关状态,就可以将多余的调用屏蔽:
let enqueued = false;
function expensiveOperation() {
console.log('Invoked at', Date.now());
enqueued = false;
}
window.addEventListener('scroll', () => {
if (!enqueued) {
enqueued = true;
window.requestAnimationFrame(expensiveOperation);
}
});
因为重绘是非常频繁的操作,所以这还算不上真正的节流。更好的办法是配合使用一个计时器来限制操作执行的频率。这样,计时器可以限制实际的操作执行间隔,而 requestAnimationFrame 控制在浏览器的哪个渲染周期中执行。下面的例子可以将回调限制为不超过 50 毫秒执行一次:
let enabled = true;
function expensiveOperation() {
console.log('Invoked at', Date.now());
}
window.addEventListener('scroll', () => {
if (enabled) {
enabled = false;
window.requestAnimationFrame(expensiveOperation);
window.setTimeout(() => (enabled = true), 50);
}
});
基本的画布功能
创建 <canvas>
元素时至少要设置其 width 和 height 属性,这 样才能告诉浏览器在多大面积上绘图。出现在开始和结束标签之间的内容是后备数据,会在浏览器不支持 <canvas>
元素时显示。比如:
<canvas id="drawing" width="200" height="200"> A drawing of something. </canvas>
其他元素一样,width 和 height 属性也可以在 DOM 节点上设置,因此可以随时修改。整个元素还可以通过 CSS 添加样式,并且元素在添加样式或实际绘制内容前是不可见的。
要在画布上绘制图形,首先要取得绘图上下文。使用 getContext()
方法可以获取对绘图上下文的引用。对于平面图形,需要给这个方法传入参数"2d",表示要获取 2D 上下文对象:
let drawing = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext('2d');
// 其他代码
}
使用 <canvas>
元素时,最好先测试一下 getContext()
方法是否存在。有些浏览器对 HTML 规范中没有的元素会创建默认 HTML 元素对象。这就意味着即使 drawing 包含一个有效的元素引用, getContext()
方法也未必存在。
可以使用 toDataURL()
方法导出 <canvas>
元素上的图像。这个方法接收一个参数:要生成图像的 MIME 类型(与用来创建图形的上下文无关)。例如,要从画布上导出一张 PNG 格式的图片,可以这样做:
let canvas = document.getElementById('drawing');
// 确保浏览器支持<canvas>
if (canvas.getContext) {
// 取得图像的数据URI
let imgURI = canvas.toDataURL('image/png');
// 显示图片
let image = document.createElement('img');
image.src = imgURI;
document.body.appendChild(image);
}
浏览器默认将图像编码为 PNG 格式,除非另行指定。Firefox 和 Opera 还支持传入"image/jpeg" 24 进行 JPEG 编码。因为这个方法是后来才增加到规范中的,所以支持的浏览器也是在后面的版本实现的,包括 IE9、Firefox 3.5 和 Opera 10。