动画与 Canvas 图形
毋庸置疑,
使用 requestAnimationFrame
早期定时动画
以前,在 JavaScript 中创建动画基本上就是使用 setInterval()来控制动画的执行。虽然使用 setInterval()的定时动画比使用多个 setTimeout()实现循环效率更高,但也不是没有问题。无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。
时间间隔的问题
知道何时绘制下一帧是创造平滑动画的关键。直到几年前,都没有办法确切保证何时能让浏览器把下一帧绘制出来。随着
requestAnimationFrame
浏览器知道 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.width != "100%") { //控制动画何时停止
requestAnimationFrame(updateProgress); //更新用户界面时需要再手动调用它一次
}
}
requestAnimationFrame(updateProgress); //只会调用一次传入的函数
因为 requestAnimationFrame()只会调用一次传入的函数,所以每次更新用户界面时需要再手动调用它一次。同样,也需要控制动画何时停止。结果就会得到非常平滑的动画。
传给 requestAnimationFrame()的函数实际上可以接收一个参数,此参数是一个 DOMHighResTimeStamp 的实例(比如 performance.now()返回的值,表示下次重绘的时间。这一点非常重要:requestAnimationFrame()实际上把重绘任务安排在了未来一个已知的时间点上,而且通过这个参数告诉了开发者。
cancelAnimationFrame
与 setTimeout()类似,requestAnimationFrame()也返回一个请求 ID,可以用于通过另一个方法 cancelAnimationFrame()来取消重绘任务。下面的例子展示了刚把一个任务加入队列又立即将其取消:
let requestID = window.requestAnimationFrame(() => {
console.log('Repaint!');
});
window.cancelAnimationFrame(requestID);
通过 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 id="drawing" width="200" height="200">A drawing of something.</canvas>
且元素在添加样式或实际绘制内容前是不可见的。要在画布上绘制图形,首先要取得绘图上下文。使用 getContext()方法可以获取对绘图上下文的引用。对于平面图形,需要给这个方法传入参数"2d",表示要获取 2D 上下文对象:
let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) { //测试一下 getContext()方法是否存在
let context = drawing.getContext("2d");
// 其他代码
}
可以使用 toDataURL()方法导出
let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
// 取得图像的数据 URI,这里是导出canvas中的图像地址imgURI
let imgURI = drawing.toDataURL("image/png");
// 然后创建一个图片元素,地址是canvas导入的imgURI,之前在对应的结点位置插入图片元素就可以显示了
let image = document.createElement("img");
image.src = imgURI;
document.body.appendChild(image);
}
2D 绘图上下文
填充和描边
2D 上下文有两个基本绘制操作:填充和描边。
//可以为后续描边和填充相关的操作添加默认样式
context.strokeStyle = "red"; //描边
context.fillStyle = "#0000ff"; //填充
绘制矩形
与绘制矩形相关的方法有 3 个:fillRect()、strokeRect()和 clearRect()。这些方法都接收 4 个参数:矩形 x 坐标、矩形 y 坐标、矩形宽度和矩形高度。
fillRect()方法使用通过 fillStyle 属性绘制矩形,填充颜色
// 绘制红色矩形
context.fillStyle = "#ff0000";
context.fillRect(10, 10, 50, 50);
// 绘制半透明蓝色矩形
context.fillStyle = "rgba(0,0,255,0.5)";
context.fillRect(30, 30, 50, 50);
strokeRect()方法使用通过 strokeStyle 属性指定的颜色绘制矩形轮廓。
// 绘制红色轮廓的矩形
context.strokeStyle = "#ff0000";
context.strokeRect(10, 10, 50, 50);
// 绘制半透明蓝色轮廓的矩形
context.strokeStyle = "rgba(0,0,255,0.5)";
context.strokeRect(30, 30, 50, 50);
//描边宽度由 lineWidth 属性控制,它可以是任意整数值。类似地,lineCap 属性控制线条端点的形状["butt"(平头)、"round"(出圆//头)或"square"(出方头)],而 lineJoin属性控制线条交点的形状["round"(圆转)、"bevel"(取平)或"miter"(出尖)]。
clearRect()方法可以擦除画布中某个区域。
// 绘制红色矩形
context.fillStyle = "#ff0000";
context.fillRect(10, 10, 50, 50);
// 绘制半透明蓝色矩形
context.fillStyle = "rgba(0,0,255,0.5)";
context.fillRect(30, 30, 50, 50);
// 在前两个矩形重叠的区域擦除一个矩形区域
context.clearRect(40, 40, 10, 10);
绘制路径
通过路径可以创建复杂的形状和线条。要绘制路径,必须首先调用beginPath()方法以表示要开始绘制新路径。然后,再调用下列方法来绘制路径。
arc(x, y, radius, startAngle, endAngle, counterclockwise):以坐标(x, y)为圆心,以 radius 为半径绘制一条弧线,起始角度为 startAngle,结束角度为 endAngle(都是弧度)。最后一个参数 counterclockwise 表示是否逆时针计算起始角度和结束角度(默认为顺时针)。
arcTo(x1, y1, x2, y2, radius):以给定半径 radius,经由(x1, y1)绘制一条从上一点到(x2, y2)的弧线。
bezierCurveTo(c1x, c1y, c2x, c2y, x, y):以(c1x, c1y)和(c2x, c2y)为控制点,绘制一条从上一点到(x, y)的弧线(三次贝塞尔曲线)。
lineTo(x, y):绘制一条从上一点到(x, y)的直线。
moveTo(x, y):不绘制线条,只把绘制光标移动到(x, y)。
quadraticCurveTo(cx, cy, x, y):以(cx, cy)为控制点,绘制一条从上一点到(x, y)的弧线(二次贝塞尔曲线)。
rect(x, y, width, height):以给定宽度和高度在坐标点(x, y)绘制一个矩形。这个方法与 strokeRect()和 fillRect()的区别在于,它创建的是一条路径,而不是独立的图形。
创建路径之后,可以使用 closePath()方法绘制一条返回起点的线。如果路径已经完成,则既可以指定 fillStyle 属性并调用 fill()方法来填充路径,也可以指定 strokeStyle 属性并调用stroke()方法来描画路径,还可以调用 clip()方法基于已有路径创建一个新剪切区域。
let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext("2d");
// 创建路径
context.beginPath();
// 绘制外圆
context.arc(100, 100, 99, 0, 2 * Math.PI, false); //默认绘制线的宽度是1px
// 绘制内圆
context.moveTo(194, 100); //将绘制光标移动
context.arc(100, 100, 94, 0, 2 * Math.PI, false);
// 绘制分针
context.moveTo(100, 100); //将绘制光标移动
context.lineTo(100, 15); //画线段
// 绘制时针
context.moveTo(100, 100);
context.lineTo(35, 100);
// 描画路径
context.stroke(); //启动画笔绘制出来
}
路径是 2D 上下文的主要绘制机制,为绘制结果提供了很多控制。因为路径经常被使用,所以也有一个 isPointInPath()方法,接收 x 轴和 y 轴坐标作为参数。这个方法用于确定指定的点是否在路径上,可以在关闭路径前随时调用,比如:
if (context.isPointInPath(100, 100)) {
alert("Point (100, 100) is in the path.");
}
绘制文本
2D绘图上下文还提供了绘制文本的方法,即fillText()和 strokeText()。这两个方法都接收 4 个参数:要绘制的字符串、x 坐标、y 坐标和可选的最大像素宽度。而且,这两个方法最终绘制的结果都取决于以下 3 个属性。
font:以 CSS 语法指定的字体样式、大小、字体族等,比如"10px Arial"。
textAlign:指定文本的对齐方式,可能的值包括"start"、"end"、"left"、"right"和"center"。推荐使用"start"和"end",不使用"left"和"right",因为前者无论在从左到右书写的语言还是从右到左书写的语言中含义都更明确。
textBaseLine :指定文本的基线,可能的值包括 "top" 、 "hanging" 、 "middle" 、"alphabetic"、"ideographic"和"bottom"。
这些属性都有相应的默认值,因此没必要每次绘制文本时都设置它们。
// 正常
context.font = "bold 14px Arial";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("12", 100, 20); //文字水平居中对齐
// 与开头对齐
context.textAlign = "start";
context.fillText("12", 100, 40); //文字水平向左对齐
// 与末尾对齐
context.textAlign = "end";
context.fillText("12", 100, 60); //文字水平向右对齐
类似地,通过修改 textBaseline属性,可以改变文本的垂直对齐方式。
context.font = "bold 14px Arial";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("12", 60, 20); //文字垂直居中对齐
context.textBaseline = "top";
context.fillText("12", 80, 20); //文字垂直top对齐
context.textBaseline = "hanging";
context.fillText("12", 100, 20); //文字垂直hanging对齐
context.textBaseline = "alphabetic";
context.fillText("12", 120, 20); //文字垂直alphabetic对齐
context.textBaseline = "ideographic";
context.fillText("12", 140, 20); //文字垂直ideographic对齐
context.textBaseline = "bottom";
context.fillText("12", 160, 20); //文字垂直bottom对齐
由于绘制文本很复杂,特别是想把文本绘制到特定区域的时候,因此 2D 上下文提供了用于辅助确定文本大小的 measureText()方法。这个方法接收一个参数,即要绘制的文本,然后返回一个TextMetrics 对象。这个返回的对象目前只有一个属性 width,不过将来应该会增加更多度量指标。
例如,假设要把文本"Hello world!"放到一个 140 像素宽的矩形中,可以使用以下代码,从 100 像素的字体大小开始计算,不断递减,直到文本大小合适:
let fontSize = 100;
context.font = fontSize + "px Arial";
while(context.measureText("Hello world!").width > 140) {
fontSize--;
context.font = fontSize + "px Arial";
}
context.fillText("Hello world!", 10, 10);
context.fillText("Font size is " + fontSize + "px", 10, 50);
fillText()和 strokeText()方法还有第四个参数,即文本的最大宽度。这个参数是可选的(Firefox 4 是第一个实现它的浏览器)提供了此参数,但要绘制的字符串超出了最大宽度限制,则文本会以正确的字符高度绘制,这时字符会被水平压缩,以达到限定宽度。
变换(暂时忽略)
绘制图像
以使用 drawImage()方法。这个方法可以接收 3 组不同的参数,并产生不同的结果。最简单的调用是传入一个 HTML 的\元素,以及表示绘制目标的 x 和 y 坐标,结果是把图像绘制到指定位置。
let image = document.images[0];
context.drawImage(image, 10, 10);
//如果想改变所绘制图像的大小,可以再传入另外两个参数:目标宽度和目标高度。
context.drawImage(image, 0, 10, 50, 50, 0, 100, 40, 60);
还可以只把图像绘制到上下文中的一个区域。此时,需要给 drawImage()提供 9 个参数:要绘制的图像、源图像 x 坐标、源图像 y 坐标、源图像宽度、源图像高度、目标区域 x 坐标、目标区域 y 坐标、目标区域宽度和目标区域高度。这个重载后的 drawImage()方法可以实现最大限度的控制,比如:
//最终,原始图像中只有一部分会绘制到画布上。
//这一部分从(0, 10)开始,50 像素宽、50 像素高。而绘制到画布上时,会从(0, 100)开始,变成 40 像素宽、60 像素高。
context.drawImage(image, 0, 10, 50, 50, 0, 100, 40, 60);
第一个参数除了可以是 HTML 的\元素,还可以是另一个
阴影
2D 上下文可以根据以下属性的值自动为已有形状或路径生成阴影。
shadowColor:CSS 颜色值,表示要绘制的阴影颜色,默认为黑色。
shadowOffsetX:阴影相对于形状或路径的 x 坐标的偏移量,默认为 0。
shadowOffsetY:阴影相对于形状或路径的 y 坐标的偏移量,默认为 0。
shadowBlur:像素,表示阴影的模糊量。默认值为 0,表示不模糊。
渐变
渐变通过 CanvasGradient 的实例表示,在 2D 上下文中创建和修改都非常简单。要创建一个新的线性渐变,可以调用上下文的 createLinearGradient()方法。这个方法接收 4 个参数:起点 x 坐标、起点 y 坐标、终点 x 坐标和终点 y 坐标。调用之后,该方法会以指定大小创建一个新的 CanvasGradient对象并返回实例。
有了 gradient 对象后,接下来要使用 addColorStop()方法为渐变指定色标。这个方法接收两个参数:色标位置和 CSS 颜色字符串。色标位置通过 0~1 范围内的值表示,0 是第一种颜色,1 是最后一种颜色。比如:
let gradient = context.createLinearGradient(30, 30, 70, 70);
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
// 绘制红色矩形
context.fillStyle = "#ff0000";
context.fillRect(10, 10, 50, 50);
// 绘制渐变矩形
context.fillStyle = gradient;
context.fillRect(30, 30, 50, 50);
以上代码执行之后绘制的矩形只有左上角有一部分白色。这是因为矩形的起点在渐变的中间,此时颜色的过渡几乎要完成了。结果矩形大部分地方是黑色的,因为渐变不会重复。保持渐变与形状的一致非常重要,有时候可能需要写个函数计算相应的坐标。比如:
function createRectLinearGradient(context, x, y, width, height) {
return context.createLinearGradient(x, y, x+width, y+height);
}
这个函数会基于起点的 x、y 坐标和传入的宽度、高度创建渐变对象,之后调用 fillRect()方法时可以使用相同的值:
let gradient = createRectLinearGradient(context, 30, 30, 50, 50);
gradient.addColorStop(0, "white");
gradient.addColorStop(1, "black");
// 绘制渐变矩形
context.fillStyle = gradient;
context.fillRect(30, 30, 50, 50);
径向渐变(或放射性渐变)要使用 createRadialGradient()方法来创建。这个方法接收 6 个参数,分别对应两个圆形圆心的坐标和半径。前 3 个参数指定起点圆形中心的 x、y 坐标和半径,后 3 个参数指定终点圆形中心的 x、y 坐标和半径。
图案
调用 createPattern()方法并传入两个参数:一个 HTML \元素和一个表示该如何重复图像的字符串。第二个参数的值与 CSS 的background-repeat 属性是一样的,包括"repeat"、"repeat-x"、"repeat-y"和"no-repeat"。比如:
let image = document.images[0],
pattern = context.createPattern(image, "repeat");
// 绘制矩形
context.fillStyle = pattern;
context.fillRect(10, 10, 150, 150);
图像数据
getImageData()方法获取原始图像数据。这个方法接收 4 个参数:要取得数据中第一个像素的左上角坐标和要取得的像素宽度及高度。
合成
2D上下文中绘制的所有内容都会应用两个属性:globalAlpha 和 globalComposition Operation,其中,globalAlpha 属性是一个范围在 0~1 的值(包括 0和 1),用于指定所有绘制内容的透明度,默认值为 0。如果所有后来的绘制都需要使用同样的透明度,那么可以将 globalAlpha 设置为适当的值,执行绘制,然后再把 globalAlpha 设置为 0。
WebGL(3d方面的,暂时忽略)
WebGL 是画布的 3D 上下文。与其他 Web 技术不同,WebGL 不是 W3C 制定的标准,OpenGL 这种 3D 图形语言很复杂,本书不会涉及过多相关概念。不过,要使用 WebGL 最好熟悉OpenGL ES 2.0,因为很多概念可以照搬过来。
表单脚本(跟我本身浏览器出入较大)
JavaScript 较早的一个用途是承担一部分服务器端表单处理的责任。
表单基础
Web 表单在 HTML 中以
文章评论