第十五章: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()
比这里显示的方法更难使用,而且永远不应该需要。
JavaScript 权威指南第七版(GPT 重译)(六)(2)https://developer.aliyun.com/article/1485426