
2025-03-03 日报 Day114

今日的鸡汤
花会沿路盛开,
你以后的路也是。
今日学习内容
1、JS 红皮书 P549-562 第十八章:动画与 Canvas 图形
今日笔记
1、图形和动画已经日益成为浏览器中现代 Web 应用程序的必备功能,但实现起来仍然比较困难。视觉上复杂的功能要求性能调优和硬件加速,不能拖慢浏览器。
2、使用requestAnimationFrame: 很长时间以来,计时器和定时执行都是 JavaScript 动画最先进的工具。这个方法会告诉浏览器要执行动画了,于是浏览器可以通过最优方式确定重绘的时序。
- 早期定时动画: 以前,在 JavaScript 中创建动画基本上就是使用 setInterval()来控制动画的执行。下面的例子展示了使用 setInterval()的基本模式:这种定时动画的问题在于无法准确知晓循环之间的延时。定时间隔必须足够短,这样才能让不同的动画类型都能平滑顺畅,但又要足够长,以便产生浏览器可以渲染出来的变化。一般计算机显示器的屏幕刷新率都是 60Hz,基本上意味着每秒需要重绘 60 次。大多数浏览器会限制重绘频率,使其不超出屏幕的刷新率,这是因为超过刷新率,用户也感知不到。
1
2
3
4
5
6
7
8(function() {
function updateAnimations() {
doAnimation1();
doAnimation2();
// 其他任务
}
setInterval(updateAnimations, 100);
})();
因此,实现平滑动画最佳的重绘间隔为 1000 毫秒/60,大约 17 毫秒。以这个速度重绘可以实现最平滑的动画,因为这已经是浏览器的极限了。如果同时运行多个动画,可能需要加以限流,以免 17 毫秒的重绘间隔过快,导致动画过早运行完。
无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。简单来讲,这里毫秒延时并不是说何时这些代码会执行,而只是说到时候会把回调加到任务队列。如果添加到队列后,主线程还被其他任务占用,比如正在处理用户操作,那么回调就不会马上执行。 - 时间间隔的问题: 知道何时绘制下一帧是创造平滑动画的关键。浏览器自身计时器的精度让这个问题雪上加霜。浏览器的计时器精度不足毫秒。以下是几个浏览器计时器的精度情况:
IE8 及更早版本的计时器精度为 15.625 毫秒;
IE9 及更晚版本的计时器精度为 4 毫秒;
Firefox 和 Safari 的计时器精度为约 10 毫秒;
Chrome 的计时器精度为 4 毫秒。
更麻烦的是,浏览器又开始对切换到后台或不活跃标签页中的计时器执行限流。因此即使将时间间隔设定为最优,也免不了只能得到近似的结果。 - requestAnimationFrame: 浏览器知道 CSS过渡和动画应该什么时候开始,并据此计算出正确的时间间隔,到时间就去刷新用户界面。但对于 JavaScript动画,浏览器不知道动画什么时候开始。他给出的方案是创造一个名为 mozRequestAnimationFrame()的新方法,用以通知浏览器某些 JavaScript 代码要执行动画了。这样浏览器就可以在运行某些代码后进行适当的优化。目前所有浏览器都支持这个方法不带前缀的版本,即 requestAnimationFrame()。
requestAnimationFrame()方法接收一个参数,此参数是一个要在重绘屏幕前调用的函数。这个函数就是修改 DOM 样式以反映下一次重绘有什么变化的地方。为了实现动画循环,可以把多个requestAnimationFrame()调用串联起来,就像以前使用 setTimeout()时一样:目前为止,requestAnimationFrame()已经解决了浏览器不知道 JavaScript 动画何时开始的问题,以及最佳间隔是多少的问题,但是,不知道自己的代码何时实际执行的问题呢?1
2
3
4
5
6
7
8function 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()的函数实际上可以接收一个参数,此参数是一个 DOMHighResTimeStamp 的实例(比如 performance.now()返回的值),表示下次重绘的时间。这一点非常重要:requestAnimationFrame()实际上把重绘任务安排在了未来一个已知的时间点上,而且通过这个参数告诉了开发者。基于这个参数,就可以更好地决定如何调优动画了。 - cancelAnimationFrame: 与 setTimeout()类似,requestAnimationFrame()也返回一个请求 ID,可以用于通过另一个方法 cancelAnimationFrame()来取消重绘任务。下面的例子展示了刚把一个任务加入队列又立即将其取消:
1
2
3
4let requestID = window.requestAnimationFrame(() => {
console.log('Repaint!');
});
window.cancelAnimationFrame(requestID); - 通过requestAnimationFrame节流: 支持这个方法的浏览器实际上会暴露出作为钩子的回调队列。所谓钩子(hook),就是浏览器在执行下一次重绘之前的一个点。这个回调队列是一个可修改的函数列表,包含应该在重绘之前调用的函数。每次调用requestAnimationFrame()都会在队列上推入一个回调函数,队列的长度没有限制。
这个回调队列的行为不一定跟动画有关。不过,通过 requestAnimationFrame()递归地向队列中加入回调函数,可以保证每次重绘最多只调用一次回调函数。这是一个非常好的节流工具。在频繁执行影响页面外观的代码时(比如滚动事件监听器),可以利用这个回调队列进行节流。
先来看一个原生实现,其中的滚动事件监听器每次触发都会调用名为 expensiveOperation()(耗时操作)的函数。当向下滚动网页时,这个事件很快就会被触发并执行成百上千次:如果想把事件处理程序的调用限制在每次重绘前发生,那么可以像这样下面把它封装到 requestAnimationFrame()调用中:1
2
3
4
5
6function expensiveOperation() {
console.log('Invoked at', Date.now());
}
window.addEventListener('scroll', () => {
expensiveOperation();
});这样会把所有回调的执行集中在重绘钩子,但不会过滤掉每次重绘的多余调用。此时,定义一个标志变量,由回调设置其开关状态,就可以将多余的调用屏蔽:1
2
3
4
5
6function expensiveOperation() {
console.log('Invoked at', Date.now());
}
window.addEventListener('scroll', () => {
window.requestAnimationFrame(expensiveOperation);
});因为重绘是非常频繁的操作,所以这还算不上真正的节流。更好的办法是配合使用一个计时器来限制操作执行的频率。这样,计时器可以限制实际的操作执行间隔,而 requestAnimationFrame 控制在浏览器的哪个渲染周期中执行。下面的例子可以将回调限制为不超过 50 毫秒执行一次:1
2
3
4
5
6
7
8
9
10
11let enqueued = false;
function expensiveOperation() {
console.log('Invoked at', Date.now());
enqueued = false;
}
window.addEventListener('scroll', () => {
if (!enqueued) {
enqueued = true;
window.requestAnimationFrame(expensiveOperation);
}
});1
2
3
4
5
6
7
8
9
10
11let 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);
}
});
3、基本的画布功能: 创建
1 | <canvas id="drawing" width="200" height="200">A drawing of something.</canvas> |
要在画布上绘制图形,首先要取得绘图上下文。使用 getContext()方法可以获取对绘图上下文的引用。对于平面图形,需要给这个方法传入参数”2d”,表示要获取 2D 上下文对象:
1 | let drawing = document.getElementById("drawing"); |
可以使用 toDataURL()方法导出
1 | let drawing = document.getElementById("drawing"); |
浏览器默认将图像编码为 PNG 格式,除非另行指定。
注意 如果画布中的图像是其他域绘制过来的,toDataURL()方法就会抛出错误。相关内容本章后面会讨论。
4、2D绘图上下文: 2D 绘图上下文提供了绘制 2D 图形的方法,包括矩形、弧形和路径。2D 上下文的坐标原点(0, 0)在
- 填充和描边: 2D 上下文有两个基本绘制操作:填充和描边。填充以指定样式(颜色、渐变或图像)自动填充形状,而描边只为图形边界着色。大多数 2D 上下文操作有填充和描边的变体,显示效果取决于两个属性:fillStyle 和 strokeStyle。
1
2
3
4
5
6
7let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext("2d");
context.strokeStyle = "red";
context.fillStyle = "#0000ff";
} - 绘制矩形: 矩形是唯一一个可以直接在 2D 绘图上下文中绘制的形状。与绘制矩形相关的方法有 3 个:fillRect()、strokeRect()和 clearRect()。这些方法都接收 4 个参数:矩形 x 坐标、矩形 y 坐标、矩形宽度和矩形高度。这几个参数的单位都是像素。
fillRect()方法用于以指定颜色在画布上绘制并填充矩形。填充的颜色使用 fillStyle 属性指定。来看下面的例子:strokeRect()方法使用通过 strokeStyle 属性指定的颜色绘制矩形轮廓。下面是一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext("2d");
/*
* 引自 MDN 文档
*/
// 绘制红色矩形
context.fillStyle = "#ff0000";
context.fillRect(10, 10, 50, 50);
// 绘制半透明蓝色矩形
context.fillStyle = "rgba(0,0,255,0.5)";
context.fillRect(30, 30, 50, 50);
}注意 描边宽度由 lineWidth 属性控制,它可以是任意整数值。类似地,lineCap 属性控制线条端点的形状[”butt”(平头)、”round”(出圆头)或”square”(出方头)],而 lineJoin属性控制线条交点的形状[”round”(圆转)、”bevel”(取平)或”miter”(出尖)]1
2
3
4
5
6
7
8
9
10
11
12
13
14let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext("2d");
/*
* 引自 MDN 文档
*/
// 绘制红色轮廓的矩形
context.strokeStyle = "#ff0000";
context.strokeRect(10, 10, 50, 50);
// 绘制半透明蓝色轮廓的矩形
context.strokeStyle = "rgba(0,0,255,0.5)";
context.strokeRect(30, 30, 50, 50);
}
使用 clearRect()方法可以擦除画布中某个区域。该方法用于把绘图上下文中的某个区域变透明。通过先绘制形状再擦除指定区域,可以创建出有趣的效果,比如从已有矩形中开个孔。来看下面的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16let drawing = document.getElementById("drawing");
// 确保浏览器支持<canvas>
if (drawing.getContext) {
let context = drawing.getContext("2d");
/*
* 引自 MDN 文档
*/
// 绘制红色矩形
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);
} - 绘制路径: 2D 绘图上下文支持很多在画布上绘制路径的方法。通过路径可以创建复杂的形状和线条。要绘制路径,必须首先调用 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()方法基于已有路径创建一个新剪切区域。路径是 2D 上下文的主要绘制机制,为绘制结果提供了很多控制。因为路径经常被使用,所以也有一个 isPointInPath()方法,接收 x 轴和 y 轴坐标作为参数。这个方法用于确定指定的点是否在路径上,可以在关闭路径前随时调用,比如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20let 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);
// 绘制内圆
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();
}1
2
3if (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”。
fillText()方法使用fillStyle 属性绘制文本,而 strokeText()方法使用 strokeStyle 属性。通常,fillText()方法是使用最多的,因为它模拟了在网页中渲染文本。例如,下面的例子会在前一节示例的表盘顶部绘制数字“12”:因为把 textAlign 设置为了”center”,把 textBaseline 设置为了”middle”,所以(100, 20)表示文本水平和垂直中心点的坐标。如果 textAlign 是”start”,那么 x 坐标在从左到右书写的语言中表示文本的左侧坐标,而”end”会让 x 坐标在从左到右书写的语言中表示文本的右侧坐标。1
2
3
4context.font = "bold 14px Arial";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("12", 100, 20);由于绘制文本很复杂,特别是想把文本绘制到特定区域的时候,因此 2D 上下文提供了用于辅助确定文本大小的 measureText()方法。这个方法接收一个参数,即要绘制的文本,然后返回一个TextMetrics 对象。这个返回的对象目前只有一个属性 width,不过将来应该会增加更多度量指标。1
2
3
4
5
6
7
8
9
10
11// 正常
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);
measureText()方法使用 font、textAlign 和 textBaseline 属性当前的值计算绘制指定文本后的大小。例如,假设要把文本”Hello world!”放到一个 140 像素宽的矩形中,可以使用以下代码,从 100 像素的字体大小开始计算,不断递减,直到文本大小合适:fillText()和 strokeText()方法还有第四个参数,即文本的最大宽度。这个参数是可选的(Firefox 4 是第一个实现它的浏览器),如果调用 fillText()和 strokeText()时提供了此参数,但要绘制的字符串超出了最大宽度限制,则文本会以正确的字符高度绘制,这时字符会被水平压缩,以达到限定宽度。1
2
3
4
5
6
7
8let 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); - 变换: 上下文变换可以操作绘制在画布上的图像。2D 绘图上下文支持所有常见的绘制变换。在创建绘制上下文时,会以默认值初始化变换矩阵,从而让绘制操作如实应用到绘制结果上。对绘制上下文应用变换,可以导致以不同的变换矩阵应用绘制操作,从而产生不同的结果。
以下方法可用于改变绘制上下文的变换矩阵。
rotate(angle):围绕原点把图像旋转 angle 弧度。
scale(scaleX, scaleY):通过在 x 轴乘以 scaleX、在 y 轴乘以 scaleY 来缩放图像。scaleX和 scaleY 的默认值都是 1.0。
translate(x, y):把原点移动到(x, y)。执行这个操作后,坐标(0, 0)就会变成(x, y)。
transform(m1_1, m1_2, m2_1, m2_2, dx, dy):像下面这样通过矩阵乘法直接修改矩阵。
m1_1 m1_2 dx
m2_1 m2_2 dy
0 0 1
setTransform(m1_1, m1_2, m2_1, m2_2, dx, dy):把矩阵重置为默认值,再以传入的参数调用 transform()。
变换可以简单,也可以复杂。例如,在前面绘制表盘的例子中,如果把坐标原点移动到表盘中心,那再绘制表针就非常简单了:把原点移动到(100, 100),也就是表盘的中心后,要绘制表针只需简单的数学计算即可。这是因为所有计算都是基于(0, 0),而不是(100, 100)了。当然,也可以使用 rotate()方法来转动表针:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22let 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);
// 绘制内圆
context.moveTo(194, 100);
context.arc(100, 100, 94, 0, 2 * Math.PI, false);
// 移动原点到表盘中心
context.translate(100, 100);
// 绘制分针
context.moveTo(0, 0);
context.lineTo(0, -85);
// 绘制时针
context.moveTo(0, 0);
context.lineTo(-65, 0);
// 描画路径
context.stroke();
}所有这些变换,包括 fillStyle 和 strokeStyle 属性,会一直保留在上下文中,直到再次修改它们。虽然没有办法明确地将所有值都重置为默认值,但有两个方法可以帮我们跟踪变化。如果想着什么时候再回到当前的属性和变换状态,可以调用 save()方法。调用这个方法后,所有这一时刻的设置会被放到一个暂存栈中。保存之后,可以继续修改上下文。而在需要恢复之前的上下文时,可以调用restore()方法。这个方法会从暂存栈中取出并恢复之前保存的设置。多次调用 save()方法可以在暂存栈中存储多套设置,然后通过 restore()可以系统地恢复。下面来看一个例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24let 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);
// 绘制内圆
context.moveTo(194, 100);
context.arc(100, 100, 94, 0, 2 * Math.PI, false);
// 移动原点到表盘中心
context.translate(100, 100);
// 旋转表针
context.rotate(1);
// 绘制分针
context.moveTo(0, 0);
context.lineTo(0, -85);
// 绘制时针
context.moveTo(0, 0);
context.lineTo(-65, 0);
// 描画路径
context.stroke();
}以上代码先将 fillStyle 设置为红色,然后调用 save()。接着,将 fillStyle 修改为绿色,坐标移动到(100, 100),并再次调用 save(),保存设置。随后,将 fillStyle 属性设置为蓝色并绘制一个矩形。因为此时坐标被移动了,所以绘制矩形的坐标实际上是(100, 100)。在调用 restore()之后,fillStyle 恢复为绿色,因此这一次绘制的矩形是绿色的。而绘制矩形的坐标是(110, 110),因为变换仍在起作用。再次调用 restore()之后,变换被移除,fillStyle 也恢复为红色。绘制最后一个矩形的坐标变成了(0, 0)。1
2
3
4
5
6
7
8
9
10
11context.fillStyle = "#ff0000";
context.save();
context.fillStyle = "#00ff00";
context.translate(100, 100);
context.save();
context.fillStyle = "#0000ff";
context.fillRect(0, 0, 100, 200); // 在(100, 100)绘制蓝色矩形
context.restore();
context.fillRect(10, 10, 100, 200); // 在(100, 100)绘制绿色矩形
context.restore();
context.fillRect(0, 0, 100, 200); // 在(0, 0)绘制红色矩形
注意,save()方法只保存应用到绘图上下文的设置和变换,不保存绘图上下文的内容。