第15章 Web浏览器中的JavaScript
JavaScript语言创建于1994年,其明确目的是在web浏览器显示的文档中启用动态行为。自那以后,这种语言有了很大的发展,与此同时,web平台的范围和功能也在爆炸性地增长。今天,JavaScript程序员可以把web看作是一个功能齐全的应用程序开发平台。Web浏览器专门用于显示格式化的文本和图像,但与本机操作系统一样,浏览器还提供其他服务,包括图形、视频、音频、网络、存储和线程。JavaScript是一种使web应用程序能够使用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)
客户端JavaScript
在这本书中,在网络上,你会看到术语“客户端JavaScript”。这个术语只是编写在web浏览器中运行的JavaScript的同义词,它与运行在web服务器上的“服务器端”代码形成鲜明对比。
这两个“端”字是指将web服务器和web浏览器分开的网络连接的两端,web软件开发通常需要在“两端”编写代码。客户端和服务器端通常也被称为“前端”和“后端”。
这本书的前几版试图全面地涵盖web浏览器定义的所有JavaScript API,结果,这本书在十年前已经太长了。web API的数量和复杂性在不断增长,我认为试图在一本书中涵盖它们已经没有意义了。在第七版中,我的目标是明确介绍JavaScript语言,并深入介绍如何在Node和web浏览器中使用JavaScript语言。本章不能涵盖所有的web API,但它详细介绍了最重要的API,以便您立即开始使用它们。而且,在了解了这里介绍的核心API之后,您应该能够在需要时使用新的API(如§15.15中总结的API)。
Node只有一个实现和一个权威的文档源。相比之下,Web API是由主要的Web浏览器供应商达成一致的,而权威文档采用的是为实现API的C++程序员而设计的规范,而不是使用JavaScript程序员的规范。幸运的是,Mozilla的“MDN网络文档”项目是一个可靠而全面的web API文档源1。
遗留API
自JavaScript首次发布以来的25年中,浏览器供应商一直在为程序员添加属性和API。其中许多API现在已经过时。它们包括:
- 从未被其他浏览器供应商标准化和/或从未实现过的专有API。微软的IE定义了很多这样的API。有些(比如innerHTML属性)被证明是有用的,并且最终被标准化了。其他方法(比如attachEvent()方法)已经过时多年了。
- 效率低下的API(如document.write()方法)对性能的影响非常严重,以至于不再可以接受它们的使用。
- 过时的API早已被新的API所取代,以达到同样的目的。一个允许设置文档背景颜色的示例document.bgColor。随着CSS的出现,document.bgColor变成了一个没有实际用途的奇特的特例。
- 设计拙劣的API被更好的API所取代。在web的早期,标准委员会以一种与语言无关的方式定义了关键的文档对象模型API,以便在Java程序中使用相同的API来处理XML文档,并在JavaScript程序中使用同一API来处理HTML文档。这就导致了一个不太适合JavaScript语言的API,它具有web程序员并不特别关心的属性。从早期的设计错误中恢复需要几十年的时间,但是今天的web浏览器支持一个大大改进的文档对象模型。
浏览器供应商可能需要在可预见的将来支持这些遗留API,以确保向后兼容性,但本书不再需要对它们进行文档记录,也不需要您了解它们。web平台已经成熟和稳定,如果你是一个经验丰富的web开发人员,还记得这本书的第四或第五版,那么你可能会忘记很多过时的知识,就像你有新的东西需要学习一样。
15.1 网络编程基础
本节将解释用于web的JavaScript程序是如何构造的,它们是如何加载到web浏览器中的,它们如何获得输入,如何生成输出,以及它们如何通过响应事件异步运行。
15.1.1 HTML script 标签中的JavaScript
Web浏览器显示HTML文档。如果您希望web浏览器执行JavaScript代码,那么HTML文档中必须包含(或引用)代码,而这正是HTML<script>标签所做的。
JavaScript代码可以在HTML文件中的<script>和</script>标签之间显示。例如,这里是一个HTML文件,其中包含一个带有JavaScript代码的script标签,动态更新文档的一个元素,使其行为类似于数字时钟:
<!DOCTYPE html> <!-- 这是一个HTML5文件 -->
<html>
<!-- 根元素 -->
<head>
<!-- 标题,脚本和样式可以在这里 -->
<title>数字时钟</title>
<style>
/* 时钟的CSS样式表 */
#clock {
/* 样式应用于id=“clock”的元素 */
font: bold 24px sans-serif;
/* 使用粗体大字体 */
background: #ddf;
/* 在浅蓝灰色的背景上。 */
padding: 15px;
/* 用一些空白把它围起来 */
border: solid black 2px;
/* 和一个实体黑色边框 */
border-radius: 10px;
/* 有圆角。 */
}
</style>
</head>
<body>
<!-- 主体保存文档的内容。 -->
<h1>数字时钟</h1> <!-- 显示标题。 -->
<span id="clock"></span> <!-- 我们将在这个元素中插入时间。 -->
<script>
// 定义函数以显示当前时间
function displayTime() {
let clock = document.querySelector("#clock"); // 获取id=“clock”的元素
let now = new Date(); // 获取当前时间
clock.textContent = now.toLocaleTimeString(); // 在时钟中显示时间
}
displayTime() // 马上显示时间
setInterval(displayTime, 1000); // 然后每秒钟更新一次。
</script>
</body>
</html>
尽管JavaScript代码可以直接嵌入到<script>标签中,但更常见的做法是使用<script>标签的src属性(attribute)来指定包含JavaScript代码的文件的URL(绝对URL或相对于显示的HTML文件URL的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代码,也就是说,它有助于保持内容和行为的分离。
- 当多个网页共享相同的JavaScript代码时,使用src属性可以只维护该代码的一个副本,而不必在代码更改时编辑每个HTML文件。
- 如果一个JavaScript代码文件由多个页面共享,则只需下载一次,由使用它的第一个页面下载,后续页面可以从浏览器缓存中检索该文件。
- 由于src属性采用任意URL作为其值,所以来自一个web服务器的JavaScript程序或web页面可以使用其他web服务器导出的代码。很多网络广告都依赖于这个事实。
模块
§10.3记录了JavaScript模块并涵盖了它们的导入和导出指令。如果您使用模块编写了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属性覆盖此默认值,这将导致在加载模块及其所有依赖项后立即执行代码。
对于直接包含在HTML中的代码,async和defer属性的一个简单替代方法是将脚本放在HTML文件的末尾。这样,脚本可以在文档内容被解析并准备好操作之前运行。
按需加载脚本
有时,您可能有一些JavaScript代码,当第一次加载文档时不使用这些代码,并且只有在用户执行某些操作(如单击按钮或打开菜单)时才需要该代码。如果您使用模块开发代码,您可以使用import()按需加载模块,如§10.3.6所述。
如果不使用模块,只需在需要加载脚本时向文档添加<script>标签,就可以按需加载JavaScript文件:
// 从指定的URL异步加载和执行脚本
// 返回一个在脚本加载后解决的Promise。
function importScript(url) {
return new Promise((resolve, reject) => {
let s = document.createElement("script"); // 创建<script>元素
s.onload = () => {
resolve(); }; // 加载后解决Promise
s.onerror = (e) => {
reject(e); }; // 失败时拒绝
s.src = url; // 设置脚本URL
document.head.append(s); // 将<script>添加到文档
});
}
这个importScript()函数使用DOM API(§15.3)创建一个新的<script>标签并将其添加到文档<head>中。它使用事件处理程序(§15.2)来确定脚本何时成功加载或何时加载失败。
15.1.2 文档对象模型(DOM)
客户端JavaScript编程中最重要的对象之一是Document对象,它表示在浏览器窗口或选项卡中显示的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元素对象,对于文档中每次运行的文本,都有一个对应的Text(文本)对象。Element(元素)和Text(文本)类以及Document(文档)类本身都是更通用的Node(节点)类的子类,节点对象被组织成树状结构,JavaScript可以使用DOM API查询和遍历这些树结构。本文档的DOM表示如图15-1所示。
图15-1. HTML文档的树表示
如果您还不熟悉计算机编程中的树结构,那么知道它们借用了家谱中的术语是很有帮助的。节点正上方的节点是该节点的父节点。另一个节点正下方一个级别的节点是该节点的子节点。处于同一级别且具有相同父级的节点是兄弟节点。另一个节点下任意数量级别的节点集都是该节点的后代。父节点、祖父母节点和节点上方的所有其他节点都是该节点的祖先。
DOM API包括创建新元素和文本节点的方法,以及将它们作为其他元素对象的子节点插入到文档中的方法。还有一些方法可以在文档中移动元素并完全删除它们。虽然服务器端应用程序可能通过使用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)之类的属性,history表示窗口的浏览历史,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和函数声明是通过全局对象的属性共享的。更新的const、let和class声明也被共享并具有相同的文档范围,但它们不作为JavaScript代码可以访问的任何对象的属性存在。
15.1.5 JavaScript程序的执行
在客户端JavaScript中没有程序的正式定义,但是可以说JavaScript程序由文档中或从文档引用的所有JavaScript代码组成。这些独立的代码共享一个全局窗口对象,这使它们能够访问表示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编程中,事件处理程序函数(如这里描述的“加载”事件处理程序)注册其他事件处理程序是很常见的。
JavaScript程序的加载阶段相对较短:理想情况下不到一秒钟。一旦加载了文档,事件驱动阶段将持续到文档通过web浏览器显示的时间。因为这个阶段是异步的和事件驱动的,所以可能会有很长一段时间没有执行JavaScript,中间是由用户或网络事件触发的突发活动。接下来我们将更详细地介绍这两个阶段。
客户端JavaScript线程模型
JavaScript是一种单线程语言,单线程执行使编程更加简单