落笔 Blog

落笔
Be a hero of your own
  1. 首页
  2. 书籍系列
  3. 红宝书学习记录
  4. 正文

十八 十九章:动画与Canvas图形&表单脚本

2023年6月19日 1035点热度 0人点赞 0条评论

动画与 Canvas 图形

毋庸置疑,是 HTML5 最受欢迎的新特性。这个元素会占据一块页面区域,让 JavaScript可以动态在上面绘制图片。自身提供了一些 API,但并非所有浏览器都支持这些API,其中包括支持基础绘图能力的 2D 上下文和被称为 WebGL 的 3D 上下文。支持的浏览器的最新版本现在都支持 2D 上下文和 WebGL。

使用 requestAnimationFrame

早期定时动画

以前,在 JavaScript 中创建动画基本上就是使用 setInterval()来控制动画的执行。虽然使用 setInterval()的定时动画比使用多个 setTimeout()实现循环效率更高,但也不是没有问题。无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。

时间间隔的问题

知道何时绘制下一帧是创造平滑动画的关键。直到几年前,都没有办法确切保证何时能让浏览器把下一帧绘制出来。随着的流行和 HTML5 游戏的兴起,开发者发现 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); 
 } 
});

基本的画布功能

创建元素时至少要设置其 width 和 height 属性,这样才能告诉浏览器在多大面积上绘图。出现在开始和结束标签之间的内容是后备数据,会在浏览器不支持元素时显示。比如:

<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()方法导出元素上的图像。这个方法接收一个参数:要生成图像的 MIME 类型(与用来创建图形的上下文无关)。例如,要从画布上导出一张 PNG 格式的图片,可以这样做:

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);

501a7477bdc87962f8aaba9348d17f6ed01166b0
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"(出尖)]。

a133384f446ea71f399bf6a1b0f2a754629a4e94
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);

7b6a845cd8adad7d7379d102663831e182f964c7

绘制路径

通过路径可以创建复杂的形状和线条。要绘制路径,必须首先调用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();                 //启动画笔绘制出来
}

38b8049831c7c64db5c66e16b605c36e54e55dd2
路径是 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);        //文字水平向右对齐

f933b03f983bb6e8eee9ad0be4693d8c800536cb
类似地,通过修改 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对齐

e7b1d12e4ccb2d8474d8a744d328c851d50e7150
由于绘制文本很复杂,特别是想把文本绘制到特定区域的时候,因此 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 是第一个实现它的浏览器)提供了此参数,但要绘制的字符串超出了最大宽度限制,则文本会以正确的字符高度绘制,这时字符会被水平压缩,以达到限定宽度。
f0ba5e36f893f61f581e57dd3b30ccc3e56fbb0e

变换(暂时忽略)

绘制图像

以使用 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);

585e59b843cc5db9c0e544b5ccc00d88ba215a38
以上代码执行之后绘制的矩形只有左上角有一部分白色。这是因为矩形的起点在渐变的中间,此时颜色的过渡几乎要完成了。结果矩形大部分地方是黑色的,因为渐变不会重复。保持渐变与形状的一致非常重要,有时候可能需要写个函数计算相应的坐标。比如:

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);

dcd59cd3d4223ef583fa393bd2276818991627d8
径向渐变(或放射性渐变)要使用 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 中以

元素表示,在 JavaScript 中则以 HTMLFormElement 类型表示。HTMLFormElement 类型继承自 HTMLElement 类型,因此拥有与其他 HTML 元素一样的默认属性。不过,HTMLFormElement 也有自己的属性和方法。
acceptCharset:服务器可以接收的字符集,等价于 HTML 的 accept-charset 属性。
action:请求的 URL,等价于 HTML 的 action 属性。
elements:表单中所有控件的 HTMLCollection。 ? enctype:请求的编码类型,等价于 HTML 的 enctype 属性。
length:表单中控件的数量。
method:HTTP 请求的方法类型,通常是"get"或"post",等价于 HTML 的 method 属性。
name:表单的名字,等价于 HTML 的 name 属性。
reset():把表单字段重置为各自的默认值。
submit():提交表单。
target:用于发送请求和接收响应的窗口的名字,等价于 HTML 的 target 属性。
最常用的是将表单当作普通元素为它指定一个 id 属性,从而可以使用 getElementById()来获取表单,
此外,使用 document.forms 集合可以获取页面上所有的表单元素。

提交表单

点击下面例子中定义的所有按钮都可以提交它们所在的表单:

<!-- 通用提交按钮 --> 
<input type="submit" value="Submit Form"> 
<!-- 自定义提交按钮 --> 
<button type="submit">Submit Form</button> 
<!-- 图片按钮 --> 
<input type="image" src="graphic.gif">

以这种方式提交表单会在向服务器发送请求之前触发 submit 事件。这样就提供了一个验证表单数据的机会,可以根据验证结果决定是否真的要提交。

let form = document.getElementById("myForm"); 
form.addEventListener("submit", (event) => { 
 // 阻止表单提交
 event.preventDefault(); 
});

当然,也可以通过编程方式在 JavaScript 中调用 submit()方法来提交表单。可以在任何时候调用这个方法来提交表单,而且表单中不存在提交按钮也不影响表单提交。下面是一个例子:(我测试的时候,这些表单验证的方法已经废弃了)

let form = document.getElementById("myForm"); 
// 提交表单        通过 submit()提交表单时,submit 事件不会触发。因此在调用这个方法前要先做数据验证。
form.submit();

表单提交的一个最大的问题是可能会提交两次表单。如果提交表单之后没有什么反应,那么没有耐心的用户可能会多次点击提交按钮。结果是很烦人的(因为服务器要处理重复的请求),甚至可能造成损失(如果用户正在购物,则可能会多次下单)。解决这个问题主要有两种方式:在表单提交后禁用提交按钮,或者通过 onsubmit 事件处理程序取消之后的表单提交。

重置表单

用户单击重置按钮可以重置表单。重置按钮可以使用 type 属性为"reset"的或

标签: 暂无
最后更新:2023年8月1日

落笔

这个人很懒,什么都没留下

点赞
< 上一篇

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复

© 2022 落笔 Blog.

One Piece is a real treasure

粤ICP备2023064929号