第十五章:JavaScript 在 Web 浏览器中
JavaScript 语言是在 1994 年创建的,旨在使 Web 浏览器显示的文档具有动态行为。自那时以来,该语言已经发生了显著的演变,与此同时,Web 平台的范围和功能也迅速增长。今天,JavaScript 程序员可以将 Web 视为一个功能齐全的应用程序开发平台。Web 浏览器专门用于显示格式化文本和图像,但是,像本机操作系统一样,浏览器还提供其他服务,包括图形、视频、音频、网络、存储和线程。JavaScript 是一种使 Web 应用程序能够使用 Web 平台提供的服务的语言,本章演示了您如何使用这些最重要的服务。
本章从网络平台的编程模型开始,解释了脚本如何嵌入在 HTML 页面中(§15.1),以及 JavaScript 代码如何通过事件异步触发(§15.2)。接下来的部分将记录启发性材料之后的核心 JavaScript API,使您的 Web 应用程序能够:
-
控制文档内容(§15.3)和样式(§15.4)
-
确定文档元素的屏幕位置(§15.5)
-
创建可重用的用户界面组件(§15.6)
-
绘制图形(§15.7 和§15.8)
-
播放和生成声音(§15.9)
-
管理浏览器导航和历史记录(§15.10)
-
在网络上交换数据(§15.11)
-
在用户计算机上存储数据(§15.12)
-
使用线程执行并发计算(§15.13)
本书的早期版本试图全面涵盖 Web 浏览器定义的所有 JavaScript API,结果,十年前这本书太长了。Web API 的数量和复杂性继续增长,我不再认为尝试在一本书中涵盖它们所有是有意义的。截至第七版,我的目标是全面覆盖 JavaScript 语言,并提供深入介绍如何在 Node 和 Web 浏览器中使用该语言。本章无法涵盖所有 Web API,但它以足够的细节介绍了最重要的 API,以便您可以立即开始使用它们。并且,学习了这里介绍的核心 API 后,您应该能够在需要时学习新的 API(比如§15.15 中总结的那些)。
Node 有一个单一的实现和一个单一的权威文档来源。相比之下,Web API 是由主要的 Web 浏览器供应商之间的共识定义的,权威文档采用了面向实现 API 的 C++程序员的规范形式,而不是面向将使用它的 JavaScript 程序员。幸运的是,Mozilla 的“MDN web docs”项目是 Web API 文档的一个可靠和全面的来源¹。
15.1 Web 编程基础
本节解释了 Web 上的 JavaScript 程序的结构,它们如何加载到 Web 浏览器中,如何获取输入,如何产生输出,以及如何通过响应事件异步运行。
15.1.1 HTML 中的 JavaScript <script>标签
Web 浏览器显示 HTML 文档。如果您希望 Web 浏览器执行 JavaScript 代码,您必须在 HTML 文档中包含(或引用)该代码,这就是 HTML <script>标签的作用。
JavaScript 代码可以内联出现在 HTML 文件中的<script>和</script>标签之间。例如,这是一个包含 JavaScript 代码的脚本标签的 HTML 文件,动态更新文档的一个元素,使其表现得像一个数字时钟:
<!DOCTYPE html> <!-- This is an HTML5 file -->
<html> <!-- The root element -->
<head> <!-- Title, scripts & styles can go here -->
<title>Digital Clock</title>
<style> /* A CSS stylesheet for the clock */
#clock { /* Styles apply to element with id="clock" */
font: bold 24px sans-serif; /* Use a big bold font */
background: #ddf; /* on a light bluish-gray background. */
padding: 15px; /* Surround it with some space */
border: solid black 2px; /* and a solid black border */
border-radius: 10px; /* with rounded corners. */
}
</style>
</head>
<body> <!-- The body holds the content of the document. -->
<h1>Digital Clock</h1> <!-- Display a title. -->
<span id="clock"></span> <!-- We will insert the time into this element. -->
<script>
// Define a function to display the current time
function displayTime() {
let clock = document.querySelector("#clock"); // Get element with id="clock"
let now = new Date(); // Get current time
clock.textContent = now.toLocaleTimeString(); // Display time in the clock
}
displayTime() // Display the time right away
setInterval(displayTime, 1000); // And then update it every second.
</script>
</body>
</html>
尽管 JavaScript 代码可以直接嵌入在<script>标签中,但更常见的做法是使用<script>标签的src属性来指定包含 JavaScript 代码的文件的 URL(绝对 URL 或相对于显示的 HTML 文件的 URL)。如果我们将这个 HTML 文件中的 JavaScript 代码提取出来并存储在自己的scripts/digital_clock.js文件中,那么<script>标签可能会引用该代码文件,如下所示:
<script src="scripts/digital_clock.js"></script>
一个 JavaScript 文件包含纯 JavaScript,没有<script>标签或任何其他 HTML。按照惯例,JavaScript 代码文件的名称以*.js*结尾。
带有src属性的<script>标签的行为与指定的 JavaScript 文件的内容直接出现在<script>和</script>标签之间完全相同。请注意,即使指定了src属性,HTML 文档中也需要关闭</script>标签:HTML 不支持<script/>标签。
使用src属性有许多优点:
-
通过允许您从 HTML 文件中删除大块 JavaScript 代码,简化了您的 HTML 文件 - 也就是说,它有助于保持内容和行为分离。
-
当多个网页共享相同的 JavaScript 代码时,使用
src属性可以让您仅维护该代码的单个副本,而无需在代码更改时编辑每个 HTML 文件。 -
如果一个 JavaScript 代码文件被多个页面共享,只需要被第一个使用它的页面下载一次,随后的页面可以从浏览器缓存中检索它。
-
因为
src属性以任意 URL 作为其值,所以来自一个 web 服务器的 JavaScript 程序或网页可以使用其他 web 服务器导出的代码。许多互联网广告都依赖于这一点。
模块
§10.3 文档了 JavaScript 模块,并涵盖它们的import和export指令。如果您使用模块编写了 JavaScript 程序(并且没有使用代码捆绑工具将所有模块组合成单个非模块化的 JavaScript 文件),那么您必须使用带有type="module"属性的<script>标签加载程序的顶层模块。如果这样做,那么您指定的模块将被加载,它导入的所有模块也将被加载,以及(递归地)导入的所有模块也将被加载。详细信息请参见§10.3.5。
指定脚本类型
在 web 的早期,人们认为浏览器可能会实现除 JavaScript 外的其他语言,程序员们在他们的<script>标签中添加了language="javascript"和type="application/javascript"等属性。这是完全不必要的。JavaScript 是 web 的默认(也是唯一)语言。language属性已被弃用,只有两个原因可以在<script>标签上使用type属性:
-
指定脚本为模块
-
将数据嵌入网页而不显示它(参见§15.3.4)
脚本何时运行:异步和延迟
当 JavaScript 首次添加到 web 浏览器时,没有 API 可以遍历和操作已经呈现的文档的结构和内容。JavaScript 代码影响文档内容的唯一方法是在文档加载过程中动态生成内容。它通过使用document.write()方法将 HTML 文本注入到脚本位置来实现这一点。
使用document.write()不再被认为是良好的风格,但它是可能的事实意味着当 HTML 解析器遇到<script>元素时,默认情况下必须运行脚本,以确保它在恢复解析和呈现文档之前不输出任何 HTML。这可能会显著减慢网页的解析和呈现速度。
幸运的是,默认的同步或阻塞脚本执行模式并不是唯一的选择。<script>标签可以具有defer和async属性,这会导致脚本以不同的方式执行。这些是布尔属性——它们没有值;它们只需要出现在<script>标签上。请注意,这些属性仅在与src属性一起使用时才有意义:
<script defer src="deferred.js"></script>
<script async src="async.js"></script>
defer和async属性都是告诉浏览器链接的脚本不使用document.write()来生成 HTML 输出的方式,因此浏览器可以在下载脚本的同时继续解析和渲染文档。defer属性会导致浏览器推迟执行脚本,直到文档完全加载和解析完成,并且准备好被操作。async属性会导致浏览器尽快运行脚本,但不会在下载脚本时阻止文档解析。如果一个<script>标签同时具有这两个属性,async属性优先。
注意,延迟脚本按照它们在文档中出现的顺序运行。异步脚本在加载时运行,这意味着它们可能无序执行。
带有type="module"属性的脚本默认在文档加载后执行,就像它们有一个defer属性一样。您可以使用async属性覆盖此默认行为,这将导致代码在模块及其所有依赖项加载后立即执行。
一个简单的替代方案是async和defer属性——特别是对于直接包含在 HTML 中的代码——只需将脚本放在 HTML 文件的末尾。这样,脚本可以运行,知道它前面的文档内容已被解析并准备好被操作。
按需加载脚本
有时,您可能有一些 JavaScript 代码在文档首次加载时不被使用,只有在用户执行某些操作,如点击按钮或打开菜单时才需要。如果您正在使用模块开发代码,可以使用import()按需加载模块,如§10.3.6 中所述。
如果您不使用模块,可以在希望脚本加载时向文档添加一个<script>标签来按需加载 JavaScript 文件:
// Asynchronously load and execute a script from a specified URL
// Returns a Promise that resolves when the script has loaded.
function importScript(url) {
return new Promise((resolve, reject) => {
let s = document.createElement("script"); // Create a <script> element
s.onload = () => { resolve(); }; // Resolve promise when loaded
s.onerror = (e) => { reject(e); }; // Reject on failure
s.src = url; // Set the script URL
document.head.append(s); // Add <script> to document
});
}
这个importScript()函数使用 DOM API(§15.3)来创建一个新的<script>标签,并将其添加到文档的<head>中。它使用事件处理程序(§15.2)来确定脚本何时成功加载或加载失败。
15.1.2 文档对象模型
在客户端 JavaScript 编程中最重要的对象之一是文档对象,它代表在浏览器窗口或标签中显示的 HTML 文档。用于处理 HTML 文档的 API 称为文档对象模型,或 DOM,在§15.3 中有详细介绍。但是 DOM 在客户端 JavaScript 编程中如此重要,以至于应该在这里介绍。
HTML 文档包含嵌套在一起的 HTML 元素,形成一棵树。考虑以下简单的 HTML 文档:
<html>
<head>
<title>Sample Document</title>
</head>
<body>
<h1>An HTML Document</h1>
<p>This is a <i>simple</i> document.
</body>
</html>
顶层的<html>标签包含<head>和<body>标签。<head>标签包含一个<title>标签。<body>标签包含<h1>和<p>标签。<title>和<h1>标签包含文本字符串,<p>标签包含两个文本字符串,中间有一个<i>标签。
DOM API 反映了 HTML 文档的树结构。对于文档中的每个 HTML 标签,都有一个对应的 JavaScript Element 对象,对于文档中的每个文本运行,都有一个对应的 Text 对象。Element 和 Text 类,以及 Document 类本身,都是更一般的 Node 类的子类,Node 对象组织成 JavaScript 可以使用 DOM API 查询和遍历的树结构。此文档的 DOM 表示是 图 15-1 中描绘的树。
图 15-1。HTML 文档的树形表示
如果您对计算机编程中的树结构不熟悉,了解它们从家谱中借来的术语会有所帮助。直接在节点上方的节点是该节点的父节点。直接在另一个节点下一级的节点是该节点的子节点。在同一级别且具有相同父节点的节点是兄弟节点。在另一个节点下的任意级别的节点是该节点的后代节点。父节点、祖父节点和其他所有在节点上方的节点都是该节点的祖先节点。
DOM API 包括用于创建新的 Element 和 Text 节点,并将它们作为其他 Element 对象的子节点插入文档的方法。还有用于在文档中移动元素和完全删除它们的方法。虽然服务器端应用程序可能通过使用 console.log() 写入字符串来生成纯文本输出,但客户端 JavaScript 应用程序可以通过使用 DOM API 构建或操作文档树来生成格式化的 HTML 输出。
每个 HTML 标签类型都对应一个 JavaScript 类,文档中每个标签的出现都由该类的一个实例表示。例如,<body> 标签由 HTMLBodyElement 的一个实例表示,<table> 标签由 HTMLTableElement 的一个实例表示。JavaScript 元素对象具有与标签的 HTML 属性对应的属性。例如,代表 <img> 标签的 HTMLImageElement 实例具有一个与标签的 src 属性对应的 src 属性。src 属性的初始值是出现在 HTML 标签中的属性值,使用 JavaScript 设置此属性会改变 HTML 属性的值(并导致浏览器加载和显示新图像)。大多数 JavaScript 元素类只是反映 HTML 标签的属性,但有些定义了额外的方法。例如,HTMLAudioElement 和 HTMLVideoElement 类定义了像 play() 和 pause() 这样的方法,用于控制音频和视频文件的播放。
15.1.3 Web 浏览器中的全局对象
每个浏览器窗口或标签页都有一个全局对象(§3.7)。在该窗口中运行的所有 JavaScript 代码(除了在工作线程中运行的代码;参见§15.13)共享这个单一全局对象。无论文档中有多少脚本或模块,这一点都是真实的:文档中的所有脚本和模块共享一个全局对象;如果一个脚本在该对象上定义了一个属性,那么其他所有脚本也能看到这个属性。
全局对象是 JavaScript 标准库的定义位置——parseInt() 函数、Math 对象、Set 类等等。在 Web 浏览器中,全局对象还包含各种 Web API 的主要入口点。例如,document 属性代表当前显示的文档,fetch() 方法发起 HTTP 网络请求,Audio() 构造函数允许 JavaScript 程序播放声音。
在 Web 浏览器中,全局对象承担双重职责:除了定义内置类型和函数之外,它还表示当前 Web 浏览器窗口,并定义诸如 history(§15.10.2)这样的属性,表示窗口的浏览历史,以及 innerWidth,保存窗口的宽度(以像素为单位)。这个全局对象的一个属性名为 window,其值是全局对象本身。这意味着您可以简单地在客户端代码中输入 window 来引用全局对象。在使用特定于窗口的功能时,通常最好包含一个 window. 前缀:例如,window.innerWidth 比 innerWidth 更清晰。
15.1.4 脚本共享命名空间
使用模块时,在模块顶层(即在任何函数或类定义之外)定义的常量、变量、函数和类对于模块是私有的,除非它们被明确导出,这样,其他模块可以有选择地导入它们。(请注意,模块的这个属性也受到代码捆绑工具的尊重。)
然而,对于非模块脚本,情况完全不同。如果脚本中的顶层代码定义了常量、变量、函数或类,那个声明将对同一文档中的所有其他脚本可见。如果一个脚本定义了一个函数 f(),另一个脚本定义了一个类 c,那么第三个脚本可以调用该函数并实例化该类,而无需采取任何导入操作。因此,如果您不使用模块,在您的文档中的独立脚本共享一个单一命名空间,并且表现得好像它们都是单个更大脚本的一部分。这对于小型程序可能很方便,但在更大的程序中,特别是当一些脚本是第三方库时,需要避免命名冲突可能会成为问题。
这个共享命名空间的工作方式有一些历史上的怪癖。在顶层使用 var 和 function 声明会在共享的全局对象中创建属性。如果一个脚本定义了一个顶层函数 f(),那么同一文档中的另一个脚本可以将该函数调用为 f() 或 window.f()。另一方面,ES6 声明 const、let 和 class 在顶层使用时不会在全局对象中创建属性。然而,它们仍然在共享的命名空间中定义:如果一个脚本定义了一个类 C,其他脚本将能够使用 new C() 创建该类的实例,但不能使用 new window.C()。
总结一下:在模块中,顶层声明的作用域是模块,并且可以被明确导出。然而,在非模块脚本中,顶层声明的作用域是包含文档,并且这些声明被文档中的所有脚本共享。旧的 var 和 function 声明通过全局对象的属性共享。新的 const、let 和 class 声明也是共享的,并具有相同的文档作用域,但它们不作为 JavaScript 代码可以访问的任何对象的属性存在。
15.1.5 JavaScript 程序的执行
在客户端 JavaScript 中,程序 没有正式的定义,但我们可以说 JavaScript 程序包括文档中的所有 JavaScript 代码或引用的代码。这些独立的代码片段共享一个全局 Window 对象,使它们可以访问表示 HTML 文档的相同底层 Document 对象。不是模块的脚本还共享一个顶层命名空间。
如果网页包含嵌入的框架(使用 <iframe> 元素),嵌入文档中的 JavaScript 代码具有不同的全局对象和文档对象,与包含文档中的代码不同,并且可以被视为一个单独的 JavaScript 程序。但请记住,JavaScript 程序的边界没有正式的定义。如果容器文档和包含文档都是从同一服务器加载的,那么一个文档中的代码可以与另一个文档中的代码互动,并且您可以将它们视为单个程序的两个互动部分,如果您愿意的话。§15.13.6 解释了一个 JavaScript 程序如何与在 <iframe> 中运行的 JavaScript 代码发送和接收消息。
你可以将 JavaScript 程序执行看作是分为两个阶段进行的。在第一阶段中,文档内容被加载,<script> 元素中的代码(包括内联脚本和外部脚本)被运行。脚本通常按照它们在文档中出现的顺序运行,尽管这种默认顺序可以通过我们描述的 async 和 defer 属性进行修改。单个脚本中的 JavaScript 代码从上到下运行,当然,受 JavaScript 的条件语句、循环和其他控制语句的影响。在第一阶段中,一些脚本实际上并没有执行任何操作,而是仅仅定义函数和类供第二阶段使用。其他脚本可能在第一阶段做了大量工作,然后在第二阶段不做任何事情。想象一下一个位于文档末尾的脚本,它会查找文档中的所有 <h1> 和 <h2> 标签,并通过在文档开头生成并插入目录来修改文档。这完全可以在第一阶段完成。(参见 §15.3.6 中的一个实现此功能的示例。)
一旦文档加载完成并且所有脚本都运行完毕,JavaScript 执行进入第二阶段。这个阶段是异步和事件驱动的。如果一个脚本要参与这个第二阶段,那么,在第一阶段必须至少注册一个事件处理程序或其他回调函数,这些函数将被异步调用。在这个事件驱动的第二阶段,Web 浏览器根据异步发生的事件调用事件处理程序函数和其他回调。事件处理程序通常是响应用户输入(鼠标点击、按键等)而被调用,但也可能是由网络活动、文档和资源加载、经过的时间或 JavaScript 代码中的错误触发。事件和事件处理程序在 §15.2 中有详细描述。
在事件驱动阶段最先发生的一些事件是“DOMContentLoaded”和“load”事件。“DOMContentLoaded”在 HTML 文档完全加载和解析后触发。“load”事件在文档的所有外部资源(如图像)也完全加载后触发。JavaScript 程序通常使用其中一个事件作为触发器或启动信号。通常可以看到这样的程序,其脚本定义函数但除了注册一个事件处理程序函数以在执行的事件驱动阶段开始时由“load”事件触发外不执行任何操作。然后,这个“load”事件处理程序会操作文档并执行程序应该执行的任何操作。请注意,在 JavaScript 编程中,像这里描述的“load”事件处理程序这样的事件处理程序函数通常会注册其他事件处理程序。
JavaScript 程序的加载阶段相对较短:理想情况下不超过一秒。一旦文档加载完成,基于事件驱动的阶段将持续到网页被浏览器显示的整个时间。由于这个阶段是异步和事件驱动的,可能会出现长时间的不活动期,期间不执行任何 JavaScript,然后会因用户或网络事件触发而出现活动突发。接下来我们将更详细地介绍这两个阶段。
客户端 JavaScript 线程模型
JavaScript 是一种单线程语言,单线程执行使编程变得简单得多:您可以编写代码,确保两个事件处理程序永远不会同时运行。您可以操作文档内容,知道没有其他线程同时尝试修改它,而在编写 JavaScript 代码时永远不需要担心锁、死锁或竞争条件。
单线程执行意味着在脚本和事件处理程序执行时,Web 浏览器停止响应用户输入。这给 JavaScript 程序员带来了负担:这意味着 JavaScript 脚本和事件处理程序不能运行太长时间。如果脚本执行了计算密集型任务,它将延迟文档加载,用户将在脚本完成之前看不到文档内容。如果事件处理程序执行了计算密集型任务,浏览器可能会变得无响应,可能导致用户认为它已崩溃。
Web 平台定义了一种受控并发形式,称为“Web Worker”。Web Worker 是用于执行计算密集型任务的后台线程,而不会冻结用户界面。在 Web Worker 线程中运行的代码无法访问文档内容,也不与主线程或其他 Worker 共享任何状态,并且只能通过异步消息事件与主线程和其他 Worker 进行通信,因此主线程无法检测到并发,Web Worker 不会改变 JavaScript 程序的基本单线程执行模型。有关 Web 安全线程机制的完整详细信息,请参见§15.13。
客户端 JavaScript 时间轴
我们已经看到 JavaScript 程序开始于脚本执行阶段,然后过渡到事件处理阶段。这两个阶段可以进一步分解为以下步骤:
-
Web 浏览器创建一个 Document 对象并开始解析网页,随着解析 HTML 元素及其文本内容,将 Element 对象和 Text 节点添加到文档中。此时
document.readyState属性的值为“loading”。 -
当 HTML 解析器遇到一个没有任何
async、defer或type="module"属性的<script>标签时,它将该脚本标签添加到文档中,然后执行该脚本。脚本是同步执行的,而 HTML 解析器在脚本下载(如果需要)和运行时暂停。这样的脚本可以使用document.write()将文本插入输入流,当解析器恢复时,该文本将成为文档的一部分。这样的脚本通常只是定义函数并注册事件处理程序以供以后使用,但它可以遍历和操作文档树,就像它在那个时候存在的那样。也就是说,没有async或defer属性的非模块脚本可以看到自己的<script>标签和在它之前出现的文档内容。 -
当解析器遇到设置了
async属性的<script>元素时,它开始下载脚本文本(如果脚本是一个模块,它还会递归下载所有脚本的依赖项),并继续解析文档。脚本将在下载后尽快执行,但解析器不会停止等待它下载。异步脚本不能使用document.write()方法。它们可以看到自己的<script>标签和在它之前出现的所有文档内容,并且可能或可能不具有对额外文档内容的访问权限。 -
当文档完全解析时,
document.readyState属性更改为“interactive”。 -
任何设置了
defer属性的脚本(以及没有设置async属性的任何模块脚本)按照它们在文档中出现的顺序执行。异步脚本也可能在此时执行。延迟脚本可以访问完整的文档,它们不能使用document.write()方法。 -
浏览器在 Document 对象上触发“DOMContentLoaded”事件。这标志着从同步脚本执行阶段到程序执行的异步、事件驱动阶段的转变。但请注意,此时可能仍有尚未执行的
async脚本。 -
此时文档已完全解析,但浏览器可能仍在等待其他内容(如图像)加载。当所有这些内容加载完成,并且所有
async脚本已加载和执行时,document.readyState属性将更改为“complete”,并且网络浏览器在 Window 对象上触发“load”事件。 -
从这一点开始,事件处理程序将异步调用以响应用户输入事件、网络事件、定时器到期等。
15.1.6 程序输入和输出
与任何程序一样,客户端 JavaScript 程序处理输入数据以生成输出数据。有各种可用的输入:
-
文档本身的内容,JavaScript 代码可以使用 DOM API(§15.3)访问。
-
用户输入,以事件的形式,例如鼠标点击(或触摸屏点击)HTML
<button>元素,或输入到 HTML<textarea>元素中的文本,例如。§15.2 演示了 JavaScript 程序如何响应这些用户事件。 -
正在显示的文档的 URL 可以作为
document.URL在客户端 JavaScript 中使用。如果将此字符串传递给URL()构造函数(§11.9),您可以轻松访问 URL 的路径、查询和片段部分。 -
HTTP“Cookie”请求头的内容可以作为
document.cookie在客户端代码中使用。Cookie 通常由服务器端代码用于维护用户会话,但如果必要,客户端代码也可以读取(和写入)它们。有关详细信息,请参见§15.12.2。 -
全局的
navigator属性提供了关于网络浏览器、其运行的操作系统以及每个操作系统的功能的信息。例如,navigator.userAgent是一个标识网络浏览器的字符串,navigator.language是用户首选语言,navigator.hardwareConcurrency返回可用于网络浏览器的逻辑 CPU 数量。类似地,全局的screen属性通过screen.width和screen.height属性提供了用户的显示尺寸访问。在某种意义上,这些navigator和screen对象对于网络浏览器来说就像环境变量对于 Node 程序一样。
客户端 JavaScript 通常通过使用 DOM API(§15.3)操纵 HTML 文档或使用更高级的框架如 React 或 Angular 来操纵文档来生成输出。客户端代码还可以使用 console.log() 和相关方法(§11.8)生成输出。但这些输出只在 Web 开发者控制台中可见,因此在调试时很有用,但不适用于用户可见的输出。
15.1.7 程序错误
与直接运行在操作系统之上的应用程序(如 Node 应用程序)不同,Web 浏览器中的 JavaScript 程序实际上不能真正“崩溃”。如果在运行 JavaScript 程序时发生异常,并且没有 catch 语句来处理它,将在开发者控制台中显示错误消息,但已注册的任何事件处理程序仍在运行并响应事件。
如果您想定义一个最后一道防线的错误处理程序,在发生此类未捕获异常时调用,将 Window 对象的 onerror 属性设置为一个错误处理程序函数。当未捕获的异常传播到调用堆栈的最顶层并且即将在开发者控制台中显示错误消息时,window.onerror 函数将被调用,带有三个字符串参数。window.onerror 的第一个参数是描述错误的消息。第二个参数是一个包含导致错误的 JavaScript 代码的 URL 的字符串。第三个参数是错误发生的文档中的行号。如果 onerror 处理程序返回 true,它告诉浏览器处理程序已处理了错误,不需要进一步操作——换句话说,浏览器不应显示自己的错误消息。
当 Promise 被拒绝且没有 .catch() 函数来处理它时,这就像未处理的异常:您的程序中出现了意外错误或逻辑错误。您可以通过定义 window.onunhandledrejection 函数或使用 window.addEventListener() 注册一个“unhandledrejection”事件处理程序来检查这种情况。传递给此处理程序的事件对象将具有一个 promise 属性,其值是被拒绝的 Promise 对象,以及一个 reason 属性,其值是将传递给 .catch() 函数的内容。与前面描述的错误处理程序一样,如果在未处理的拒绝事件对象上调用 preventDefault(),它将被视为已处理,并且不会在开发者控制台中引发错误消息。
定义 onerror 或 onunhandledrejection 处理程序通常不是必需的,但如果您想要将客户端错误报告给服务器(例如使用 fetch() 函数进行 HTTP POST 请求),以便获取有关用户浏览器中发生的意外错误的信息,这可能非常有用。
15.1.8 Web 安全模型
Web 页面可以在您的个人设备上执行任意 JavaScript 代码这一事实具有明显的安全影响,浏览器供应商努力平衡两个竞争目标:
-
定义强大的客户端 API 以实现有用的 Web 应用程序
-
防止恶意代码读取或更改您的数据,危害您的隐私,欺诈您,或浪费您的时间
接下来的小节快速概述了您作为 JavaScript 程序员应该了解的安全限制和问题。
JavaScript 不能做什么
Web 浏览器对抗恶意代码的第一道防线是它们根本不支持某些功能。例如,客户端 JavaScript 不提供任何方法来写入或删除客户端计算机上的任意文件或列出任意目录。这意味着 JavaScript 程序无法删除数据或植入病毒。
同样,客户端 JavaScript 没有通用的网络功能。客户端 JavaScript 程序可以发出 HTTP 请求(§15.11.1)。另一个名为 WebSockets 的标准(§15.11.3)定义了一个类似套接字的 API,用于与专用服务器通信。但是这些 API 都不允许直接访问更广泛的网络。通用的互联网客户端和服务器不能使用客户端 JavaScript 编写。
同源策略
同源策略是对 JavaScript 代码可以与之交互的 Web 内容的广泛安全限制。当一个网页包含<iframe>元素时,通常会出现这种情况。在这种情况下,同源策略规定了一个框架中的 JavaScript 代码与其他框架内容的交互。具体来说,脚本只能读取与包含脚本的文档具有相同源的窗口和文档的属性。
文档的源被定义为文档加载的 URL 的协议、主机和端口。从不同 web 服务器加载的文档具有不同的源。通过同一主机的不同端口加载的文档具有不同的源。使用http:协议加载的文档与使用https:协议加载的文档具有不同的源,即使它们来自同一 web 服务器。浏览器通常将每个file: URL 视为单独的源,这意味着如果您正在开发一个显示来自同一服务器的多个文档的程序,您可能无法使用file: URL 在本地进行测试,而必须在开发过程中运行一个静态 web 服务器。
重要的是要理解脚本本身的源对同源策略不重要:重要的是脚本嵌入的文档的源。例如,假设由主机 A 托管的脚本被包含在由主机 B 提供的网页中(使用<script>元素的src属性)。该脚本的源是主机 B,并且脚本可以完全访问包含它的文档的内容。如果文档包含一个来自主机 B 的第二个文档的<iframe>,那么脚本也可以完全访问该第二个文档的内容。但是,如果顶级文档包含另一个显示来自主机 C(甚至来自主机 A)的文档的<iframe>,那么同源策略就会生效,并阻止脚本访问这个嵌套文档。
同源策略也适用于脚本化的 HTTP 请求(参见§15.11.1)。JavaScript 代码可以向包含文档所在的 web 服务器发出任意 HTTP 请求,但它不允许脚本与其他 web 服务器通信(除非这些 web 服务器通过 CORS 选择加入,我们将在下文描述)。
同源策略对使用多个子域的大型网站造成问题。例如,源自orders.example.com的脚本可能需要从example.com的文档中读取属性。为了支持这种多域网站,脚本可以通过将document.domain设置为域后缀来更改其源。因此,源自https://orders.example.com的脚本可以通过将document.domain设置为“example.com”来将其源更改为https://example.com。但是该脚本不能将document.domain设置为“orders.example”、“ample.com”或“com”。
放宽同源策略的第二种技术是跨域资源共享(CORS),它允许服务器决定愿意提供哪些来源。CORS 使用一个新的 Origin: 请求头和一个新的 Access-Control-Allow-Origin 响应头来扩展 HTTP。它允许服务器使用一个头来明确列出可以请求文件的来源,或者使用通配符允许任何站点请求文件。浏览器遵守这些 CORS 头,并且除非它们存在,否则不放宽同源限制。
跨站脚本
跨站脚本,或 XSS,是一种安全问题类别,攻击者向目标网站注入 HTML 标记或脚本。客户端 JavaScript 程序员必须意识到并防范跨站脚本。
如果网页动态生成文档内容并且基于用户提交的数据而不先通过“消毒”该数据来删除其中嵌入的 HTML 标记,则该网页容易受到跨站脚本攻击。作为一个简单的例子,考虑以下使用 JavaScript 通过名称向用户问候的网页:
<script>
let name = new URL(document.URL).searchParams.get("name");
document.querySelector('h1').innerHTML = "Hello " + name;
</script>
这个两行脚本从文档 URL 的“name”查询参数中提取输入。然后使用 DOM API 将 HTML 字符串注入到文档中的第一个 <h1> 标签中。此页面旨在通过以下 URL 调用:
http://www.example.com/greet.html?name=David
当像这样使用时,它会显示文本“Hello David。”但考虑一下当它被调用时会发生什么:
name=%3Cimg%20src=%22x.png%22%20onload=%22alert(%27hacked%27)%22/%3E
当 URL 转义参数被解码时,此 URL 导致以下 HTML 被注入到文档中:
Hello <img src="x.png" onload="alert('hacked')"/>
图像加载完成后,onload 属性中的 JavaScript 字符串将被执行。全局 alert() 函数会显示一个模态对话框。单个对话框相对无害,但表明在该网站上可能存在任意代码执行,因为它显示了未经过滤的 HTML。
跨站脚本攻击之所以被称为如此,是因为涉及到多个站点。站点 B 包含一个特制链接(就像前面示例中的那个)到站点 A。如果站点 B 能说服用户点击该链接,他们将被带到站点 A,但该站点现在将运行来自站点 B 的代码。该代码可能破坏页面或导致其功能失效。更危险的是,恶意代码可能读取站点 A 存储的 cookie(也许是账号号码或其他个人身份信息)并将数据发送回站点 B。注入的代码甚至可以跟踪用户的按键操作并将数据发送回站点 B。
通常,防止 XSS 攻击的方法是在使用未受信任的数据创建动态文档内容之前,从中删除 HTML 标记。你可以通过用等效的 HTML 实体替换未受信任输入字符串中的特殊 HTML 字符来修复之前显示的 greet.html 文件:
name = name
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/\//g, "/")
解决 XSS 问题的另一种方法是构建您的 Web 应用程序,使得不受信任的内容始终显示在具有设置为禁用脚本和其他功能的 sandbox 属性的 <iframe> 中。
跨站脚本是一种根深蒂固的漏洞,其根源深入到网络架构中。值得深入了解这种漏洞,但进一步讨论超出了本书的范围。有许多在线资源可帮助您防范跨站脚本。
15.2 事件
客户端 JavaScript 程序使用异步事件驱动的编程模型。在这种编程风格中,当文档或浏览器或与之关联的某个元素或对象发生有趣的事情时,Web 浏览器会生成一个事件。例如,当 Web 浏览器完成加载文档时,当用户将鼠标移动到超链接上时,或者当用户在键盘上按下键时,Web 浏览器会生成一个事件。如果 JavaScript 应用程序关心特定类型的事件,它可以注册一个或多个函数,在发生该类型的事件时调用这些函数。请注意,这并不是 Web 编程的独有特性:所有具有图形用户界面的应用程序都是这样设计的——它们等待与之交互(即,它们等待事件发生),然后做出响应。
在客户端 JavaScript 中,事件可以发生在 HTML 文档中的任何元素上,这一事实使得 Web 浏览器的事件模型比 Node 的事件模型复杂得多。我们从一些重要的定义开始,这些定义有助于解释事件模型:
事件类型
此字符串指定发生的事件类型。例如,“mousemove”类型表示用户移动了鼠标。“keydown”类型表示用户按下键盘上的键。而“load”类型表示文档(或其他资源)已经从网络加载完成。由于事件类型只是一个字符串,有时被称为事件名称,确实,我们使用这个名称来识别我们所讨论的事件类型。
事件目标
这是事件发生的对象或与之相关联的对象。当我们谈论事件时,必须同时指定类型和目标。例如,窗口上的加载事件,或<button>元素上的点击事件。窗口、文档和元素对象是客户端 JavaScript 应用程序中最常见的事件目标,但有些事件会在其他类型的对象上触发。例如,Worker 对象(一种线程,在§15.13 中介绍)是“message”事件的目标,当工作线程向主线程发送消息时会触发该事件。
事件处理程序,或事件监听器
此函数处理或响应事件。² 应用程序通过指定事件类型和事件目标向 Web 浏览器注册其事件处理程序函数。当指定类型的事件发生在指定目标上时,浏览器会调用处理程序函数。当为对象调用事件处理程序时,我们说浏览器已经“触发”、“触发”或“分发”了事件。有多种注册事件处理程序的方法,处理程序注册和调用的详细信息在§15.2.2 和§15.2.3 中有解释。
事件对象
此对象与特定事件相关联,并包含有关该事件的详细信息。事件对象作为参数传递给事件处理程序函数。所有事件对象都有一个type属性,指定事件类型,以及一个target属性,指定事件目标。每种事件类型为其关联的事件对象定义了一组属性。与鼠标事件相关联的对象包括鼠标指针的坐标,例如,与键盘事件相关联的对象包含有关按下的键和按下的修改键的详细信息。许多事件类型仅定义了一些标准属性,如type和target,并不包含其他有用信息。对于这些事件,事件的简单发生才是重要的,而不是事件的详细信息。
事件传播
这是浏览器决定触发事件处理程序的对象的过程。对于特定于单个对象的事件(例如 Window 对象上的“load”事件或 Worker 对象上的“message”事件),不需要传播。但是,对于发生在 HTML 文档中的元素上的某些类型的事件,它们会传播或“冒泡”到文档树上。如果用户将鼠标移动到超链接上,那么 mousemove 事件首先在定义该链接的<a>元素上触发。然后在包含元素上触发:可能是一个<p>元素,一个<section>元素,以及文档对象本身。有时,在文档或其他容器元素上注册一个事件处理程序比在每个感兴趣的单个元素上注册处理程序更方便。事件处理程序可以阻止事件的传播,使其不会继续冒泡并且不会触发包含元素上的处理程序。处理程序通过调用事件对象的方法来执行此操作。在另一种事件传播形式中,称为事件捕获,在容器元素上特别注册的处理程序有机会在事件传递到其实际目标之前拦截(或“捕获”)事件。事件冒泡和捕获在§15.2.4 中有详细介绍。
一些事件与默认操作相关联。例如,当单击超链接时,浏览器的默认操作是跟随链接并加载新页面。事件处理程序可以通过调用事件对象的方法来阻止此默认操作。这有时被称为“取消”事件,并在§15.2.5 中有介绍。
15.2.1 事件类别
客户端 JavaScript 支持如此多的事件类型,以至于本章无法涵盖所有事件。然而,将事件分组到一些一般类别中可能是有用的,以说明支持的事件范围和各种各样的事件:
与设备相关的输入事件
这些事件与特定的输入设备直接相关,例如鼠标或键盘。它们包括“mousedown”,“mousemove”,“mouseup”,“touchstart”,“touchmove”,“touchend”,“keydown”和“keyup”等事件类型。
与设备无关的输入事件
这些输入事件与特定的输入设备没有直接关联。例如,“click”事件表示链接或按钮(或其他文档元素)已被激活。通常是通过鼠标点击完成,但也可以通过键盘或(在触摸设备上)通过轻触完成。 “input”事件是“keydown”事件的与设备无关的替代品,并支持键盘输入以及剪切和粘贴以及用于表意文字的输入方法等替代方法。 “pointerdown”,“pointermove”和“pointerup”事件类型是鼠标和触摸事件的与设备无关的替代品。它们适用于鼠标类型指针,触摸屏幕以及笔或笔式输入。
用户界面事件
UI 事件是更高级别的事件,通常在 HTML 表单元素上定义 Web 应用程序的用户界面。它们包括“focus”事件(当文本输入字段获得键盘焦点时),“change”事件(当用户更改表单元素显示的值时)和“submit”事件(当用户单击表单中的提交按钮时)。
状态更改事件
一些事件不是直接由用户活动触发的,而是由网络或浏览器活动触发的,并指示某种生命周期或状态相关的变化。“load”和“DOMContentLoaded”事件分别在文档加载结束时在 Window 和 Document 对象上触发,可能是最常用的这些事件(参见“客户端 JavaScript 时间线”)。浏览器在网络连接状态发生变化时在 Window 对象上触发“online”和“offline”事件。浏览器的历史管理机制(§15.10.4)在响应浏览器的后退按钮时触发“popstate”事件。
特定于 API 的事件
HTML 和相关规范定义的许多 Web API 包括它们自己的事件类型。HTML <video> 和 <audio> 元素定义了一长串相关事件类型,如“waiting”、“playing”、“seeking”、“volumechange”等,您可以使用它们来自定义媒体播放。一般来说,异步的 Web 平台 API 在 JavaScript 添加 Promise 之前是基于事件的,并定义了特定于 API 的事件。例如,IndexedDB API(§15.12.3)在数据库请求成功或失败时触发“success”和“error”事件。虽然用于发出 HTTP 请求的新 fetch() API(§15.11.1)是基于 Promise 的,但它替代的 XMLHttpRequest API 定义了许多特定于 API 的事件类型。
注册事件处理程序
注册事件处理程序有两种基本方法。第一种是来自 Web 早期的,在事件目标上设置对象或文档元素的属性。第二种(更新且更通用)技术是将处理程序传递给对象或元素的 addEventListener() 方法。
设置事件处理程序属性
注册事件处理程序的最简单方法是将事件目标的属性设置为所需的事件处理程序函数。按照惯例,事件处理程序属性的名称由单词“on”后跟事件名称组成:onclick、onchange、onload、onmouseover等。请注意,这些属性名称区分大小写,并且全部小写书写,即使事件类型(如“mousedown”)由多个单词组成。以下代码包括两种此类事件处理程序的注册:
// Set the onload property of the Window object to a function.
// The function is the event handler: it is invoked when the document loads.
window.onload = function() {
// Look up a <form> element
let form = document.querySelector("form#shipping");
// Register an event handler function on the form that will be invoked
// before the form is submitted. Assume isFormValid() is defined elsewhere.
form.onsubmit = function(event) { // When the user submits the form
if (!isFormValid(this)) { // check whether form inputs are valid
event.preventDefault(); // and if not, prevent form submission.
}
};
};
事件处理程序属性的缺点在于,它们设计时假设事件目标最多只有一个每种事件类型的处理程序。通常最好使用 addEventListener() 注册事件处理程序,因为该技术不会覆盖任何先前注册的处理程序。
设置事件处理程序属性
文档元素的事件处理程序属性也可以直接在 HTML 文件中作为相应 HTML 标记的属性定义。在 HTML 中,可以使用在 <body> 标记上的属性定义应该在 JavaScript 中注册在 Window 元素上的处理程序。尽管这种技术在现代 Web 开发中通常不受欢迎,但它是可能的,并且在此处记录,因为您可能仍然在现有代码中看到它。
当将事件处理程序定义为 HTML 属性时,属性值应为 JavaScript 代码的字符串。该代码应为事件处理程序函数的主体,而不是完整的函数声明。换句话说,您的 HTML 事件处理程序代码不应被大括号包围并以 function 关键字为前缀。例如:
<button onclick="console.log('Thank you');">Please Click</button>
如果 HTML 事件处理程序属性包含多个 JavaScript 语句,则必须记住使用分号分隔这些语句或将属性值跨多行断开。
当您将 JavaScript 代码的字符串指定为 HTML 事件处理程序属性的值时,浏览器会将您的字符串转换为一个类似于这个函数的函数:
function(event) {
with(document) {
with(this.form || {}) {
with(this) {
/* your code here */
}
}
}
}
event参数意味着您的处理程序代码可以将当前事件对象称为event。with语句意味着您的处理程序代码可以直接引用目标对象、包含的<form>(如果有)和包含的文档对象的属性,就像它们是作用域中的变量一样。with语句在严格模式下是禁止的(§5.6.3),但是 HTML 属性中的 JavaScript 代码永远不会是严格模式。以这种方式定义的事件处理程序在定义了意外变量的环境中执行。这可能是令人困惑的错误源,是避免在 HTML 中编写事件处理程序的一个很好的理由。
addEventListener()
任何可以成为事件目标的对象——包括 Window 和 Document 对象以及所有文档元素——都定义了一个名为addEventListener()的方法,您可以使用该方法为该目标注册事件处理程序。addEventListener()接受三个参数。第一个是要注册处理程序的事件类型。事件类型(或名称)是一个字符串,不包括在设置事件处理程序属性时使用的“on”前缀。addEventListener()的第二个参数是应在发生指定类型事件时调用的函数。第三个参数是可选的,下面会解释。
以下代码为<button>元素注册了两个“click”事件处理程序。请注意两种技术之间的区别:
<button id="mybutton">Click me</button>
<script>
let b = document.querySelector("#mybutton");
b.onclick = function() { console.log("Thanks for clicking me!"); };
b.addEventListener("click", () => { console.log("Thanks again!"); });
</script>
调用addEventListener()时,第一个参数为“click”不会影响onclick属性的值。在此代码中,单击按钮将向开发者控制台记录两条消息。如果我们先调用addEventListener()然后设置onclick,我们仍然会记录两条消息,只是顺序相反。更重要的是,您可以多次调用addEventListener()为同一对象的同一事件类型注册多个处理程序函数。当对象上发生事件时,为该类型事件注册的所有处理程序按照注册顺序被调用。在同一对象上多次调用具有相同参数的addEventListener()不会产生任何效果——处理程序函数仅注册一次,并且重复调用不会改变调用处理程序的顺序。
addEventListener()与removeEventListener()方法配对使用,它期望相同的两个参数(加上可选的第三个参数),但是从对象中删除事件处理程序函数而不是添加它。通常有用的是暂时注册事件处理程序,然后不久之后将其删除。例如,当您获得“mousedown”事件时,您可能会为“mousemove”和“mouseup”事件注册临时事件处理程序,以便查看用户是否拖动鼠标。然后,当“mouseup”事件到达时,您将取消注册这些处理程序。在这种情况下,您的事件处理程序移除代码可能如下所示:
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
addEventListener()的可选第三个参数是布尔值或对象。如果传递true,则您的处理程序函数将被注册为捕获事件处理程序,并在事件分发的不同阶段被调用。我们将在§15.2.4 中介绍事件捕获。如果在注册事件监听器时传递第三个参数为true,那么如果要删除处理程序,则必须在removeEventListener()的第三个参数中也传递true。
注册捕获事件处理程序只是addEventListener()支持的三个选项之一,而不是传递单个布尔值,您还可以传递一个明确指定所需选项的对象:
document.addEventListener("click", handleClick, {
capture: true,
once: true,
passive: true
});
如果 Options 对象的capture属性设置为true,那么事件处理程序将被注册为捕获处理程序。如果该属性为false或被省略,则处理程序将为非捕获。
如果 Options 对象的once属性设置为true,则事件侦听器将在触发一次后自动删除。如果此属性为false或省略,则处理程序永远不会自动删除。
如果 Options 对象的passive属性设置为true,则表示事件处理程序永远不会调用preventDefault()来取消默认操作(参见§15.2.5)。这对于移动设备上的触摸事件尤为重要 - 如果“touchmove”事件的事件处理程序可以阻止浏览器的默认滚动操作,那么浏览器无法实现平滑滚动。这个passive属性提供了一种注册这种潜在干扰性事件处理程序的方法,但让 Web 浏览器知道它可以安全地开始其默认行为 - 例如滚动 - 而事件处理程序正在运行。平滑滚动对于良好的用户体验非常重要,因此 Firefox 和 Chrome 默认将“touchmove”和“mousewheel”事件设置为被动。因此,如果您确实想要注册一个调用preventDefault()的处理程序来处理这些事件中的一个,那么应明确将passive属性设置为false。
您还可以向removeEventListener()传递一个 Options 对象,但capture属性是唯一相关的属性。在移除侦听器时,无需指定once或passive,这些属性将被忽略。
15.2.3 事件处理程序调用
一旦注册了事件处理程序,当指定类型的事件发生在指定对象上时,Web 浏览器将自动调用它。本节详细描述了事件处理程序的调用,解释了事件处理程序参数、调用上下文(this值)以及事件处理程序的返回值的含义。
事件处理程序参数
事件处理程序以一个 Event 对象作为它们的唯一参数调用。Event 对象的属性提供有关事件的详细信息:
type
发生的事件类型。
target
事件发生的对象。
currentTarget
事件传播时,此属性是当前事件处理程序注册的对象。
timeStamp
代表事件发生时间的时间戳(以毫秒为单位),但不代表绝对时间。您可以通过从第一个事件的时间戳中减去第二个事件的时间戳来确定两个事件之间的经过时间。
isTrusted
如果事件是由 Web 浏览器本身分派的,则此属性将为true,如果事件是由 JavaScript 代码分派的,则此属性将为false。
特定类型的事件具有额外的属性。例如,鼠标和指针事件具有clientX和clientY属性,指定事件发生时的窗口坐标。
事件处理程序上下文
当您通过设置属性注册事件处理程序时,看起来就像您正在为目标对象定义一个新方法:
target.onclick = function() { /* handler code */ };
因此,不足为奇,事件处理程序作为定义它们的对象的方法调用。也就是说,在事件处理程序的主体内,this关键字指的是注册事件处理程序的对象。
处理程序以目标作为它们的this值调用,即使使用addEventListener()注册。但是对于定义为箭头函数的处理程序,这种方式不起作用:箭头函数始终具有与其定义的作用域相同的this值。
处理程序返回值
在现代 JavaScript 中,事件处理程序不应返回任何内容。您可能会在旧代码中看到返回值的事件处理程序,返回值通常是向浏览器发出信号,告诉它不要执行与事件关联的默认操作。例如,如果表单中的提交按钮的onclick处理程序返回false,那么 Web 浏览器将不会提交表单(通常是因为事件处理程序确定用户的输入未通过客户端验证)。
阻止浏览器执行默认操作的标准和首选方法是在事件对象上调用preventDefault()方法(§15.2.5)。
调用顺序
事件目标可能对特定类型的事件注册了多个事件处理程序。当发生该类型的事件时,浏览器按照注册的顺序调用所有处理程序。有趣的是,即使您混合使用addEventListener()注册的事件处理程序和在对象属性上注册的事件处理程序(如onclick),这也是正确的。
15.2.4 事件传播
当事件的目标是 Window 对象或其他独立对象时,浏览器只需调用该对象上的适当处理程序来响应事件。但是,当事件目标是 Document 或文档元素时,情况就更加复杂。
在调用目标元素上注册的事件处理程序后,大多数事件会在 DOM 树中“冒泡”。将调用目标父级的事件处理程序。然后调用目标祖父级上注册的处理程序。这将一直持续到 Document 对象,然后继续到 Window 对象。事件冒泡提供了一种替代方法,可以在共同祖先元素上注册单个处理程序,并在那里处理事件,而不是在许多单独的文档元素上注册处理程序。例如,您可以在<form>元素上注册一个“change”处理程序,而不是为表单中的每个元素注册“change”处理程序。
大多数发生在文档元素上的事件都会冒泡。值得注意的例外是“focus”、“blur”和“scroll”事件。文档元素上的“load”事件会冒泡,但在 Document 对象处停止冒泡,不会传播到 Window 对象上。(仅当整个文档加载完成时,Window 对象的“load”事件处理程序才会被触发。)
事件冒泡是事件传播的第三个“阶段”。目标对象本身的事件处理程序的调用是第二阶段。第一阶段,甚至在调用目标处理程序之前发生,称为“捕获”阶段。请记住,addEventListener()接受一个可选的第三个参数。如果该参数为true或{capture:true},则事件处理程序将被注册为捕获事件处理程序,在事件传播的第一阶段调用。事件传播的捕获阶段类似于反向的冒泡阶段。首先调用 Window 对象的捕获处理程序,然后调用 Document 对象的捕获处理程序,然后是 body 对象,依此类推,直到调用事件目标的父级的捕获事件处理程序。在事件目标本身上注册的捕获事件处理程序不会被调用。
事件捕获提供了一个机会,在事件传递到目标之前查看事件。捕获事件处理程序可用于调试,或者可以与下一节描述的事件取消技术一起使用,以过滤事件,从而永远不会实际调用目标事件处理程序。事件捕获的一个常见用途是处理鼠标拖动,其中需要由被拖动的对象处理鼠标移动事件,而不是文档元素。
15.2.5 事件取消
浏览器会响应许多用户事件,即使您的代码没有:当用户在超链接上单击鼠标时,浏览器会跟随链接。如果 HTML 文本输入元素具有键盘焦点并且用户键入键,则浏览器将输入用户的输入。如果用户在触摸屏设备上移动手指,则浏览器会滚动。如果您为此类事件注册了事件处理程序,可以通过调用事件对象的preventDefault()方法来阻止浏览器执行其默认操作。(除非您使用passive选项注册了处理程序,这会使preventDefault()无效。)
取消与事件关联的默认操作只是一种事件取消的方式。我们还可以通过调用事件对象的stopPropagation()方法来取消事件的传播。如果在同一对象上定义了其他处理程序,则其余处理程序仍将被调用,但在调用stopPropagation()后不会调用任何其他对象上的事件处理程序。stopPropagation()在捕获阶段、事件目标本身以及冒泡阶段起作用。stopImmediatePropagation()的工作方式类似于stopPropagation(),但它还阻止调用在同一对象上注册的任何后续事件处理程序。
15.2.6 分派自定义事件
客户端 JavaScript 的事件 API 是一个相对强大的 API,您可以使用它来定义和分派自己的事件。例如,假设您的程序需要定期执行长时间计算或进行网络请求,并且在此操作挂起期间,其他操作是不可能的。您希望通过显示“旋转器”来告知用户应用程序正在忙碌。但是忙碌的模块不需要知道旋转器应该显示在哪里。相反,该模块可能只需分派一个事件来宣布它正在忙碌,然后在不再忙碌时再分派另一个事件。然后,UI 模块可以为这些事件注册事件处理程序,并采取适当的 UI 操作来通知用户。
如果一个 JavaScript 对象有一个addEventListener()方法,那么它是一个“事件目标”,这意味着它也有一个dispatchEvent()方法。您可以使用CustomEvent()构造函数创建自己的事件对象,并将其传递给dispatchEvent()。CustomEvent()的第一个参数是一个字符串,指定您的事件类型,第二个参数是一个指定事件对象属性的对象。将此对象的detail属性设置为表示事件内容的字符串、对象或其他值。如果计划在文档元素上分派事件并希望它冒泡到文档树,将bubbles:true添加到第二个参数中:
// Dispatch a custom event so the UI knows we are busy
document.dispatchEvent(new CustomEvent("busy", { detail: true }));
// Perform a network operation
fetch(url)
.then(handleNetworkResponse)
.catch(handleNetworkError)
.finally(() => {
// After the network request has succeeded or failed, dispatch
// another event to let the UI know that we are no longer busy.
document.dispatchEvent(new CustomEvent("busy", { detail: false }));
});
// Elsewhere, in your program you can register a handler for "busy" events
// and use it to show or hide the spinner to let the user know.
document.addEventListener("busy", (e) => {
if (e.detail) {
showSpinner();
} else {
hideSpinner();
}
});
15.3 脚本化文档
客户端 JavaScript 存在的目的是将静态 HTML 文档转换为交互式 Web 应用程序。因此,脚本化 Web 页面的内容确实是 JavaScript 的核心目的。
每个 Window 对象都有一个指向 Document 对象的document属性。Document 对象代表窗口的内容,本节的主题就是它。然而,Document 对象并不是独立存在的。它是 DOM 中用于表示和操作文档内容的中心对象。
DOM 是在§15.1.2 中介绍的。本节详细解释了 API。它涵盖了:
-
如何从文档中查询或选择单个元素。
-
如何遍历文档,以及如何找到任何文档元素的祖先、同级和后代。
-
如何查询和设置文档元素的属性。
-
如何查询、设置和修改文档的内容。
-
如何通过创建、插入和删除节点来修改文档的结构。
15.3.1 选择文档元素
客户端 JavaScript 程序经常需要操作文档中的一个或多个元素。全局的document属性指向 Document 对象,而 Document 对象有head和body属性,分别指向<head>和<body>标签的 Element 对象。但是,想要操作文档中嵌套更深的元素的程序必须以某种方式获取或选择指向这些文档元素的 Element 对象。
使用 CSS 选择器选择元素
CSS 样式表具有非常强大的语法,称为选择器,用于描述文档中的元素或元素集。DOM 方法querySelector()和querySelectorAll()允许我们查找与指定 CSS 选择器匹配的文档中的元素或元素。在介绍这些方法之前,我们将从快速教程开始,介绍 CSS 选择器语法。
CSS 选择器可以根据标签名、它们的id属性的值或它们的class属性中的单词描述元素:
div // Any <div> element
#nav // The element with id="nav"
.warning // Any element with "warning" in its class attribute
#字符用于基于id属性匹配,.字符用于基于class属性匹配。也可以根据更一般的属性值选择元素:
p[lang="fr"] // A paragraph written in French: <p lang="fr">
*[name="x"] // Any element with a name="x" attribute
请注意,这些示例将标签名选择器(或*标签名通配符)与属性选择器结合使用。还可以使用更复杂的组合:
span.fatal.error // Any <span> with "fatal" and "error" in its class
span[lang="fr"].warning // Any <span> in French with class "warning"
选择器还可以指定文档结构:
#log span // Any <span> descendant of the element with id="log"
#log>span // Any <span> child of the element with id="log"
body>h1:first-child // The first <h1> child of the <body>
img + p.caption // A <p> with class "caption" immediately after an <img>
h2 ~ p // Any <p> that follows an <h2> and is a sibling of it
如果两个选择器用逗号分隔,这意味着我们选择了匹配任一选择器的元素:
button, input[type="button"] // All <button> and <input type="button"> elements
正如您所看到的,CSS 选择器允许我们通过类型、ID、类、属性和文档中的位置引用文档中的元素。querySelector()方法将 CSS 选择器字符串作为其参数,并返回在文档中找到的第一个匹配元素,如果没有匹配项,则返回null:
// Find the document element for the HTML tag with attribute id="spinner"
let spinner = document.querySelector("#spinner");
querySelectorAll()类似,但它返回文档中所有匹配的元素,而不仅仅返回第一个:
// Find all Element objects for <h1>, <h2>, and <h3> tags
let titles = document.querySelectorAll("h1, h2, h3");
querySelectorAll()的返回值不是 Element 对象的数组。相反,它是一种称为 NodeList 的类似数组的对象。NodeList 对象具有length属性,并且可以像数组一样进行索引,因此您可以使用传统的for循环对它们进行循环。NodeLists 也是可迭代的,因此您也可以将它们与for/of循环一起使用。如果要将 NodeList 转换为真正的数组,只需将其传递给Array.from()。
querySelectorAll()返回的 NodeList 如果文档中没有任何匹配的元素,则length属性将设置为 0。
querySelector()和querySelectorAll()也由 Element 类和 Document 类实现。当在元素上调用这些方法时,它们只会返回该元素的后代元素。
请注意,CSS 定义了::first-line和::first-letter伪元素。在 CSS 中,这些匹配文本节点的部分而不是实际元素。如果与querySelectorAll()或querySelector()一起使用,它们将不匹配。此外,许多浏览器将拒绝返回:link和:visited伪类的匹配项,因为这可能会暴露用户的浏览历史信息。
另一种基于 CSS 的元素选择方法是closest()。该方法由 Element 类定义,以选择器作为其唯一参数。如果选择器与调用它的元素匹配,则返回该元素。否则,返回选择器匹配的最近祖先元素,如果没有匹配项,则返回null。在某种意义上,closest()是querySelector()的相反:closest()从一个元素开始,并在树中查找匹配项,而querySelector()从一个元素开始,并在树中查找匹配项。当您在文档树的高级别注册事件处理程序时,closest()可能很有用。例如,如果您处理“click”事件,您可能想知道它是否是单击超链接。事件对象将告诉您目标是什么,但该目标可能是链接内部的文本而不是超链接的<a>标签本身。您的事件处理程序可以这样查找最近的包含超链接:
// Find the closest enclosing <a> tag that has an href attribute.
let hyperlink = event.target.closest("a[href]");
这是您可能使用closest()的另一种方式:
// Return true if the element e is inside of an HTML list element
function insideList(e) {
return e.closest("ul,ol,dl") !== null;
}
相关方法matches()不返回祖先或后代:它只是测试一个元素是否被 CSS 选择器匹配,并在是这样时返回true,否则返回false:
// Return true if e is an HTML heading element
function isHeading(e) {
return e.matches("h1,h2,h3,h4,h5,h6");
}
其他元素选择方法
除了querySelector()和querySelectorAll(),DOM 还定义了一些更或多或少已经过时的元素选择方法。你可能仍然会看到一些这些方法(尤其是getElementById())在使用中,然而:
// Look up an element by id. The argument is just the id, without
// the CSS selector prefix #. Similar to document.querySelector("#sect1")
let sect1 = document.getElementById("sect1");
// Look up all elements (such as form checkboxes) that have a name="color"
// attribute. Similar to document.querySelectorAll('*[name="color"]');
let colors = document.getElementsByName("color");
// Look up all <h1> elements in the document.
// Similar to document.querySelectorAll("h1")
let headings = document.getElementsByTagName("h1");
// getElementsByTagName() is also defined on elements.
// Get all <h2> elements within the sect1 element.
let subheads = sect1.getElementsByTagName("h2");
// Look up all elements that have class "tooltip."
// Similar to document.querySelectorAll(".tooltip")
let tooltips = document.getElementsByClassName("tooltip");
// Look up all descendants of sect1 that have class "sidebar"
// Similar to sect1.querySelectorAll(".sidebar")
let sidebars = sect1.getElementsByClassName("sidebar");
像querySelectorAll()一样,这段代码中的方法返回一个 NodeList(除了getElementById(),它返回一个单个的 Element 对象)。然而,与querySelectorAll()不同,这些旧的选择方法返回的 NodeList 是“活动的”,这意味着如果文档内容或结构发生变化,列表的长度和内容也会发生变化。
预选元素
由于历史原因,Document 类定义了一些快捷属性来访问某些类型的节点。例如,images、forms和links属性提供了对文档中<img>、<form>和<a>元素(但只有具有href属性的<a>标签)的简单访问。这些属性指的是 HTMLCollection 对象,它们很像 NodeList 对象,但可以通过元素 ID 或名称进行索引。例如,通过document.forms属性,你可以访问<form id="address">标签:
document.forms.address;
一个更过时的用于选择元素的 API 是document.all属性,它类似于文档中所有元素的 HTMLCollection。document.all已被弃用,你不应该再使用它。
15.3.2 文档结构和遍历
一旦你从文档中选择了一个元素,有时候你需要找到文档的结构相关部分(父元素、兄弟元素、子元素)。当我们主要关注文档中的元素而不是其中的文本(以及文本之间的空白,这也是文本),有一个遍历 API 允许我们将文档视为元素对象树,忽略文档中也包含的文本节点。这个遍历 API 不涉及任何方法;它只是一组元素对象上的属性,允许我们引用给定元素的父元素、子元素和兄弟元素:
parentNode
这个元素的属性指的是元素的父元素,它将是另一个元素或一个文档对象。
children
这个 NodeList 包含一个元素的元素子节点,但不包括非元素子节点,比如文本节点(和注释节点)。
childElementCount
元素子节点的数量。返回与children.length相同的值。
firstElementChild, lastElementChild
这些属性指的是一个元素的第一个和最后一个元素子节点。如果元素没有元素子节点,则它们为null。
nextElementSibling, previousElementSibling
这些属性指的是元素的前一个或后一个兄弟元素,如果没有这样的兄弟元素则为null。
使用这些元素属性,文档的第一个子元素的第二个子元素可以用以下任一表达式引用:
document.children[0].children[1]
document.firstElementChild.firstElementChild.nextElementSibling
(在标准的 HTML 文档中,这两个表达式都指的是文档的<body>标签。)
这里有两个函数,演示了如何使用这些属性递归地对文档进行深度优先遍历,对文档中的每个元素调用指定的函数:
// Recursively traverse the Document or Element e, invoking the function
// f on e and on each of its descendants
function traverse(e, f) {
f(e); // Invoke f() on e
for(let child of e.children) { // Iterate over the children
traverse(child, f); // And recurse on each one
}
}
function traverse2(e, f) {
f(e); // Invoke f() on e
let child = e.firstElementChild; // Iterate the children linked-list style
while(child !== null) {
traverse2(child, f); // And recurse
child = child.nextElementSibling;
}
}
以节点树的形式的文档
如果你想遍历文档或文档的某个部分,并且不想忽略文本节点,你可以使用所有 Node 对象上定义的另一组属性。这将允许你看到元素、文本节点,甚至注释节点(代表文档中的 HTML 注释)。
所有 Node 对象定义以下属性:
parentNode
这个节点的父节点,对于没有父节点的节点来说为null。
childNodes
一个只读的 NodeList,包含节点的所有子节点(不仅仅是元素子节点)。
firstChild, lastChild
一个节点的第一个和最后一个子节点,或者如果节点没有子节点则为null。
nextSibling, previousSibling
节点的下一个和上一个兄弟节点。这些属性将节点连接成一个双向链表。
nodeType
一个指定节点类型的数字。文档节点的值为 9。元素节点的值为 1。文本节点的值为 3。注释节点的值为 8。
nodeValue
Text 或 Comment 节点的文本内容。
nodeName
Element 的 HTML 标签名,转换为大写。
使用这些 Node 属性,可以使用以下表达式引用文档的第一个子节点的第二个子节点:
document.childNodes[0].childNodes[1]
document.firstChild.firstChild.nextSibling
假设所讨论的文档如下:
<html><head><title>Test</title></head><body>Hello World!</body></html>
然后,第一个子节点的第二个子节点是<body>元素。它的nodeType为 1,nodeName为“BODY”。
但是,请注意,此 API 对文档文本的变化非常敏感。例如,如果在<html>和<head>标签之间插入一个换行符修改了文档,那么表示该换行符的 Text 节点将成为第一个子节点的第一个子节点,第二个子节点将是<head>元素,而不是<body>元素。
为了演示基于 Node 的遍历 API,这里是一个返回元素或文档中所有文本的函数:
// Return the plain-text content of element e, recursing into child elements.
// This method works like the textContent property
function textContent(e) {
let s = ""; // Accumulate the text here
for(let child = e.firstChild; child !== null; child = child.nextSibling) {
let type = child.nodeType;
if (type === 3) { // If it is a Text node
s += child.nodeValue; // add the text content to our string.
} else if (type === 1) { // And if it is an Element node
s += textContent(child); // then recurse.
}
}
return s;
}
此函数仅用于演示—在实践中,您只需编写e.textContent即可获取元素e的文本内容。
15.3.3 属性
HTML 元素由标签名和一组称为属性的名称/值对组成。例如,定义超链接的<a>元素使用其href属性的值作为链接的目的地。
Element 类定义了用于查询、设置、测试和删除元素属性的通用getAttribute()、setAttribute()、hasAttribute()和removeAttribute()方法。但是 HTML 元素的属性值(对于所有标准 HTML 元素的标准属性)作为表示这些元素的 HTMLElement 对象的属性可用,并且通常更容易作为 JavaScript 属性处理,而不是调用getAttribute()和相关方法。
HTML 属性作为元素属性
表示 HTML 文档元素的 Element 对象通常定义了反映元素 HTML 属性的读/写属性。Element 定义了通用 HTML 属性的属性,如id、title、lang和dir,以及像onclick这样的事件处理程序属性。特定于元素的子类型定义了特定于这些元素的属性。例如,要查询图像的 URL,可以使用表示<img>元素的 HTMLElement 的src属性:
let image = document.querySelector("#main_image");
let url = image.src; // The src attribute is the URL of the image
image.id === "main_image" // => true; we looked up the image by id
同样地,你可以使用以下代码设置<form>元素的表单提交属性:
let f = document.querySelector("form"); // First <form> in the document
f.action = "https://www.example.com/submit"; // Set the URL to submit it to.
f.method = "POST"; // Set the HTTP request type.
对于一些元素,例如<input>元素,一些 HTML 属性名称映射到不同命名的属性。例如,<input>的 HTML value属性在 JavaScript 中由defaultValue属性镜像。<input>元素的 JavaScript value属性包含用户当前的输入,但对value属性的更改不会影响defaultValue属性或value属性。
HTML 属性不区分大小写,但 JavaScript 属性名称区分大小写。要将属性名称转换为 JavaScript 属性,将其写成小写。但是,如果属性超过一个单词,将第一个单词后的每个单词的第一个字母大写:例如,defaultChecked和tabIndex。但是,事件处理程序属性如onclick是一个例外,它们以小写形式编写。
一些 HTML 属性名称在 JavaScript 中是保留字。对于这些属性,一般规则是在属性名称前加上“html”。例如,HTML <label>元素的for属性变为 JavaScript 的htmlFor属性。class是 JavaScript 中的保留字,而非常重要的 HTML class属性是规则的例外:在 JavaScript 代码中变为className。
代表 HTML 属性的属性通常具有字符串值。但是,当属性是布尔值或数字值(例如 <input> 元素的 defaultChecked 和 maxLength 属性)时,属性是布尔值或数字,而不是字符串。事件处理程序属性始终具有函数(或 null)作为它们的值。
请注意,用于获取和设置属性值的基于属性的 API 不定义任何删除元素属性的方法。特别是,delete 运算符不能用于此目的。如果需要删除属性,请使用 removeAttribute() 方法。
class 属性
HTML 元素的 class 属性是一个特别重要的属性。它的值是一个空格分隔的 CSS 类列表,适用于元素并影响其在 CSS 中的样式。由于 class 在 JavaScript 中是一个保留字,因此此属性的值可以通过 Element 对象上的 className 属性获得。className 属性可以设置和返回 class 属性的值作为字符串。但是 class 属性的命名不太合适:它的值是 CSS 类的列表,而不是单个类,通常在客户端 JavaScript 编程中,希望从此列表中添加和删除单个类名,而不是将列表作为单个字符串处理。
因此,Element 对象定义了一个 classList 属性,允许您将 class 属性视为列表。classList 属性的值是一个可迭代的类似数组的对象。尽管属性的名称是 classList,但它更像是一组类,并定义了 add()、remove()、contains() 和 toggle() 方法:
// When we want to let the user know that we are busy, we display
// a spinner. To do this we have to remove the "hidden" class and add the
// "animated" class (assuming the stylesheets are configured correctly).
let spinner = document.querySelector("#spinner");
spinner.classList.remove("hidden");
spinner.classList.add("animated");
数据集属性
有时,在 HTML 元素上附加额外信息是有用的,通常是当 JavaScript 代码将选择这些元素并以某种方式操作它们时。在 HTML 中,任何名称为小写并以前缀“data-”开头的属性都被视为有效,您可以将它们用于任何目的。这些“数据集属性”不会影响它们所在元素的呈现,并且它们定义了一种标准的方法来附加额外数据,而不会影响文档的有效性。
在 DOM 中,Element 对象具有一个 dataset 属性,指向一个对象,该对象具有与其前缀去除的 data- 属性对应的属性。因此,dataset.x 将保存 data-x 属性的值。连字符属性映射到驼峰命名属性名称:属性 data-section-number 变为属性 dataset.sectionNumber。
假设一个 HTML 文档包含以下文本:
<h2 id="title" data-section-number="16.1">Attributes</h2>
然后,您可以编写如下 JavaScript 代码来访问该部分编号:
let number = document.querySelector("#title").dataset.sectionNumber;
15.3.4 元素内容
再次查看 图 15-1 中显示的文档树,并问问自己 <p> 元素的“内容”是什么。我们可能以两种方式回答这个问题:
-
内容是 HTML 字符串“This is a simple document”。
-
内容是纯文本字符串“This is a simple document”。
这两种答案都是有效的,每个答案在其自身的方式上都是有用的。接下来的部分将解释如何处理元素内容的 HTML 表示和纯文本表示。
元素内容作为 HTML
读取 Element 的 innerHTML 属性会返回该元素的内容作为标记字符串。在元素上设置此属性会调用 Web 浏览器的解析器,并用新字符串的解析表示替换元素的当前内容。您可以通过打开开发者控制台并输入以下内容来测试:
document.body.innerHTML = "<h1>Oops</h1>";
您会看到整个网页消失,并被单个标题“Oops”替换。Web 浏览器非常擅长解析 HTML,并且设置innerHTML通常相当高效。但请注意,使用+=运算符将文本附加到innerHTML属性不高效,因为它需要序列化步骤将元素内容转换为字符串,然后需要解析步骤将新字符串转换回元素内容。
警告
在使用这些 HTML API 时,非常重要的一点是绝对不要将用户输入插入文档中。如果这样做,您将允许恶意用户将自己的脚本注入到您的应用程序中。有关详细信息,请参见“跨站脚本”。
元素的outerHTML属性类似于innerHTML,只是它的值包括元素本身。当您查询outerHTML时,该值包括元素的开头和结尾标记。当您在元素上设置outerHTML时,新内容将替换元素本身。
一个相关的元素方法是insertAdjacentHTML(),它允许您在指定元素的“相邻”位置插入任意 HTML 标记的字符串。标记作为第二个参数传递给此方法,而“相邻”的确切含义取决于第一个参数的值。第一个参数应该是一个带有“beforebegin”、“afterbegin”、“beforeend”或“afterend”值之一的字符串。这些值对应于图 15-2 中说明的插入点。
图 15-2. insertAdjacentHTML()的插入点
元素内容作为纯文本
有时,您希望将元素的内容查询为纯文本,或者将纯文本插入文档中(而无需转义 HTML 标记中使用的尖括号和和号)。标准的做法是使用textContent属性:
let para = document.querySelector("p"); // First <p> in the document
let text = para.textContent; // Get the text of the paragraph
para.textContent = "Hello World!"; // Alter the text of the paragraph
textContent属性由 Node 类定义,因此适用于文本节点和元素节点。对于元素节点,它会查找并返回元素所有后代中的所有文本。
Element 类定义了类似于textContent的innerText属性。innerText具有一些不寻常和复杂的行为,例如尝试保留表格格式。然而,它在各个浏览器之间的规范和实现并不一致,因此不应再使用。
15.3.5 创建、插入和删除节点
我们已经看到如何使用 HTML 字符串和纯文本查询和更改文档内容。我们还看到我们可以遍历文档以检查它由哪些单独的元素和文本节点组成。还可以在单个节点级别更改文档。Document 类定义了用于创建元素对象的方法,而 Element 和 Text 对象具有在树中插入、删除和替换节点的方法。
使用 Document 类的createElement()方法创建一个新元素,并使用其append()和prepend()方法将文本字符串或其他元素附加到其中:
let paragraph = document.createElement("p"); // Create an empty <p> element
let emphasis = document.createElement("em"); // Create an empty <em> element
emphasis.append("World"); // Add text to the <em> element
paragraph.append("Hello ", emphasis, "!"); // Add text and <em> to <p>
paragraph.prepend("¡"); // Add more text at start of <p>
paragraph.innerHTML // => "¡Hello <em>World</em>!"
append()和prepend()接受任意数量的参数,可以是节点对象或字符串。字符串参数会自动转换为文本节点。(您可以使用document.createTextNode()显式创建文本节点,但很少有理由这样做。)append()将参数添加到子节点列表的末尾。prepend()将参数添加到子节点列表的开头。
如果您想要将元素或文本节点插入包含元素的子节点列表的中间位置,则append()或prepend()都不适用。在这种情况下,您应该获取一个兄弟节点的引用,并调用before()在该兄弟节点之前插入新内容,或者调用after()在该兄弟节点之后插入新内容。例如:
// Find the heading element with class="greetings"
let greetings = document.querySelector("h2.greetings");
// Now insert the new paragraph and a horizontal rule after that heading
greetings.after(paragraph, document.createElement("hr"));
像append()和prepend()一样,after()和before()接受任意数量的字符串和元素参数,并在将字符串转换为文本节点后将它们全部插入文档中。append()和prepend()仅在 Element 对象上定义,但after()和before()适用于 Element 和 Text 节点:您可以使用它们相对于 Text 节点插入内容。
请注意,元素只能插入文档中的一个位置。如果元素已经在文档中并且您将其插入到其他位置,它将被移动到新位置,而不是复制:
// We inserted the paragraph after this element, but now we
// move it so it appears before the element instead
greetings.before(paragraph);
如果您确实想要复制一个元素,请使用cloneNode()方法,传递true以复制其所有内容:
// Make a copy of the paragraph and insert it after the greetings element
greetings.after(paragraph.cloneNode(true));
您可以通过调用其remove()方法从文档中删除 Element 或 Text 节点,或者您可以通过调用replaceWith()来替换它。remove()不接受任何参数,replaceWith()接受任意数量的字符串和元素,就像before()和after()一样:
// Remove the greetings element from the document and replace it with
// the paragraph element (moving the paragraph from its current location
// if it is already inserted into the document).
greetings.replaceWith(paragraph);
// And now remove the paragraph.
paragraph.remove();
DOM API 还定义了一组用于插入和删除内容的较旧一代方法。appendChild()、insertBefore()、replaceChild()和removeChild()比这里显示的方法更难使用,而且永远不应该需要。
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”,然后将top和left样式设置为所需的坐标来动态定位元素。在使用 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 中设置样式属性时需要单位,就像在样式表中设置样式属性时一样。将元素e的marginLeft属性值设置为 300 像素的正确方法是:
e.style.marginLeft = "300px";
如果要将 CSS 属性设置为计算值,请确保在计算结束时附加单位:
e.style.left = `${x0 + left_border + left_padding}px`;
请记住,一些 CSS 属性,例如margin,是其他属性的快捷方式,例如margin-top,margin-right,margin-bottom和margin-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属性,而是使用marginLeft、marginTop等。同样,不要查询border甚至borderWidth,而是使用borderLeftWidth、borderTopWidth等。 -
计算样式的
cssText属性是未定义的。
通过getComputedStyle()返回的 CSSStyleDeclaration 对象通常包含有关元素的更多信息,而不是从该元素的内联style属性获取的 CSSStyleDeclaration。但计算样式可能会有些棘手,查询它们并不总是提供你期望的信息。考虑font-family属性:它接受一个逗号分隔的所需字体系列列表,以实现跨平台可移植性。当你查询计算样式的fontFamily属性时,你只是获取适用于元素的最具体font-family样式的值。这可能返回一个值,如“arial,helvetica,sans-serif”,这并不告诉你实际使用的字体。同样,如果一个元素没有绝对定位,尝试通过计算样式的top和left属性查询其位置和大小通常会返回值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-name和animation-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 坐标向下增加。然而,我们可以使用两个不同的点作为坐标系原点:元素的 x 和 y 坐标可以相对于文档的左上角或相对于显示文档的视口的左上角。在顶级窗口和标签中,“视口”是实际显示文档内容的浏览器部分:它不包括浏览器的“chrome”(如菜单、工具栏和标签)。对于在 <iframe> 标签中显示的文档,DOM 中定义嵌套文档的视口的是 iframe 元素。无论哪种情况,当我们谈论元素的位置时,必须清楚我们是使用文档坐标还是视口坐标。(请注意,有时视口坐标被称为“窗口坐标”。)
如果文档比视口小,或者没有滚动,文档的左上角在视口的左上角,文档和视口坐标系是相同的。然而,一般来说,要在两个坐标系之间转换,必须添加或减去滚动偏移量。例如,如果一个元素在文档坐标中有 200 像素的 y 坐标,而用户向下滚动了 75 像素,那么该元素在视口坐标中的 y 坐标为 125 像素。同样,如果一个元素在用户水平滚动视口 200 像素后在视口坐标中有 400 的 x 坐标,那么元素在文档坐标中的 x 坐标为 600。
如果我们使用印刷纸质文档的思维模型,逻辑上可以假设文档中的每个元素在文档坐标中必须有一个唯一的位置,无论用户滚动了多少。这是纸质文档的一个吸引人的特性,对于简单的网页文档也适用,但总的来说,在网页上文档坐标实际上并不起作用。问题在于 CSS overflow 属性允许文档中的元素包含比其能显示的更多内容。元素可以有自己的滚动条,并作为包含的内容的视口。网页允许在滚动文档中滚动元素意味着不可能使用单个 (x,y) 点描述文档中元素的位置。
因为文档坐标实际上不起作用,客户端 JavaScript 倾向于使用视口坐标。例如,下面描述的 getBoundingClientRect() 和 elementFromPoint() 方法使用视口坐标,而鼠标和指针事件对象的 clientX 和 clientY 属性也使用这个坐标系。
当你使用 CSS position:fixed 明确定位元素时,top 和 left 属性是以视口坐标解释的。如果使用 position:relative,元素的定位是相对于如果没有设置 position 属性时的位置。如果使用 position:absolute,那么 top 和 left 是相对于文档或最近的包含定位元素的。这意味着,例如,相对定位元素位于相对定位元素内部,是相对于容器元素而不是相对于整个文档的。有时候,创建一个相对定位的容器并将 top 和 left 设置为 0(使容器正常布局)非常有用,以便为其中包含的绝对定位元素建立一个新的坐标系原点。我们可能将这个新的坐标系称为“容器坐标”,以区别于文档坐标和视口坐标。
15.5.2 查询元素的几何信息
您可以通过调用其getBoundingClientRect()方法来确定元素的大小(包括 CSS 边框和填充,但不包括边距)和位置(在视口坐标中)。它不带参数并返回一个具有属性left、right、top、bottom、width和height的对象。left和top属性给出元素左上角的x和y坐标,right和bottom属性给出右下角的坐标。这些值之间的差异是width和height属性。
块元素,如图像、段落和<div>元素在浏览器布局时始终是矩形的。然而,内联元素,如<span>、<code>和<b>元素,可能跨越多行,因此可能由多个矩形组成。例如,想象一下,某些文本在<em>和</em>标签中显示,跨越两行。其矩形包括第一行的末尾和第二行的开头。如果您在此元素上调用getBoundingClientRect(),边界矩形将包括两行的整个宽度。如果要查询内联元素的各个矩形,请调用getClientRects()方法以获取一个只读的类似数组的对象,其元素是类似于getBoundingClientRect()返回的矩形对象。
15.5.3 确定点处的元素
getBoundingClientRect()方法允许我们确定元素在视口中的当前位置。有时我们想要反向操作,并确定视口中给定位置的元素是哪个。您可以使用文档对象的elementFromPoint()方法来确定这一点。使用点的x和y坐标调用此方法(使用视口坐标,而不是文档坐标:例如,鼠标事件的clientX和clientY坐标)。elementFromPoint()返回一个在指定位置的元素对象。用于选择元素的命中检测算法没有明确定义,但此方法的意图是返回该点处最内部(最深度嵌套)和最上层(最高 CSS z-index属性)的元素。
15.5.4 滚动
Window 对象的scrollTo()方法接受点的x和y坐标(在文档坐标中)并将其设置为滚动条偏移量。也就是说,它滚动窗口,使指定点位于视口的左上角。如果指定的点太靠近文档的底部或右边缘,浏览器会尽可能将其移动到左上角,但无法完全到达那里。以下代码将浏览器滚动,以便看到文档的最底部页面:
// 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属性以指定水平滚动时元素的位置。这些属性的合法值为start、end、nearest和center。
视口大小、内容大小和滚动位置
正如我们所讨论的,浏览器窗口和其他 HTML 元素可以显示滚动内容。在这种情况下,我们有时需要知道视口的大小、内容的大小以及内容在视口内的滚动偏移量。本节涵盖了这些细节。
对于浏览器窗口,视口大小由window.innerWidth和window.innerHeight属性给出。(为移动设备优化的网页通常在<head>中使用<meta name="viewport">标签来设置页面所需的视口宽度。)文档的总大小与<html>元素的大小相同,即document.documentElement。您可以在document.documentElement上调用getBoundingClientRect()来获取文档的宽度和高度,或者您可以使用document.documentElement的offsetWidth和offsetHeight属性。文档在其视口内的滚动偏移量可通过window.scrollX和window.scrollY获得。这些是只读属性,因此您无法设置它们来滚动文档:请改用window.scrollTo()。
对于元素来说情况会有些复杂。每个 Element 对象定义以下三组属性:
offsetWidth clientWidth scrollWidth
offsetHeight clientHeight scrollHeight
offsetLeft clientLeft scrollLeft
offsetTop clientTop scrollTop
offsetParent
元素的offsetWidth和offsetHeight属性返回其在屏幕上的大小(以 CSS 像素为单位)。返回的大小包括元素的边框和填充,但不包括边距。offsetLeft和offsetTop属性返回元素的x和y坐标。对于许多元素,这些值是文档坐标。但对于定位元素的后代和一些其他元素(如表格单元格),这些属性返回相对于祖先元素而不是文档本身的坐标。offsetParent属性指定这些属性相对于哪个元素。这些偏移属性都是只读的。
clientWidth和clientHeight类似于offsetWidth和offsetHeight,只是它们不包括边框大小,只包括内容区域及其填充。clientLeft和clientTop属性并不是很有用:它们返回元素的填充外部与边框外部之间的水平和垂直距离。通常,这些值只是左边框和上边框的宽度。这些客户端属性都是只读的。对于像<i>、<code>和<span>这样的内联元素,它们都返回 0。
scrollWidth和scrollHeight返回元素内容区域的大小加上其填充加上任何溢出内容。当内容适合内容区域而不溢出时,这些属性与clientWidth和clientHeight相同。但当存在溢出时,它们包括溢出的内容并返回大于clientWidth和clientHeight的值。scrollLeft和scrollTop给出元素内容在元素视口内的滚动偏移量。与这里描述的所有其他属性不同,scrollLeft和scrollTop是可写属性,您可以设置它们来滚动元素内的内容。(在大多数浏览器中,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 中展示的自定义元素没有很好地封装。当设置其 diameter 或 color 属性时,它会通过更改自己的 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 的方式:
-
您可以像使用 .png 或 .jpeg 图像一样使用 .svg 图像文件与常规 HTML
<img>标签。 -
由于基于 XML 的 SVG 格式与 HTML 如此相似,您实际上可以直接将 SVG 标记嵌入到 HTML 文档中。如果这样做,浏览器的 HTML 解析器允许您省略 XML 命名空间,并将 SVG 标记视为 HTML 标记。
-
您可以使用 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 参考资料以了解更多信息。您可能还会注意到样式表很奇怪。像fill、stroke-width和text-anchor这样的样式不是正常的 CSS 样式属性。在这种情况下,CSS 基本上用于设置文档中出现的 SVG 标签的属性。还要注意,CSS 的font简写属性不适用于 SVG 标签,您必须显式设置font-family、font-size和font-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,该命名空间是字面字符串“www.w3.org/2000/svg”。
图 15-6. 使用 JavaScript 构建的 SVG 饼图(数据来自 Stack Overflow 的 2018 年开发者调查最受欢迎技术)
除了使用createElementNS()之外,示例 15-4 中的饼图绘制代码相对简单。有一点数学计算将被绘制的数据转换为饼图角度。然而,示例的大部分是创建 SVG 元素并在这些元素上设置属性的 DOM 代码。
这个示例中最不透明的部分是绘制实际饼图片段的代码。用于显示每个片段的元素是<path>。这个 SVG 元素描述由线条和曲线组成的任意形状。形状描述由<path>元素的d属性指定。该属性的值使用字母代码和数字的紧凑语法,指定坐标、角度和其他值。例如,字母 M 表示“移动到”,后面跟着x和y坐标。字母 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 标记来定义一个带有适当width和height属性的画布,然后添加像这样的代码来初始化变量c:
let canvas = document.querySelector("#my_canvas_id");
let c = canvas.getContext('2d');