JavaScript 权威指南第七版(GPT 重译)(六二)

126 阅读1小时+

15.8.1 路径和多边形

在画布上绘制线条并填充由这些线条围起来的区域时,首先需要定义一个路径。路径是一个或多个子路径的序列。子路径是由线段(或者后面我们将看到的曲线段)连接的两个或多个点的序列。使用beginPath()方法开始一个新路径。使用moveTo()方法开始一个新的子路径。一旦用moveTo()确定了子路径的起始点,你可以通过调用lineTo()将该点连接到一个新点形成一条直线。以下代码定义了包含两条线段的路径:

c.beginPath();        // Start a new path
c.moveTo(100, 100);   // Begin a subpath at (100,100)
c.lineTo(200, 200);   // Add a line from (100,100) to (200,200)
c.lineTo(100, 200);   // Add a line from (200,200) to (100,200)

这段代码仅仅定义了一个路径;它并没有在画布上绘制任何东西。要绘制(或“描边”)路径中的两条线段,调用stroke()方法;要填充由这些线段定义的区域,调用fill()

c.fill();             // Fill a triangular area
c.stroke();           // Stroke two sides of the triangle

这段代码(以及一些额外的用于设置线宽和填充颜色的代码)生成了图 15-7 中显示的图形。

js7e 1506

图 15-7. 一个简单的路径,填充和描边

注意在图 15-7 中定义的子路径是“开放”的。它只包含两条线段,结束点没有连接回起始点。这意味着它没有围起一个区域。fill()方法通过假设一条直线连接子路径中的最后一个点和第一个点来填充开放的子路径。这就是为什么这段代码填充了一个三角形,但只描绘了三角形的两条边。

如果你想要描绘刚才显示的三角形的所有三条边,你可以调用closePath()方法将子路径的结束点连接到起始点。(你也可以调用lineTo(100,100),但那样你会得到三条共享起始点和结束点但并非真正闭合的线段。当使用宽线条绘制时,如果使用closePath()效果更好。)

还有另外两点关于stroke()fill()需要注意。首先,这两个方法都作用于当前路径中的所有子路径。假设我们在前面的代码中添加了另一个子路径:

c.moveTo(300,100);    // Begin a new subpath at (300,100);
c.lineTo(300,200);    // Draw a vertical line down to (300,200);

如果我们随后调用了stroke(),我们将绘制一个三角形的两条相连边和一条不相连的垂直线。

关于stroke()fill()的第二点是,它们都不会改变当前路径:你可以调用fill(),而当你调用stroke()时,路径仍然存在。当你完成一个路径并想要开始另一个路径时,你必须记得调用beginPath()。如果不这样做,你将不断向现有路径添加新的子路径,并且可能会一遍又一遍地绘制那些旧的子路径。

示例 15-5 定义了一个用于绘制正多边形的函数,并演示了使用moveTo()lineTo()closePath()定义子路径以及使用fill()stroke()绘制这些路径。它生成了图 15-8 中显示的图形。

js7e 1507

图 15-8. 正多边形
示例 15-5. 使用 moveTo()、lineTo()和 closePath()绘制正多边形
// Define a regular polygon with n sides, centered at (x,y) with radius r.
// The vertices are equally spaced along the circumference of a circle.
// Put the first vertex straight up or at the specified angle.
// Rotate clockwise, unless the last argument is true.
function polygon(c, n, x, y, r, angle=0, counterclockwise=false) {
    c.moveTo(x + r*Math.sin(angle),  // Begin a new subpath at the first vertex
             y - r*Math.cos(angle)); // Use trigonometry to compute position
    let delta = 2*Math.PI/n;         // Angular distance between vertices
    for(let i = 1; i < n; i++) {     // For each of the remaining vertices
        angle += counterclockwise?-delta:delta; // Adjust angle
        c.lineTo(x + r*Math.sin(angle),         // Add line to next vertex
                 y - r*Math.cos(angle));
    }
    c.closePath();                   // Connect last vertex back to the first
}

// Assume there is just one canvas, and get its context object to draw with.
let c = document.querySelector("canvas").getContext("2d");

// Start a new path and add polygon subpaths
c.beginPath();
polygon(c, 3, 50, 70, 50);                   // Triangle
polygon(c, 4, 150, 60, 50, Math.PI/4);       // Square
polygon(c, 5, 255, 55, 50);                  // Pentagon
polygon(c, 6, 365, 53, 50, Math.PI/6);       // Hexagon
polygon(c, 4, 365, 53, 20, Math.PI/4, true); // Small square inside the hexagon

// Set some properties that control how the graphics will look
c.fillStyle = "#ccc";    // Light gray interiors
c.strokeStyle = "#008";  // outlined with dark blue lines
c.lineWidth = 5;         // five pixels wide.

// Now draw all the polygons (each in its own subpath) with these calls
c.fill();                // Fill the shapes
c.stroke();              // And stroke their outlines

请注意,此示例绘制了一个六边形,内部有一个正方形。正方形和六边形是分开的子路径,但它们重叠。当发生这种情况(或者当单个子路径相交时),画布需要能够确定哪些区域在路径内部,哪些在外部。画布使用称为“非零环绕规则”的测试来实现这一点。在这种情况下,正方形的内部没有填充,因为正方形和六边形是以相反的方向绘制的:六边形的顶点是沿着圆周顺时针连接的线段。正方形的顶点是逆时针连接的。如果正方形也是顺时针绘制的,那么调用fill()将填充正方形的内部。

15.8.2 画布尺寸和坐标

<canvas>元素的widthheight属性以及 Canvas 对象的对应widthheight属性指定了画布的尺寸。默认的画布坐标系统将原点(0,0)放在画布的左上角。x坐标向右增加,y坐标向下增加。可以使用浮点值指定画布上的点。

画布的尺寸不能在不完全重置画布的情况下进行更改。设置 Canvas 的widthheight属性(即使将它们设置为当前值)都会清除画布,擦除当前路径,并将所有图形属性(包括当前变换和裁剪区域)重置为其原始状态。

画布的widthheight属性指定了画布可以绘制的实际像素数。每个像素分配了四个字节的内存,因此如果widthheight都设置为 100,画布将分配 40,000 字节来表示 10,000 个像素。

widthheight属性还指定了画布在屏幕上显示的默认大小(以 CSS 像素为单位)。如果window.devicePixelRatio为 2,则 100×100 个 CSS 像素实际上是 40,000 个硬件像素。当画布的内容绘制到屏幕上时,内存中的 10,000 个像素需要放大到覆盖屏幕上的 40,000 个物理像素,这意味着您的图形不会像它们本应该那样清晰。

为了获得最佳的图像质量,您不应该使用widthheight属性来设置画布的屏幕大小。相反,应该使用 CSS 的widthheight样式属性设置画布的所需屏幕大小的 CSS 像素大小。然后,在开始 JavaScript 代码绘制之前,将画布对象的widthheight属性设置为 CSS 像素乘以window.devicePixelRatio的数量。继续前面的例子,这种技术会导致画布显示为 100×100 个 CSS 像素,但分配内存为 200×200 个像素。(即使使用这种技术,用户也可以放大画布,如果放大,可能会看到模糊或像素化的图形。这与 SVG 图形形成对比,无论屏幕大小或缩放级别如何,SVG 图形始终保持清晰。)

15.8.3 图形属性

示例 15-5 在画布的上下文对象上设置了 fillStylestrokeStylelineWidth 属性。这些属性是指定由 fill()stroke() 使用的颜色以及由 stroke() 绘制的线条的宽度的图形属性。请注意,这些参数不是传递给 fill()stroke() 方法的,而是画布的一般 图形状态 的一部分。如果定义了一个绘制形状的方法,并且没有自己设置这些属性,那么调用该方法的调用者可以在调用方法之前通过设置 strokeStylefillStyle 属性来定义形状的颜色。图形状态与绘图命令的分离是 Canvas API 的基础,并类似于通过将 CSS 样式表应用于 HTML 文档来实现的演示与内容的分离。

画布的上下文对象上有许多属性(以及一些方法),它们会影响画布的图形状态。下面详细介绍了它们。

线条样式

lineWidth 属性指定了 stroke() 绘制的线条的宽度(以 CSS 像素为单位)。默认值为 1。重要的是要理解线条宽度是在调用 stroke() 时由 lineWidth 属性确定的,而不是在调用 lineTo() 和其他构建路径方法时确定的。要完全理解 lineWidth 属性,重要的是将路径视为无限细的一维线条。stroke() 方法绘制的线条和曲线位于路径的中心,lineWidth 的一半位于路径的两侧。如果要描边一个闭合路径,并且只希望线条出现在路径外部,先描边路径,然后用不透明颜色填充以隐藏出现在路径内部的描边部分。或者如果只希望线条出现在闭合路径内部,先调用 save()clip() 方法,然后调用 stroke()restore()。(save()restore()clip() 方法将在后面描述。)

当绘制宽度超过大约两个像素的线条时,lineCaplineJoin 属性会对路径端点的视觉外观以及两个路径段相遇的顶点产生显著影响。图 15-9 展示了 lineCaplineJoin 的值及其结果的图形外观。

js7e 1508

图 15-9. lineCap 和 lineJoin 属性

lineCap 的默认值为“butt”。lineJoin 的默认值为“miter”。但是,请注意,如果两条线以非常狭窄的角度相交,则结果的斜接可能会变得非常长并且在视觉上会分散注意力。如果给定顶点处的斜接长度超过线宽的一半乘以 miterLimit 属性的值,那么该顶点将以斜角连接而不是斜接连接绘制。miterLimit 的默认值为 10。

stroke() 方法可以绘制虚线、点线以及实线,画布的图形状态包括一个作为“虚线模式”的数字数组,指定要绘制多少像素,然后要省略多少像素。与其他线条绘制属性不同,虚线模式是使用 setLineDash()getLineDash() 方法设置和查询的,而不是使用属性。要指定一个点线模式,可以像这样使用 setLineDash()

c.setLineDash([18, 3, 3, 3]); // 18px dash, 3px space, 3px dot, 3px space

最后,lineDashOffset 属性指定了从哪里开始绘制虚线模式。默认值为 0。使用这里显示的虚线模式描绘的路径以一个 18 像素的虚线开始,但如果将 lineDashOffset 设置为 21,则相同的路径将以一个点开始,然后是一个空格和一个虚线。

颜色、图案和渐变

fillStylestrokeStyle属性指定如何填充和描边路径。单词“style”通常表示颜色,但这些属性也可用于指定颜色渐变或用于填充和描边的图像。(请注意,绘制线基本上与在线两侧填充一个窄区域相同,填充和描边本质上是相同的操作。)

如果要使用纯色(或半透明颜色)进行填充或描边,只需将这些属性设置为有效的 CSS 颜色字符串即可。不需要其他操作。

要使用颜色渐变进行填充(或描边),将fillStyle(或strokeStyle)设置为上下文的createLinearGradient()createRadialGradient()方法返回的 CanvasGradient 对象。createLinearGradient()的参数是定义颜色沿其变化的线的两点的坐标(它不需要是水平或垂直的)。createRadialGradient()的参数指定两个圆的中心和半径。(它们不需要同心,但第一个圆通常完全位于第二个圆内部。)小圆内部或大圆外部的区域将填充为纯色;两者之间的区域将填充为颜色渐变。

创建定义将填充画布区域的 CanvasGradient 对象后,必须通过调用 CanvasGradient 的addColorStop()方法来定义渐变颜色。该方法的第一个参数是介于 0.0 和 1.0 之间的数字。第二个参数是 CSS 颜色规范。您必须至少调用此方法两次来定义简单的颜色渐变,但可以调用多次。0.0 处的颜色将出现在渐变的起始处,而 1.0 处的颜色将出现在结束处。如果指定了其他颜色,它们将出现在渐变中指定的分数位置。在您指定的点之间,颜色将平滑插值。以下是一些示例:

// A linear gradient, diagonally across the canvas (assuming no transforms)
let bgfade = c.createLinearGradient(0,0,canvas.width,canvas.height);
bgfade.addColorStop(0.0, "#88f");  // Start with light blue in upper left
bgfade.addColorStop(1.0, "#fff");  // Fade to white in lower right

// A gradient between two concentric circles. Transparent in the middle
// fading to translucent gray and then back to transparent.
let donut = c.createRadialGradient(300,300,100, 300,300,300);
donut.addColorStop(0.0, "transparent");           // Transparent
donut.addColorStop(0.7, "rgba(100,100,100,.9)");  // Translucent gray
donut.addColorStop(1.0, "rgba(0,0,0,0)");         // Transparent again

关于渐变的一个重要点是,它们不是位置无关的。创建渐变时,您为渐变指定边界。如果您尝试填充超出这些边界的区域,您将得到渐变的一端或另一端定义的纯色。

除了颜色和颜色渐变外,您还可以使用图像进行填充和描边。要实现这一点,将fillStylestrokeStyle设置为上下文对象的createPattern()方法返回的 CanvasPattern。该方法的第一个参数应为包含您要填充或描边的图像的<img><canvas>元素。(请注意,源图像或画布不需要插入文档中才能以这种方式使用。)createPattern()的第二个参数是字符串“repeat”,“repeat-x”,“repeat-y”或“no-repeat”,指定背景图像是否(以及在哪些维度上)重复。

文本样式

font属性指定文本绘制方法fillText()strokeText()使用的字体(请参阅“文本”)。font属性的值应为与 CSS font属性相同语法的字符串。

textAlign属性指定文本在调用fillText()strokeText()时相对于传递给 X 坐标的水平对齐方式。合法值为“start”,“left”,“center”,“right”和“end”。默认值为“start”,对于从左到右的文本,其含义与“left”相同。

textBaseline属性指定文本在y坐标上如何与垂直对齐。默认值为“alphabetic”,适用于拉丁文和类似脚本。值“ideographic”适用于中文和日文等脚本。值“hanging”适用于梵文和类似脚本(用于印度许多语言)。“top”、“middle”和“bottom”基线纯粹是几何基线,基于字体的“em 方块”。

阴影

上下文对象的四个属性控制阴影的绘制。如果适当设置这些属性,你绘制的任何线条、区域、文本或图像都将产生阴影,使其看起来好像漂浮在画布表面之上。

shadowColor属性指定阴影的颜色。默认为完全透明的黑色,除非将此属性设置为半透明或不透明颜色,否则阴影将不会出现。此属性只能设置为颜色字符串:不允许使用图案和渐变来创建阴影。使用半透明阴影颜色会产生最逼真的阴影效果,因为它允许背景透过阴影显示出来。

shadowOffsetXshadowOffsetY属性指定阴影的 X 和 Y 偏移量。两个属性的默认值都为 0,将阴影直接放在你的绘图下方,看不见。如果将这两个属性都设置为正值,阴影将出现在你绘制的下方和右侧,就好像有一个光源在屏幕外部的左上方映射到画布上。较大的偏移量会产生更大的阴影,并使绘制的对象看起来好像漂浮在画布上方。这些值不受坐标变换的影响(§15.8.5):阴影方向和“高度”保持一致,即使形状被旋转和缩放。

shadowBlur属性指定阴影边缘的模糊程度。默认值为 0,产生清晰、未模糊的阴影。较大的值会产生更多模糊,直到达到一个实现定义的上限。

半透明和合成

如果你想使用半透明颜色描边或填充路径,可以使用支持 alpha 透明度的 CSS 颜色语法,如“rgba(…)”来设置strokeStylefillStyle。 “RGBA”中的“a”代表“alpha”,取值范围在 0(完全透明)和 1(完全不透明)之间。但 Canvas API 提供了另一种处理半透明颜色的方式。如果你不想为每种颜色显式指定 alpha 通道,或者想要向不透明图像或图案添加半透明度,可以设置globalAlpha属性。你绘制的每个像素的 alpha 值都将乘以globalAlpha。默认值为 1,不添加透明度。如果将globalAlpha设置为 0,则绘制的所有内容将完全透明,画布上将不会显示任何内容。但如果将此属性设置为 0.5,则原本不透明的像素将变为 50% 不透明,原本 50% 不透明的像素将变为 25% 不透明。

当你描边线条、填充区域、绘制文本或复制图像时,通常期望新像素绘制在已经存在于画布中的像素之上。如果绘制的是不透明像素,它们将简单地替换已经存在的像素。如果绘制的是半透明像素,则新的(“源”)像素将与旧的(“目标”)像素结合,使旧像素透过新像素显示出来,透明度取决于该像素的透明度。

将新的(可能是半透明的)源像素与现有的(可能是半透明的)目标像素组合的过程称为合成,先前描述的合成过程是 Canvas API 结合像素的默认方式。但是,您可以设置globalCompositeOperation属性以指定其他组合像素的方式。默认值是“source-over”,这意味着源像素被绘制在目标像素“上方”,如果源是半透明的,则与目标像素组合。但是,如果将globalCompositeOperation设置为“destination-over”,则画布将像新的源像素被绘制在现有目标像素下方一样组合像素。如果目标是半透明或透明的,则结果颜色中的一些或全部源像素颜色是可见的。作为另一个示例,合成模式“source-atop”将源像素与目标像素的透明度组合,以便在已完全透明的画布部分上不绘制任何内容。globalCompositeOperation有许多合法值,但大多数只有专门用途,这里里不涵盖。

保存和恢复图形状态

由于 Canvas API 在上下文对象上定义了图形属性,您可能会尝试多次调用getContext()以获取多个上下文对象。如果可以这样做,您可以在每个上下文中定义不同的属性:每个上下文将像不同的画笔一样,可以使用不同的颜色绘制或绘制不同宽度的线条。不幸的是,您不能以这种方式使用画布。每个<canvas>元素只有一个上下文对象,每次调用getContext()都会返回相同的 CanvasRenderingContext2D 对象。

尽管 Canvas API 只允许您一次定义一组图形属性,但它允许您保存当前的图形状态,以便稍后可以更改它并轻松地恢复它。save()方法将当前的图形状态推送到保存状态的堆栈上。restore()方法弹出堆栈并恢复最近保存的状态。本节中描述的所有属性都是保存状态的一部分,当前的变换和裁剪区域也是如此(稍后将对两者进行解释)。重要的是,当前定义的路径和当前点不是图形状态的一部分,不能保存和恢复。

15.8.4 画布绘图操作

我们已经看到了一些基本的画布方法——beginPath()moveTo()lineTo()closePath()fill()stroke()——用于定义、填充和绘制线条和多边形。但 Canvas API 还包括其他绘图方法。

矩形

CanvasRenderingContext2D 定义了四种绘制矩形的方法。这四种矩形方法都需要两个参数,指定矩形的一个角,然后是矩形的宽度和高度。通常,您指定左上角,然后传递正宽度和正高度,但也可以指定其他角并传递负尺寸。

fillRect() 使用当前的fillStyle填充指定的矩形。strokeRect() 使用当前的strokeStyle和其他线条属性描绘指定矩形的轮廓。clearRect() 类似于fillRect(),但它忽略当前的填充样式,并用透明黑色像素(所有空画布的默认颜色)填充矩形。这三种方法的重要之处在于它们不会影响当前路径或路径中的当前点。

最后一个矩形方法被命名为rect(),它会影响当前路径:它将指定的矩形添加到路径的子路径中。与其他定义路径方法一样,它本身不填充或描边任何内容。

曲线

路径是子路径的序列,子路径是连接点的序列。在我们在§15.8.1 中定义的路径中,这些点是用直线段连接的,但这并不总是这样。CanvasRenderingContext2D 对象定义了许多方法,这些方法向子路径添加一个新点,并使用曲线将当前点连接到该新点:

arc()

这种方法向路径中添加一个圆或圆的一部分(弧)。要绘制的圆弧由六个参数指定:圆的中心的xy坐标,圆的半径,圆弧的起始和结束角度,以及这两个角度之间的圆弧的方向(顺时针或逆时针)。如果路径中有当前点,则此方法将当前点与圆弧的起始点用一条直线连接(在绘制楔形或饼状图时很有用),然后将圆弧的起始点与圆弧的结束点用一部分圆连接起来,将圆弧的结束点作为新的当前点。如果在调用此方法时没有当前点,则它只会将圆弧添加到路径中。

ellipse()

这种方法与arc()非常相似,但它向路径中添加一个椭圆或椭圆的一部分。它有两个半径而不是一个:一个x轴半径和一个y轴半径。此外,由于椭圆不是径向对称的,因此此方法需要另一个参数,指定椭圆围绕其中心顺时针旋转的弧度数。

arcTo()

这种方法绘制一条直线和一个圆弧,就像arc()方法一样,但它使用不同的参数指定要绘制的圆弧。arcTo()的参数指定了点 P1 和 P2 以及半径。添加到路径中的圆弧具有指定的半径。它从当前点到 P1 的切线点开始,并在 P1 和 P2 之间的(虚拟)线的切线点结束。这种看似不寻常的指定圆弧的方法实际上非常有用,用于绘制具有圆角的形状。如果指定半径为 0,此方法只会从当前点画一条直线到 P1。然而,如果半径不为零,则它会从当前点沿着 P1 的方向画一条直线,然后将该线围绕成一个圆,直到指向 P2 的方向。

bezierCurveTo()

这种方法向子路径添加一个新点 P,并使用三次贝塞尔曲线将其连接到当前点。曲线的形状由两个“控制点”C1 和 C2 指定。在曲线的起始点(当前点处),曲线朝向 C1 的方向。在曲线的结束点(点 P 处),曲线从 C2 的方向到达。在这些点之间,曲线的方向平滑变化。点 P 成为子路径的新当前点。

quadraticCurveTo()

这种方法类似于bezierCurveTo(),但它使用二次贝塞尔曲线而不是三次贝塞尔曲线,并且只有一个控制点。

您可以使用这些方法绘制类似于图 15-10 中的路径。

js7e 1509

图 15-10。画布中的曲线路径

示例 15-6 显示了用于创建图 15-10 的代码。此代码中演示的方法是 Canvas API 中最复杂的方法之一;请参考在线参考资料以获取有关这些方法及其参数的完整详细信息。

示例 15-6。向路径添加曲线
// A utility function to convert angles from degrees to radians
function rads(x) { return Math.PI*x/180; }

// Get the context object of the document's canvas element
let c = document.querySelector("canvas").getContext("2d");

// Define some graphics attributes and draw the curves
c.fillStyle = "#aaa";     // Gray fills
c.lineWidth = 2;          // 2-pixel black (by default) lines

// Draw a circle.
// There is no current point, so draw just the circle with no straight
// line from the current point to the start of the circle.
c.beginPath();
c.arc(75,100,50,          // Center at (75,100), radius 50
      0,rads(360),false); // Go clockwise from 0 to 360 degrees
c.fill();                 // Fill the circle
c.stroke();               // Stroke its outline.

// Now draw an ellipse in the same way
c.beginPath();            // Start new path not connected to the circle
c.ellipse(200, 100, 50, 35, rads(15),  // Center, radii, and rotation
          0, rads(360), false);        // Start angle, end angle, direction

// Draw a wedge. Angles are measured clockwise from the positive x axis.
// Note that arc() adds a line from the current point to the arc start.
c.moveTo(325, 100);       // Start at the center of the circle.
c.arc(325, 100, 50,       // Circle center and radius
      rads(-60), rads(0), // Start at angle -60 and go to angle 0
      true);              // counterclockwise
c.closePath();            // Add radius back to the center of the circle

// Similar wedge, offset a bit, and in the opposite direction
c.moveTo(340, 92);
c.arc(340, 92, 42, rads(-60), rads(0), false);
c.closePath();

// Use arcTo() for rounded corners. Here we draw a square with
// upper left corner at (400,50) and corners of varying radii.
c.moveTo(450, 50);           // Begin in the middle of the top edge.
c.arcTo(500,50,500,150,30);  // Add part of top edge and upper right corner.
c.arcTo(500,150,400,150,20); // Add right edge and lower right corner.
c.arcTo(400,150,400,50,10);  // Add bottom edge and lower left corner.
c.arcTo(400,50,500,50,0);    // Add left edge and upper left corner.
c.closePath();               // Close path to add the rest of the top edge.

// Quadratic Bezier curve: one control point
c.moveTo(525, 125);                      // Begin here
c.quadraticCurveTo(550, 75, 625, 125);   // Draw a curve to (625, 125)
c.fillRect(550-3, 75-3, 6, 6);           // Mark the control point (550,75)

// Cubic Bezier curve
c.moveTo(625, 100);                      // Start at (625, 100)
c.bezierCurveTo(645,70,705,130,725,100); // Curve to (725, 100)
c.fillRect(645-3, 70-3, 6, 6);           // Mark control points
c.fillRect(705-3, 130-3, 6, 6);

// Finally, fill the curves and stroke their outlines.
c.fill();
c.stroke();

文本

要在画布中绘制文本,通常使用fillText()方法,该方法使用fillStyle属性指定的颜色(或渐变或图案)绘制文本。对于大文本尺寸的特殊效果,可以使用strokeText()绘制单个字体字形的轮廓。这两种方法的第一个参数是要绘制的文本,第二个和第三个参数是文本的xy坐标。这两种方法都不会影响当前路径或当前点。

fillText()strokeText()接受一个可选的第四个参数。如果提供了这个参数,则指定要显示的文本的最大宽度。如果使用font属性绘制的文本宽度超过指定值,画布将通过缩放或使用更窄或更小的字体来适应它。

如果需要在绘制文本之前自行测量文本,请将其传递给measureText()方法。该方法返回一个指定使用当前font绘制时文本测量的 TextMetrics 对象。在撰写本文时,TextMetrics对象中唯一包含的“度量”是宽度。像这样查询字符串的屏幕宽度:

let width = c.measureText(text).width;

如果您想在画布中居中显示一串文本,这将非常有用。

图像

除了矢量图形(路径、线条等)外,Canvas API 还支持位图图像。drawImage()方法将源图像的像素(或源图像内的矩形)复制到画布上,并根据需要对图像的像素进行缩放和旋转。

drawImage()可以使用三、五或九个参数调用。在所有情况下,第一个参数都是要复制像素的源图像。这个图像参数通常是一个<img>元素,但也可以是另一个<canvas>元素,甚至是一个<video>元素(从中将复制一帧)。如果指定的<img><video>元素仍在加载数据,则drawImage()调用将不起作用。

drawImage()的三参数版本中,第二个和第三个参数指定要绘制图像左上角的xy坐标。在此方法的版本中,整个源图像都会被复制到画布上。xy坐标在当前坐标系中解释,并且根据当前生效的画布变换,必要时会对图像进行缩放和旋转。

drawImage()的五参数版本在前述的xy参数中添加了widthheight参数。这四个参数定义了画布内的目标矩形。源图像的左上角位于(x,y),右下角位于(x+width, y+height)。同样,整个源图像都会被复制。使用此方法的版本,源图像将被缩放以适应目标矩形。

drawImage()的九参数版本同时指定源矩形和目标矩形,并仅复制源矩形内的像素。第二至第五个参数指定源矩形,它们以 CSS 像素为单位。如果源图像是另一个画布,则源矩形使用该画布的默认坐标系,并忽略已指定的任何变换。第六至第九个参数指定将绘制图像的目标矩形,并且以画布的当前坐标系而不是默认坐标系为准。

除了将图像绘制到画布中,我们还可以使用 toDataURL() 方法将画布的内容提取为图像。与这里描述的所有其他方法不同,toDataURL() 是 Canvas 元素本身的方法,而不是上下文对象的方法。通常不带参数调用 toDataURL(),它会将画布的内容作为 PNG 图像编码为字符串返回,使用 data: URL。返回的 URL 适用于 <img> 元素的使用,您可以使用类似以下代码对画布进行静态快照:

let img = document.createElement("img");  // Create an <img> element
img.src = canvas.toDataURL();             // Set its src attribute
document.body.appendChild(img);           // Append it to the document

15.8.5 坐标系变换

正如我们所指出的,画布的默认坐标系将原点放在左上角,x 坐标向右增加,y 坐标向下增加。在此默认系统中,点的坐标直接映射到 CSS 像素(然后直接映射到一个或多个设备像素)。某些画布操作和属性(例如提取原始像素值和设置阴影偏移)始终使用此默认坐标系。除了默认坐标系外,每个画布还有一个“当前变换矩阵”作为其图形状态的一部分。该矩阵定义了画布的当前坐标系。在大多数画布操作中,当您指定点的坐标时,它被视为当前坐标系中的点,而不是默认坐标系中的点。当前变换矩阵用于将您指定的坐标转换为默认坐标系中的等效坐标。

setTransform() 方法允许您直接设置画布的变换矩阵,但坐标系变换通常更容易指定为一系列平移、旋转和缩放操作。图 15-11 说明了这些操作及其对画布坐标系的影响。生成该图的程序连续七次绘制了相同的坐标轴。每次变化的唯一事物是当前变换。请注意,变换不仅影响绘制的线条,还影响文本。

js7e 1510

图 15-11. 坐标系变换

translate() 方法简单地将坐标系的原点向左、向右、向上或向下移动。rotate() 方法按指定角度顺时针旋转坐标轴。(Canvas API 总是用弧度指定角度。要将度数转换为弧度,除以 180 并乘以 Math.PI。)scale() 方法沿着 xy 轴拉伸或收缩距离。

将负的比例因子传递给 scale() 方法会使该轴在原点处翻转,就像在镜子中反射一样。这就是在 图 15-11 的左下角所做的事情:translate() 用于将原点移动到画布的左下角,然后 scale() 用于翻转 y 轴,使得随着页面向上移动,y 坐标增加。这样的翻转坐标系在代数课上很常见,可能对绘制图表上的数据点有用。但请注意,这会使文本难以阅读!

数学上理解变换

我发现最容易理解变换的方法是几何上的,将 translate()rotate()scale() 视为转换坐标系的轴,如 图 15-11 所示。也可以将变换理解为代数方程,这些方程将变换后坐标系中点 (x,y) 的坐标映射回先前坐标系中相同点 (x',y') 的坐标。

方法调用 c.translate(dx,dy) 可以用以下方程描述:

x' = x + dx;  // An X coordinate of 0 in the new system is dx in the old
y' = y + dy;

缩放操作有类似简单的方程。调用 c.scale(sx,sy) 可以描述如下:

x' = sx * x;
y' = sy * y;

旋转更加复杂。调用 c.rotate(a) 由以下三角函数方程描述:

x' = x * cos(a) - y * sin(a);
y' = y * cos(a) + x * sin(a);

注意变换的顺序很重要。 假设我们从画布的默认坐标系开始,然后将其平移,然后缩放。 为了将当前坐标系中的点(x,y)映射回默认坐标系中的点(x'',y''),我们必须首先应用缩放方程将点映射到平移但未缩放的坐标系中的中间点(x',y'),然后使用平移方程从这个中间点映射到(x'',y'')。 结果如下:

x'' = sx*x + dx;
y'' = sy*y + dy;

另一方面,如果我们在调用translate()之前调用了scale(),则得到的方程将不同:

x'' = sx*(x + dx);
y'' = sy*(y + dy);

在代数上考虑变换序列时,要记住的关键是必须从最后(最近)的变换向前工作到第一个。 然而,在几何上考虑变换的轴时,您从第一个变换向最后一个变换工作。

画布支持的变换称为仿射变换。 仿射变换可以修改点之间的距离和线之间的角度,但平行线在仿射变换后始终保持平行——例如,不可能用仿射变换指定鱼眼镜头畸变。 任意仿射变换可以用这些方程中的六个参数af来描述:

x' = ax + cy + e
y' = bx + dy + f

您可以通过将这六个参数传递给transform()方法,对当前坐标系应用任意变换。 图 15-11 展示了两种类型的变换——倾斜和围绕指定点旋转——您可以像这样使用transform()方法实现:

// Shear transform:
//   x' = x + kx*y;
//   y' = ky*x + y;
function shear(c, kx, ky) { c.transform(1, ky, kx, 1, 0, 0); }

// Rotate theta radians counterclockwise around the point (x,y)
// This can also be accomplished with a translate, rotate, translate sequence
function rotateAbout(c, theta, x, y) {
    let ct = Math.cos(theta);
    let st = Math.sin(theta);
    c.transform(ct, -st, st, ct, -x*ct-y*st+x, x*st-y*ct+y);
}

setTransform()方法接受与transform()相同的参数,但是不是转换当前坐标系,而是忽略当前系统,转换默认坐标系,并使结果成为新的当前坐标系。 setTransform()对于临时将画布重置为其默认坐标系很有用:

c.save();                      // Save current coordinate system
c.setTransform(1,0,0,1,0,0);   // Revert to the default coordinate system
// Perform operations using default CSS pixel coordinates
c.restore();                   // Restore the saved coordinate system

变换示例

示例 15-7 通过递归使用translate()rotate()scale()方法来绘制科赫雪花分形图,展示了坐标系变换的强大功能。 此示例的输出显示在图 15-12 中,显示了具有 0、1、2、3 和 4 个递归级别的科赫雪花。

js7e 1511

图 15-12. 科赫雪花

生成这些图形的代码很简洁,但其使用递归坐标系变换使其有些难以理解。 即使您不理解所有细微之处,也请注意代码中仅包含一次对lineTo()方法的调用。 图 15-12 中的每个线段都是这样绘制的:

c.lineTo(len, 0);

变量len的值在程序执行过程中不会改变,因此每个线段的位置、方向和长度由平移、旋转和缩放操作确定。

示例 15-7. 具有变换的科赫雪花
let deg = Math.PI/180;  // For converting degrees to radians

// Draw a level-n Koch snowflake fractal on the canvas context c,
// with lower-left corner at (x,y) and side length len.
function snowflake(c, n, x, y, len) {
    c.save();           // Save current transformation
    c.translate(x,y);   // Translate origin to starting point
    c.moveTo(0,0);      // Begin a new subpath at the new origin
    leg(n);             // Draw the first leg of the snowflake
    c.rotate(-120*deg); // Now rotate 120 degrees counterclockwise
    leg(n);             // Draw the second leg
    c.rotate(-120*deg); // Rotate again
    leg(n);             // Draw the final leg
    c.closePath();      // Close the subpath
    c.restore();        // And restore original transformation

    // Draw a single leg of a level-n Koch snowflake.
    // This function leaves the current point at the end of the leg it has
    // drawn and translates the coordinate system so the current point is (0,0).
    // This means you can easily call rotate() after drawing a leg.
    function leg(n) {
        c.save();               // Save the current transformation
        if (n === 0) {          // Nonrecursive case:
            c.lineTo(len, 0);   //   Just draw a horizontal line
        }                       //                                       _  _
        else {                  // Recursive case: draw 4 sub-legs like:  \/
            c.scale(1/3,1/3);   // Sub-legs are 1/3 the size of this leg
            leg(n-1);           // Recurse for the first sub-leg
            c.rotate(60*deg);   // Turn 60 degrees clockwise
            leg(n-1);           // Second sub-leg
            c.rotate(-120*deg); // Rotate 120 degrees back
            leg(n-1);           // Third sub-leg
            c.rotate(60*deg);   // Rotate back to our original heading
            leg(n-1);           // Final sub-leg
        }
        c.restore();            // Restore the transformation
        c.translate(len, 0);    // But translate to make end of leg (0,0)
    }
}

let c = document.querySelector("canvas").getContext("2d");
snowflake(c, 0, 25, 125, 125);  // A level-0 snowflake is a triangle
snowflake(c, 1, 175, 125, 125); // A level-1 snowflake is a 6-sided star
snowflake(c, 2, 325, 125, 125); // etc.
snowflake(c, 3, 475, 125, 125);
snowflake(c, 4, 625, 125, 125); // A level-4 snowflake looks like a snowflake!
c.stroke();                     // Stroke this very complicated path

15.8.6 裁剪

定义路径后,通常会调用stroke()fill()(或两者)。 您还可以调用clip()方法来定义裁剪区域。 一旦定义了裁剪区域,就不会在其外部绘制任何内容。 图 15-13 展示了使用裁剪区域生成的复杂图形。 图中垂直条纹沿中间运行,底部的文本是在定义三角形裁剪区域之后未裁剪的描边,然后填充的。

js7e 1512

图 15-13. 未裁剪的笔画和裁剪的填充

图 15-13 是使用示例 15-5 的polygon()方法和以下代码生成的:

// Define some drawing attributes
c.font = "bold 60pt sans-serif";    // Big font
c.lineWidth = 2;                    // Narrow lines
c.strokeStyle = "#000";             // Black lines

// Outline a rectangle and some text
c.strokeRect(175, 25, 50, 325);     // A vertical stripe down the middle
c.strokeText("<canvas>", 15, 330);  // Note strokeText() instead of fillText()

// Define a complex path with an interior that is outside.
polygon(c,3,200,225,200);           // Large triangle
polygon(c,3,200,225,100,0,true);    // Smaller reverse triangle inside

// Make that path the clipping region.
c.clip();

// Stroke the path with a 5 pixel line, entirely inside the clipping region.
c.lineWidth = 10;       // Half of this 10 pixel line will be clipped away
c.stroke();

// Fill the parts of the rectangle and text that are inside the clipping region
c.fillStyle = "#aaa";             // Light gray
c.fillRect(175, 25, 50, 325);     // Fill the vertical stripe
c.fillStyle = "#888";             // Darker gray
c.fillText("<canvas>", 15, 330);  // Fill the text

需要注意的是,当你调用clip()时,当前路径本身会被剪切到当前剪切区域,然后被剪切的路径成为新的剪切区域。这意味着clip()方法可以缩小剪切区域,但不能扩大它。没有方法可以重置剪切区域,因此在调用clip()之前,通常应该调用save(),这样以后就可以restore()未剪切的区域。

15.8.7 像素处理

getImageData()方法返回一个表示画布矩形区域的原始像素(作为 R、G、B 和 A 分量)的 ImageData 对象。您可以使用createImageData()创建空的ImageData对象。ImageData 对象中的像素是可写的,因此您可以按照自己的方式设置它们,然后使用putImageData()将这些像素复制回画布。

这些像素处理方法提供了对画布的非常低级访问。您传递给getImageData()的矩形位于默认坐标系统中:其尺寸以 CSS 像素为单位,不受当前变换的影响。当您调用putImageData()时,您指定的位置也是以默认坐标系统中的尺寸来衡量的。此外,putImageData()忽略所有图形属性。它不执行任何合成,不将像素乘以globalAlpha,也不绘制阴影。

像素处理方法对于实现图像处理非常有用。示例 15-8 展示了如何创建一个简单的运动模糊或“涂抹”效果,就像图 15-14 中显示的那样。

js7e 1513

图 15-14. 通过图像处理创建的运动模糊效果

以下代码演示了getImageData()putImageData(),并展示了如何迭代并修改 ImageData 对象中的像素值。

示例 15-8. 使用 ImageData 进行运动模糊
// Smear the pixels of the rectangle to the right, producing a
// sort of motion blur as if objects are moving from right to left.
// n must be 2 or larger. Larger values produce bigger smears.
// The rectangle is specified in the default coordinate system.
function smear(c, n, x, y, w, h) {
    // Get the ImageData object that represents the rectangle of pixels to smear
    let pixels = c.getImageData(x, y, w, h);

    // This smear is done in-place and requires only the source ImageData.
    // Some image processing algorithms require an additional ImageData to
    // store transformed pixel values. If we needed an output buffer, we could
    // create a new ImageData with the same dimensions like this:
    //   let output_pixels = c.createImageData(pixels);

    // Get the dimensions of the grid of pixels in the ImageData object
    let width = pixels.width, height = pixels.height;

    // This is the byte array that holds the raw pixel data, left-to-right and
    // top-to-bottom. Each pixel occupies 4 consecutive bytes in R,G,B,A order.
    let data = pixels.data;

    // Each pixel after the first in each row is smeared by replacing it with
    // 1/nth of its own value plus m/nths of the previous pixel's value
    let m = n-1;

    for(let row = 0; row < height; row++) {  // For each row
        let i = row*width*4 + 4;  // The offset of the second pixel of the row
        for(let col = 1; col < width; col++, i += 4) { // For each column
            data[i] =   (data[i] + data[i-4]*m)/n;     // Red pixel component
            data[i+1] = (data[i+1] + data[i-3]*m)/n;   // Green
            data[i+2] = (data[i+2] + data[i-2]*m)/n;   // Blue
            data[i+3] = (data[i+3] + data[i-1]*m)/n;   // Alpha component
        }
    }

    // Now copy the smeared image data back to the same position on the canvas
    c.putImageData(pixels, x, y);
}

15.9 音频 API

HTML <audio><video>标签允许您轻松地在网页中包含声音和视频。这些是具有重要 API 和复杂用户界面的复杂元素。您可以使用play()pause()方法控制媒体播放。您可以设置volumeplaybackRate属性来控制音频音量和播放速度。您可以通过设置currentTime属性跳转到媒体中的特定时间。

我们不会在这里进一步详细介绍<audio><video>标签。以下小节演示了两种向网页添加脚本化声音效果的方法。

15.9.1 Audio()构造函数

您不必在 HTML 文档中包含<audio>标签以在网页中包含声音效果。您可以使用普通的 DOMdocument.createElement()方法动态创建<audio>元素,或者作为快捷方式,您可以简单地使用Audio()构造函数。您不必将创建的元素添加到文档中才能播放它。您只需调用其play()方法即可:

// Load the sound effect in advance so it is ready for use
let soundeffect = new Audio("soundeffect.mp3");

// Play the sound effect whenever the user clicks the mouse button
document.addEventListener("click", () => {
    soundeffect.cloneNode().play(); // Load and play the sound
});

注意这里使用了cloneNode()。如果用户快速点击鼠标,我们希望能够同时播放多个重叠的声音效果副本。为了做到这一点,我们需要多个音频元素。因为音频元素没有添加到文档中,所以当它们播放完毕时会被垃圾回收。

15.9.2 WebAudio API

除了使用 Audio 元素播放录制的声音外,Web 浏览器还允许使用 WebAudio API 生成和播放合成声音。使用 WebAudio API 就像连接旧式电子合成器的插线一样。使用 WebAudio,您创建一组 AudioNode 对象,表示波形的源、变换或目的地,然后将这些节点连接到一个网络中以产生声音。API 并不特别复杂,但要全面解释需要理解超出本书范围的电子音乐和信号处理概念。

下面的代码示例使用 WebAudio API 合成一个在大约一秒钟内淡出的短和弦。这个示例演示了 WebAudio API 的基础知识。如果你对此感兴趣,你可以在网上找到更多关于这个 API 的信息:

// Begin by creating an audioContext object. Safari still requires
// us to use webkitAudioContext instead of AudioContext.
let audioContext = new (this.AudioContext||this.webkitAudioContext)();

// Define the base sound as a combination of three pure sine waves
let notes = [ 293.7, 370.0, 440.0 ]; // D major chord: D, F# and A

// Create oscillator nodes for each of the notes we want to play
let oscillators = notes.map(note => {
    let o = audioContext.createOscillator();
    o.frequency.value = note;
    return o;
});

// Shape the sound by controlling its volume over time.
// Starting at time 0 quickly ramp up to full volume.
// Then starting at time 0.1 slowly ramp down to 0.
let volumeControl = audioContext.createGain();
volumeControl.gain.setTargetAtTime(1, 0.0, 0.02);
volumeControl.gain.setTargetAtTime(0, 0.1, 0.2);

// We're going to send the sound to the default destination:
// the user's speakers
let speakers = audioContext.destination;

// Connect each of the source notes to the volume control
oscillators.forEach(o => o.connect(volumeControl));

// And connect the output of the volume control to the speakers.
volumeControl.connect(speakers);

// Now start playing the sounds and let them run for 1.25 seconds.
let startTime = audioContext.currentTime;
let stopTime = startTime + 1.25;
oscillators.forEach(o => {
    o.start(startTime);
    o.stop(stopTime);
});

// If we want to create a sequence of sounds we can use event handlers
oscillators[0].addEventListener("ended", () => {
    // This event handler is invoked when the note stops playing
});

15.10 位置、导航和历史

location属性既可以用于 Window 对象,也可以用于 Document 对象,它指的是 Location 对象,代表着窗口中显示的文档的当前 URL,并提供了一个 API 用于在窗口中加载新文档。

Location 对象非常类似于 URL 对象(§11.9),你可以使用protocolhostnameportpath等属性来访问当前文档的 URL 的各个部分。href属性返回整个 URL 作为字符串,toString()方法也是如此。

Location 对象的hashsearch属性是比较有趣的。hash属性返回 URL 中的“片段标识符”部分,如果有的话:一个井号(#)后跟一个元素 ID。search属性类似。它返回以问号开头的 URL 部分:通常是某种查询字符串。一般来说,URL 的这部分用于对 URL 进行参数化,并提供了一种在其中嵌入参数的方式。虽然这些参数通常是为在服务器上运行的脚本而设计的,但也可以在启用 JavaScript 的页面中使用。

URL 对象有一个searchParams属性,它是search属性的解析表示。Location 对象没有searchParams属性,但如果你想解析window.location.search,你可以简单地从 Location 对象创建一个 URL 对象,然后使用 URL 的searchParams

let url = new URL(window.location);
let query = url.searchParams.get("q");
let numResults = parseInt(url.searchParams.get("n") || "10");

除了可以引用为window.locationdocument.location的 Location 对象,以及我们之前使用的URL()构造函数,浏览器还定义了一个document.URL属性。令人惊讶的是,这个属性的值不是一个 URL 对象,而只是一个字符串。该字符串保存当前文档的 URL。

15.10.1 加载新文档

如果你将一个字符串分配给window.locationdocument.location,那么该字符串会被解释为一个 URL,浏览器会加载它,用新文档替换当前文档:

window.location = "http://www.oreilly.com"; // Go buy some books!

你也可以将相对 URL 分配给location。它们相对于当前 URL 解析:

document.location = "page2.html";           // Load the next page

一个裸的片段标识符是一种特殊类型的相对 URL,它不会导致浏览器加载新文档,而只是滚动,以使具有与片段匹配的idname的文档元素在浏览器窗口顶部可见。作为一个特例,片段标识符#top会使浏览器跳转到文档的开头(假设没有元素具有id="top"属性):

location = "#top";                          // Jump to the top of the document

Location 对象的各个属性都是可写的,设置它们会改变位置 URL,并导致浏览器加载一个新文档(或者在hash属性的情况下,在当前文档内导航):

document.location.path = "pages/3.html"; // Load a new page
document.location.hash = "TOC";          // Scroll to the table of contents
location.search = "?page=" + (page+1);   // Reload with new query string

你也可以通过向 Location 对象的assign()方法传递一个新字符串来加载新页面。这与将字符串分配给location属性相同,因此并不特别有趣。

另一方面,Location 对象的replace()方法非常有用。当你向replace()传递一个字符串时,它被解释为一个 URL,并导致浏览器加载一个新页面,就像assign()一样。不同之处在于replace()替换了浏览器历史中的当前文档。如果文档 A 中的脚本设置location属性或调用assign()加载文档 B,然后用户点击返回按钮,浏览器将返回到文档 A。如果你使用replace(),那么文档 A 将从浏览器历史中删除,当用户点击返回按钮时,浏览器将返回到在显示文档 A 之前显示的文档。

当脚本无条件加载新文档时,replace() 方法比 assign() 更好。否则,点击返回按钮会将浏览器返回到原始文档,并且同样的脚本会再次加载新文档。假设你有一个使用 JavaScript 增强的页面版本和一个不使用 JavaScript 的静态版本。如果确定用户的浏览器不支持你想要使用的 Web 平台 API,你可以使用 location.replace() 来加载静态版本:

// If the browser does not support the JavaScript APIs we need,
// redirect to a static page that does not use JavaScript.
if (!isBrowserSupported()) location.replace("staticpage.html");

注意,传递给 replace() 的 URL 是相对的。相对 URL 被解释为相对于其出现的页面,就像它们在超链接中使用时一样。

除了 assign()replace() 方法,Location 对象还定义了 reload(),它简单地使浏览器重新加载文档。

15.10.2 浏览历史

Window 对象的 history 属性指的是窗口的 History 对象。History 对象将窗口的浏览历史建模为文档和文档状态的列表。History 对象的 length 属性指定浏览历史列表中的元素数量,但出于安全原因,脚本不允许访问存储的 URL。(如果允许,任何脚本都可以窥探您的浏览历史。)

History 对象有 back()forward() 方法,行为类似于浏览器的返回和前进按钮:它们使浏览器在其浏览历史中向后或向前移动一步。第三个方法 go() 接受一个整数参数,可以在历史记录列表中跳过任意数量的页面向前(对于正参数)或向后(对于负参数):

history.go(-2);   // Go back 2, like clicking the Back button twice
history.go(0);    // Another way to reload the current page

如果一个窗口包含子窗口(如 <iframe> 元素),子窗口的浏览历史与主窗口的历史按时间顺序交错。这意味着在主窗口上调用 history.back()(例如)可能会导致其中一个子窗口导航回到先前显示的文档,但保持主窗口处于当前状态。

这里描述的 History 对象可以追溯到 Web 早期,当时文档是被动的,所有计算都在服务器上执行。如今,Web 应用程序经常动态生成或加载内容,并显示新的应用程序状态,而不实际加载新文档。如果这样的应用程序希望用户能够使用返回和前进按钮(或等效手势)以直观的方式从一个应用程序状态导航到另一个状态,它们必须执行自己的历史管理。有两种方法可以实现这一点,将在接下来的两个部分中描述。

15.10.3 使用 hashchange 事件进行历史管理

一种历史管理技术涉及 location.hash 和“hashchange”事件。以下是您需要了解的关键事实,以理解这种技术:

  • location.hash 属性设置 URL 的片段标识符,传统上用于指定要滚动到的文档部分的 ID。但 location.hash 不一定要是元素 ID:你可以将其设置为任何字符串。只要没有元素恰好将该字符串作为其 ID,当你像这样设置 hash 属性时,浏览器不会滚动。

  • 设置 location.hash 属性会更新在位置栏中显示的 URL,并且非常重要的是,会向浏览器的历史记录中添加一个条目。

  • 每当文档的片段标识符发生变化时,浏览器会在 Window 对象上触发“hashchange”事件。如果你明确设置了location.hash,就会触发“hashchange”事件。正如我们所提到的,对 Location 对象的更改会在浏览器的浏览历史中创建一个新条目。因此,如果用户现在点击“后退”按钮,浏览器将返回到在设置location.hash之前的上一个 URL。但这意味着片段标识符再次发生了变化,因此在这种情况下会再次触发“hashchange”事件。这意味着只要你能为应用程序的每种可能状态创建一个唯一的片段标识符,“hashchange”事件将通知你用户在浏览历史中前进和后退的情况。

要使用这种历史管理机制,您需要能够将渲染应用程序“页面”所需的状态信息编码为相对较短的文本字符串,适合用作片段标识符。您还需要编写一个将页面状态转换为字符串的函数,以及另一个函数来解析字符串并重新创建它表示的页面状态。

一旦你编写了这些函数,剩下的就很容易了。定义一个window.onhashchange函数(或使用addEventListener()注册一个“hashchange”监听器),读取location.hash,将该字符串转换为你的应用程序状态的表示,并采取必要的操作来显示新的应用程序状态。

当用户与您的应用程序交互(例如点击链接)以导致应用程序进入新状态时,不要直接呈现新状态。相反,将所需的新状态编码为字符串,并将location.hash设置为该字符串。这将触发“hashchange”事件,您对该事件的处理程序将显示新状态。使用这种迂回的技术确保新状态被插入到浏览历史中,以便“后退”和“前进”按钮继续工作。

15.10.4 使用 pushState() 进行历史管理

管理历史的第二种技术略微复杂,但比“hashchange”事件更不像是一种黑客技巧。这种更健壮的历史管理技术基于history.pushState()方法和“popstate”事件。当 Web 应用程序进入新状态时,它调用history.pushState()将表示状态的对象添加到浏览器的历史记录中。如果用户然后点击“后退”按钮,浏览器将触发一个带有保存的状态对象副本的“popstate”事件,应用程序使用该对象重新创建其先前的状态。除了保存的状态对象外,应用程序还可以保存每个状态的 URL,如果您希望用户能够将应用程序的内部状态添加到书签并共享链接,则这一点很重要。

pushState()的第一个参数是一个包含恢复文档当前状态所需的所有状态信息的对象。这个对象使用 HTML 的结构化克隆算法保存,比JSON.stringify()更灵活,可以支持 Map、Set 和 Date 对象以及类型化数组和 ArrayBuffers。

第二个参数原本是状态的标题字符串,但大多数浏览器不支持,你应该只传递一个空字符串。第三个参数是一个可选的 URL,将立即显示在位置栏中,用户通过“后退”和“前进”按钮返回到该状态时也会显示。相对 URL 会相对于文档的当前位置解析。将每个状态与 URL 关联起来允许用户将应用程序的内部状态添加到书签。但请记住,如果用户保存了书签,然后一天后访问它,你将不会收到关于该访问的“popstate”事件:你将需要通过解析 URL 来恢复应用程序状态。

除了pushState()方法外,History 对象还定义了replaceState(),它接受相同的参数,但是替换当前历史状态而不是向浏览历史添加新状态。当首次加载使用pushState()的应用程序时,通常最好调用replaceState()来为应用程序的初始状态定义一个状态对象。

当用户使用“后退”或“前进”按钮导航到保存的历史状态时,浏览器在 Window 对象上触发“popstate”事件。与事件相关联的事件对象有一个名为state的属性,其中包含您传递给pushState()的状态对象的副本(另一个结构化克隆)。

示例 15-9 是一个简单的 Web 应用程序——在图 15-15 中显示的猜数字游戏——它使用pushState()保存其历史记录,允许用户“返回”以查看或重新做出他们的猜测。

js7e 1514

图 15-15。一个猜数字游戏
示例 15-9。使用 pushState()进行历史管理
<html><head><title>I'm thinking of a number...</title>
<style>
body { height: 250px; display: flex; flex-direction: column;
       align-items: center; justify-content: space-evenly; }
#heading { font: bold 36px sans-serif; margin: 0; }
#container { border: solid black 1px; height: 1em; width: 80%; }
#range { background-color: green; margin-left: 0%; height: 1em; width: 100%; }
#input { display: block; font-size: 24px; width: 60%; padding: 5px; }
#playagain { font-size: 24px; padding: 10px; border-radius: 5px; }
</style>
</head>
<body>
<h1 id="heading">I'm thinking of a number...</h1>
<!-- A visual representation of the numbers that have not been ruled out -->
<div id="container"><div id="range"></div></div>
<!-- Where the user enters their guess -->
<input id="input" type="text">
<!-- A button that reloads with no search string. Hidden until game ends. -->
<button id="playagain" hidden onclick="location.search='';">Play Again</button>
<script>
/**
 * An instance of this GameState class represents the internal state of
 * our number guessing game. The class defines static factory methods for
 * initializing the game state from different sources, a method for
 * updating the state based on a new guess, and a method for modifying the
 * document based on the current state.
 */
class GameState {
    // This is a factory function to create a new game
    static newGame() {
        let s = new GameState();
        s.secret = s.randomInt(0, 100);  // An integer: 0 < n < 100
        s.low = 0;                       // Guesses must be greater than this
        s.high = 100;                    // Guesses must be less than this
        s.numGuesses = 0;                // How many guesses have been made
        s.guess = null;                  // What the last guess was
        return s;
    }

    // When we save the state of the game with history.pushState(), it is just
    // a plain JavaScript object that gets saved, not an instance of GameState.
    // So this factory function re-creates a GameState object based on the
    // plain object that we get from a popstate event.
    static fromStateObject(stateObject) {
        let s = new GameState();
        for(let key of Object.keys(stateObject)) {
            s[key] = stateObject[key];
        }
        return s;
    }

    // In order to enable bookmarking, we need to be able to encode the
    // state of any game as a URL. This is easy to do with URLSearchParams.
    toURL() {
        let url = new URL(window.location);
        url.searchParams.set("l", this.low);
        url.searchParams.set("h", this.high);
        url.searchParams.set("n", this.numGuesses);
        url.searchParams.set("g", this.guess);
        // Note that we can't encode the secret number in the url or it
        // will give away the secret. If the user bookmarks the page with
        // these parameters and then returns to it, we will simply pick a
        // new random number between low and high.
        return url.href;
    }

    // This is a factory function that creates a new GameState object and
    // initializes it from the specified URL. If the URL does not contain the
    // expected parameters or if they are malformed it just returns null.
    static fromURL(url) {
        let s = new GameState();
        let params = new URL(url).searchParams;
        s.low = parseInt(params.get("l"));
        s.high = parseInt(params.get("h"));
        s.numGuesses = parseInt(params.get("n"));
        s.guess = parseInt(params.get("g"));

        // If the URL is missing any of the parameters we need or if
        // they did not parse as integers, then return null;
        if (isNaN(s.low) || isNaN(s.high) ||
            isNaN(s.numGuesses) || isNaN(s.guess)) {
            return null;
        }

        // Pick a new secret number in the right range each time we
        // restore a game from a URL.
        s.secret = s.randomInt(s.low, s.high);
        return s;
    }

    // Return an integer n, min < n < max
    randomInt(min, max) {
        return min + Math.ceil(Math.random() * (max - min - 1));
    }

    // Modify the document to display the current state of the game.
    render() {
        let heading = document.querySelector("#heading"); // The <h1> at the top
        let range = document.querySelector("#range");     // Display guess range
        let input = document.querySelector("#input");     // Guess input field
        let playagain = document.querySelector("#playagain");

        // Update the document heading and title
        heading.textContent = document.title =
            `I'm thinking of a number between ${this.low} and ${this.high}.`;

 // Update the visual range of numbers
 range.style.marginLeft = `${this.low}%`;
 range.style.width = `${(this.high-this.low)}%`;

 // Make sure the input field is empty and focused.
 input.value = "";
 input.focus();

 // Display feedback based on the user's last guess. The input
        // placeholder will show because we made the input field empty.
        if (this.guess === null) {
            input.placeholder = "Type your guess and hit Enter";
        } else if (this.guess < this.secret) {
            input.placeholder = `${this.guess} is too low. Guess again`;
        } else if (this.guess > this.secret) {
            input.placeholder = `${this.guess} is too high. Guess again`;
        } else {
            input.placeholder = document.title = `${this.guess} is correct!`;
            heading.textContent = `You win in ${this.numGuesses} guesses!`;
            playagain.hidden = false;
        }
    }

    // Update the state of the game based on what the user guessed.
    // Returns true if the state was updated, and false otherwise.
    updateForGuess(guess) {
        // If it is a number and is in the right range
        if ((guess > this.low) && (guess < this.high)) {
            // Update state object based on this guess
            if (guess < this.secret) this.low = guess;
            else if (guess > this.secret) this.high = guess;
            this.guess = guess;
            this.numGuesses++;
            return true;
        }
        else { // An invalid guess: notify user but don't update state
            alert(`Please enter a number greater than ${
                   this.low} and less than ${this.high}`);
            return false;
        }
    }
}

// With the GameState class defined, making the game work is just a matter
// of initializing, updating, saving and rendering the state object at
// the appropriate times.

// When we are first loaded, we try get the state of the game from the URL
// and if that fails we instead begin a new game. So if the user bookmarks a
// game that game can be restored from the URL. But if we load a page with
// no query parameters we'll just get a new game.
let gamestate = GameState.fromURL(window.location) || GameState.newGame();

// Save this initial state of the game into the browser history, but use
// replaceState instead of pushState() for this initial page
history.replaceState(gamestate, "", gamestate.toURL());

// Display this initial state
gamestate.render();

// When the user guesses, update the state of the game based on their guess
// then save the new state to browser history and render the new state
document.querySelector("#input").onchange = (event) => {
    if (gamestate.updateForGuess(parseInt(event.target.value))) {
        history.pushState(gamestate, "", gamestate.toURL());
    }
    gamestate.render();
};

// If the user goes back or forward in history, we'll get a popstate event
// on the window object with a copy of the state object we saved with
// pushState. When that happens, render the new state.
window.onpopstate = (event) => {
    gamestate = GameState.fromStateObject(event.state); // Restore the state
    gamestate.render();                                 // and display it
};
</script>
</body></html>

15.11 网络

每次加载网页时,浏览器都会使用 HTTP 和 HTTPS 协议进行网络请求,获取 HTML 文件以及文件依赖的图像、字体、脚本和样式表。但除了能够响应用户操作进行网络请求外,Web 浏览器还公开了用于网络的 JavaScript API。

本节涵盖了三个网络 API:

  • fetch()方法为进行 HTTP 和 HTTPS 请求定义了基于 Promise 的 API。fetch() API 使基本的 GET 请求变得简单,但也具有全面的功能集,支持几乎任何可能的 HTTP 用例。

  • 服务器发送事件(Server-Sent Events,简称 SSE)API 是 HTTP“长轮询”技术的一种方便的基于事件的接口,其中 Web 服务器保持网络连接打开,以便在需要时向客户端发送数据。

  • WebSockets 是一种不是 HTTP 的网络协议,但设计用于与 HTTP 互操作。它定义了一个异步消息传递 API,客户端和服务器可以相互发送和接收消息,类似于 TCP 网络套接字。

15.11.1 fetch()

对于基本的 HTTP 请求,使用fetch()是一个三步过程:

  1. 调用fetch(),传递要检索内容的 URL。

  2. 获取由第 1 步异步返回的响应对象,当 HTTP 响应开始到达时调用此响应对象的方法来请求响应的主体。

  3. 获取由第 2 步异步返回的主体对象,并根据需要进行处理。

fetch() API 完全基于 Promise,并且这里有两个异步步骤,因此当使用fetch()时,通常期望有两个then()调用或两个await表达式。(如果你忘记了它们是什么,可能需要重新阅读第十三章后再继续本节。)

如果你使用then()并期望服务器响应你的请求是 JSON 格式的,fetch()请求看起来是这样的:

fetch("/api/users/current")            // Make an HTTP (or HTTPS) GET request
    .then(response => response.json()) // Parse its body as a JSON object
    .then(currentUser => {             // Then process that parsed object
        displayUserInfo(currentUser);
    });

使用asyncawait关键字向返回普通字符串而不是 JSON 对象的 API 发出类似请求:

async function isServiceReady() {
    let response = await fetch("/api/service/status");
    let body = await response.text();
    return body === "ready";
}

如果你理解了这两个代码示例,那么你就知道了使用fetch() API 所需了解的 80%。接下来的小节将演示如何进行比这里显示的更复杂的请求和接收响应。

HTTP 状态码、响应头和网络错误

在§15.11.1 中显示的三步fetch()过程省略了所有错误处理代码。这里是一个更现实的版本:

fetch("/api/users/current")   // Make an HTTP (or HTTPS) GET request.
    .then(response => {       // When we get a response, first check it
        if (response.ok &&    // for a success code and the expected type.
            response.headers.get("Content-Type") === "application/json") {
            return response.json(); // Return a Promise for the body.
        } else {
            throw new Error(        // Or throw an error.
                `Unexpected response status ${response.status} or content type`
            );
        }
    })
    .then(currentUser => {    // When the response.json() Promise resolves
        displayUserInfo(currentUser); // do something with the parsed body.
    })
    .catch(error => {         // Or if anything went wrong, just log the error.
        // If the user's browser is offline, fetch() itself will reject.
        // If the server returns a bad response then we throw an error above.
        console.log("Error while fetching current user:", error);
    });

fetch() 返回的 Promise 解析为一个 Response 对象。这个对象的 status 属性是 HTTP 状态码,比如成功请求的 200 或“未找找”响应的 404。(statusText 给出与数字状态码相对应的标准英文文本。)方便的是,Response 的 ok 属性在 status 为 200 或 200 到 299 之间的任何代码时为 true,对于其他任何代码都为 false

fetch() 在服务器响应开始到达时解析其 Promise,通常在完整响应体到达之前。即使响应体还不可用,你也可以在 fetch 过程的第二步检查头部信息。Response 对象的 headers 属性是一个 Headers 对象。使用它的 has() 方法测试头部是否存在,或使用 get() 方法获取头部的值。HTTP 头部名称不区分大小写,因此你可以向这些函数传递小写或混合大小写的头部名称。

Headers 对象也是可迭代的,如果你需要的话:

fetch(url).then(response => {
    for(let [name,value] of response.headers) {
        console.log(`${name}: ${value}`);
    }
});

如果 Web 服务器响应你的 fetch() 请求,那么返回的 Promise 将以 Response 对象实现,即使服务器的响应是 404 Not Found 错误或 500 Internal Server Error。fetch() 仅在无法完全联系到 Web 服务器时拒绝其返回的 Promise。如果用户的计算机离线,服务器无响应,或 URL 指定的主机名不存在,就会发生这种情况。因为这些情况可能发生在任何网络请求上,所以每次进行 fetch() 调用时都包含一个 .catch() 子句总是一个好主意。

设置请求参数

有时候在发起请求时,你可能想要传递额外的参数。这可以通过在 URL 后面添加 ? 后的名称/值对来实现。URL 和 URLSearchParams 类(在 §11.9 中介绍)使得构造这种形式的 URL 变得容易,fetch() 函数接受 URL 对象作为其第一个参数,因此你可以像这样在 fetch() 请求中包含请求参数:

async function search(term) {
    let url = new URL("/api/search");
    url.searchParams.set("q", term);
    let response = await fetch(url);
    if (!response.ok) throw new Error(response.statusText);
    let resultsArray = await response.json();
    return resultsArray;
}

设置请求头部

有时候你需要在 fetch() 请求中设置头部。例如,如果你正在进行需要凭据的 Web API 请求,那么可能需要包含包含这些凭据的 Authorization 头部。为了做到这一点,你可以使用 fetch() 的两个参数版本。与之前一样,第一个参数是指定要获取的 URL 的字符串或 URL 对象。第二个参数是一个对象,可以提供额外的选项,包括请求头部:

let authHeaders = new Headers();
// Don't use Basic auth unless it is over an HTTPS connection.
authHeaders.set("Authorization",
                `Basic ${btoa(`${username}:${password}`)}`);
fetch("/api/users/", { headers: authHeaders })
    .then(response => response.json())             // Error handling omitted...
    .then(usersList => displayAllUsers(usersList));

fetch() 的第二个参数中可以指定许多其他选项,我们稍后会再次看到它。将两个参数传递给 fetch() 的替代方法是将相同的两个参数传递给 Request() 构造函数,然后将生成的 Request 对象传递给 fetch()

let request = new Request(url, { headers });
fetch(request).then(response => ...);

解析响应体

在我们演示的三步 fetch() 过程中,第二步通过调用 Response 对象的 json()text() 方法结束,并返回这些方法返回的 Promise 对象。然后,第三步开始,当该 Promise 解析为响应体解析为 JSON 对象或简单文本字符串时。

这可能是两种最常见的情况,但并不是获取 Web 服务器响应体的唯一方式。除了 json()text(),Response 对象还有这些方法:

arrayBuffer()

这个方法返回一个解析为 ArrayBuffer 的 Promise。当响应包含二进制数据时,这是很有用的。你可以使用 ArrayBuffer 创建一个类型化数组(§11.2)或一个 DataView 对象(§11.2.5),从中读取二进制数据。

blob()

此方法返回一个解析为 Blob 对象的 Promise。本书未详细介绍 Blob,但其名称代表“二进制大对象”,在期望大量二进制数据时非常有用。如果要求响应主体为 Blob,则浏览器实现可能会将响应数据流式传输到临时文件,然后返回表示该临时文件的 Blob 对象。因此,Blob 对象不允许像 ArrayBuffer 一样随机访问响应主体。一旦有了 Blob,你可以使用 URL.createObjectURL() 创建一个引用它的 URL,或者你可以使用基于事件的 FileReader API 异步获取 Blob 的内容作为字符串或 ArrayBuffer。在撰写本文时,一些浏览器还定义了基于 Promise 的 text()arrayBuffer() 方法,提供了更直接的方式来获取 Blob 的内容。

formData()

此方法返回一个解析为 FormData 对象的 Promise。如果你期望 Response 的主体以“multipart/form-data”格式编码,则应使用此方法。这种格式在向服务器发出 POST 请求时很常见,但在服务器响应中不常见,因此此方法不经常使用。

流式传输响应主体

除了异步返回完整响应主体的五种响应方法之外,还有一种选项可以流式传输响应主体,这在网络上到达响应主体的块时可以进行某种处理时非常有用。但是,如果你想要显示进度条,让用户看到下载的进度,流式传输响应也很有用。

Response 对象的 body 属性是一个 ReadableStream 对象。如果你已经调用了像 text()json() 这样读取、解析和返回主体的响应方法,那么 bodyUsed 将为 true,表示 body 流已经被读取。但是,如果 bodyUsedfalse,则表示流尚未被读取。在这种情况下,你可以调用 response.body 上的 getReader() 来获取一个流读取器对象,然后使用该读取器对象的 read() 方法异步从流中读取文本块。read() 方法返回一个解析为具有 donevalue 属性的对象的 Promise。如果整个主体已被读取或流已关闭,则 done 将为 true。而 value 将是下一个块,作为 Uint8Array,如果没有更多块,则为 undefined

如果使用 asyncawait,则此流式 API 相对简单,但如果尝试使用原始 Promise,则会出乎意料地复杂。示例 15-10 通过定义一个 streamBody() 函数来演示该 API。假设你想要下载一个大型 JSON 文件并向用户报告下载进度。你无法使用 Response 对象的 json() 方法来实现,但可以使用 streamBody() 函数,如下所示(假设已定义了一个 updateProgress() 函数来设置 HTML <progress> 元素的 value 属性):

fetch('big.json')
    .then(response => streamBody(response, updateProgress))
    .then(bodyText => JSON.parse(bodyText))
    .then(handleBigJSONObject);

可以按照 示例 15-10 中所示实现 streamBody() 函数。

示例 15-10. 从 fetch() 请求中流式传输响应主体
/**
 * An asynchronous function for streaming the body of a Response object
 * obtained from a fetch() request. Pass the Response object as the first
 * argument followed by two optional callbacks.
 *
 * If you specify a function as the second argument, that reportProgress
 * callback will be called once for each chunk that is received. The first
 * argument passed is the total number of bytes received so far. The second
 * argument is a number between 0 and 1 specifying how complete the download
 * is. If the Response object has no "Content-Length" header, however, then
 * this second argument will always be NaN.
 *
 * If you want to process the data in chunks as they arrive, specify a
 * function as the third argument. The chunks will be passed, as Uint8Array
 * objects, to this processChunk callback.
 *
 * streamBody() returns a Promise that resolves to a string. If a processChunk
 * callback was supplied then this string is the concatenation of the values
 * returned by that callback. Otherwise the string is the concatenation of
 * the chunk values converted to UTF-8 strings.
 */
async function streamBody(response, reportProgress, processChunk) {
    // How many bytes are we expecting, or NaN if no header
    let expectedBytes = parseInt(response.headers.get("Content-Length"));
    let bytesRead = 0;                       // How many bytes received so far
    let reader = response.body.getReader();  // Read bytes with this function
    let decoder = new TextDecoder("utf-8");  // For converting bytes to text
    let body = "";                           // Text read so far

    while(true) {                                 // Loop until we exit below
        let {done, value} = await reader.read();  // Read a chunk

        if (value) {                              // If we got a byte array:
            if (processChunk) {                   // Process the bytes if
                let processed = processChunk(value);  // a callback was passed.
                if (processed) {
                    body += processed;
                }
            } else {                              // Otherwise, convert bytes
                body += decoder.decode(value, {stream: true}); // to text.
            }

            if (reportProgress) {                 // If a progress callback was
                bytesRead += value.length;        // passed, then call it
                reportProgress(bytesRead, bytesRead / expectedBytes);
            }
        }
        if (done) {                               // If this is the last chunk,
            break;                                // exit the loop
        }
    }

    return body;   // Return the body text we accumulated
}

此流式 API 在撰写本文时是新的,并预计会发展。特别是,计划使 ReadableStream 对象异步可迭代,以便可以与 for/await 循环一起使用(§13.4.1)。

指定请求方法和请求体

到目前为止,我们展示的每个 fetch() 示例都是进行了 HTTP(或 HTTPS)GET 请求。如果你想要使用不同的请求方法(如 POST、PUT 或 DELETE),只需使用 fetch() 的两个参数版本,传递一个带有 method 参数的 Options 对象:

fetch(url, { method: "POST" }).then(r => r.json()).then(handleResponse);

POST 和 PUT 请求通常具有包含要发送到服务器的数据的请求体。只要 method 属性未设置为 "GET""HEAD"(不支持请求体),您可以通过设置 Options 对象的 body 属性来指定请求体:

fetch(url, {
    method: "POST",
    body: "hello world"
})

当您指定请求体时,浏览器会自动向请求添加适当的 “Content-Length” 头。当请求体是字符串时,如前面的示例中,浏览器将默认将 “Content-Type” 头设置为 “text/plain;charset=UTF-8”。如果您指定了更具体类型的字符串体,如 “text/html” 或 “application/json”,则可能需要覆盖此默认值:

fetch(url, {
    method: "POST",
    headers: new Headers({"Content-Type": "application/json"}),
    body: JSON.stringify(requestBody)
})

fetch() 选项对象的 body 属性不一定要是字符串。如果您有二进制数据在类型化数组、DataView 对象或 ArrayBuffer 中,可以将 body 属性设置为该值,并指定适当的 “Content-Type” 头。如果您有 Blob 形式的二进制数据,只需将 body 设置为 Blob。Blob 具有指定其内容类型的 type 属性,该属性的值用作 “Content-Type” 头的默认值。

使用 POST 请求时,将一组名称/值参数传递到请求体中(而不是将它们编码到 URL 的查询部分)是相当常见的。有两种方法可以实现这一点:

  • 您可以使用 URLSearchParams 指定参数名称和值(我们在本节前面看到过,并在 §11.9 中有文档),然后将 URLSearchParams 对象作为 body 属性的值传递。如果这样做,请求体将设置为类似 URL 查询部分的字符串,并且“Content-Type” 头将自动设置为 “application/x-www-form-urlencoded;charset=UTF-8”。

  • 如果您使用 FormData 对象指定参数名称和值,请求体将使用更详细的多部分编码,并且“Content-Type” 将设置为 “multipart/form-data; boundary=…”,其中包含一个与请求体匹配的唯一边界字符串。当您要上传的值很长,或者是每个都可能具有自己的“Content-Type”的文件或 Blob 对象时,使用 FormData 对象特别有用。可以通过将 <form> 元素传递给 FormData() 构造函数来创建和初始化 FormData 对象的值。但也可以通过调用不带参数的 FormData() 构造函数并使用 set()append() 方法初始化它表示的名称/值对来创建“multipart/form-data” 请求体。

使用 fetch() 上传文件

从用户计算机上传文件到 Web 服务器是一项常见任务,可以使用 FormData 对象作为请求体。获取 File 对象的常见方法是在 Web 页面上显示一个 <input type="file"> 元素,并侦听该元素上的 “change” 事件。当发生 “change” 事件时,输入元素的 files 数组应至少包含一个 File 对象。还可以通过 HTML 拖放 API 获取 File 对象。该 API 不在本书中介绍,但您可以从传递给 “drop” 事件的事件对象的 dataTransfer.files 数组中获取文件。

还要记住,File 对象是 Blob 的一种,有时上传 Blob 可能很有用。假设您编写了一个允许用户在 <canvas> 元素中创建绘图的 Web 应用程序。您可以使用以下代码将用户的绘图上传为 PNG 文件:

// The canvas.toBlob() function is callback-based.
// This is a Promise-based wrapper for it.
async function getCanvasBlob(canvas) {
    return new Promise((resolve, reject) => {
        canvas.toBlob(resolve);
    });
}

// Here is how we upload a PNG file from a canvas
async function uploadCanvasImage(canvas) {
    let pngblob = await getCanvasBlob(canvas);
    let formdata = new FormData();
    formdata.set("canvasimage", pngblob);
    let response = await fetch("/upload", { method: "POST", body: formdata });
    let body = await response.json();
}

跨域请求

大多数情况下,fetch() 被 Web 应用程序用于从自己的 Web 服务器请求数据。这些请求被称为同源请求,因为传递给 fetch() 的 URL 与包含发出请求的脚本的文档具有相同的源(协议加主机名加端口)。

出于安全原因,Web 浏览器通常禁止(尽管对于图像和脚本有例外)跨域网络请求。然而,跨域资源共享(CORS)使安全的跨域请求成为可能。当fetch()与跨域 URL 一起使用时,浏览器会向请求添加一个“Origin”头部(并且不允许通过headers属性覆盖它)以通知 Web 服务器请求来自具有不同来源的文档。如果服务器用适当的“Access-Control-Allow-Origin”头部响应请求,那么请求会继续。否则,如果服务器没有明确允许请求,那么fetch()返回的 Promise 将被拒绝。

中止请求

有时候你可能想要中止一个已经发出的fetch()请求,也许是因为用户点击了取消按钮或请求花费的时间太长。fetch API 允许使用 AbortController 和 AbortSignal 类来中止请求。(这些类定义了一个通用的中止机制,适用于其他 API 的使用。)

如果你想要中止一个fetch()请求的选项,那么在开始请求之前创建一个 AbortController 对象。控制器对象的signal属性是一个 AbortSignal 对象。将这个信号对象作为你传递给fetch()的选项对象的signal属性的值。这样做后,你可以调用控制器对象的abort()方法来中止请求,这将导致与 fetch 请求相关的任何 Promise 对象拒绝并抛出异常。

这里是使用 AbortController 机制强制执行 fetch 请求超时的示例:

// This function is like fetch(), but it adds support for a timeout
// property in the options object and aborts the fetch if it is not complete
// within the number of milliseconds specified by that property.
function fetchWithTimeout(url, options={}) {
    if (options.timeout) {  // If the timeout property exists and is nonzero
        let controller = new AbortController();  // Create a controller
        options.signal = controller.signal;      // Set the signal property
        // Start a timer that will send the abort signal after the specified
        // number of milliseconds have passed. Note that we never cancel
        // this timer. Calling abort() after the fetch is complete has
        // no effect.
        setTimeout(() => { controller.abort(); }, options.timeout);
    }
    // Now just perform a normal fetch
    return fetch(url, options);
}

杂项请求选项

我们已经看到 Options 对象可以作为fetch()的第二个参数(或Request()构造函数的第二个参数)传递,以指定请求方法、请求头和请求体。它还支持许多其他选项,包括这些:

cache

使用这个属性来覆盖浏览器的默认缓存行为。HTTP 缓存是一个复杂的主题,超出了本书的范围,但如果你了解它的工作原理,你可以使用以下cache的合法值:

"default"

这个值指定了默认的缓存行为。缓存中的新鲜响应直接从缓存中提供,而陈旧响应在提供之前会被重新验证。

"no-store"

这个值使浏览器忽略其缓存。当请求发出时,不会检查缓存是否匹配,并且当响应到达时也不会更新缓存。

"reload"

这个值告诉浏览器始终进行正常的网络请求,忽略缓存。然而,当响应到达时,它会被存储在缓存中。

"no-cache"

这个(误导性命名的)值告诉浏览器不要从缓存中提供新鲜值。在返回之前,新鲜或陈旧的缓存值会被重新验证。

"force-cache"

这个值告诉浏览器即使缓存中的响应是陈旧的也要提供。

redirect

这个属性控制浏览器如何处理来自服务器的重定向响应。三个合法的值是:

"follow"

这是默认值,它使浏览器自动跟随重定向。如果使用这个默认值,通过fetch()获取的 Response 对象不应该有status在 300 到 399 范围内。

"error"

这个值使fetch()在服务器返回重定向响应时拒绝其返回的 Promise。

"manual"

这个值意味着你想要手动处理重定向响应,并且fetch()返回的 Promise 可能会解析为一个带有status在 300 到 399 范围内的 Response 对象。在这种情况下,你将不得不使用 Response 的“Location”头部来手动跟随重定向。

referrer

您可以将此属性设置为包含相对 URL 的字符串,以指定 HTTP“Referer”标头的值(历史上错误拼写为三个 R 而不是四个)。如果将此属性设置为空字符串,则“Referer”标头将从请求中省略。