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

时间:2024-03-28 16:28:54

第十五章: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 模块,并涵盖它们的importexport指令。如果您使用模块编写了 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>标签可以具有deferasync属性,这会导致脚本以不同的方式执行。这些是布尔属性——它们没有值;它们只需要出现在<script>标签上。请注意,这些属性仅在与src属性一起使用时才有意义:

<script defer src="deferred.js"></script>
<script async src="async.js"></script>

deferasync属性都是告诉浏览器链接的脚本不使用document.write()来生成 HTML 输出的方式,因此浏览器可以在下载脚本的同时继续解析和渲染文档。defer属性会导致浏览器推迟执行脚本,直到文档完全加载和解析完成,并且准备好被操作。async属性会导致浏览器尽快运行脚本,但不会在下载脚本时阻止文档解析。如果一个<script>标签同时具有这两个属性,async属性优先。

注意,延迟脚本按照它们在文档中出现的顺序运行。异步脚本在加载时运行,这意味着它们可能无序执行。

带有type="module"属性的脚本默认在文档加载后执行,就像它们有一个defer属性一样。您可以使用async属性覆盖此默认行为,这将导致代码在模块及其所有依赖项加载后立即执行。

一个简单的替代方案是asyncdefer属性——特别是对于直接包含在 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 中描绘的树。

js7e 1501

图 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.innerWidthinnerWidth 更清晰。

15.1.4 脚本共享命名空间

使用模块时,在模块顶层(即在任何函数或类定义之外)定义的常量、变量、函数和类对于模块是私有的,除非它们被明确导出,这样,其他模块可以有选择地导入它们。(请注意,模块的这个属性也受到代码捆绑工具的尊重。)

然而,对于非模块脚本,情况完全不同。如果脚本中的顶层代码定义了常量、变量、函数或类,那个声明将对同一文档中的所有其他脚本可见。如果一个脚本定义了一个函数 f(),另一个脚本定义了一个类 c,那么第三个脚本可以调用该函数并实例化该类,而无需采取任何导入操作。因此,如果您不使用模块,在您的文档中的独立脚本共享一个单一命名空间,并且表现得好像它们都是单个更大脚本的一部分。这对于小型程序可能很方便,但在更大的程序中,特别是当一些脚本是第三方库时,需要避免命名冲突可能会成为问题。

这个共享命名空间的工作方式有一些历史上的怪癖。在顶层使用 varfunction 声明会在共享的全局对象中创建属性。如果一个脚本定义了一个顶层函数 f(),那么同一文档中的另一个脚本可以将该函数调用为 f()window.f()。另一方面,ES6 声明 constletclass 在顶层使用时不会在全局对象中创建属性。然而,它们仍然在共享的命名空间中定义:如果一个脚本定义了一个类 C,其他脚本将能够使用 new C() 创建该类的实例,但不能使用 new window.C()

总结一下:在模块中,顶层声明的作用域是模块,并且可以被明确导出。然而,在非模块脚本中,顶层声明的作用域是包含文档,并且这些声明被文档中的所有脚本共享。旧的 varfunction 声明通过全局对象的属性共享。新的 constletclass 声明也是共享的,并具有相同的文档作用域,但它们不作为 JavaScript 代码可以访问的任何对象的属性存在。

15.1.5 JavaScript 程序的执行

在客户端 JavaScript 中,程序 没有正式的定义,但我们可以说 JavaScript 程序包括文档中的所有 JavaScript 代码或引用的代码。这些独立的代码片段共享一个全局 Window 对象,使它们可以访问表示 HTML 文档的相同底层 Document 对象。不是模块的脚本还共享一个顶层命名空间。

如果网页包含嵌入的框架(使用 <iframe> 元素),嵌入文档中的 JavaScript 代码具有不同的全局对象和文档对象,与包含文档中的代码不同,并且可以被视为一个单独的 JavaScript 程序。但请记住,JavaScript 程序的边界没有正式的定义。如果容器文档和包含文档都是从同一服务器加载的,那么一个文档中的代码可以与另一个文档中的代码互动,并且您可以将它们视为单个程序的两个互动部分,如果您愿意的话。§15.13.6 解释了一个 JavaScript 程序如何与在 <iframe> 中运行的 JavaScript 代码发送和接收消息。

你可以将 JavaScript 程序执行看作是分为两个阶段进行的。在第一阶段中,文档内容被加载,<script> 元素中的代码(包括内联脚本和外部脚本)被运行。脚本通常按照它们在文档中出现的顺序运行,尽管这种默认顺序可以通过我们描述的 asyncdefer 属性进行修改。单个脚本中的 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 程序开始于脚本执行阶段,然后过渡到事件处理阶段。这两个阶段可以进一步分解为以下步骤:

  1. Web 浏览器创建一个 Document 对象并开始解析网页,随着解析 HTML 元素及其文本内容,将 Element 对象和 Text 节点添加到文档中。此时document.readyState属性的值为“loading”。

  2. 当 HTML 解析器遇到一个没有任何asyncdefertype="module"属性的<script>标签时,它将该脚本标签添加到文档中,然后执行该脚本。脚本是同步执行的,而 HTML 解析器在脚本下载(如果需要)和运行时暂停。这样的脚本可以使用document.write()将文本插入输入流,当解析器恢复时,该文本将成为文档的一部分。这样的脚本通常只是定义函数并注册事件处理程序以供以后使用,但它可以遍历和操作文档树,就像它在那个时候存在的那样。也就是说,没有asyncdefer属性的非模块脚本可以看到自己的<script>标签和在它之前出现的文档内容。

  3. 当解析器遇到设置了async属性的<script>元素时,它开始下载脚本文本(如果脚本是一个模块,它还会递归下载所有脚本的依赖项),并继续解析文档。脚本将在下载后尽快执行,但解析器不会停止等待它下载。异步脚本不能使用document.write()方法。它们可以看到自己的<script>标签和在它之前出现的所有文档内容,并且可能或可能不具有对额外文档内容的访问权限。

  4. 当文档完全解析时,document.readyState属性更改为“interactive”。

  5. 任何设置了defer属性的脚本(以及没有设置async属性的任何模块脚本)按照它们在文档中出现的顺序执行。异步脚本也可能在此时执行。延迟脚本可以访问完整的文档,它们不能使用document.write()方法。

  6. 浏览器在 Document 对象上触发“DOMContentLoaded”事件。这标志着从同步脚本执行阶段到程序执行的异步、事件驱动阶段的转变。但请注意,此时可能仍有尚未执行的async脚本。

  7. 此时文档已完全解析,但浏览器可能仍在等待其他内容(如图像)加载。当所有这些内容加载完成,并且所有async脚本已加载和执行时,document.readyState属性将更改为“complete”,并且网络浏览器在 Window 对象上触发“load”事件。

  8. 从这一点开始,事件处理程序将异步调用以响应用户输入事件、网络事件、定时器到期等。

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.widthscreen.height属性提供了用户的显示尺寸访问。在某种意义上,这些navigatorscreen对象对于网络浏览器来说就像环境变量对于 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(),它将被视为已处理,并且不会在开发者控制台中引发错误消息。

定义 onerroronunhandledrejection 处理程序通常不是必需的,但如果您想要将客户端错误报告给服务器(例如使用 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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#x27;")
    .replace(/\//g, "&#x2F;")

解决 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属性,指定事件目标。每种事件类型为其关联的事件对象定义了一组属性。与鼠标事件相关联的对象包括鼠标指针的坐标,例如,与键盘事件相关联的对象包含有关按下的键和按下的修改键的详细信息。许多事件类型仅定义了一些标准属性,如typetarget,并不包含其他有用信息。对于这些事件,事件的简单发生才是重要的,而不是事件的详细信息。

事件传播

这是浏览器决定触发事件处理程序的对象的过程。对于特定于单个对象的事件(例如 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 的事件类型。