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

简介: JavaScript 权威指南第七版(GPT 重译)(六)

JavaScript 权威指南第七版(GPT 重译)(六)(1)https://developer.aliyun.com/article/1485425

15.3.6 示例:生成目录

示例 15-1 展示了如何为文档动态创建目录。它演示了前几节描述的许多文档脚本化技术。示例有很好的注释,您应该没有问题跟踪代码。

示例 15-1。使用 DOM API 生成目录
/**
 * TOC.js: create a table of contents for a document.
 *
 * This script runs when the DOMContentLoaded event is fired and
 * automatically generates a table of contents for the document.
 * It does not define any global symbols so it should not conflict
 * with other scripts.
 *
 * When this script runs, it first looks for a document element with
 * an id of "TOC". If there is no such element it creates one at the
 * start of the document. Next, the function finds all <h2> through
 * <h6> tags, treats them as section titles, and creates a table of
 * contents within the TOC element. The function adds section numbers
 * to each section heading and wraps the headings in named anchors so
 * that the TOC can link to them. The generated anchors have names
 * that begin with "TOC", so you should avoid this prefix in your own
 * HTML.
 *
 * The entries in the generated TOC can be styled with CSS. All
 * entries have a class "TOCEntry". Entries also have a class that
 * corresponds to the level of the section heading. <h1> tags generate
 * entries of class "TOCLevel1", <h2> tags generate entries of class
 * "TOCLevel2", and so on. Section numbers inserted into headings have
 * class "TOCSectNum".
 *
 * You might use this script with a stylesheet like this:
 *
 *   #TOC { border: solid black 1px; margin: 10px; padding: 10px; }
 *   .TOCEntry { margin: 5px 0px; }
 *   .TOCEntry a { text-decoration: none; }
 *   .TOCLevel1 { font-size: 16pt; font-weight: bold; }
 *   .TOCLevel2 { font-size: 14pt; margin-left: .25in; }
 *   .TOCLevel3 { font-size: 12pt; margin-left: .5in; }
 *   .TOCSectNum:after { content: ": "; }
 *
 * To hide the section numbers, use this:
 *
 *   .TOCSectNum { display: none }
 **/
document.addEventListener("DOMContentLoaded", () => {
    // Find the TOC container element.
    // If there isn't one, create one at the start of the document.
    let toc = document.querySelector("#TOC");
    if (!toc) {
        toc = document.createElement("div");
        toc.id = "TOC";
        document.body.prepend(toc);
    }
    // Find all section heading elements. We're assuming here that the
    // document title uses <h1> and that sections within the document are
    // marked with <h2> through <h6>.
    let headings = document.querySelectorAll("h2,h3,h4,h5,h6");
    // Initialize an array that keeps track of section numbers.
    let sectionNumbers = [0,0,0,0,0];
    // Now loop through the section header elements we found.
    for(let heading of headings) {
        // Skip the heading if it is inside the TOC container.
        if (heading.parentNode === toc) {
            continue;
        }
        // Figure out what level heading it is.
        // Subtract 1 because <h2> is a level-1 heading.
        let level = parseInt(heading.tagName.charAt(1)) - 1;
        // Increment the section number for this heading level
        // and reset all lower heading level numbers to zero.
        sectionNumbers[level-1]++;
        for(let i = level; i < sectionNumbers.length; i++) {
            sectionNumbers[i] = 0;
        }
        // Now combine section numbers for all heading levels
        // to produce a section number like 2.3.1.
        let sectionNumber = sectionNumbers.slice(0, level).join(".");
        // Add the section number to the section header title.
        // We place the number in a <span> to make it styleable.
        let span = document.createElement("span");
        span.className = "TOCSectNum";
        span.textContent = sectionNumber;
        heading.prepend(span);
        // Wrap the heading in a named anchor so we can link to it.
        let anchor = document.createElement("a");
        let fragmentName = `TOC${sectionNumber}`;
        anchor.name = fragmentName;
        heading.before(anchor);    // Insert anchor before heading
        anchor.append(heading);    // and move heading inside anchor
        // Now create a link to this section.
        let link = document.createElement("a");
        link.href = `#${fragmentName}`;     // Link destination
        // Copy the heading text into the link. This is a safe use of
        // innerHTML because we are not inserting any untrusted strings.
        link.innerHTML = heading.innerHTML;
        // Place the link in a div that is styleable based on the level.
        let entry = document.createElement("div");
        entry.classList.add("TOCEntry", `TOCLevel${level}`);
        entry.append(link);
        // And add the div to the TOC container.
        toc.append(entry);
    }
});

15.4 脚本化 CSS

我们已经看到 JavaScript 可以控制 HTML 文档的逻辑结构和内容。它还可以通过脚本化 CSS 来控制这些文档的视觉外观和布局。以下各小节解释了 JavaScript 代码可以使用的几种不同技术来处理 CSS。

这是一本关于 JavaScript 的书,不是关于 CSS 的书,本节假设您已经掌握了如何使用 CSS 来为 HTML 内容设置样式的工作知识。但值得一提的是,一些常常从 JavaScript 中脚本化的 CSS 样式:

  • display样式设置为“none”可以隐藏一个元素。稍后可以通过将display设置为其他值来显示元素。
  • 您可以通过将position样式设置为“absolute”、“relative”或“fixed”,然后将topleft样式设置为所需的坐标来动态定位元素。在使用 JavaScript 显示动态内容(如模态对话框和工具提示)时,这一点很重要。
  • 您可以使用transform样式来移动、缩放和旋转元素。
  • 您可以使用transition样式对其他 CSS 样式的更改进行动画处理。这些动画由 Web 浏览器自动处理,不需要 JavaScript,但您可以使用 JavaScript 来启动动画。

15.4.1 CSS 类

使用 JavaScript 影响文档内容的样式的最简单方法是从 HTML 标签的class属性中添加和删除 CSS 类名。这很容易通过 Element 对象的classList属性来实现,如“class 属性”中所述。

例如,假设您的文档样式表包含一个“hidden”类的定义:

.hidden {
  display:none;
}

使用这种定义的样式,您可以通过以下代码隐藏(然后显示)一个元素:

// Assume that this "tooltip" element has class="hidden" in the HTML file.
// We can make it visible like this:
document.querySelector("#tooltip").classList.remove("hidden");
// And we can hide it again like this:
document.querySelector("#tooltip").classList.add("hidden");

15.4.2 内联样式

继续上一个工具提示示例,假设文档结构中只有一个工具提示元素,并且我们希望在显示之前动态定位它。一般来说,我们无法为工具提示的每种可能位置创建不同的样式表类,因此classList属性无法帮助我们定位。

在这种情况下,我们需要脚本化工具提示元素的style属性,以设置特定于该元素的内联样式。DOM 为所有 Element 对象定义了一个与style属性对应的style属性。然而,与大多数这样的属性不同,style属性不是一个字符串。相反,它是一个 CSSStyleDeclaration 对象:CSS 样式的解析表示形式,它以文本形式出现在style属性中。为了使用 JavaScript 显示和设置我们假设的工具提示的位置,我们可能会使用类似于以下代码:

function displayAt(tooltip, x, y) {
    tooltip.style.display = "block";
    tooltip.style.position = "absolute";
    tooltip.style.left = `${x}px`;
    tooltip.style.top = `${y}px`;
}

当使用 CSSStyleDeclaration 对象的样式属性时,请记住所有值必须指定为字符串。在样式表或style属性中,您可以这样写:

display: block; font-family: sans-serif; background-color: #ffffff;

要在 JavaScript 中为具有相同效果的元素e执行相同的操作,您必须引用所有值:

e.style.display = "block";
e.style.fontFamily = "sans-serif";
e.style.backgroundColor = "#ffffff";

请注意,分号放在字符串外部。这些只是普通的 JavaScript 分号;您在 CSS 样式表中使用的分号不是 JavaScript 中设置的字符串值的一部分。

此外,请记住,许多 CSS 属性需要像“px”表示像素或“pt”表示点这样的单位。因此,像这样设置marginLeft属性是不正确的:

e.style.marginLeft = 300;    // Incorrect: this is a number, not a string
e.style.marginLeft = "300";  // Incorrect: the units are missing

在 JavaScript 中设置样式属性时需要单位,就像在样式表中设置样式属性时一样。将元素emarginLeft属性值设置为 300 像素的正确方法是:

e.style.marginLeft = "300px";

如果要将 CSS 属性设置为计算值,请确保在计算结束时附加单位:

e.style.left = `${x0 + left_border + left_padding}px`;

请记住,一些 CSS 属性,例如margin,是其他属性的快捷方式,例如margin-topmargin-rightmargin-bottommargin-left。CSSStyleDeclaration 对象具有与这些快捷属性对应的属性。例如,您可以这样设置margin属性:

e.style.margin = `${top}px ${right}px ${bottom}px ${left}px`;

有时,您可能会发现将元素的内联样式设置或查询为单个字符串值比作为 CSSStyleDeclaration 对象更容易。为此,您可以使用 Element 的getAttribute()setAttribute()方法,或者您可以使用 CSSStyleDeclaration 对象的cssText属性:

// Copy the inline styles of element e to element f:
f.setAttribute("style", e.getAttribute("style"));
// Or do it like this:
f.style.cssText = e.style.cssText;

当查询元素的style属性时,请记住它仅表示元素的内联样式,大多数元素的大多数样式是在样式表中指定而不是内联的。此外,当查询style属性时获得的值将使用实际在 HTML 属性上使用的任何单位和任何快捷属性格式,并且您的代码可能需要进行一些复杂的解析来解释它们。一般来说,如果您想查询元素的样式,您可能需要计算样式,下面将讨论。

15.4.3 计算样式

元素的计算样式是浏览器从元素的内联样式加上所有样式表中的所有适用样式规则推导(或计算)出的属性值集合:它是实际用于显示元素的属性集合。与内联样式一样,计算样式用 CSSStyleDeclaration 对象表示。然而,与内联样式不同,计算样式是只读的。您不能设置这些样式,但是元素的计算 CSSStyleDeclaration 对象可以让您确定浏览器在呈现该元素时使用了哪些样式属性值。

使用 Window 对象的getComputedStyle()方法获取元素的计算样式。此方法的第一个参数是所需的计算样式的元素。可选的第二个参数用于指定 CSS 伪元素,例如“::before”或“::after”:

let title = document.querySelector("#section1title");
let styles = window.getComputedStyle(title);
let beforeStyles = window.getComputedStyle(title, "::before");

getComputedStyle()的返回值是一个表示应用于指定元素(或伪元素)的所有样式的 CSSStyleDeclaration 对象。表示内联样式的 CSSStyleDeclaration 对象和表示计算样式的 CSSStyleDeclaration 对象之间有一些重要的区别:

  • 计算样式属性是只读的。
  • 计算样式属性是绝对的:相对单位如百分比和点会被转换为绝对值。任何指定大小的属性(如边距大小或字体大小)将具有以像素为单位的值。这个值将是一个带有“px”后缀的字符串,因此你仍然需要解析它,但你不必担心解析或转换其他单位。值为颜色的属性将以“rgb()”或“rgba()”格式返回。
  • 快捷属性不会被计算,只有它们所基于的基本属性会被计算。例如,不要查询margin属性,而是使用marginLeftmarginTop等。同样,不要查询border甚至borderWidth,而是使用borderLeftWidthborderTopWidth等。
  • 计算样式的cssText属性是未定义的。

通过getComputedStyle()返回的 CSSStyleDeclaration 对象通常包含有关元素的更多信息,而不是从该元素的内联style属性获取的 CSSStyleDeclaration。但计算样式可能会有些棘手,查询它们并不总是提供你期望的信息。考虑font-family属性:它接受一个逗号分隔的所需字体系列列表,以实现跨平台可移植性。当你查询计算样式的fontFamily属性时,你只是获取适用于元素的最具体font-family样式的值。这可能返回一个值,如“arial,helvetica,sans-serif”,这并不告诉你实际使用的字体。同样,如果一个元素没有绝对定位,尝试通过计算样式的topleft属性查询其位置和大小通常会返回值auto。这是一个完全合法的 CSS 值,但这可能不是你要找的。

尽管 CSS 可以精确指定文档元素的位置和大小,但查询元素的计算样式并不是确定元素大小和位置的首选方法。查看§15.5.2 以获取更简单、可移植的替代方法。

15.4.4 脚本样式表

除了操作类属性和内联样式,JavaScript 还可以操作样式表本身。样式表与 HTML 文档关联,可以通过<style>标签或<link rel="stylesheet">标签进行关联。这两者都是常规的 HTML 标签,因此你可以给它们都添加id属性,然后使用document.querySelector()查找它们。

<style><link>标签的 Element 对象都有一个disabled属性,你可以使用它来禁用整个样式表。你可以使用如下代码:

// This function switches between the "light" and "dark" themes
function toggleTheme() {
    let lightTheme = document.querySelector("#light-theme");
    let darkTheme = document.querySelector("#dark-theme");
    if (darkTheme.disabled) {          // Currently light, switch to dark
        lightTheme.disabled = true;
        darkTheme.disabled = false;
    } else {                           // Currently dark, switch to light
        lightTheme.disabled = false;
        darkTheme.disabled = true;
    }
}

另一种简单的脚本样式表的方法是使用我们已经看过的 DOM 操作技术将新样式表插入文档中。例如:

function setTheme(name) {
    // Create a new <link rel="stylesheet"> element to load the named stylesheet
    let link = document.createElement("link");
    link.id = "theme";
    link.rel = "stylesheet";
    link.href = `themes/${name}.css`;
    // Look for an existing link with id "theme"
    let currentTheme = document.querySelector("#theme");
    if (currentTheme) {
        // If there is an existing theme, replace it with the new one.
        currentTheme.replaceWith(link);
    } else {
        // Otherwise, just insert the link to the theme stylesheet.
        document.head.append(link);
    }
}

更直接地,你也可以将一个包含<style>标签的 HTML 字符串插入到你的文档中。例如:

document.head.insertAdjacentHTML(
    "beforeend",
    "<style>body{transform:rotate(180deg)}</style>"
);

浏览器定义了一个 API,允许 JavaScript 查看样式表内部,查询、修改、插入和删除该样式表中的样式规则。这个 API 是如此专门化,以至于这里没有记录。你可以在 MDN 上搜索“CSSStyleSheet”和“CSS Object Model”来了解它。

15.4.5 CSS 动画和事件

假设你在样式表中定义了以下两个 CSS 类:

.transparent { opacity: 0; }
.fadeable { transition: opacity .5s ease-in }

如果你将第一个样式应用于一个元素,它将完全透明,因此看不见。但如果你应用第二个样式,告诉浏览器当元素的不透明度发生变化时,该变化应该在 0.5 秒内进行动画处理,“ease-in”指定不透明度变化动画应该从缓慢开始然后加速。

现在假设你的 HTML 文档包含一个带有“fadeable”类的元素:

<div id="subscribe" class="fadeable notification">...</div>

在 JavaScript 中,你可以添加“transparent”类:

document.querySelector("#subscribe").classList.add("transparent");

此元素已配置为动画不透明度变化。添加“transparent”类会改变不透明度并触发动画:浏览器会使元素“淡出”,使其在半秒钟内完全透明。

这也适用于相反的情况:如果您删除“fadeable”元素的“transparent”类,那也是一个不透明度变化,元素会重新淡入并再次变得可见。

JavaScript 不需要做任何工作来实现这些动画:它们是纯 CSS 效果。但是 JavaScript 可以用来触发它们。

JavaScript 也可以用于监视 CSS 过渡的进度,因为 Web 浏览器在过渡开始和结束时会触发事件。当过渡首次触发时,会分发“transitionrun”事件。这可能发生在任何视觉变化开始之前,当指定了transition-delay样式时。一旦视觉变化开始,就会分发“transitionstart”事件,当动画完成时,就会分发“transitionend”事件。当然,所有这些事件的目标都是正在进行动画的元素。传递给这些事件处理程序的事件对象是一个 TransitionEvent 对象。它有一个propertyName属性,指定正在进行动画的 CSS 属性,以及一个elapsedTime属性,对于“transitionend”事件,它指定自“transitionstart”事件以来经过了多少秒。

除了过渡效果,CSS 还支持一种更复杂的动画形式,简称为“CSS 动画”。这些使用 CSS 属性,如animation-nameanimation-duration,以及特殊的@keyframes规则来定义动画细节。CSS 动画的工作原理超出了本书的范围,但再次,如果您在 CSS 类上定义了所有动画属性,那么您可以通过将该类添加到要进行动画处理的元素来使用 JavaScript 触发动画。

与 CSS 过渡类似,CSS 动画也会触发事件,您的 JavaScript 代码可以监听这些事件。“animationstart”在动画开始时分发,“animationend”在动画完成时分发。如果动画重复多次,则在每次重复之后(除最后一次)都会分发“animationiteration”事件。事件目标是被动画化的元素,传递给处理程序函数的事件对象是一个 AnimationEvent 对象。这些事件包括一个animationName属性,指定定义动画的animation-name属性,以及一个elapsedTime属性,指定自动画开始以来经过了多少秒。

15.5 文档几何和滚动

到目前为止,在本章中,我们已经将文档视为元素和文本节点的抽象树。但是当浏览器在窗口中呈现文档时,它会创建文档的视觉表示,其中每个元素都有位置和大小。通常,Web 应用程序可以将文档视为元素树,而无需考虑这些元素如何在屏幕上呈现。然而,有时需要确定元素的精确几何形状。例如,如果您想使用 CSS 动态定位一个元素(如工具提示)在一些普通的浏览器定位元素旁边,您需要能够确定该元素的位置。

以下小节解释了如何在文档的抽象、基于树的模型和在浏览器窗口中布局的几何、基于坐标的视图之间来回切换。

15.5.1 文档坐标和视口坐标

文档元素的位置以 CSS 像素为单位,x 坐标向右增加,y 坐标向下增加。然而,我们可以使用两个不同的点作为坐标系原点:元素的 xy 坐标可以相对于文档的左上角或相对于显示文档的视口的左上角。在顶级窗口和标签中,“视口”是实际显示文档内容的浏览器部分:它不包括浏览器的“chrome”(如菜单、工具栏和标签)。对于在 <iframe> 标签中显示的文档,DOM 中定义嵌套文档的视口的是 iframe 元素。无论哪种情况,当我们谈论元素的位置时,必须清楚我们是使用文档坐标还是视口坐标。(请注意,有时视口坐标被称为“窗口坐标”。)

如果文档比视口小,或者没有滚动,文档的左上角在视口的左上角,文档和视口坐标系是相同的。然而,一般来说,要在两个坐标系之间转换,必须添加或减去滚动偏移量。例如,如果一个元素在文档坐标中有 200 像素的 y 坐标,而用户向下滚动了 75 像素,那么该元素在视口坐标中的 y 坐标为 125 像素。同样,如果一个元素在用户水平滚动视口 200 像素后在视口坐标中有 400 的 x 坐标,那么元素在文档坐标中的 x 坐标为 600。

如果我们使用印刷纸质文档的思维模型,逻辑上可以假设文档中的每个元素在文档坐标中必须有一个唯一的位置,无论用户滚动了多少。这是纸质文档的一个吸引人的特性,对于简单的网页文档也适用,但总的来说,在网页上文档坐标实际上并不起作用。问题在于 CSS overflow 属性允许文档中的元素包含比其能显示的更多内容。元素可以有自己的滚动条,并作为包含的内容的视口。网页允许在滚动文档中滚动元素意味着不可能使用单个 (x,y) 点描述文档中元素的位置。

因为文档坐标实际上不起作用,客户端 JavaScript 倾向于使用视口坐标。例如,下面描述的 getBoundingClientRect()elementFromPoint() 方法使用视口坐标,而鼠标和指针事件对象的 clientXclientY 属性也使用这个坐标系。

当你使用 CSS position:fixed 明确定位元素时,topleft 属性是以视口坐标解释的。如果使用 position:relative,元素的定位是相对于如果没有设置 position 属性时的位置。如果使用 position:absolute,那么 topleft 是相对于文档或最近的包含定位元素的。这意味着,例如,相对定位元素位于相对定位元素内部,是相对于容器元素而不是相对于整个文档的。有时候,创建一个相对定位的容器并将 topleft 设置为 0(使容器正常布局)非常有用,以便为其中包含的绝对定位元素建立一个新的坐标系原点。我们可能将这个新的坐标系称为“容器坐标”,以区别于文档坐标和视口坐标。

15.5.2 查询元素的几何信息

您可以通过调用其getBoundingClientRect()方法来确定元素的大小(包括 CSS 边框和填充,但不包括边距)和位置(在视口坐标中)。它不带参数并返回一个具有属性leftrighttopbottomwidthheight的对象。lefttop属性给出元素左上角的xy坐标,rightbottom属性给出右下角的坐标。这些值之间的差异是widthheight属性。

块元素,如图像、段落和<div>元素在浏览器布局时始终是矩形的。然而,内联元素,如<span><code><b>元素,可能跨越多行,因此可能由多个矩形组成。例如,想象一下,某些文本在<em></em>标签中显示,跨越两行。其矩形包括第一行的末尾和第二行的开头。如果您在此元素上调用getBoundingClientRect(),边界矩形将包括两行的整个宽度。如果要查询内联元素的各个矩形,请调用getClientRects()方法以获取一个只读的类似数组的对象,其元素是类似于getBoundingClientRect()返回的矩形对象。

15.5.3 确定点处的元素

getBoundingClientRect()方法允许我们确定元素在视口中的当前位置。有时我们想要反向操作,并确定视口中给定位置的元素是哪个。您可以使用文档对象的elementFromPoint()方法来确定这一点。使用点的xy坐标调用此方法(使用视口坐标,而不是文档坐标:例如,鼠标事件的clientXclientY坐标)。elementFromPoint()返回一个在指定位置的元素对象。用于选择元素的命中检测算法没有明确定义,但此方法的意图是返回该点处最内部(最深度嵌套)和最上层(最高 CSS z-index属性)的元素。

15.5.4 滚动

Window 对象的scrollTo()方法接受点的xy坐标(在文档坐标中)并将其设置为滚动条偏移量。也就是说,它滚动窗口,使指定点位于视口的左上角。如果指定的点太靠近文档的底部或右边缘,浏览器会尽可能将其移动到左上角,但无法完全到达那里。以下代码将浏览器滚动,以便看到文档的最底部页面:

// Get the heights of the document and viewport.
let documentHeight = document.documentElement.offsetHeight;
let viewportHeight = window.innerHeight;
// And scroll so the last "page" shows in the viewport
window.scrollTo(0, documentHeight - viewportHeight);

Window 的scrollBy()方法类似于scrollTo(),但其参数是相对的,并添加到当前滚动位置:

// Scroll 50 pixels down every 500 ms. Note there is no way to turn this off!
setInterval(() => { scrollBy(0,50)}, 500);

如果你想要使用scrollTo()scrollBy()平滑滚动,请传递一个对象参数,而不是两个数字,就像这样:

window.scrollTo({
  left: 0,
  top: documentHeight - viewportHeight,
  behavior: "smooth"
});

通常,我们不是要在文档中滚动到数值位置,而是要滚动以使文档中的某个特定元素可见。您可以使用所需 HTML 元素上的scrollIntoView()方法来实现这一点。此方法确保调用它的元素在视口中可见。默认情况下,它尝试将元素的顶部边缘放在视口的顶部或附近。如果将false作为唯一参数传递,它将尝试将元素的底部边缘放在视口的底部。浏览器还将根据需要水平滚动视口以使元素可见。

您还可以将对象传递给scrollIntoView(),设置behavior:"smooth"属性以实现平滑滚动。您可以设置block属性以指定元素在垂直方向上的位置,并设置inline属性以指定水平滚动时元素的位置。这些属性的合法值为startendnearestcenter

视口大小、内容大小和滚动位置

正如我们所讨论的,浏览器窗口和其他 HTML 元素可以显示滚动内容。在这种情况下,我们有时需要知道视口的大小、内容的大小以及内容在视口内的滚动偏移量。本节涵盖了这些细节。

对于浏览器窗口,视口大小由window.innerWidthwindow.innerHeight属性给出。(为移动设备优化的网页通常在<head>中使用<meta name="viewport">标签来设置页面所需的视口宽度。)文档的总大小与<html>元素的大小相同,即document.documentElement。您可以在document.documentElement上调用getBoundingClientRect()来获取文档的宽度和高度,或者您可以使用document.documentElementoffsetWidthoffsetHeight属性。文档在其视口内的滚动偏移量可通过window.scrollXwindow.scrollY获得。这些是只读属性,因此您无法设置它们来滚动文档:请改用window.scrollTo()

对于元素来说情况会有些复杂。每个 Element 对象定义以下三组属性:

offsetWidth     clientWidth      scrollWidth
offsetHeight    clientHeight     scrollHeight
offsetLeft      clientLeft       scrollLeft
offsetTop       clientTop        scrollTop
offsetParent

元素的offsetWidthoffsetHeight属性返回其在屏幕上的大小(以 CSS 像素为单位)。返回的大小包括元素的边框和填充,但不包括边距。offsetLeftoffsetTop属性返回元素的xy坐标。对于许多元素,这些值是文档坐标。但对于定位元素的后代和一些其他元素(如表格单元格),这些属性返回相对于祖先元素而不是文档本身的坐标。offsetParent属性指定这些属性相对于哪个元素。这些偏移属性都是只读的。

clientWidthclientHeight类似于offsetWidthoffsetHeight,只是它们不包括边框大小,只包括内容区域及其填充。clientLeftclientTop属性并不是很有用:它们返回元素的填充外部与边框外部之间的水平和垂直距离。通常,这些值只是左边框和上边框的宽度。这些客户端属性都是只读的。对于像<i><code><span>这样的内联元素,它们都返回 0。

scrollWidthscrollHeight返回元素内容区域的大小加上其填充加上任何溢出内容。当内容适合内容区域而不溢出时,这些属性与clientWidthclientHeight相同。但当存在溢出时,它们包括溢出的内容并返回大于clientWidthclientHeight的值。scrollLeftscrollTop给出元素内容在元素视口内的滚动偏移量。与这里描述的所有其他属性不同,scrollLeftscrollTop是可写属性,您可以设置它们来滚动元素内的内容。(在大多数浏览器中,Element 对象也像 Window 对象一样具有scrollTo()scrollBy()方法,但这些方法尚未得到普遍支持。)

Web 组件

HTML 是一种用于文档标记的语言,为此定义了一套丰富的标签。在过去的三十年里,它已经成为描述 Web 应用程序用户界面的语言,但基本的 HTML 标签如<input><button>对于现代 UI 设计来说是不足够的。Web 开发人员可以让其工作,但只能通过使用 CSS 和 JavaScript 来增强基本 HTML 标签的外观和行为。考虑一个典型的用户界面组件,比如在图 15-3 中显示的搜索框。

图 15-3。一个搜索框用户界面组件

HTML <input>元素可用于接受用户的单行输入,但它没有任何显示图标的方法,比如左侧的放大镜和右侧的取消 X。为了在 Web 上实现这样一个现代用户界面元素,我们至少需要使用四个 HTML 元素:一个<input>元素用于接受和显示用户的输入,两个<img>元素(或在这种情况下,两个显示 Unicode 图标的<span>元素),以及一个容器<div>元素来容纳这三个子元素。此外,我们必须使用 CSS 来隐藏<input>元素的默认边框,并为容器定义一个边框。我们还需要使用 JavaScript 使所有 HTML 元素协同工作。当用户点击 X 图标时,我们需要一个事件处理程序来清除<input>元素中的输入,例如。

每次想在 Web 应用程序中显示一个搜索框都需要做很多工作,而今天大多数 Web 应用程序并不是使用“原始”HTML 编写的。相反,许多 Web 开发人员使用像 React 和 Angular 这样的框架,支持创建可重用的用户界面组件,比如这里显示的搜索框。Web 组件是一个基于 Web 标准的浏览器原生替代方案,它基于三个相对较新的 Web 标准添加,允许 JavaScript 使用新的标签扩展 HTML,这些标签可以作为独立的、可重用的 UI 组件。

接下来的小节将解释如何在自己的 Web 页面中使用其他开发人员定义的 Web 组件,然后解释 Web 组件基于的三种技术,并最终在一个示例中将这三种技术结合起来,实现图 15-3 中显示的搜索框元素。

15.6.1 使用 Web 组件

Web 组件是用 JavaScript 定义的,因此为了在 HTML 文件中使用 Web 组件,你需要包含定义组件的 JavaScript 文件。由于 Web 组件是一种相对较新的技术,它们通常被编写为 JavaScript 模块,因此你可以像这样在 HTML 中包含一个:

<script type="module" src="components/search-box.js">

Web 组件定义自己的 HTML 标签名称,重要的限制是这些标签名称必须包含连字符。这意味着未来版本的 HTML 可以引入不带连字符的新标签,而且不会与任何人的 Web 组件冲突。要使用 Web 组件,只需在 HTML 文件中使用其标签:

<search-box placeholder="Search..."></search-box>

Web 组件可以像常规 HTML 标签一样具有属性;你使用的组件的文档应告诉你支持哪些属性。Web 组件不能用自闭合标签来定义。例如,你不能写<search-box/>。你的 HTML 文件必须包含开放标签和闭合标签。

像常规 HTML 元素一样,一些 Web 组件被编写为期望有子元素,而另一些则被编写为不期望(也不会显示)子元素。一些 Web 组件被编写为可以选择接受特殊标记的子元素,这些子元素将出现在命名的“插槽”中。图 15-3 中显示的<search-box>组件,并在示例 15-3 中实现,使用“插槽”来显示两个图标。如果你想使用带有不同图标的<search-box>,可以使用如下 HTML:

<search-box>
  <img src="images/search-icon.png" slot="left"/>
  <img src="images/cancel-icon.png" slot="right"/>
</search-box>

slot 属性是 HTML 的扩展,用于指定哪些子元素应该放在哪里。在这个示例中定义的插槽名称“left”和“right”由 Web 组件定义。如果您使用的组件支持插槽,那么这一点应该包含在其文档中。

我之前提到,Web 组件通常作为 JavaScript 模块实现,并且可以通过<script type="module">标签加载到 HTML 文件中。您可能还记得本章开头提到的模块在文档内容解析后加载,就像它们有一个deferred标签一样。这意味着 Web 浏览器通常会在运行告诉它<search-box>是什么的代码之前解析和呈现<search-box>等标签。这在使用 Web 组件时是正常的。Web 浏览器中的 HTML 解析器对于它们不理解的输入非常灵活和宽容。当它们在组件被定义之前遇到一个 Web 组件标签时,它们会向 DOM 树添加一个通用的 HTMLElement,即使它们不知道如何处理它。稍后,当自定义元素被定义时,通用元素会被“升级”,以便看起来和行为符合预期。

如果一个 Web 组件有子元素,在组件定义之前这些子元素可能会显示不正确。您可以使用以下 CSS 来保持 Web 组件隐藏,直到它们被定义:

/*
 * Make the <search-box> component invisible before it is defined.
 * And try to duplicate its eventual layout and size so that nearby
 * content does not move when it becomes defined.
 */
search-box:not(:defined) {
    opacity:0;
    display: inline-block;
    width: 300px;
    height: 50px;
}

像常规 HTML 元素一样,Web 组件可以在 JavaScript 中使用。如果在 Web 页面中包含了<search-box>标签,那么您可以使用querySelector()和适当的 CSS 选择器获取对它的引用,就像对任何其他 HTML 标签一样。通常,只有在定义组件的模块运行后才有意义这样做,因此在查询 Web 组件时要小心不要太早。Web 组件实现通常(但这不是必需的)为它们支持的每个 HTML 属性定义一个 JavaScript 属性。而且,像 HTML 元素一样,它们也可以定义有用的方法。再次强调,您使用的 Web 组件的文档应该指定哪些属性和方法对您的 JavaScript 代码是可用的。

现在您已经了解如何使用 Web 组件,接下来的三节将介绍允许我们实现它们的三个 Web 浏览器功能。

15.6.2 HTML 模板

HTML <template> 标签与 Web 组件只有松散的关系,但它确实为在 Web 页面中频繁出现的组件提供了一个有用的优化。<template> 标签及其子元素从不被 Web 浏览器呈现,仅在使用 JavaScript 的 Web 页面上才有用。这个标签的理念是,当一个 Web 页面包含多个相同基本 HTML 结构的重复(例如表中的行或 Web 组件的内部实现)时,我们可以使用 <template> 一次定义该元素结构,然后使用 JavaScript 根据需要复制该结构多次。

在 JavaScript 中,<template> 标签由 HTMLTemplateElement 对象表示。这个对象定义了一个content属性,这个属性的值是<template>的所有子节点的 DocumentFragment。您可以克隆这个 DocumentFragment,然后根据需要将克隆的副本插入到您的文档中。片段本身不会被插入,但它的子节点会被插入。假设您正在处理一个包含<table><template id="row">标签的文档,模板定义了该表的行结构。您可以像这样使用模板:

let tableBody = document.querySelector("tbody");
let template = document.querySelector("#row");
let clone = template.content.cloneNode(true);  // deep clone
// ...Use the DOM to insert content into the <td> elements of the clone...
// Now add the cloned and initialized row into the table
tableBody.append(clone);

模板元素不必在 HTML 文档中直接出现才能发挥作用。您可以在 JavaScript 代码中创建模板,使用innerHTML创建其子元素,然后根据需要制作尽可能多的克隆而无需解析innerHTML的开销。这就是 HTML 模板在 Web 组件中通常的用法,示例 15-3 演示了这种技术。

15.6.3 自定义元素

使 Web 组件能够实现的第二个 Web 浏览器功能是“自定义元素”:将 JavaScript 类与 HTML 标签名称关联起来,以便文档中的任何此类标签自动转换为 DOM 树中的类实例。customElements.define() 方法以 Web 组件标签名称作为第一个参数(请记住标签名称必须包含连字符),以 HTMLElement 的子类作为第二个参数。文档中具有该标签名称的任何现有元素都会“升级”为新创建的类实例。如果浏览器将来解析任何 HTML,它将自动为遇到的每个标签创建一个类的实例。

传递给 customElements.define() 的类应该扩展 HTMLElement,而不是更具体的类型,如 HTMLButtonElement。回想一下第九章中提到的,当 JavaScript 类扩展另一个类时,构造函数必须在使用 this 关键字之前调用 super(),因此如果自定义元素类有构造函数,它应该在执行任何其他操作之前调用 super()(不带参数)。

浏览器将自动调用自定义元素类的某些“生命周期方法”。当自定义元素的实例插入文档中时,将调用 connectedCallback() 方法,许多元素使用此方法执行初始化。还有一个 disconnectedCallback() 方法在元素从文档中移除时(如果有的话)被调用,尽管这不太常用。

如果自定义元素类定义了一个静态的 observedAttributes 属性,其值是属性名称数组,并且如果在自定义元素的实例上设置(或更改)了任何命名属性,则浏览器将调用 attributeChangedCallback() 方法,传递属性名称、其旧值和新值。此回调可以采取任何必要步骤来根据其属性值更新组件。

自定义元素类也可以定义任何其他属性和方法。通常,它们会定义获取器和设置器方法,使元素的属性可以作为 JavaScript 属性使用。

作为自定义元素的一个示例,假设我们希望能够在常规文本段落中显示圆形。我们希望能够编写类似于以下 HTML 以渲染像图 15-4 中显示的数学问题:

<p>
  The document has one marble: <inline-circle></inline-circle>.
  The HTML parser instantiates two more marbles:
  <inline-circle diameter="1.2em" color="blue"></inline-circle>
  <inline-circle diameter=".6em" color="gold"></inline-circle>.
  How many marbles does the document contain now?
</p>

图 15-4. 内联圆形自定义元素

我们可以使用 示例 15-2 中显示的代码来实现这个 <inline-circle> 自定义元素:

示例 15-2. <inline-circle> 自定义元素
customElements.define("inline-circle", class InlineCircle extends HTMLElement {
    // The browser calls this method when an <inline-circle> element
    // is inserted into the document. There is also a disconnectedCallback()
    // that we don't need in this example.
    connectedCallback() {
        // Set the styles needed to create circles
        this.style.display = "inline-block";
        this.style.borderRadius = "50%";
        this.style.border = "solid black 1px";
        this.style.transform = "translateY(10%)";
        // If there is not already a size defined, set a default size
        // that is based on the current font size.
        if (!this.style.width) {
            this.style.width = "0.8em";
            this.style.height = "0.8em";
        }
    }
    // The static observedAttributes property specifies which attributes
    // we want to be notified about changes to. (We use a getter here since
    // we can only use "static" with methods.)
    static get observedAttributes() { return ["diameter", "color"]; }
    // This callback is invoked when one of the attributes listed above
    // changes, either when the custom element is first parsed, or later.
    attributeChangedCallback(name, oldValue, newValue) {
        switch(name) {
        case "diameter":
            // If the diameter attribute changes, update the size styles
            this.style.width = newValue;
            this.style.height = newValue;
            break;
        case "color":
            // If the color attribute changes, update the color styles
            this.style.backgroundColor = newValue;
            break;
        }
    }
    // Define JavaScript properties that correspond to the element's
    // attributes. These getters and setters just get and set the underlying
    // attributes. If a JavaScript property is set, that sets the attribute
    // which triggers a call to attributeChangedCallback() which updates
    // the element styles.
    get diameter() { return this.getAttribute("diameter"); }
    set diameter(diameter) { this.setAttribute("diameter", diameter); }
    get color() { return this.getAttribute("color"); }
    set color(color) { this.setAttribute("color", color); }
});

15.6.4 影子 DOM

在示例 15-2 中展示的自定义元素没有很好地封装。当设置其 diametercolor 属性时,它会通过更改自己的 style 属性来响应,这不是我们从真正的 HTML 元素中期望的行为。要将自定义元素转变为真正的 Web 组件,它应该使用强大的封装机制,即影子 DOM

Shadow DOM 允许将“影子根”附加到自定义元素(以及 <div><span><body><article><main><nav><header><footer><section><p><blockquote><aside><h1><h6> 元素)上,称为“影子主机”。影子主机元素,像所有 HTML 元素一样,已经是后代元素和文本节点的普通 DOM 树的根。影子根是另一个更私密的后代元素树的根,从影子主机发芽,可以被视为一个独立的小型文档。

“shadow DOM” 中的 “shadow” 一词指的是从影子根源的元素“隐藏在阴影中”:它们不是正常 DOM 树的一部分,不出现在其宿主元素的 children 数组中,并且不会被正常的 DOM 遍历方法(如 querySelector())访问。相比之下,影子宿主的正常、常规 DOM 子元素有时被称为 “light DOM”。

要理解影子 DOM 的目的,想象一下 HTML <audio><video> 元素:它们显示了一个用于控制媒体播放的非平凡用户界面,但播放和暂停按钮以及其他 UI 元素不是 DOM 树的一部分,也不能被 JavaScript 操纵。鉴于 Web 浏览器设计用于显示 HTML,浏览器供应商自然希望使用 HTML 显示这些内部 UI。事实上,大多数浏览器长期以来一直在做类似的事情,而影子 DOM 使其成为 Web 平台的标准部分。

影子 DOM 封装

影子 DOM 的关键特征是提供的封装。影子根的后代元素对于常规 DOM 树是隐藏的,并且独立的,几乎就像它们在一个独立的文档中一样。影子 DOM 提供了三种非常重要的封装类型:

  • 如前所述,影子 DOM 中的元素对于像 querySelectorAll() 这样的常规 DOM 方法是隐藏的。当创建一个影子根并将其附加到其影子宿主时,它可以以 “open” 或 “closed” 模式创建。尽管更常见的是,影子根以 “open” 模式创建,这意味着影子宿主具有一个 shadowRoot 属性,JavaScript 可以使用它来访问影子根的元素,如果有某种原因需要这样做。
  • 在影子根下定义的样式是私有的,并且永远不会影响外部的 light DOM 元素。(影子根可以为其宿主元素定义默认样式,但这些样式将被 light DOM 样式覆盖。)同样,适用于影子宿主元素的 light DOM 样式对影子根的后代元素没有影响。影子 DOM 中的元素将从 light DOM 继承诸如字体大小和背景颜色之类的属性,并且影子 DOM 中的样式可以选择使用在 light DOM 中定义的 CSS 变量。然而,在大多数情况下,light DOM 的样式和影子 DOM 的样式是完全独立的:Web 组件的作者和 Web 组件的用户不必担心样式表之间的冲突或冲突。以这种方式“范围” CSS 可能是影子 DOM 最重要的特性。
  • 在影子 DOM 中发生的一些事件(如 “load”)被限制在影子 DOM 中。其他事件,包括焦点、鼠标和键盘事件会冒泡并传播出去。当起源于影子 DOM 的事件越过边界并开始在 light DOM 中传播时,其 target 属性会更改为影子宿主元素,因此看起来好像是直接在该元素上发生的。

影子 DOM 插槽和 light DOM 子元素

作为影子宿主的 HTML 元素有两个后代树。一个是 children[] 数组—宿主元素的常规 light DOM 后代—另一个是影子根及其所有后代,您可能想知道如何在同一宿主元素内显示两个不同的内容树。工作原理如下:

  • 影子根的后继元素始终显示在影子宿主内。
  • 如果这些后代包括一个 <slot> 元素,则主机元素的常规 light DOM 子元素将显示为该 <slot> 的子元素,替换插槽中的任何 shadow DOM 内容。如果 shadow DOM 不包含 <slot>,则主机的任何 light DOM 内容都不会显示。如果 shadow DOM 有一个 <slot>,但 shadow host 没有 light DOM 子元素,则插槽的 shadow DOM 内容将作为默认显示。
  • 当 light DOM 内容显示在 shadow DOM 插槽中时,我们说这些元素已被“分发”,但重要的是要理解这些元素实际上并未成为 shadow DOM 的一部分。它们仍然可以使用 querySelector() 进行查询,并且它们仍然显示在 light DOM 中,作为主机元素的子元素或后代。
  • 如果 shadow DOM 定义了多个带有 name 属性命名的 <slot>,那么 shadow host 的子元素可以通过指定 slot="slotname" 属性来指定它们想要出现在哪个插槽中。我们在 §15.6.1 中演示了这种用法的示例,当我们演示如何自定义 <search-box> 组件显示的图标时。

Shadow DOM API

尽管 Shadow DOM 功能强大,但它的 JavaScript API 并不多。要将 light DOM 元素转换为 shadow host,只需调用其 attachShadow() 方法,将 {mode:"open"} 作为唯一参数传递。此方法返回一个 shadow root 对象,并将该对象设置为主机的 shadowRoot 属性的值。shadow root 对象是一个 DocumentFragment,您可以使用 DOM 方法向其添加内容,或者只需将其 innerHTML 属性设置为 HTML 字符串。

如果您的 Web 组件需要知道 shadow DOM <slot> 的 light DOM 内容何时更改,它可以直接在 <slot> 元素上注册“slotchanged”事件的监听器。

15.6.5 示例:一个 Web 组件

图 15-3 展示了一个 <search-box> Web 组件。示例 15-3 演示了定义 Web 组件的三种启用技术:它将 <search-box> 组件实现为使用 <template> 标签提高效率和使用 shadow root 封装的自定义元素。

此示例展示了如何直接使用低级 Web 组件 API。实际上,今天开发的许多 Web 组件都是使用诸如 “lit-element” 等更高级别库创建的。使用库的原因之一是创建可重用和可定制组件实际上是非常困难的,并且有许多细节需要正确处理。示例 15-3 演示了 Web 组件并进行了一些基本的键盘焦点处理,但忽略了可访问性,并且没有尝试使用正确的 ARIA 属性使组件与屏幕阅读器和其他辅助技术配合使用。

示例 15-3。实现一个 Web 组件
/**
 * This class defines a custom HTML <search-box> element that displays an
 * <input> text input field plus two icons or emoji. By default, it displays a
 * magnifying glass emoji (indicating search) to the left of the text field
 * and an X emoji (indicating cancel) to the right of the text field. It
 * hides the border on the input field and displays a border around itself,
 * creating the appearance that the two emoji are inside the input
 * field. Similarly, when the internal input field is focused, the focus ring
 * is displayed around the <search-box>.
 *
 * You can override the default icons by including <span> or <img> children
 * of <search-box> with slot="left" and slot="right" attributes.
 *
 * <search-box> supports the normal HTML disabled and hidden attributes and
 * also size and placeholder attributes, which have the same meaning for this
 * element as they do for the <input> element.
 *
 * Input events from the internal <input> element bubble up and appear with
 * their target field set to the <search-box> element.
 *
 * The element fires a "search" event with the detail property set to the
 * current input string when the user clicks on the left emoji (the magnifying
 * glass). The "search" event is also dispatched when the internal text field
 * generates a "change" event (when the text has changed and the user types
 * Return or Tab).
 *
 * The element fires a "clear" event when the user clicks on the right emoji
 * (the X). If no handler calls preventDefault() on the event then the element
 * clears the user's input once event dispatch is complete.
 *
 * Note that there are no onsearch and onclear properties or attributes:
 * handlers for the "search" and "clear" events can only be registered with
 * addEventListener().
 */
class SearchBox extends HTMLElement {
    constructor() {
        super(); // Invoke the superclass constructor; must be first.
        // Create a shadow DOM tree and attach it to this element, setting
        // the value of this.shadowRoot.
        this.attachShadow({mode: "open"});
        // Clone the template that defines the descendants and stylesheet for
        // this custom component, and append that content to the shadow root.
        this.shadowRoot.append(SearchBox.template.content.cloneNode(true));
        // Get references to the important elements in the shadow DOM
        this.input = this.shadowRoot.querySelector("#input");
        let leftSlot = this.shadowRoot.querySelector('slot[name="left"]');
        let rightSlot = this.shadowRoot.querySelector('slot[name="right"]');
        // When the internal input field gets or loses focus, set or remove
        // the "focused" attribute which will cause our internal stylesheet
        // to display or hide a fake focus ring on the entire component. Note
        // that the "blur" and "focus" events bubble and appear to originate
        // from the <search-box>.
        this.input.onfocus = () => { this.setAttribute("focused", ""); };
        this.input.onblur = () => { this.removeAttribute("focused");};
        // If the user clicks on the magnifying glass, trigger a "search"
        // event.  Also trigger it if the input field fires a "change"
        // event. (The "change" event does not bubble out of the Shadow DOM.)
        leftSlot.onclick = this.input.onchange = (event) => {
            event.stopPropagation();    // Prevent click events from bubbling
            if (this.disabled) return;  // Do nothing when disabled
            this.dispatchEvent(new CustomEvent("search", {
                detail: this.input.value
            }));
        };
        // If the user clicks on the X, trigger a "clear" event.
        // If preventDefault() is not called on the event, clear the input.
        rightSlot.onclick = (event) => {
            event.stopPropagation();    // Don't let the click bubble up
            if (this.disabled) return;  // Don't do anything if disabled
            let e = new CustomEvent("clear", { cancelable: true });
            this.dispatchEvent(e);
            if (!e.defaultPrevented) {  // If the event was not "cancelled"
                this.input.value = "";  // then clear the input field
            }
        };
    }
    // When some of our attributes are set or changed, we need to set the
    // corresponding value on the internal <input> element. This life cycle
    // method, together with the static observedAttributes property below,
    // takes care of that.
    attributeChangedCallback(name, oldValue, newValue) {
        if (name === "disabled") {
            this.input.disabled = newValue !== null;
        } else if (name === "placeholder") {
            this.input.placeholder = newValue;
        } else if (name === "size") {
            this.input.size = newValue;
        } else if (name === "value") {
            this.input.value = newValue;
        }
    }
    // Finally, we define property getters and setters for properties that
    // correspond to the HTML attributes we support. The getters simply return
    // the value (or the presence) of the attribute. And the setters just set
    // the value (or the presence) of the attribute. When a setter method
    // changes an attribute, the browser will automatically invoke the
    // attributeChangedCallback above.
    get placeholder() { return this.getAttribute("placeholder"); }
    get size() { return this.getAttribute("size"); }
    get value() { return this.getAttribute("value"); }
    get disabled() { return this.hasAttribute("disabled"); }
    get hidden() { return this.hasAttribute("hidden"); }
    set placeholder(value) { this.setAttribute("placeholder", value); }
    set size(value) { this.setAttribute("size", value); }
    set value(text) { this.setAttribute("value", text); }
    set disabled(value) {
        if (value) this.setAttribute("disabled", "");
        else this.removeAttribute("disabled");
    }
    set hidden(value) {
        if (value) this.setAttribute("hidden", "");
        else this.removeAttribute("hidden");
    }
}
// This static field is required for the attributeChangedCallback method.
// Only attributes named in this array will trigger calls to that method.
SearchBox.observedAttributes = ["disabled", "placeholder", "size", "value"];
// Create a <template> element to hold the stylesheet and the tree of
// elements that we'll use for each instance of the SearchBox element.
SearchBox.template = document.createElement("template");
// We initialize the template by parsing this string of HTML. Note, however,
// that when we instantiate a SearchBox, we are able to just clone the nodes
// in the template and do have to parse the HTML again.
SearchBox.template.innerHTML = `
<style>
/*
 * The :host selector refers to the <search-box> element in the light
 * DOM. These styles are defaults and can be overridden by the user of the
 * <search-box> with styles in the light DOM.
 */
:host {
 display: inline-block;   /* The default is inline display */
 border: solid black 1px; /* A rounded border around the <input> and <slots> */
 border-radius: 5px;
 padding: 4px 6px;        /* And some space inside the border */
}
:host([hidden]) {          /* Note the parentheses: when host has hidden... */
 display:none;            /* ...attribute set don't display it */
}
:host([disabled]) {        /* When host has the disabled attribute... */
 opacity: 0.5;            /* ...gray it out */
}
:host([focused]) {         /* When host has the focused attribute... */
 box-shadow: 0 0 2px 2px #6AE;  /* display this fake focus ring. */
}
/* The rest of the stylesheet only applies to elements in the Shadow DOM. */
input {
 border-width: 0;         /* Hide the border of the internal input field. */
 outline: none;           /* Hide the focus ring, too. */
 font: inherit;           /* <input> elements don't inherit font by default */
 background: inherit;     /* Same for background color. */
}
slot {
 cursor: default;         /* An arrow pointer cursor over the buttons */
 user-select: none;       /* Don't let the user select the emoji text */
}
</style>
<div>
 <slot name="left">\u{1f50d}</slot>  <!-- U+1F50D is a magnifying glass -->
 <input type="text" id="input" />    <!-- The actual input element -->
 <slot name="right">\u{2573}</slot>  <!-- U+2573 is an X -->
</div>
`;
// Finally, we call customElement.define() to register the SearchBox element
// as the implementation of the <search-box> tag. Custom elements are required
// to have a tag name that contains a hyphen.
customElements.define("search-box", SearchBox);

15.7 SVG:可缩放矢量图形

SVG(可缩放矢量图形)是一种图像格式。其名称中的“矢量”一词表明它与像 GIF、JPEG 和 PNG 这样指定像素值矩阵的位图图像格式 fundamentally fundamentally 不同。相反,SVG “图像”是绘制所需图形的步骤的精确、与分辨率无关(因此“可缩放”)描述。SVG 图像由使用 XML 标记语言的文本文件描述,这与 HTML 非常相似。

在 Web 浏览器中有三种使用 SVG 的方式:

  1. 您可以像使用 .png.jpeg 图像一样使用 .svg 图像文件与常规 HTML <img> 标签。
  2. 由于基于 XML 的 SVG 格式与 HTML 如此相似,您实际上可以直接将 SVG 标记嵌入到 HTML 文档中。如果这样做,浏览器的 HTML 解析器允许您省略 XML 命名空间,并将 SVG 标记视为 HTML 标记。
  3. 您可以使用 DOM API 动态创建 SVG 元素以根据需要生成图像。

接下来的小节演示了 SVG 的第二和第三种用法。但请注意,SVG 具有庞大且稍微复杂的语法。除了简单的形状绘制原语外,它还包括对任意曲线、文本和动画的支持。SVG 图形甚至可以包含 JavaScript 脚本和 CSS 样式表,以添加行为和呈现信息。SVG 的完整描述远远超出了本书的范围。本节的目标只是向您展示如何在 HTML 文档中使用 SVG 并使用 JavaScript 进行脚本化。

15.7.1 HTML 中的 SVG

当然,SVG 图像可以使用 HTML <img>标签显示。但您也可以直接在 HTML 中嵌入 SVG。如果这样做,甚至可以使用 CSS 样式表来指定字体、颜色和线宽等内容。例如,这里是一个使用 SVG 显示模拟时钟表盘的 HTML 文件:

<html>
<head>
<title>Analog Clock</title>
<style>
/* These CSS styles all apply to the SVG elements defined below */
#clock {                             /* Styles for everything in the clock:*/
   stroke: black;                    /* black lines */
   stroke-linecap: round;            /* with rounded ends */
   fill: #ffe;                       /* on an off-white background */
}
#clock .face { stroke-width: 3; }    /* Clock face outline */
#clock .ticks { stroke-width: 2; }   /* Lines that mark each hour */
#clock .hands { stroke-width: 3; }   /* How to draw the clock hands */
#clock .numbers {                    /* How to draw the numbers */
    font-family: sans-serif; font-size: 10; font-weight: bold;
    text-anchor: middle; stroke: none; fill: black;
}
</style>
</head>
<body>
  <svg id="clock" viewBox="0 0 100 100" width="250" height="250">
    <!-- The width and height attributes are the screen size of the graphic -->
    <!-- The viewBox attribute gives the internal coordinate system -->
    <circle class="face" cx="50" cy="50" r="45"/>  <!-- the clock face -->
    <g class="ticks">   <!-- tick marks for each of the 12 hours -->
      <line x1='50' y1='5.000' x2='50.00' y2='10.00'/>
      <line x1='72.50' y1='11.03' x2='70.00' y2='15.36'/>
      <line x1='88.97' y1='27.50' x2='84.64' y2='30.00'/>
      <line x1='95.00' y1='50.00' x2='90.00' y2='50.00'/>
      <line x1='88.97' y1='72.50' x2='84.64' y2='70.00'/>
      <line x1='72.50' y1='88.97' x2='70.00' y2='84.64'/>
      <line x1='50.00' y1='95.00' x2='50.00' y2='90.00'/>
      <line x1='27.50' y1='88.97' x2='30.00' y2='84.64'/>
      <line x1='11.03' y1='72.50' x2='15.36' y2='70.00'/>
      <line x1='5.000' y1='50.00' x2='10.00' y2='50.00'/>
      <line x1='11.03' y1='27.50' x2='15.36' y2='30.00'/>
      <line x1='27.50' y1='11.03' x2='30.00' y2='15.36'/>
    </g>
    <g class="numbers"> <!-- Number the cardinal directions-->
      <text x="50" y="18">12</text><text x="85" y="53">3</text>
      <text x="50" y="88">6</text><text x="15" y="53">9</text>
    </g>
    <g class="hands">   <!-- Draw hands pointing straight up. -->
      <line class="hourhand" x1="50" y1="50" x2="50" y2="25"/>
      <line class="minutehand" x1="50" y1="50" x2="50" y2="20"/>
    </g>
  </svg>
  <script src="clock.js"></script>
</body>
</html>

您会注意到<svg>标签的后代不是普通的 HTML 标签。<circle><line><text>标签具有明显的目的,因此这个 SVG 图形的工作原理应该很清楚。然而,还有许多其他 SVG 标签,您需要查阅 SVG 参考资料以了解更多信息。您可能还会注意到样式表很奇怪。像fillstroke-widthtext-anchor这样的样式不是正常的 CSS 样式属性。在这种情况下,CSS 基本上用于设置文档中出现的 SVG 标签的属性。还要注意,CSS 的font简写属性不适用于 SVG 标签,您必须显式设置font-familyfont-sizefont-weight等单独的样式属性。

15.7.2 脚本化 SVG

将 SVG 直接嵌入 HTML 文件中(而不仅仅使用静态的<img>标签)的一个原因是,这样做可以使用 DOM API 来操纵 SVG 图像。假设您在 Web 应用程序中使用 SVG 显示图标。您可以在<template>标签中嵌入 SVG(§15.6.2),然后在需要将该图标的副本插入 UI 时克隆模板内容。如果您希望图标对用户活动做出响应——例如,当用户将指针悬停在其上时更改颜色——通常可以使用 CSS 实现。

还可以动态操作直接嵌入 HTML 中的 SVG 图形。前一节中的时钟示例显示了一个静态时钟,时针和分针指向正上方,显示中午或午夜时间。但您可能已经注意到 HTML 文件包含了一个<script>标签。该脚本定期运行一个函数来检查时间,并根据需要旋转时针和分针的适当角度,使时钟实际显示当前时间,如图 15-5 所示。

图 15-5. 一个脚本化的 SVG 模拟时钟

操纵时钟的代码很简单。它根据当前时间确定时针和分针的正确角度,然后使用querySelector()查找显示这些指针的 SVG 元素,然后在它们上设置transform属性以围绕时钟表盘的中心旋转它们。该函数使用setTimeout()确保它每分钟运行一次:

(function updateClock() { // Update the SVG clock graphic to show current time
    let now = new Date();                       // Current time
    let sec = now.getSeconds();                 // Seconds
    let min = now.getMinutes() + sec/60;        // Fractional minutes
    let hour = (now.getHours() % 12) + min/60;  // Fractional hours
    let minangle = min * 6;                     // 6 degrees per minute
    let hourangle = hour * 30;                  // 30 degrees per hour
    // Get SVG elements for the hands of the clock
    let minhand = document.querySelector("#clock .minutehand");
    let hourhand = document.querySelector("#clock .hourhand");
    // Set an SVG attribute on them to move them around the clock face
    minhand.setAttribute("transform", `rotate(${minangle},50,50)`);
    hourhand.setAttribute("transform", `rotate(${hourangle},50,50)`);
    // Run this function again in 10 seconds
    setTimeout(updateClock, 10000);
}()); // Note immediate invocation of the function here.

15.7.3 使用 JavaScript 创建 SVG 图像

除了简单地在 HTML 文档中嵌入脚本化的 SVG 图像外,您还可以从头开始构建 SVG 图像,这对于创建动态加载数据的可视化效果非常有用。示例 15-4 演示了如何使用 JavaScript 创建 SVG 饼图,就像在图 15-6 中显示的那样。

尽管 SVG 标记可以包含在 HTML 文档中,但它们在技术上是 XML 标记,而不是 HTML 标记,如果要使用 JavaScript DOM API 创建 SVG 元素,就不能使用在§15.3.5 中介绍的普通createElement()函数。相反,必须使用createElementNS(),它的第一个参数是 XML 命名空间字符串。对于 SVG,该命名空间是字面字符串“http://www.w3.org/2000/svg”。

图 15-6. 使用 JavaScript 构建的 SVG 饼图(数据来自 Stack Overflow 的 2018 年开发者调查最受欢迎技术)

除了使用createElementNS()之外,示例 15-4 中的饼图绘制代码相对简单。有一点数学计算将被绘制的数据转换为饼图角度。然而,示例的大部分是创建 SVG 元素并在这些元素上设置属性的 DOM 代码。

这个示例中最不透明的部分是绘制实际饼图片段的代码。用于显示每个片段的元素是<path>。这个 SVG 元素描述由线条和曲线组成的任意形状。形状描述由<path>元素的d属性指定。该属性的值使用字母代码和数字的紧凑语法,指定坐标、角度和其他值。例如,字母 M 表示“移动到”,后面跟着xy坐标。字母 L 表示“线到”,从当前点画一条线到其后面的坐标。这个示例还使用字母 A 来绘制弧线。这个字母后面跟着描述弧线的七个数字,如果想了解更多,可以在线查找语法。

示例 15-4. 使用 JavaScript 和 SVG 绘制饼图
/**
 * Create an <svg> element and draw a pie chart into it.
 *
 * This function expects an object argument with the following properties:
 *
 *   width, height: the size of the SVG graphic, in pixels
 *   cx, cy, r: the center and radius of the pie
 *   lx, ly: the upper-left corner of the chart legend
 *   data: an object whose property names are data labels and whose
 *         property values are the values associated with each label
 *
 * The function returns an <svg> element. The caller must insert it into
 * the document in order to make it visible.
 */
function pieChart(options) {
    let {width, height, cx, cy, r, lx, ly, data} = options;
    // This is the XML namespace for svg elements
    let svg = "http://www.w3.org/2000/svg";
    // Create the <svg> element, and specify pixel size and user coordinates
    let chart = document.createElementNS(svg, "svg");
    chart.setAttribute("width", width);
    chart.setAttribute("height", height);
    chart.setAttribute("viewBox", `0 0 ${width} ${height}`);
    // Define the text styles we'll use for the chart. If we leave these
    // values unset here, they can be set with CSS instead.
    chart.setAttribute("font-family", "sans-serif");
    chart.setAttribute("font-size", "18");
    // Get labels and values as arrays and add up the values so we know how
    // big the pie is.
    let labels = Object.keys(data);
    let values = Object.values(data);
    let total = values.reduce((x,y) => x+y);
    // Figure out the angles for all the slices. Slice i starts at angles[i]
    // and ends at angles[i+1]. The angles are measured in radians.
    let angles = [0];
    values.forEach((x, i) => angles.push(angles[i] + x/total * 2 * Math.PI));
    // Now loop through the slices of the pie
    values.forEach((value, i) => {
        // Compute the two points where our slice intersects the circle
        // These formulas are chosen so that an angle of 0 is at 12 o'clock
        // and positive angles increase clockwise.
        let x1 = cx + r * Math.sin(angles[i]);
        let y1 = cy - r * Math.cos(angles[i]);
        let x2 = cx + r * Math.sin(angles[i+1]);
        let y2 = cy - r * Math.cos(angles[i+1]);
        // This is a flag for angles larger than a half circle
        // It is required by the SVG arc drawing component
        let big = (angles[i+1] - angles[i] > Math.PI) ? 1 : 0;
        // This string describes how to draw a slice of the pie chart:
        let path = `M${cx},${cy}` +     // Move to circle center.
            `L${x1},${y1}` +            // Draw line to (x1,y1).
            `A${r},${r} 0 ${big} 1` +   // Draw an arc of radius r...
            `${x2},${y2}` +             // ...ending at to (x2,y2).
            "Z";                        // Close path back to (cx,cy).
        // Compute the CSS color for this slice. This formula works for only
        // about 15 colors. So don't include more than 15 slices in a chart.
        let color = `hsl(${(i*40)%360},${90-3*i}%,${50+2*i}%)`;
        // We describe a slice with a <path> element. Note createElementNS().
        let slice = document.createElementNS(svg, "path");
        // Now set attributes on the <path> element
        slice.setAttribute("d", path);           // Set the path for this slice
        slice.setAttribute("fill", color);       // Set slice color
        slice.setAttribute("stroke", "black");   // Outline slice in black
        slice.setAttribute("stroke-width", "1"); // 1 CSS pixel thick
        chart.append(slice);                     // Add slice to chart
        // Now draw a little matching square for the key
        let icon = document.createElementNS(svg, "rect");
        icon.setAttribute("x", lx);              // Position the square
        icon.setAttribute("y", ly + 30*i);
        icon.setAttribute("width", 20);          // Size the square
        icon.setAttribute("height", 20);
        icon.setAttribute("fill", color);        // Same fill color as slice
        icon.setAttribute("stroke", "black");    // Same outline, too.
        icon.setAttribute("stroke-width", "1");
        chart.append(icon);                      // Add to the chart
        // And add a label to the right of the rectangle
        let label = document.createElementNS(svg, "text");
        label.setAttribute("x", lx + 30);        // Position the text
        label.setAttribute("y", ly + 30*i + 16);
        label.append(`${labels[i]} ${value}`);   // Add text to label
        chart.append(label);                     // Add label to the chart
    });
    return chart;
}

图 15-6 中的饼图是使用示例 15-4 中的pieChart()函数创建的,如下所示:

document.querySelector("#chart").append(pieChart({
    width: 640, height:400,    // Total size of the chart
    cx: 200, cy: 200, r: 180,  // Center and radius of the pie
    lx: 400, ly: 10,           // Position of the legend
    data: {                    // The data to chart
        "JavaScript": 71.5,
        "Java": 45.4,
        "Bash/Shell": 40.4,
        "Python": 37.9,
        "C#": 35.3,
        "PHP": 31.4,
        "C++": 24.6,
        "C": 22.1,
        "TypeScript": 18.3,
        "Ruby": 10.3,
        "Swift": 8.3,
        "Objective-C": 7.3,
        "Go": 7.2,
    }
}));

15.8 中的图形

<canvas>元素本身没有自己的外观,但在文档中创建了一个绘图表面,并向客户端 JavaScript 公开了强大的绘图 API。<canvas> API 与 SVG 之间的主要区别在于,使用 canvas 时通过调用方法创建绘图,而使用 SVG 时通过构建 XML 元素树创建绘图。这两种方法具有同等的强大功能:任何一种都可以模拟另一种。然而,在表面上,它们是非常不同的,每种方法都有其优势和劣势。例如,SVG 图形很容易通过从描述中删除元素来编辑。要从<canvas>中的相同图形中删除元素,通常需要擦除绘图并从头开始重绘。由于 Canvas 绘图 API 基于 JavaScript 且相对紧凑(不像 SVG 语法),因此在本书中对其进行了更详细的文档记录。

大部分 Canvas 绘图 API 并不是在<canvas>元素本身上定义的,而是在通过 canvas 的getContext()方法获得的“绘图上下文”对象上定义的。使用参数“2d”调用getContext()以获得一个 CanvasRenderingContext2D 对象,您可以使用它将二维图形绘制到画布上。

作为 Canvas API 的一个简单示例,以下 HTML 文档使用<canvas>元素和一些 JavaScript 来显示两个简单的形状:

<p>This is a red square: <canvas id="square" width=10 height=10></canvas>.
<p>This is a blue circle: <canvas id="circle" width=10 height=10></canvas>.
<script>
let canvas = document.querySelector("#square");  // Get first canvas element
let context = canvas.getContext("2d");           // Get 2D drawing context
context.fillStyle = "#f00";                      // Set fill color to red
context.fillRect(0,0,10,10);                     // Fill a square
canvas = document.querySelector("#circle");      // Second canvas element
context = canvas.getContext("2d");               // Get its context
context.beginPath();                             // Begin a new "path"
context.arc(5, 5, 5, 0, 2*Math.PI, true);        // Add a circle to the path
context.fillStyle = "#00f";                      // Set blue fill color
context.fill();                                  // Fill the path
</script>

我们已经看到 SVG 将复杂形状描述为由线条和曲线组成的“路径”。Canvas API 也使用路径的概念。路径不是通过字母和数字的字符串描述,而是通过一系列方法调用来定义,例如前面代码中的beginPath()arc()调用。一旦定义了路径,其他方法,如fill(),就会对该路径进行操作。上下文对象的各种属性,如fillStyle,指定了这些操作是如何执行的。

接下来的小节演示了 2D Canvas API 的方法和属性。后面的示例代码大部分操作一个名为c的变量。这个变量保存了画布的 CanvasRenderingContext2D 对象,但有时初始化该变量的代码并没有显示。为了使这些示例运行,你需要添加 HTML 标记来定义一个带有适当widthheight属性的画布,然后添加像这样的代码来初始化变量c

let canvas = document.querySelector("#my_canvas_id");
let c = canvas.getContext('2d');

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 中显示的图形。

图 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 中显示的图形。

图 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 的值及其结果的图形外观。

图 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()方法弹出堆栈并恢复最近保存的状态。本节中描述的所有属性都是保存状态的一部分,当前的变换和裁剪区域也是如此(稍后将对两者进行解释)。重要的是,当前定义的路径和当前点不是图形状态的一部分,不能保存和恢复。


JavaScript 权威指南第七版(GPT 重译)(六)(3)https://developer.aliyun.com/article/1485427

相关文章
|
13天前
|
存储 JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(一)(4)
JavaScript 权威指南第七版(GPT 重译)(一)
34 3
|
12天前
|
前端开发 JavaScript 安全
JavaScript 权威指南第七版(GPT 重译)(六)(1)
JavaScript 权威指南第七版(GPT 重译)(六)
27 3
JavaScript 权威指南第七版(GPT 重译)(六)(1)
|
13天前
|
JSON JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(四)(4)
JavaScript 权威指南第七版(GPT 重译)(四)
67 6
|
12天前
|
前端开发 JavaScript API
JavaScript 权威指南第七版(GPT 重译)(六)(3)
JavaScript 权威指南第七版(GPT 重译)(六)
55 4
|
13天前
|
存储 缓存 自然语言处理
JavaScript 权威指南第七版(GPT 重译)(三)(4)
JavaScript 权威指南第七版(GPT 重译)(三)
32 3
|
13天前
|
前端开发 JavaScript Java
JavaScript 权威指南第七版(GPT 重译)(四)(3)
JavaScript 权威指南第七版(GPT 重译)(四)
72 3
|
前端开发 JavaScript 安全
JavaScript 权威指南第七版(GPT 重译)(七)(4)
JavaScript 权威指南第七版(GPT 重译)(七)
24 0
|
JavaScript 前端开发 安全
JavaScript 权威指南第七版(GPT 重译)(二)(3)
JavaScript 权威指南第七版(GPT 重译)(二)
49 8
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(1)
JavaScript 权威指南第七版(GPT 重译)(七)
60 0
|
13天前
|
设计模式 JavaScript 前端开发
JavaScript 权威指南第七版(GPT 重译)(四)(2)
JavaScript 权威指南第七版(GPT 重译)(四)
32 2