0%

JavaScript 权威指南 15:浏览器中的 JavaScript

JavaScript 创造于 1994 年,其明确的目的就是为浏览器显示的文档赋予动态行为。今天,Web 对 JavaScript 程序员而言已经是一个完善的应用开发平台。我们通常所说的 JavaScript 指的就是在浏览器中运行的 JavaScript 代码。与之相对的是 服务器端 代码,也就是运行在服务器上的程序。客户端和服务器端经常也被称为 前端后端

Web 编程基础

HTML <script> 标签中的 JavaScript

浏览器显示 HTML 文档。如果想让浏览器执行 JavaScript 代码,那么必须在 HTML 文档中包含(或引用)相应代码,这时候就要用到 HTML <script> 标签。

  • JavaScript 代码可以出现在 HTML 文件的 <script></script> 标签之间,也就是嵌入 HTML 中
  • 使用 <script> 标签的 src 属性指定 JavaScript 代码文件的 URL。JavaScript 文件只包含纯 JavaScript 代码,不包含 <script> 或其他 HTML 标签。按照约定,JavaScript 代码文件以 .js 结尾。

如下将 JavaScript 代码之前嵌入 <script> 标签中,这段代码实现了一个数字时钟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html lang="en">
<head>
<title>Digital Clock</title>
<style>
#clock {
font: bold 20px Arial;
background-color: #ddf;
padding: 15px;
border: solid black 2px;
border-radius: 10px;
}
</style>
</head>
<body>
<h1>Digital Clock</h1>
<span id="clock"></span>
<script>
function displayTime() {
let clock = document.querySelector('#clock');
let now = new Date();
clock.textContent = now.toLocaleTimeString();
}
displayTime();
setInterval(displayTime, 1000);
</script>
</body>
</html>

使用 src 有如下优点:

  • 简化 HTML 文件,因为可以把大段的 JavaScript 代码从中移走,实现内容与行为的分离
  • 在多个网页共享同一份 JavaScript 代码时,使用 src 属性可以只维护一份代码,而无须在代码变化时修改多个 HTML 文件
  • 如果一个 JavaScript 文件被多个页面共享,那它只会被使用它的第一个页面下载一次,后续页面可以从浏览器缓存中获取该文件
  • 因为 src 以任意 URL 作为值,所以来自一个 Web 服务器的 JavaScript 程序或网页可以利用其他服务器暴露的代码。很多互联网广告就依赖这个事实

如果你用模块写了一个 JavaScript 程序(且没有使用代码打包工具把所有模块都整合到一个非 JavaScript 模块文件中)​,那必须使用一个带有 type="module" 属性的 <script> 标签来加载这个程序的顶级模块。这样,浏览器会加载你指定的模块,并加载这个模块导入的所有模块,以及(递归地)加载所有这些模块导入的模块。

由于 JavaScript 已经是 Web 的默认(也是唯一)语言。因此 <script> 标签的 language 属性被废弃了,而 type 属性也只有两个使用场景:

  • 用于指定脚本是模块
  • 在网页中嵌入数据但不会显示

浏览器在解析遇到的 <script> 元素时的默认行为是必须要运行脚本,就是为了确保不漏掉脚本可能输出的 HTML 内容,然后才能再继续解析和渲染文档。这有可能严重拖慢网页的解析和渲染过程。

默认的这种同步或阻塞式脚本执行模式并非唯一选项。<script> 标签也支持 deferasync 属性,这两个属性会导致脚本以不同的方式执行。这两个属性只对使用 src 属性的 <script> 标签起作用。

deferasync 属性都会明确告诉浏览器,当前链接的脚本中没有使用 document.write() 生成 HTML 输出(document.write() 方法可以向 HTML 中注入文本)。因此浏览器可以在下载脚本的同时继续解析和渲染文档。

  • defer 属性会让浏览器把脚本的执行推迟到文档完全加载和解析之后,此时已经可以操作文档了
  • async 属性会让浏览器尽早运行脚本,但在脚本下载期间同样不会阻塞文档解析。如果 <script> 标签上同时存在这两个属性,则 async 属性起作用
  • 推迟(defer)的脚本会按照它们在文档中出现的顺序运行。因为异步(async)脚本会在它们加载完毕后运行,所以其运行顺序无法预测

带有 type="module" 属性的脚本默认会在文档加载完毕后执行,就好像有一个 defer 属性一样。可以通过 async 属性来覆盖这个默认行为,这样会导致代码在模块及其所有依赖加载完毕后就立即执行。

如果不使用 async 和 defer 属性(特别是对那些直接包含在HTML中的代码)​,也可以选择把 <script> 标签放在 HTML 文件的末尾。这样,脚本在运行的时候就知道自己前面的文档内容已经解析,可以操作了。

有时候希望按需加载脚本,例如只有当用户执行了某些操作,才加载对应的 JavaScript 代码。如果你的代码是以模块形式写的,则可以使用 import() 来按需加载。如果没有使用模块,可以通过向文档中动态添加 <script> 标签的方式按需加载脚本:

1
2
3
4
5
6
7
8
9
function importScript(url) {
return new Promise((resolve, reject) => {
let s = document.createElement('script');
s.onload = () => { resolve(); };
s.onerror = () => { reject(); };
s.src = url;
document.head.append(s);
});
}

文档对象模型

客户端 JavaScript 编程中最重要的一个对象就是 Document 对象,它代表浏览器窗口或标签页中显示的 HTML 文档。用于操作 HTML 文档的 API 被称为 文档对象模型(Document Object Model,DOM)。

HTML 文档包含一组相互嵌套的 HTML 元素,构成了一棵树。DOM API 与 HTML 文档的这种树形结构可谓一一对应:

  • 文档中的每个 HTML 标签都有一个对应的 JavaScript Element 对象
  • 文档中的每一行文本也都有一个与之对应的 Text 对象
  • Element 和 Text 类,以及 Document 类本身,都是一个更通用的 Node 类的子类
  • 各种 Node 对象组合成一个树形结构,JavaScript 可以使用 DOM API 对其进行查询和遍历

DOM API 包含创建新 Element 和 Text 节点的方法,也包含把它们作为其他 Element 对象的孩子插入文档的方法。还有用来在文档中移动元素的方法,以及把它们从文档中彻底删除的方法。JavaScript 应用可以使用 DOM API 通过构建或操作文档树产生格式化的 HTML 输出。

每个 HTML 标签类型都有一个与之对应的 JavaScript 类,而文档中出现的每个标签在 JavaScript 中都有对应类的一个实例表示。JavaScript 中这些元素对象都有与 HTML 标签的属性对应的属性。例如,表示 <img> 标签的 HTMLImageElement 对象有一个 `src 属性,对应着标签的相应属性。

  • 这个属性的初始值就是 HTML 标签中相应属性的值
  • 在 JavaScript 中修改这个属性的值,也会改变 HTML 属性的值(并导致浏览器加载和显示新图片)​

浏览器中的全局对象

每个浏览器窗口或标签页都有一个全局对象,在一个窗口中运行的所有 JavaScript 代码(不包括工作线程中运行的代码)都共享一个全局对象。文档中的所有脚本和模块共享同一个全局对象,如果有脚本在该对象上定义了一个属性,则该属性也将对所有其他脚本可见。全局对象上定义了 JavaScript 标准库,在浏览器中,全局对象也包含各种 Web API 的主入口。例如,document 属性表示当前显示的文档,fetch() 方法用于发送 HTTP 网络请求等等。

在浏览器中,全局对象具有双重角色。它既是定义 JavaScript 语言内置类型和函数的地方,也代表当前浏览器窗口定义了 history 和 innerWidth(表示窗口的像素宽度)等 Web API 的属性。

全局对象的属性中有一个属性叫 window,它的值就是全局对象本身。这意味着在客户端代码中可以直接通过 window 引用全局对象。在使用窗口特定的功能时,最好加上 window 前缀。比如,写 window.innerWidth 比只写 innerWidth 更明确。

脚本共享一个命名空间

在模块中,定义在模块顶级(即位于任何函数或类定义之外)的常量、变量、函数和类是模块私有的,除非它们被明确地导出。被导出时,这些模块成员可以被其他模块有选择地导入。

不过在非模块脚本中,情况完全不同。如果在顶级脚本中定义了一个常量、变量、函数或类,则该声明将对同一文档中的所有脚本可见。同一个文档中共享同一个命名空间的独立脚本就如同它们是一个更大脚本的组成部分一样。这对于小程序或许会很方便,但在大型程序中避免命名冲突则会变成一件麻烦事,特别是在某些脚本还是第三方库的情况下。

这个共享的命名空间在运行时有一些历史遗留问题:

  • 比如,顶级的 var 和 function 声明会在共享的全局对象上创建属性。因此一个脚本定义了顶级函数 f(),那么同一个文档中的另一个脚本可以用 f() 或者 window.f() 调用该函数
  • 而使用 ES6 中 constletclass 的顶级声明则不会在全局对象上创建属性。但是,它们仍然会定义在一个共享的命名空间内。如果一个脚本定义了类 C,另一个脚本也可以通过 new C()(但不能通过 new window.C())创建该类的实例

简单来说,在模块中,顶级声明被限制在模块内部,可以明确导出。而在非模块脚本中,顶级声明被限制在包含文档内部,顶级声明由文档中所有的脚本共享。以前的 var 和 function 声明是通过全局对象的属性共享的,而现在的 const、let 和 class 声明也会被共享且拥有相同的文档作用域,但它们不作为 JavaScript 可以访问到的任何对象的属性存在。

JavaScript 程序的执行

客户端 JavaScript 中没有程序的正式定义,但我们可以说 JavaScript 程序由文档中包含和引用的所有 JavaScript 代码组成

  • 这些分开的代码共享同一个全局 Window 对象
  • 它们可以通过 Window 对象访问表示 HTML 文档的同一个底层 Document 对象
  • 不是模块的脚本还额外共享同一个顶级命名空间

如果网页中包含嵌入的窗格(<iframe>元素)​,被嵌入文档与嵌入它的文档中的 JavaScript 代码拥有不同的全局对象和 Document 对象,可以看成两个不同的 JavaScript 程序。但要记住,关于 JavaScript 程序的边界在哪里并没有正式的定义。如果包含文档与被包含文档是从同一个服务器加载的,则一个文档中的代码就能够与另一个文档中的代码交互。

我们可以把 JavaScript 程序的执行想象成发生在两个阶段。

  • 在第一阶段,文档内容被加载,<script> 元素指定的(内部和外部)代码运行。脚本通常按照它们在文档中出现的顺序依次执行,不过也可以使用前面介绍过的 async 和 defer 属性来修改。

    • 有的脚本在这个阶段并不真正做任何事,仅仅是定义供第二阶段使用的函数和类
    • 而有的脚本在第一阶段可能会做很多重要的事情,而在第二阶段则什么也不做
  • 当文档加载完毕且所有脚本都运行之后,JavaScript执行就进入了第二阶段。这个阶段是异步的、事件驱动的。如果脚本要在第二阶段执行,那么它在第一阶段必须要做一件事,就是至少要注册一个将被异步调用的事件处理程序或其他回调函数

    • 在事件驱动的第二阶段,作为对异步事件的回应,浏览器会调用事件处理程序或其他回调
    • 事件处理程序通常是为响应用户操作(如鼠标点击、敲击键盘等)而被调用的,但也可能会被网络活动、文档和资源加载事件、流逝的时间或者 JavaScript 代码中的错误触发

事件驱动阶段发生的第一批事件主要有:

  • DOMContent-Loaded:在 HTML 文档被完全加载和解析后触发
  • load:事件在所有文档的外部资源(如图片)都完全加载后触发

JavaScript 程序的加载阶段相对比较短,理想情况下少于 1 秒。文档加载一完成,事件驱动阶段将在浏览器显示文档的过程中一直持续。因为这个阶段是异步的和事件驱动的,所以可能会有很长一段时间什么也不会发生,也不会执行任何 JavaScript 代码。而这个过程时不时地会被用户操作或网络事件打断。

接下来更详细介绍这两个阶段:

JavaScript 是单线程的语言,而单线程执行让编程更容易:你可以保证自己写的两个事件处理程序永远不会同时运行,在操作文档内容时,你敢肯定不会有别的线程会同时去修改它。单线程执行意味着浏览器会在脚本和事件处理程序执行期间停止响应用户输入。JavaScript 程序员为此有责任确保 JavaScript 脚本和事件处理程序不会长时间运行。

Web 平台定义了一种受控的编程模型,即 Web 工作线程(Web worker)。工作线程是一个后台线程,可以执行计算密集型任务而不冻结用户界面。工作线程中运行的代码无权访问文档内容,不会与主线程或其他工作线程共享任何状态,只能通过异步消息事件与主线程或其他工作线程通信。因此这种并发对主线程没有影响,工作线程也不会改变JavaScript 程序的单线程执行模型。

客户端 JavaScript 时间线

  • 浏览器创建 Document 对象并开始解析网页,随着对 HTML 元素及其文本内容的解析,不断向文档中添加 Element 对象和 Text 节点。此时,document.readyState 属性的值是 loading

  • HTML解析器在碰到一个没有 async、defer 或 type=“module” 属性的 <script> 标签时,会把该标签添加到文档中,然后执行其中的脚本。脚本是同步执行的,而且在脚本下载(如果需要)和运行期间,HTML 解析器会暂停

    • 脚本可以使用 document.write() 向输入流中插入文本,而该文本在解析器恢复时将成为文档的一部分
    • 类似这样的脚本经常只会定义函数和注册事件处理程序,以便后面使用,但它也可以遍历和操作当时已经存在的文档树
    • 因此,不带 async 或 defer 属性的非模块脚本可以看到它自己的 <script> 标签及该标签之前的文档内容
  • 解析器在碰到一个有 async 属性集的 <script> 元素时,会开始下载该脚本的代码(如果该脚本是模块,也会递归地下载模块的所有依赖)并继续解析文档。脚本在下载完成后会尽快执行,但解析器不会停下来等待它下载

    • 异步(async)脚本必须不使用 document.write() 方法
    • 它们可以看到自己的 <script> 标签及该标签之前的文档内容,同时也有可能访问更多文档内容
  • 当文档解析完成后,document.readState 属性变成 interactive

  • 任何有 defer 属性集的脚本(以及任何没有 async 属性的模块脚本)都会在按照它们在文档中出现的顺序依次执行

    • 延迟脚本可以访问完整的文档,必须不使用 document.write() 方法
  • 浏览器在 Document 对象上派发 DOMContentLoaded 事件。这标志着程序执行从同步脚本执行阶段过渡到异步的事件驱动阶段,但要注意,此时仍然可能存在尚未执行的 async 脚本。

  • 此时文档已经解析完全,但浏览器可能仍在等待其他内容(如图片)加载。当所有外部资源都加载完成,且所有async 脚本都加载并执行完成时document.readyState 属性变成 complete​,浏览器在 Window 对象上派发 load 事件

  • 从这一刻起,作为对用户输入事件、网络事件、定时器超时等的响应,浏览器开始异步调用事件处理程序

程序的输入与输出

与任何程序一样,客户端 JavaScript 程序也处理输入数据,产生输出数据。输入的来源有很多种:

  • 文档的内容本身,JavaScript 代码可以通过 DOM API 来访问
  • 事件形式的用户输入
  • 当前显示文档的 URL 可以在客户端 JavaScript 中通过 document.URL 读到
  • HTTP cookie 请求头的内容在客户端代码中可以通过 document.cookie 读到。cookie 通常被服务器端代码用来维持用户会话,但需要时客户端代码也可以读取 cookie
  • 全局 navigator 属性暴露了关于浏览器、操作系统以及它们能力的信息。类似地,全局 screen 属性暴露了用户显示器尺寸的信息

客户端 JavaScript 通常以借助 DOM API 操作 HTML 文档的形式(或者通过使用 React 或 Angular 等高级框架操作文档)产生输出。客户端代码也可以使用 console.log() 及其相关方法产生输出。但这种输出只能在开发者控制台看到,因此只能用于调试,不能用作对用户的输出。

程序错误

在浏览器中运行的 JavaScript 程序不会真正 崩溃​。如果 JavaScript 程序在运行期间出现异常,且代码中没有 catch 语句处理它,开发者控制台将会显示一条错误消息,但任何已经注册的事件处理程序照样会继续运行和响应事件。

如果你想定义一个终极错误处理程序,希望在出现这种未捕获异常时调用,那可以把 Window 对象的 onerror 属性设置为一个错误处理函数。当未捕获异常沿调用栈一路向上传播,错误消息即将显示在开发者控制台中时,window.onerror 函数将会以三个字符串参数被调用。如果 onerror 处理程序返回 true,意味着通知浏览器它已经处理了错误,不需要进一步行动了。

如果期约被拒绝而没有 .catch() 函数处理它,那么这种情况非常类似未处理异常,也就是程序中意料之外的错误或逻辑错误。可以通过定义 window.onunhandledrejection 函数或者使用 window.addEventListener()unhandledrejection 事件注册一个处理程序来发现它:如果在这个未处理拒绝事件对象上调用preventDefault(),浏览器就会认为错误已经处理,而不会在开发者控制台中显示错误消息了。

事件

客户端 JavaScript 程序使用异步事件驱动的编程模型。在这种编程风格下,浏览器会在文档、浏览器或者某些元素或与之关联的对象发生某些值得关注的事情时生成事件。如果 JavaScript 应用关注特定类型的事件,那它可以注册一个或多个函数,让这些函数在该类型事件发生时被调用。

在客户端 JavaScript 中,事件可以在 HTML 文档中的任何元素上发生,这也导致了浏览器的事件模型比 Node 的事件模型明显更复杂。

  • 事件类型:事件类型是一个字符串,表示发生了什么事件。有时也被称为 事件名称
  • 事件目标:事件目标是一个对象,而事件就发生在该对象上或者事件与该对象有关。例如某个 <button> 元素上发生了单击事件
  • 事件处理程序:事件处理程序或事件监听器是一个函数,负责处理或响应事件。当事件目标上发生指定类型的事件时,浏览器会调用这个处理程序
  • 事件对象:事件对象是与特定事件关联的对象,包含有关该事件的细节。事件对象作为事件处理程序的参数传入。所有事件对象都有 type 和 target 属性,分别表示事件类型和事件目标。每种事件类型都为相关的事件对象定义了一组属性
  • 事件传播:事件传播是一个过程,浏览器在这个过程中会决定对哪些对象触发事件处理程序。对于发生在 HTML 文档中的某些事件,则会 冒泡(bubble)到文档根元素。事件处理程序可以阻止事件传播,从而让事件不再冒泡。为此,事件处理程序需要调用事件对象上的一个方法。在另外一种事件传播形式,即事件捕获(event capturing)中,注册在 包含元素 上的处理程序在事件被发送到实际目标之前,有机会先拦截(或捕获)事件

有些事件有与之关联的默认动作(default action)。比如,单击一个超链接,默认动作是让浏览器跟随链接,加载一个新页面。事件处理程序可以通过调用事件对象的一个方法来阻止这个默认动作,有时也称为 取消 事件。

事件类别

客户端 JavaScript 支持的事件类型非常多,可以将这些事件分成通用的类别:

  • 设备相关输入事件:这类事件直接与特定输入设备(例如鼠标或键盘)相关,例如 mouseupkeyup
  • 设备无关输入事件:这类输入事件并不与特定输入设备直接相关,例如 input 事件
  • 用户界面事件:UI 事件是高级事件,通常在定义应用界面的 HTML 表单元素上触发。这类事件包括 focuschangesubmit
  • 状态变化事件:有些事件并不直接由用户活动触发,而是由网络或浏览器活动触发。这类事件表示某种生命期或状态相关的变化。例如分别由 Window 和 Document 对象在文档加载结束时触发的 loadDOMContentLoaded 事件
  • API 特定事件:有一些 HTML 及相关规范定义的 Web API 包含自己的事件类型。例如 HTML 的 <video><audio> 元素定义了一系列事件

注册事件处理程序

有两种注册事件处理程序的方式:

  • 第一种是 Web 早期就有的,即设置作为事件目标的对象或文档元素的一个属性
  • 第二种(更新也更通用)是把处理程序传给这个对象或元素的 addEventListener() 方法

注册事件处理程序最简单的方式就是把事件目标的一个属性设置为关联的事件处理程序函数。按照惯例,事件处理程序属性的名字都由 on 和事件名称组成,例如 onclickonload 等。注意,这些属性名是区分大小写的,必须全部小写,即便事件类型包含多个单词(如 mousedown​)​。

1
2
3
4
5
6
7
8
window.onload = function() {
let form = document.querySelector("form#shipping");
form.onsubmit = function(e) {
if (!isFormValid(this)) {
event.preventDefault();
}
}
}

使用事件处理程序属性有一个缺点,即这种方式假设事件目标对每种事件最多只有一个处理程序。一般来说,使用 addEventListener() 注册事件处理程序更好,因为该技术不会重写之前注册的处理程序。

文档元素的事件处理程序属性也可以直接在 HTML 文件中作为对应HTML标签的属性来定义(在JavaScript中注册在Window元素上的处理程序在HTML中可以定义为 <body> 标签的属性)。现代 Web 开发中通常不提倡使用这种技术,但它是可能的。

在使用 HTML 属性定义事件处理程序时,属性的值应该是一段 JavaScript 代码字符串。这段代码应该是事件处理程序函数的函数体,不是完整的函数声明。例如

1
<button onclick="alert('hello, js');">Refresh</button>

如果一个 HTML 事件处理程序属性包含多条 JavaScript 语句,则必须用分号分隔这些语句,或者用回车把这个属性值分成多行。在给 HTML 事件处理程序属性指定 JavaScript 代码字符串时,浏览器会把这个字符串转换为一个函数,这个函数类似如下所示:

1
2
3
4
5
6
7
8
9
function(event) {
with(document) {
with(this.form || {}) {
with(this) {
/* your code */
}
}
}
}

任何可以作为事件目标的对象(包括 Window 和 Document 对象以及所有文档元素)​,都定义了一个名为 addEventListener() 的方法,可以使用它来注册目标为调用对象的事件处理程序。例如:

1
2
3
4
let b = document.querySelector("#mybutton");
b.onclick = function() { console.log("clicked1"); };
b.addEventListener("click", () => { console.log("clicked2"); });

  • 第一个参数是注册处理程序的事件类型
  • 第二个参数是当指定类型的事件发生时调用的函数
  • 第三个参数是可选的,它可以是一个布尔值或者对象
    • 如果传入 true,函数就会被注册为捕获事件处理程序,从而在事件派发的另一个阶段调用它
    • 注册捕获事件处理程序只是 addEventListener() 支持的3个选项之一。如果要传入其他选项,可以给第三个参数传一个对象,显式指定这些选项
    • 如果 Options(选项)对象的 capture 属性为 true,那么函数就会被注册为捕获处理程序。如果这个属性为 false 或省略该属性,那么处理程序就不会注册到捕获阶段
    • 如果选项对象有 once 属性且值为 true,那么事件监听器在被触发一次后会自动移除。如果这个属性为false或省略该属性,那么处理程序永远不会被自动移除
    • 如果选项对象有 passive 属性且值为true,则表示事件处理程序永远不调用 prevent Default() 取消默认动作。passive 属性提供了一种机制,即在注册一个可能存在破坏性操作的事件处理程序时,这个属性让浏览器知道可以在事件处理程序运行的同时安全地开始其默认行为(如滚动)​。
1
document.addEventListener("click", handleClick, {capture: true, once: true, passive: true});

click 作为第一参数调用 addEventListener() 不会影响 onclick 属性的值。因此上述代码单击一次按钮会在开发者控制台打印两条消息。更重要的是,可以多次调用 addEventListener() 在同一个对象上为同一事件类型注册多个处理程序。当对象上发生该事件时,所有为这个事件而注册的处理程序都会按照注册它们的顺序被调用。但是在同一个对象上以相同的参数多次调用 addEventListener() 没有作用,同一个处理程序只能注册一次,重复调用不会改变处理程序被调用的顺序。

addEventListener() 对应的是 removeEventListener() 方法,它们的前两个参数是一样的(第三个参数也是可选的)​,只不过是用来从同一个对象上移除而不是添加事件处理程序。

  • 如果在注册事件监听器时给第三个参数传了 true,那么要移除该事件处理程序,必须在调用 removeEventListener() 时也传入 true 作为第三个参数
  • 可以把选项对象传给 removeEventListener(),但其中只有 capture 属性才是有用的,即使指定了其他属性也会被忽略

调用事件处理程序

注册事件处理程序后,浏览器会在指定对象发生指定事件时自动调用它。事件处理程序被调用时会接收到一个 Event 对象作为唯一的参数,这个 Event 对象的属性提供了事件的详细信息:

  • type:发生事件的类型
  • target:发生事件的对象
  • currentTarget:对于传播的事件,这个属性是注册当前事件处理程序的对象
  • timeStamp:表示事件发生时间的时间戳(毫秒)​,不是绝对时间
  • isTrusted:如果事件由浏览器自身派发,这个属性为 true;如果事件由 JavaScript 代码派发,这个属性为 false

无论是通过哪种方式注册事件处理程序(设置属性还是 addEventListener()),事件处理程序将作为它所在对象的方法被调用。换句话说,在事件处理程序的函数体中,this 关键字引用的是注册事件处理程序的对象。对于箭头函数,其 this 的值始终等于定义它的作用域的 this 值。

在现代 JavaScript 中,事件处理程序不应该返回值。在比较老的代码中,我们还可以看到返回值的事件处理程序,而且返回的值通常用于告诉浏览器不要执行与事件相关的默认动作。阻止浏览器执行默认动作的标准且推荐的方式,是调用 Event 对象的 preventDefault() 方法。

一个事件目标可能会为一种事件注册多个处理程序。当这种事件发生时,浏览器会按照注册处理程序的顺序调用它们。即使注册方式不一样,该规则同样适用。

事件传播

如果事件的目标是 Window 或其他独立对象,浏览器对这个事件的响应就是简单地调用该对象上对应的事件处理程序。如果事件目标是 Document 或其他文档元素,就没有那么简单了。

注册在目标元素上的事件处理程序被调用后,多数事件都会沿 DOM 树向上 冒泡

  • 目标父元素的事件处理程序会被调用。然后注册在目标祖父元素上的事件处理程序会被调用
  • 就这样一直向上到 Document 对象,然后到 Window 对象

由于事件冒泡,我们可以不用给个别文档元素注册很多事件处理程序,而是只在它们的公共祖先元素上注册一个事件处理程序,然后在其中处理事件。多数在文档元素上发生的事件都会冒泡。明显的例外是 focus blurscroll事件。文档元素的 load 事件冒泡,但到 Document 对象就会停止冒泡,不会传播到 Window 对象。

事件冒泡是事件传播的第三个 阶段,调用目标对象本身的事件处理程序是第二个阶段,第一阶段,也就是在目标处理程序被调用之前的阶段,叫作 捕获 阶段:

  • addEventListener() 接收的第三个可选参数吧。如果这个参数是 true 或 {capture:true},那么就表明该事件处理程序会注册为捕获事件处理程序,将在事件传播的第一阶段被调用
  • 事件传播的捕获阶段差不多与冒泡阶段正好相反,最先调用的是 Window 对象上注册的捕获处理程序,然后才调用 Document 对象的捕获处理程序,接着才是 <body> 元素。然后沿 DOM 树一直向下,直到事件目标父元素的捕获事件处理程序被调用
  • 注册在事件目标本身的捕获事件处理程序不会在这个阶段被调用

事件捕获提供了把事件发送到目标之前先行处理的机会。

事件取消

浏览器对很多用户事件都会作出响应,无论你是否在代码中指定。比如,用户在一个链接上单击鼠标,浏览器就会跟随该链接。如果你为这些事件注册了事件处理程序,那么就可以阻止浏览器执行其默认动作,为此要调用事件对象的 preventDefault() 方法(除非你注册处理程序时传入了 passive 选项,该选项会导致 preventDefault() 无效)。

取消与事件关联的默认动作只是事件取消的一种情况。除此之外,还可以调用事件对象的 stopPropagation() 方法,取消事件传播。

  • 如果同一对象上也注册了其他处理程序,则这些处理程序仍然会被调用
  • 但是,在这个对象上调用 stopPropagation() 方法之后,其他对象上的事件处理程序都不会再被调用
  • stopPropagation() 可以在捕获阶段、在事件目标本身,以及在冒泡阶段起作用

stopImmediatePropagation()stopPropagation() 类似,只不过它也会阻止在同一个对象上注册的后续事件处理程序的执行。

派发自定义事件

客户端 JavaScript 事件 API 相对比较强大,可以使用它定义和派发自己的事件:

  • 如果一个 JavaScript 对象有 addEventListener() 方法,那它就是一个 事件目标​。这意味着该对象也有一个 dispatchEvent() 方法,可以通过该方法向该对象派发事件
  • 可以通过 CustomEvent() 构造函数创建自定义事件对象,然后再把它传给 dispatchEvent()
  • CustomEvent() 的第一个参数是一个字符串,表示事件类型;第二个参数是一个对象,用于指定事件对象的属性
  • 如果你想在一个文档元素上派发自己的事件,并希望它沿文档树向上冒泡,则要在第二个参数中添加 bubbles:true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 派发事件,通知 UI,自己忙
document.dispatchEvent(new CustomEvent("busy", {detail: true}));

fetch(url)
.then(handleNetworkResponse)
.catch(handleNetworkError)
.finally(() => {
// 派发事件,通知 UI,自己已经不忙
document.dispatchEvent(new CustomEvent("busy", {detail: false}));
});

// 注册 busy 事件处理程序
document.addEventListener("busy", (e) => {
if (e.detail) {
showSpinner();
} else {
hideSpinner();
}
}

操作 DOM

客户端 JavaScript 存在的目的就是把静态 HTML 文档转换为交互式 Web 应用。因此通过脚本操作网页内容无疑是 JavaScript 的核心目标。

每个 Window 对象都有一个 document 属性,引用一个 Document 对象。这个 Document 对象代表窗口的内容,它是 DOM 中表示和操作文档内容的核心对象。

选择 Document 元素

客户端 JavaScript 程序经常需要操作文档中的一个或多个元素。全局 document 属性引用 Document 对象,而Document 对象有 head 和 body 属性,分别引用 <head><body> 标签对应的 Element 对象。但一个程序要想操作文档中嵌入层级更多的元素,必须先通过某种方式获取或选择表示该元素的 Element 对象。

通过CSS选择符选择元素:

CSS 的选择符可以用来描述文档中元素或元素的集合,CSS 选择符可以通过元素类型(标签)​、ID、类名、属性,以及元素在文档中的位置来引用元素。DOM 方法 querySelector() 和 querySelectorAll() 让我们能够在文档中找到与指定选择符匹配的元素。

  • querySelector() 方法接收一个 CSS 选择符字符串作为参数,返回它在文档中找到的第一个匹配的元素;如果没有找到,则返回 null
  • querySelectorAll() 是类似的,只不过返回文档中所有的匹配元素,而不是只返回第一个

querySelectorAll() 的返回值不是 Element 对象的数组,而是一个类似数组的 NodeList对象:

  • NodeList 对象有一个 length 属性,可以像数组一样通过索引访问,因此可以使用传统的 for 循环遍历
  • NodeList 也是可迭代对象,因此也可以在 for/of 循环中使用它们
  • 如果想把 NodeList 转换为真正的数组,只要把它传给 Array.from() 即可
  • 如果查找结果为空,NodeList 对象的 length 属性为 0

Element 类和 Document 类都实现了 querySelector()querySelectorAll()当在元素上调用时,这两个方法只返回该元素后代中的元素

还有一个基于 CSS 的元素选择方法:closest()。这个方法是 Element 类定义的,以一个选择符作为唯一参数:

  • 如果选择符匹配那个调用它的元素,则返回该元素
  • 否则,就返回与选择符匹配的最近祖先元素;如果没有匹配,则返回 null
1
2
// 查找有 href 属性的最近的、外围 <a> 标签
let hyperlink = event.target.closest("a[href]");

另一个相关的方法 matches() 既不返回祖先,也不返回后代,只会检查元素是否与选择符匹配。如果匹配,返回 true;否则,返回 false。

其他选择元素的方法:

除了 querySelector()querySelectorAll(),DOM 也定义了一些老式的元素选择方法。如今,这些方法多多少少已经被废弃了,这里还是了解一下:

  • getElemenById():通过 id 属性查找元素(可以通过 CSS 的 id 选择器替代)
  • getElementsByName():通过元素的 name 属性查找元素(可以通过 CSS 的属性选择器替代)
  • getElementsByTagName():通过元素类型查找元素(可以通过 CSS 的标签选择器替代)
  • getElementsByClassName():通过元素的 class 属性查找元素(可以通过 CSS 的类选择器替代)

上面代码中的方法也返回 NodeList(除了 getElementById(),它返回一个 Element 对象)​。但是,与 querySelectorAll() 不同的是,这些老式选择方法返回的 NodeList活的​。所谓 活的​,指的是这些 NodeList 的 length 属性和其中包含的元素会随着文档内容或结构的变化而变化。

预选择的元素:

由于历史原因,Document 类定义了一些快捷属性,可以通过它们直接访问某种节点:

  • 通过 images、forms 和 links属性可以直接访问文档中的 <img><form><a> 元素(有 href 属性的 <a> 标签)​
  • 这些属性引用的是 HTMLCollection 对象。它与 NodeList 对象非常相似,只是还可以通过元素 ID 或名字来索引其中的元素
  • document.all 属性包含文档中的所有元素,这个属性引用的对象类似于 HTMLCollection。这个 API 已经被废弃,实际开发中不应该使用

文档结构与遍历

从 Document 中选择一个 Element 之后,常常还需要查找文档结构中相关的部分。如果我们只关心文档中的 Element 而非其中的文本,有一个遍历 API 可以让我们把文档作为一棵 Element 对象树,树中不包含同样属于文档的 Text 节点。这个遍历 API 不涉及任何方法,而只是 Element 对象上的一组属性:

  • parentNode:这个属性引用元素的父节点,也就是另一个 Element 对象,或者 Document 对象
  • children:这个属性是 NodeList,包含元素的所有子元素,但是不含非 Element 节点(如 Text 节点)
  • childElementCount:这个属性是元素所有子元素的个数。与 children.length 返回的值相同
  • firstElementChild、lastElementChild:这两个属性分别引用元素的第一个子元素和最后一个子元素。如果没有子元素,它们的值为 null
  • previousElementSibling、nextElementSibling:这两个属性分别引用元素左侧紧邻的同辈元素和右侧紧邻的同辈元素,如果没有相应的同辈元素则为 null

因此,表达式 document.children[0].children[1] 可以引用 Document 第一个子元素(html 标签)的第二个子元素(body 标签)。

如下代码则实现了对文档的深度遍历:

1
2
3
4
5
6
function traverse(e, f) {
f(e);
for (let c of e.children) {
traverse(c, f);
}
}

如果在遍历文档或文档中的某些部分时不想忽略 Text 节点,可以使用另一组在所有 Node 对象上都有定义的属性。通过这些属性可以看到 Element、Text 节点,甚至 Comment 节点(表示文档中的 HTML 注释)。所有 Node 对象都定义了以下属性:

  • parentNode:当前节点的父节点,对于没有父节点的节点或 Document 对象则为 null
  • childNodes:只读的 NodeList 对象,包含节点的所有子节点(不仅仅是 Element 子节点)​
  • firstChild、lastChild:当前节点的第一个子节点和最后一个子节点,如果没有子节点则为 null
  • previousSibling、nextSibling:当前节点的前一个同辈节点和后一个同辈节点。这两个属性通过双向链表连接节点
  • nodeType:表示当前节点类型的数值。Document 节点的值为 9,Element 节点的值为 1,Text 节点的值为 3,Comment 节点的值为 8
  • nodeValue:Text 或 Comment 节点的文本内容
  • nodeName:Element 节点的 HTML 标签名,会转换为全部大写

不过要注意,这套 API 对于文档中文本的变化极为敏感。如果插入在标签之间插入一个换行符,可能就增加了一个 Text 节点。

属性

HTML元素由标签名和一组称为属性的名/值对构成。Element 类定义了通用的 getAttribute()setAttribute()hasAttribute()removeAttribute() 方法,用于查询、设置、检测和删除元素的属性。

同时,HTML 元素的属性(指所有标准 HTML 元素的标准属性)同时也在表示这些元素的 HTMLElement 对象上具有相应的属性。使用 JavaScript 属性来存取它们,通常要比调用 getAttribute() 及其他方法来得更便捷。

HTMLElement 为通用 HTML 属性(如 id、title、lang 和 dir)和事件处理程序属性(如 onclick)定义了属性。特定的 Element 子类型则定义了特定于相应元素的属性。

1
2
3
4
5
6
let image = document.querySelector("#main_image");
let url = image.src;

let f = document.querySelector("form");
f.action = "https://example.com/submit";
f.method = "POST";
  • HTML 属性是不区分大小写的,但 JavaScript 属性名区分大小写。要把 HTML 属性转换为 JavaScript 属性,全部小写即可。如果 HTML 属性包含多个单词,则从第二个单词开始,每个单词的首字母都大写。不过,事件处理程序属性是例外,比如 onclick,需要全部小写。

  • 有些 HTML 属性名是 JavaScript 中的保留字。对于这些属性,通用规则是对应的 JavaScript 属性包含前缀 html

  • JavaScript 中表示 HTML 属性的这些属性通常都是字符串值。但是当 HTML 属性是布尔值或数字值时,相应的 JavaScript 属性值则是布尔值或数值,不是字符串。事件处理程序属性的值则始终是函数

这个基于属性的 API 只能获取和设置 HTML 中对应的属性值,并没有定义从元素中删除属性的方式。特别地,不能用 delete 操作符来删除 HTML 属性。如果真想删除 HTML 属性,可以在 JavaScript 中调用removeAttribute() 方法。

HTML 元素的 class 属性特别重要,由于 class 在 JavaScript 中是保留字,所以这个 HTML 属性是通过 Element 对象上的 className 属性反映出来的。由于它的值是一个字符串列表,为了在这个列表中添加或删除某个类名,Element 对象定义了 classList 属性,支持将 class 属性作为一个列表来操作。

有时候在 HTML 元素上附加一些信息很有用,在HTML中,任何以前缀 data- 开头的小写属性都被认为是有效的,可以将它们用于任何目的。这些 数据集(dataset)属性不影响它们所在元素的展示,在保证文档正确性的前提下定义了一种附加额外数据的标准方式。在 DOM 中,Element 对象有一个 dataset 属性,该属性引用的对象包含与 HTML 中的 data-属性 对应的属性,但不带这个前缀:即 dataset.x 保存的是 HTML 中的 data-x 属性的值(连字符分隔的属性将映射为驼峰式属性名)。

如下是一个示例:

1
<h1 id="data" data-section-number="15.1">Section 15.1</h1>
1
console.log(document.querySelector('#data').dataset.sectionNumber); // => 15.1

元素内容

接下来介绍介绍如何操作元素内容的 HTML表示纯文本表示

读取一个 Element 的 innerHTML 属性会返回该元素内容的标记字符串。在元素上设置这个属性会调用浏览器的解析器,并以新字符串解析后的表示替换元素当前的内容。

Element 的 outerHTML 属性与 innerHTML 属性类似,只是返回的值包含元素自身。在读取 outerHTML 时,该值包含元素的开始和结束标签。而在设置元素的 outerHTML 时,新内容会取代元素自身。

另一个相关的 Element 方法是 insertAdjacentHTML(),用于插入与指定元素 相邻(adjacent)的任意 HTML 标记字符串。

有时候,我们希望得到元素的纯文本内容,或者向文档中插入纯文本(不转义 HTML 中使用的尖括号和 & 字符)。这样做的标准方式是使用 textContent 属性:

  • 这个 textContent 属性是由 Node 类定义的,因此在 Text 节点和 Element 节点上都可以使用
  • 对于 Element 节点,它会找到并返回元素所有后代中的文本
  • Element 类定义了一个 innerText 属性,与 textContent 类似。但 innerText 有一些少见和复杂的行为,如试图阻止表格格式化。这个属性的定义不严谨,因此不应该再使用
1
<h1 id="data">Section 15.1<i>test</i><p>test2</p></h1>
1
2
3
4
5
6
// => Section 15.1<i>test</i><p>test2</p>
console.log(document.querySelector('#data').innerHTML);
// => <h1 id="data" data-section-number="15.1">Section 15.1<i>test</i><p>test2</p></h1>
console.log(document.querySelector('#data').outerHTML);
// => Section 15.1testtest2
console.log(document.querySelector('#data').textContent);

行内(即那些没有 src 属性的)<script> 元素有一个text属性,可以用于获取它们的文本。浏览器永远不会显示 <script> 元素的内容,HTML解析器会忽略脚本中的尖括号和 & 字符。这就让 <script> 元素成为在 Web 应用中嵌入任意文本数据的理想场所。

  • 只要把这个元素的 type 属性设置为某个值(如 text/x-custom-data)​,明确它不是可执行的 JavaScript 代码即可。这样,JavaScript 解释器将会忽略这个脚本
  • 但该元素还会出现在文档树中,它的 text 属性可以返回你在其中保存的数据

创建、插入和删除节点

Document 类定义了创建 Element 对象的方法,而 Element 和 Text 对象拥有在树中插入、删除和替换节点的方法。

  • 使用 Document 类的 createElement() 方法可以创建一个新元素,并通过自己的 append()prepend() 方法为自己添加文本或其他元素

  • append()prepend() 接收任意多个参数,这些参数可以是 Node 对象或字符串

    • append() 把参数添加到孩子列表的末尾,prepend() 把参数添加到孩子列表的开头
    • 字符串参数会自动转换为 Text 节点
    • 可以使用 document.createTextNode() 来创建 Text 节点,但很少需要这样做
  • 在得到一个同辈节点时,可以调用 before() 在该同辈前面插入新内容,或调用 after() 在该同辈后面插入新内容

    • after()before() 也接收任意个数的字符串和元素参数,在将字符串转换为 Text 节点后把它们全部插入文档中
    • append()prepend() 只在 Element 对象上有定义,但 after()before() 同时存在于 Element 和 Text 节点上,因此可以使用它们相对于 Text 节点插入内容
  • 要注意的是,元素只能被插入到文档中的一个地方。如果某个元素已经在文档中了,你又把它插入到了其他地方,那它会转移到新位置,而不会复制一个新的过去

  • 如果确实想创建一个元素的副本,可以使用 cloneNode() 方法,传入 true 以复制其全部内容

  • 调用 remove() 方法可以把 Element 或 Text 节点从文档中删除,或者可以调用 replaceWith() 替换它

    • remove() 不接收参数
    • replaceWith()before()after() 一样,接收任意个数的字符串和元素
1
2
3
4
let p = document.createElement('p');
let em = document.createElement('em');
em.append("World");
p.append("hello", em, "!");

DOM API 也定义了插入和删除内容的老一代方法。比如,appendChild()insertBefore()replaceChild()removeChild(),都比这里介绍的方法难用,因此不应该再使用它们了。

生成目录

如下 JavaScript 程序演示了如何动态为文档创建目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
document.addEventListener("DOMContentLoaded", () => {
let toc = document.querySelector("#TOC");
if (!toc) {
toc = document.createElement("div");
toc.id = "TOC";
document.body.prepend(toc);
}

let headings = document.querySelectorAll("h2,h3,h4,h5,h6");
let sectionNumbers = [0, 0, 0, 0, 0];

for (let heading of headings) {
if (heading.parentNode === toc) {
continue;
}

let level = parseInt(heading.tagName.charAt(1)) - 1;
sectionNumbers[level - 1]++;
for (let i = level; i < sectionNumbers.length; i++) {
sectionNumbers[i] = 0;
}

let sectionNumber = sectionNumbers.slice(0, level).join(".");

let span = document.createElement("span");
span.className = "TOCSecNum";
//span.textContent = sectionNumber;
heading.prepend(span);

let anchor = document.createElement("a");
let fragmentName = `TOC${sectionNumber}`;
anchor.name = fragmentName;
heading.before(anchor);
anchor.append(heading);

let link = document.createElement("a");
link.href = `#${fragmentName}`;
link.innerHTML = heading.innerHTML;

let entry = document.createElement("div");
entry.classList.add("TOCEntry", `TOCLevel${level}`);
entry.append(link);

toc.append(entry);
}
});

操作 CSS

我们已经知道,JavaScript 可以控制 HTML 文档的逻辑结构和内容。通过对 CSS 编程,JavaScript 也可以控制文档的外观和布局。

CSS 类

使用 JavaScript 影响文档内容样式的最简单方式是给 HTML 标签的 class 属性添加或删除 CSS 类名。Element 对象的 classList 属性可以用来方便地实现此类操作。

例如假设样式表中包含 hidden 类定义:

1
2
3
.hidden {
display: none;
}

则通过如下代码可以隐藏或者显式元素:

1
2
document.querySelector("#data").classList.add("hidden");
document.querySelector("#data").classList.remove("hidden");

行内样式

DOM 在所有 Element 对象上都定义了对应的 style 属性,它对应 HTML 元素的 style 属性。在 JavaScript 中,它是 CSSStyleDeclaration 对象,是对 HTML 中作为 style 属性值的 CSS 样式文本解析之后得到的一个表示。如下代码修改元素的行内样式:

1
2
3
4
5
6
function displayAt(tooltip, x, y) {
tooltip.style.display = "block";
tooltip.style.position = "absolute";
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
}
  • 很多 CSS 样式属性(比如font-size)的名字中都包含连字符,但在 JavaScript 中连字符不能出现在标识符中,如果 CSS 属性名包含一个或多个连字符,对应的 CSSStyleDeclaration 属性名将剔除连字符,并将每个连字符后面的字母变成大写。
  • 在使用 CSSStyleDeclaration 的样式属性时,要记住所有值都必须是字符串(不用在字符串中添加分号)
  • 很多 CSS 属性要求包含单位,此时在 JavaScript 中设置样式属性时单位也是必须的
  • 有些 CSS 属性是其他属性的简写形式(例如 margin 属性是 margin-top、margin-right、margin-bottom 和 margin-left 的简写),在 CSSStyleDeclaration 对象上也有与这些简写属性对应的属性

以字符串而非 CSSStyleDeclaration 对象形式设置和读取行内样式会更方便,可以使用 Element 的 getAttribute()setAttribute() 方法,或者也可以使用 CSSStyleDeclaration 对象的 cssText 属性:

1
2
f.setAttribute("style", e.getAttribute("style"));
f.style.cssText = e.style.cssText;

在读取元素的 style 属性时,应该知道它只表示元素的行内样式,而多数元素的多数样式都是在样式表中指定的,不是写在行内的。一般来说,如果你想知道一个元素的样式,那需要的可能是计算样式。

计算样式

元素的计算样式(computed style)是浏览器根据一个元素的行内样式和所有样式表中适用的样式规则导出(或计算得到)的一组属性值,浏览器实际上使用这组属性值来显示该元素:

  • 与行内样式类似,计算样式同样以 CSSStyleDeclaration 对象表示
  • 但与行内样式不同的是,计算样式是只读的,不能修改计算样式

使用 Window 对象的 getComputedStyle() 方法可以获取一个元素的计算样式:

  • 第一个参数是要查询的元素,可选的第二个参数用于指定一个 CSS 伪元素
  • 返回值是一个 CSSStyleDeclaration 对象,该对象包含应用给指定元素(或伪元素)的所有样式

表示计算样式的 CSSStyleDeclaration 对象与表示行内样式的 CSSStyleDeclaration 对象有一些重要的区别:

  • 计算样式的属性是只读的
  • 计算样式的属性是绝对值,百分比和点等相对单位都被转换成了绝对值
  • 简写属性不会被计算,只有它们代表的基础属性会被计算。例如,不能查询 margin 属性,而要查询 marginLeft、 marginTop 等
  • 计算样式的 cssText 属性是 undefined

需要注意,尽管 CSS 可以精确指定文档元素的位置和大小,但是查询元素的计算样式并非确定该元素大小和位置的理想方式。后面会介绍一种更简单易用的替代方案。

操作样式表

除了操作 class 属性和行内样式,JavaScript 也可以操作样式表。样式表是通过 <sytle> 标签或 <link rel="stylesheet"> 标签与 HTML 文档关联起来的。这两个标签都是普通的 HTML 标签,因此可以为它们指定一个 id 属性,然后使用 document.querySelector() 找到它们

<style><link> 标签对应的 Element 对象都有 disabled 属性,可以用它禁用整个样式表:

1
2
3
4
5
6
7
8
9
10
11
function toggleTheme() {
let ligthTheme = document.querySelector("#light-theme");
let darkTheme = document.querySelector("#dark-theme");
if (darkTheme.disabled) {
lightTheme.disabled = true;
darkTheme.disabled = false;
} else {
lightTheme.disabled = false;
darkTheme.disabled = true;
}
}

另一个操作样式表的简单方法是使用前面介绍的 DOM API 向文档中插入新的样式表:

1
2
3
4
5
6
7
8
9
10
11
12
13
function setTheme(name) {
let link = document.createElement("link");
link.id = "theme";
link.rel = "stylesheet";
link.href = `themes/${name}.css`;

let currentTheme = document.querySelector("#theme");
if (currentTheme) {
currentTheme.replaceWith(link);
} else {
document.head.append(link);
}
}

或者你可会直接向文档中插入一段包含 <style> 标签的HTML字符串:

1
2
3
document.head.insertAdjacentHTML(
"beforeend",
"<style>body { background-color: red; }</style>");

浏览器定义了一套 API,以便 JavaScript 能够在样式表中查询、修改、插入或删除样式规则。可以在 MDN 上自行搜索 CSS Object ModelCSSStyleSheet 了解更多信息。

CSS 动画与事件

CSS 本身支持通过 transition(过渡属性)来支持 CSS 动画,这个过程不需要 JavaScript 做任何事情,是纯粹的 CSS 动画效果。但 JavaScript 可以用来触发这种动画。

JavaScript 也可以用来监控 CSS 过渡动画的进度,因为浏览器在过渡动画的开始和结束都会触发事件:

  • 首次触发过渡时,浏览器会派发 transitionrun 事件
  • 当发生视觉变化时,又会派发 transitionstart 事件
  • 而当动画完成时,则会派发 transitionend 事件

当然,所有这些事件的目标都是发生动画的元素。这些事件传给处理程序的事件对象是一个 TransitionEvent 对象。该对象的 propertyName 属性是发生动画的 CSS 属性,

除了过渡之外,CSS 也支持更复杂的动画形式,可以称其为 CSS动画。这会用到 animation-nameanimation-duration 和特殊的 @keyframes 规则来定义动画细节。与 CSS 过渡类似,CSS 动画也触发事件,可以供 JavaScript 代码监听。

文档几何与滚动

当浏览器在窗口中渲染文档时,它会创建文档的一个视觉表示,其中每个元素都有自己的位置和大小。有时候,Web 应用可以把文档看成元素的树,不考虑这些元素在屏幕上如何展示。但有时候,又必须知道某个元素精确的几何位置。因此,我们要学会在基于树的抽象文档模型和基于几何坐标系的文档视图之间切换

文档坐标和视口坐标

文档元素的位置以 CSS ,其中 x 坐标向右表示增大,y 坐标向下表示增大。但是有两个点可以用作坐标原点:

  • 元素的 x 和 y 坐标可以相对于文档的左上角
  • 也可以相对于显示文档的视口(viewport)的左上角,视口就是浏览器窗口中实际显示文档内容的区域

因此说到元素位置,必须首先搞清楚是使用文档坐标还是视口坐标。通常情况下,要实现这两种坐标系的转换,都必须加上或减去滚动位移(scroll offset)。客户端 JavaScript 更多地会使用视口坐标。

另外,在使用 CSS 定位时:

  • 对于 position: fixed:top 和 left属性相对于视口坐标来解释
  • 对于 position: relative:top 和 left 属性相对于该元素原来的位置来解释
  • 对于 position: absolute:top 和 left 属性相对于最近的、使用定位的祖先元素的位置来解释

一个 CSS 像素(也就是客户端 JavaScript 中的像素)​实际上可能相当于多个设备像素。Window 对象的 devicePixelRatio 属性表示多少设备像素对应一个软件像素,例如如果该值为 2,则意味着每个软件像素实际上是一个 2×2 硬件像素的网格。

查询元素的几何大小

调用 getBoundingClientRect() 方法可以确定元素的大小(包括 CSS 边框和内边距,不包括外边距)和位置(在视口坐标中)​。

  • 对于块级元素,它在浏览器的布局中始终是矩形
  • 行内元素则可能跨行,因此可能包含多个矩形。如果向查询行内元素的每个单独矩形,可以调用 getClientRects() 方法

确定位于某一点的元素

有时候想确定在视口中某个给定位置上的是哪个元素,为此可以使用 Document 对象的 elementFromPoint() 方法。调用这个方法并传入一个点的 x 和 y 坐标(视口坐标,而非文档坐标),elementFromPoint() 返回一个位于指定位置的 Element 对象。

滚动

Window 对象的 scrollTo() 方法接收一个点的 x 和 y 坐标(文档坐标)​,并将其设置为滚动位移。也就是说,这个方法会滚动窗口,从而让指定的点位于视口的左上角。

1
2
3
4
// 滚动浏览器,让文档最底部的页面显示出来
let documentHeight = document.documentElement.offsetHeight;
let viewportHeight = window.innerHeight;
window.scrollTo(0, documentHeight - viewportHeight);

Window 对象的 scrollBy() 方法与 scrollTo() 类似,但它的参数是个相对值,会加在当前滚动位置之上。

1
setInterval(() => { scrollBy(0, 50); }, 500);

如果想让 scrollTo()scrollBy() 平滑滚动,需要传入一个对象,而不是两个数值,对象中需要设置behavior: "smooth" 属性。

可以在某个 HTML 元素上调用 scrollIntoView() 方法,这个方法保证该 HTML 元素在视口中可见。

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

浏览器窗口和一些 HTML 元素可以显示滚动的内容。在这种情况下,我们有时候需要知道视口大小、内容大小和视口中内容的滚动位移:

  • 对浏览器窗口而言,视口大小可以通过 window.innerWidthwindow.innerHeight 属性获得
  • 文档的整体大小与 <html> 元素,即 document.documentElement 的大小相同,要获得文档的宽度和高度:
    • 可以使用 document.documentElementgetBoundingClientRect() 方法
    • 也可以使用 document.documentElementoffsetWidthoffsetHeight 属性
  • 文档在视口中的滚动位移可以通过 window.scrollXwindow.scrollY 获得,这两个属性都是只读的,不能通过设置它们的值来滚动文档

对于元素来说,问题稍微复杂,每个 Element 对象都定义了三组属性:

  • 元素的 offsetWidthoffsetHeight 属性返回它们在屏幕上的 CSS 像素大小。这个大小包含元素边框和内边距,但不包含外边距。元素的 offsetLeftoffsetTop 属性返回元素的 x 和 y 坐标。对很多元素来说,这两个值都是文档坐标,但对定位元素的后代或者另一些元素(如表格单元)来说,这两个值是相对于祖先元素而非文档的坐标。而 offsetParent 属性保存着前述坐标值相对于哪个元素。这一组属性都是只读的。

  • 元素的 clientWidthclientHeight 属性与 offsetWidthoffsetHeight 属性类似,只是它们不包含元素边框,只包含内容区及内边距。clientLeftclientTop 属性没有多大用处,它们是元素内边距外沿到边框外沿的水平和垂直距离

  • 元素的 scrollWidthscrollHeight 属性是元素内容区大小加上元素内边距,再加上溢出内容的大小。scrollLeftscrollTop 是元素内容在元素视口中的滚动位移,而且这两个属性是可写属性,因此可以通过设置它们的值来滚动元素中的内容

Web 组件

HTML 是一种文档标记语言,为此也定义了丰富的标签。HTML已经变成Web应用描述用户界面的语言,但 <input><button> 等简单的 HTML 标签并不能满足现代 UI 设计的需要。今天的多数 Web 应用都不是用 原始的 HTML 写的。相反,很多 Web 开发者使用 React、Angular 等框架,这些框架支持创建可重用的用户界面组件。

Web 组件是浏览器原生支持的替代这些框架的特性,主要涉及相对比较新的三个 Web 标准。这些 Web 标准允许 JavaScript 使用新标签扩展 HTML,扩展后的标签就是自成一体的、可重用的 UI 组件。

使用 Web 组件

Web 组件是在 JavaScript 中定义的,因此要在 HTML 中使用 Web 组件,需要包含定义该组件的 JavaScript 文件。Web 组件经常以 JavaScript 模块形式写成:

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

Web 组件要定义自己的 HTML 标签名,但有一个重要的限制就是标签名必须包含一个连字符(这意味着未来的 HTML 版本可以增加没有连字符的新标签,而这些标签不会跟任何人的 Web 组件冲突)​。要使用 Web 组件,只要像下面这样在 HTML 文件中使用其标签即可:

1
<search-box placeholder="Search..."></search-box>
  • Web 组件可以像常规 HTML 标签一样具有属性
  • Web 组件不能使用自关闭标签定义,
  • 与常规 HTML 元素类似,有的 Web 组件需要子组件,而有的 Web 组件不需要(也不显示)子组件
  • 还有的 Web 组件可选地接收有标识的子组件,这些子组件会出现在命名的 插槽(slot)中

例如:

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

还有一点需要注意,Web 组件经常以 JavaScript module 的形式引入,其自带 defer 属性,会在文档内容解析之后加载。因此浏览器会在运行 Web 组件定义代码之前,就要解析该 Web 组件所对应的自定义标签。浏览器中的 HTML 解析器很灵活,对自己不理解的输入非常宽容。当在 Web 组件还没有定义就遇到其标签时,浏览器会向 DOM 树中添加一个通用的 HTMLElement,即便它们不知道要对它做什么。之后,当自定义元素有定义之后,这个通用元素会被 升级​,从而具备预期的外观和行为。

如果 Web 组件包含子元素,那么在组件有定义之前它们可能会被不适当地显示出来。可以使用下面的 CSS 将 Web 组件隐藏到它们有定义为止:

1
2
3
4
5
6
search-box:not(:defined) {
opacity: 0;
diplay: inline-block;
width: 300px;
height: 50px;
}

与常规 HTML 元素一样,Web 组件可以在 JavaScript 中使用。一般来说,只有在定义这个组件的模块运行之后这样做才有意义。

知道了如何使用 Web 组件,接下来将介绍用于实现 Web 组件的三个浏览器特性。在这之前先介绍一些 DocumentFragment。DocumentFragment 也是一种 Node 类型,可以临时充当一组同辈节点的父节点,方便将这些同辈节点作为一个单元来使用:

  • 可以使用 document.createDocumentFragment() 来创建 DocumentFragment 节点
  • 创建 DocumentFragment 节点后,就可以像使用 Element 一样,通过 append() 为它添加内容
  • DocumentFragment 与 Element 的区别在于它没有父节点,而且当你向文档中插入 DocumentFragment 节点时,DocumentFragment 本身并不会被插入,实际上插入的是它的子节点

HTML 模版

通过 HTML 的 <template> 确实可以对网页中频繁使用的组件进行优化。<template> 标签及其子元素永远不会被浏览器渲染,只能在使用 JavaScript 的网页中使用。这个标签背后的思想是,当网页包含多个重复的基本 HTML 结构时(比如表格行或 Web 组件的内部实现),就可以使用 <template> 定义一次该结构,然后通过 JavaScript 按照需要任意重复使用该结构。

  • 在 JavaScript 中,<template> 标签对应的是一个 HTMLTemplateElement 对象
  • 它只定义了一个 content 属性,而这个属性的值是包含 <template> 所有子节点的 DocumentFragment
  • 也可以在 JavaScript 代码中创建一个模板,通过 innerHTML 创建其子节点,然后再按照需要克隆任意多个副本

自定义元素

实现 Web 的第二个浏览器特性是 自定义元素​,即可以把一个 HTML 标签与一个 JavaScript 类关联起来,然后文档中出现的这个标签就会在 DOM 树中转换为相应类的实例。

  • 创建自定义元素需要使用 customElements.define() 方法,它以标签名作为第一个参数(记住这个标签名必须包含一个连字符)​,以一个 HTMLElement的子类 作为其第二个参数
  • 文档中具有该标签名的任何元素都会被 升级 为这个类的一个新实例
  • 浏览器会自动调用自定义元素类的特定 生命期方法
    • 当自定义元素被插入文档时,会调用 connectedCallback() 方法,可以通过该方法执行初始化
    • 还有一个 disconnectedCallback() 方法,会在自定义元素从文档中被移除时调用,但用得不多
  • 如果自定义元素类定义了静态的 observedAttributes 属性,其值为一个属性名的数组。当在自定义元素的实例上设置了任意的命名属性,浏览器就会调用 attributeChangedCallback() 方法,这个回调可以根据属性值的变化采取必要的步骤以更新组件
  • 自定义元素类也可以按照需要定义其他属性和方法。通常,它们都会定义设置方法和获取方法,让元素的属性可以暴露为 JavaScript 属性

如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
customElements.define('inline-circle', class InlineCircle extends HTMLElement {
connectedCallback() {
this.style.display = 'inline-block';
this.style.borderRadius = '50%';
this.style.border = 'solid black 1px';
this.style.transform = 'translateY(10%)';
if (!this.style.width) {
this.style.width = "0.8em";
this.style.height = "0.8em";
}
}

static get observedAttributes() {
return ['diameter', 'color'];
}

attributeChangedCallback(name, oldValue, newValue) {
if (name === 'diameter') {
this.style.width = newValue;
this.style.height = newValue;
} else if (name === 'color') {
this.style.backgroundColor = newValue;
}
}

get diameter() {
return this.getAttribute('diameter');
}

set diameter(value) {
this.setAttribute('diameter', value);
}

get color() {
return this.getAttribute('color');
}

set color(value) {
this.setAttribute('color', value);
}
}
);

影子 DOM

上述自定义元素并没有恰当地封装,例如设置其 diameter 或 color 属性会导致其 style 属性被修改,而对于一个真正的 HTML 元素,这并不是我们希望看到的行为。要把一个自定义元素转换为真正的 Web 组件,还需要使用一个强大的封装机制:影子 DOM(shadow DOM)。

影子 DOM 允许把一个 影子根节点(shadow root)附加给一个自定义元素,而后者被称为 影子宿主(shadow host)。影子宿主元素与所有 HTML 元素一样,随时可以作为包含后代元素和文本节点的正常 DOM 树的根。影子根节点则是另一个更私密的后代元素树的根,这些元素从影子根节点上生长出来,可以把它们当成一个迷你文档。

影子 DOM中的 影子 指的是作为影子根节点后代的元素 藏在影子里。也就是说,这个子树并不属于常规 DOM 树,不会出现在它们宿主元素的 children 数组中,而且对 querySelector() 等常规 DOM 遍历方法也不可见。相对而言,影子宿主的常规、普通 DOM 子树有时候也被称为 阳光DOM(light DOM)。

影子 DOM 的关键特性是它所提供的封装。影子根节点的后代对常规 DOM 树而言是隐藏且独立的,几乎就像它们是在一个独立的文档中一样。

  • 影子 DOM 中的元素对 querySelectorAll() 等常规 DOM 方法是不可见的。

    • 在创建影子根节点并将其附加于影子宿主时,可以指定其模式是 开放(open)还是 关闭(closed)
    • 关闭的影子根节点将被完全封闭,不可访问
    • 对于开发模式,影子宿主会有一个 shadowRoot 属性,如果需要,JavaScript 可以通过这个属性来访问影子根节点的元素
  • 在影子根节点之下定义的样式对该子树是私有的,永远不会影响外部的阳光 DOM 元素。类似地,应用给影子宿主元素的阳光 DOM 样式也不会影响影子根节点。在大多数情况下,阳光 DOM 的样式与影子 DOM 的样式是完全独立的(影子 DOM 中的元素会从阳光 DOM 继承字体大小和背景颜色等)。可以像这样限定 CSS 的范围或许是影子 DOM 最重要的特性

  • 影子 DOM 中发生的某些事件(如 load​)会被封闭在影子 DOM 中。另外一些事件,像 focus、mouse 和键盘事件则会向上冒泡、穿透影子 DOM。当一个发源于影子 DOM 内的事件跨过了边界开始向阳光 DOM 传播时,其 target 属性会变成影子宿主元素,就好像事件直接起源于该元素一样

作为影子宿主的 HTML 元素有两个后代子树。一个是 children[​ ]数组,即宿主元素常规的阳光 DOM 后代;另一个则是影子根节点及其后代。它的工作原理如下:

  • 影子根节点的后代始终显示在影子宿主内
  • 如果这些后代中包含一个 <slot> 元素,那么宿主元素的常规阳光 DOM 子元素会像它们本来就是该 <slot> 的子元素一样显示,替代该插槽中的任何影子 DOM 元素。
  • 如果影子 DOM 不包含 <slot>,那么宿主的阳光 DOM 内容永远不会显示
  • 如果影子 DOM 有一个 <slot>,但影子宿主没有阳光 DOM 子元素,那么该插槽的影子 DOM 内容作为默认内容显示
  • 当阳光 DOM 内容显示在影子 DOM 插槽中时,我们说那些元素 已分配。此时要注意:那些元素实际上并未变成影子 DOM 的一部分。使用 querySelector() 依旧可以查询它们,它们仍然作为宿主元素的子元素或后代出现在阳光 DOM 中
  • 如果影子 DOM 定义了多个 <slot>,且通过 name 属性为它们命名,那么影子宿主的阳光 DOM 后代可以通过slot="slotname" 属性指定自己想出现在哪个插槽中

要把一个阳光 DOM 元素转换为影子宿主,只要调用其 attachShadow() 方法,传入 {mode:"open"} 这个唯一的参数即可。这个方法返回一个影子根节点对象,同时也将该对象设置为这个宿主的 shadowRoot 属性的值。这个影子根节点对象是一个 DocumentFragment,可以使用 DOM 方法为它添加内容,也可以直接将其 innerHTML 属性设置为一个 HTML 字符串。

如果你的 Web 组件想知道影子 DOM(slot)中的阳光 DOM 内容什么时候变化,那它可以直接在该 <slot> 元素上注册一个 slotchanged 事件。

如下是自定义 Web 组件的一个示例,它实现了一个搜索框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
class SearchBox extends HTMLElement {
constructor() {
super();

this.attachShadow({ mode: 'open' });
this.shadowRoot.append(SearchBox.template.content.cloneNode(true));

this.input = this.shadowRoot.querySelector('#input');
let leftSlot = this.shadowRoot.querySelector('slot[name="left"]');
let rightSlot = this.shadowRoot.querySelector('slot[name="right"]');

this.input.onfocus = () => { this.setAttribute('focused', ''); };
this.input.onblur = () => { this.removeAttribute('focused'); };

leftSlot.onclick = this.input.onchange = (event) => {
event.stopPropagation();
if (this.disabled) return;

this.dispatchEvent(new CustomEvent('search', {
detail: this.input.value
}));
}

rightSlot.onclick = (event) => {
event.stopPropagation();
if (this.disabled) return;

this.dispatchEvent(new CustomEvent('clear', {cancelable: true}));

if (!event.defaultPrevented) {
this.input.value = '';
}
}
}

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 {
this.input.value = newValue;
}
}

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(value) { this.setAttribute('value', value); }
set disabled(value) {
value ? this.setAttribute('disabled', '') : this.removeAttribute('disabled');
}
set hidden(value) {
value ? this.setAttribute('hidden', '') : this.removeAttribute('hidden');
}
}

SearchBox.obsevedAttributes = ['placeholder', 'size', 'value', 'disabled'];
SearchBox.template = document.createElement('template');

SearchBox.template.innerHTML = `
<style>

:host {
display: inline-block;
border: solid black 1px;
border-radius: 5px;
padding: 4px 6px;
}

:host([hidden]) {
display: none;
}
:host([disabled]) {
opacity: 0.5;
}
:host([focused]) {
box-show: 0 0 0 2px #6AE;
}

input {
border-width: 0;
outline: none;
font: inherit;
background: inherit;
}

slot {
cursor: default;
user-slect: none;
}
</style>
<div>
<slot name="left">\u{1f50d}</slot>
<input type="text" id="input" />
<slot name="right">\u{2573}</slot>
</div>
`;

customElements.define('search-box', SearchBox);

可伸缩矢量图形

SVG(Scalable Vector Graphics,可伸缩矢量图形)是一种图片格式。名字中的 矢量 代表着它与 GIF、JPEG、PNG 等指定像素值矩阵的光栅(raster)图片格式有着根本的不同。SVG 图片是对所期望绘制图形的精确的、分辨率无关的描述。SVG 图片是在文本文件中通过 XML 标记语言描述的。

在浏览器中有几种方式使用 SVG:

  • 可以在常规的 HTML <img> 标签中使用 .svg 图片文件
  • 因为基于 XML 的 SVG 格式与 HTML 很类似,所以可以直接把 SVG 标签嵌入在 HTML 文档中
  • 可以使用 DOM API 动态创建 SVG 元素,按需生成图片

在 HTML 中使用 SVG

SVG 图片当然可以使用 HTML 的 <img> 标签来显示,但也可以直接在 HTML 嵌入 SVG。而且在嵌入 SVG 后,甚至可以使用 CSS 样式表来指定字体、颜色和线宽:

  • 可以在 HTML 中直接内嵌 <svg> 标签
  • <svg> 标签的后代并非标准的 HTML 标签,SVG 有很多其他标签,可以在需要时再去学习

编程操作 SVG

直接在 HTML 文件中嵌入 SVG(而不是使用静态 <img> 标签)的一个原因,就是这样可以使用 DOM API 操作 SVG 图片。除了使用脚本简单地操作嵌入在 HTML 文档中的 SVG 图片,还可以通过 JavaScript 来创建SVG图片,这在可视化动态加载的数据时很有用。

尽管可以把 SVG 标签包含在 HTML 文档中,严格来讲它们仍然是 XML 标签,不是 HTML 标签。如果想通过 JavaScript DOM API 创建 SVG 元素,需要使用 createElementNS 方法,它的第一个参数是 XML 命名空间文字串。对 SVG 而言,命名空间是文字串 http://www.w3.org/2000/svg

<canvas> 与图形

在 HTML 文档中,<canvas> 元素本身并不可见,它只是创建了一个绘图表面并向客户端 JavaScript 暴露了强大的绘图 API。<canvas> API 与 SVG 的主要区别在于使用画布(canvas)绘图要调用方法,而使用 SVG 创建图形则需要构建 XML 元素树。画布绘图 API 是基于 JavaScript 的,而且相对比较简洁(不像 SVG 语法那么复杂)。

大多数画布绘图 API 都没有定义在 <canvas> 元素上,而是定义在通过画布的 getContext() 方法获得的 绘图上下文 上:

  • 在调用 getContext() 时传入 webgl 也可以获取一个 3D 图形上下文,并使用 WebGL API 来绘制 3D 图形。WebGL API 是一套庞大、复杂、低级的 JavaScript API,开发者通过它可以访问 GPU、写自定义的着色器,以及执行其他非常强大的图形操作
  • 调用 getContext() 时传入 2d 可以得到一个 Canvas RenderingContext2D 对象,使用它能够在画布上绘制二维图形

如下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<p>This is a red square: <canvas id="square" width=10 height=10></canvas></p>
<p>This is a blue circle: <canvas id="circle" width=10 height=10></canvas></p>

<script>
let canvas = document.querySelector('#square');
let context = canvas.getContext('2d');
context.fillStyle = 'red';
context.fillRect(0, 0, 10, 10);

canvas = document.querySelector('#circle');
context = canvas.getContext('2d');
context.beginPath();
context.arc(5, 5, 5, 0, Math.PI * 2, true);
context.fillStyle = '#00f';
context.fill();
</script>

路径与多边形

要在画布上画线或者填充由这些线包围的区域,首先需要定义一个路径:

  • 路径是一个或多个子路径的序列
  • 子路径则是两个或多个通过线段(或曲线段)连接起来的点的序列
  • 开始新路径要调用 beginPath() 方法
  • 开始定义子路径要调用 moveTo() 方法,在通过 moveTo() 建立起子路径的起点后,可以调用 lineTo() 将该点连接到一个新的点
1
2
3
4
c.beginPath();
c.moveTo(100, 100);
c.lineTo(200, 200);
c.lineTo(100, 200);

定义路径并不会在画布上绘制任何东西:

  • 要绘制(或描画​)路径中的两条线段,必须调用 stroke() 方法
  • 而要填充这些线段定义的区域,则要调用 fill() 方法
  • fill() 方法在填充开放路径时,就好像有一条直线连接了子路径的终点与起点一样
  • 调用 closePath() 可以把子路径的终点连接到起点,对路径进行闭合
  • stroke()fill() 这两个方法都会修改当前路径,在操作完一条路径后,如果想开始另一条路径,必须调用 beginPath()

画布大小与坐标

在 HTML 中通过 <canvas>widthheight 属性,或者在 JavaScript 中通过画布对象的 width 和 height 属性可以指定画布的大小。画布坐标系的默认原点在画布左上角的 (0,0) 点。x 坐标向右增大,y 坐标向下增大。画布中的点可以使用浮点值来指定。

  • 要修改画布大小必须完全重置画布,设置画布的 width 或 height 属性,都会清除画布,擦掉当前路径,重置所有图形属性至其初始状态
  • 在 HTML 中指定 <canvas> 的 width 和 height 属性会确定画布的实际像素数
  • 为优化图片质量,不要在 HTML 中使用 width 和 height 属性设置画布的屏幕大小。而要使用 CSS 的样式属性 width 和 height 来设置画布在屏幕上的预期大小。然后在通过 JavaScript 开始绘制前,再将画布对象的 width 和 height 属性设置为 CSS 像素数乘以 window.devicePixelRatio

图形属性

上下文对象上的一些属性(和方法)都会影响画布的图形状态:

  • lineWidth 属性指定 stroke() 绘制的线条有多宽,默认值为 1
  • 在绘制超过两像素宽的线条时,lineCap 和 lineJoin 属性会显著影响路径两端或者两条路径交点的样式
  • stroke() 方法既可以画虚线、点线,也可以画实线。而画布的图形状态中也有一组数字可以用作 虚线模式​,虚线模式要通过 setLineDash()getLineDash() 方法而不是一个属性来设置和获取
  • fillStyle 和 strokeStyle 属性指定如何填充和描绘路径。可以以实色、渐变色、图片进行填充
  • font 属性指定 fillText()strokeText() 方法在绘制文本时使用的字体,这个属性的值应该是一个字符串,语法与 CSS 的 font 属性相同。textAlign 属性指定文本的水平对齐方式,textBaseline 属性指定文本相对于 Y 坐标如何垂直对齐
  • 上下文对象有 4 个属性控制阴影的绘制:shadowColor 属性指定阴影颜色,shadowOffsetX 和 shadowOffsetY 属性指定阴影的 X 轴和 Y 轴偏移量,shadowBlur 属性指定阴影边缘的模糊程度
  • 如果想用半透明色描绘或填充路径,可以使用类似 rbga(...) 这样支持半透明值的 CSS 颜色语法设置 strokeStylefillStyle
  • 通过设置 globalCompositeOperation 属性可以指定合成像素的方式

每个 <canvas> 元素只有一个上下文对象,每次调用 getContext() 返回的都是同一个 CanvasRenderingContext2D 对象。save() 方法把当前的图形状态推到一个保存的状态栈中。restore() 方法从该栈中弹出状态,恢复最近一次保存的状态。

画布绘制操作

之前介绍的 beginPath()moveTo()lineTo() 等方法都是基本的画布方法,可以用来定义、填充、绘制线条和多边形。除此之外,Canvas API 还提供其他绘制方法。

  • CanvasRenderingContext2D 定义了 4 个绘制矩形的方法,分别是 fillRect()strokeRect()clearRect()rect()
  • CanvasRenderingContext2D 对象定义了一些方法,用于将一个新点添加到子路径,然后用一条曲线来连接当前点与新点,包括 arc()ellipse()arcTo()bezierCurveTo()quadraticCurveTo()
  • 要在画布中绘制文本,一般都使用 fillText() 方法。对于大型文本的特效,可以使用 strokeText() 绘制个别字形的轮廓
  • drawImage() 方法会将一张源图片(或源图片中某个矩形区域)的像素复制到画布上,并根据需要缩放和旋转图像的像素

坐标系变换

除了默认坐标系,每个画布的图形状态中都有一个 当前变换矩阵​。这个矩阵定义了画布的当前坐标系。在多数画布操作中,当你指定一个点的坐标时,它表示的是当前坐标系中的一个点,而不是默认坐标系中的一个点。当前变换矩阵用于将你指定的坐标转换为默认坐标系中等价的坐标。

使用 setTransform() 方法可以直接设置画布的变换矩阵,但通常还是使用一系列平移、旋转和缩放操作来变换坐标系更简单。

  • translate() 方法简单地向左、右、上、下移动坐标系原点
  • rotate() 方法按照指定的角度旋转坐标轴
  • scale() 方法沿 x 轴或 y 轴拉伸或压缩距离

通过调用 transform() 可以对当前坐标系应用任意变换,setTransform() 方法与 transform() 接收的参数一样,但它不变换当前坐标系,而是忽略当前坐标系,变换默认坐标系,并将结果作为新的当前坐标系。

剪切

定义了路径之后,我们通常会调用 stroke()fill()(或两者)​。但也可以调用 clip() 方法定义一个剪切区域。定义了剪切区域后,这个区域外部将不会被绘制。

像素操作

像素操作方法经常用于处理图片。getImageData() 方法返回一个 ImageData 对象,表示画布中某矩形区域中包含的原始像素。可以使用 createImageData() 创建空的 ImageData 对象。ImageData 对象中的像素是可写的,因此可以随意修改,然后再通过 putImageData() 把其中的像素复制到画布上。

Audio API

HTML的 <audio><video> 标签可以让我们在网页中轻松包含音频和视频。这两个元素有着重要的 API 和并不简单的用户界面:

  • 可以通过 play()pause() 方法控制媒体播放
  • 可以设置 volume 和 playbackRate 属性控制音量和播放速度
  • 设置 currentTime 属性可以跳到媒体中特定的时间点

Audio 构造函数

要在网页中包含音效,不一定要在HTML文档中包含 <audio> 标签。可以使用常规 DOM 方法 document.createElement() 或者直接使用 Audio() 构造函数动态创建 <audio> 元素。并且,要播放媒体也不一定要把创建的元素添加到文档中。只要调用它的 play() 方法即可。

1
2
3
4
let soundeffect = new Audio('soundeffect.mp3');
document.addEventListener('click', () => {
soundeffect.cloneNode().play();
}

Web Audio API

除了使用 Audio 元素播放录制的声音,浏览器也可以通过 WebAudio API 生成和播放合成音效。对于 WebAudio,要创建一组 AudioNode 对象,表示波形的来源、变换和目标,然后再将这些节点连接为一个网络以产生声音。

位置、导航与历史

Window 和 Document 对象的 location 属性引用的都是 Location 对象,该对象表示当前窗口显示文档的 URL,也提供了在窗口中加载新文档的 API。

Location 对象与 URL 对象非常相似,可以使用 protocol、hostname、port 和 path 访问当前文档 URL 的不同部分,href 属性以字符串形式返回整个 URL,hash 属性返回 URL 的 片段标识符 部分,search 属性返回 URL 中以问号开头的部分,通常是一些查询字符串。

浏览器也定义了 document.URL 属性,它是一个字符串,即当前文档的 URL。

加载新文档

如果给 window.locationdocument.location 赋值一个字符串,则该字符串将被解释为一个 URL,且浏览器会加载它,从而用新文档替换当前文档:

1
window.location = 'http://www.example.com';

可以给 location 属性赋值相对 URL,浏览器会相对于当前 URL 解析它。简单的片段标识符也是一种特殊的 URL,但它不会导致浏览器加载新文档,只会把文档中 id 或 name 匹配该片段的元素滚动到浏览器窗口顶部。作为一个特例,片段标识符 #top 会让浏览器跳到文档顶部:

1
location = "#top";

Location 对象的个别属性是可写的,设置它们会改变 URL,也会导致浏览器加载新文档。给 Location 对象的 assign() 方法传入一个新字符串也可以加载新页面,效果等同于给 location 属性赋值。

Location 的 replace() 方法也接受一个字符串参数,字符串会被当作 URL 解析,并导致浏览器加载新页面,同时replace() 会在浏览器的历史记录中替换当前文档,即在浏览器历史记录中以新文档 URL 替代当前文档的 URL。

Location 对象也定义了 reload() 方法,调用该方法会让浏览器重新加载当前文档。

浏览历史

Window 对象的 history 属性引用的是窗口的 History 对象。History 对象将窗口的浏览历史建模为文档和文档状态的列表。History 对象的 length 属性是浏览历史列表中元素的数量。但出于安全考虑,脚本不能访问存储的 URL。

History对象的 back() 和 forward() 方法就像浏览器的 后退前进 按钮,可以让浏览器在浏览历史中后退或前进一步。另一个方法 go() 接收一个整数参数,可以在历史列表中前进(正整数)或后退(负整数)任意个页面。

如果窗口包含子窗口(如 <iframe> 元素)​,子窗口的浏览历史会按时间顺序与主窗口历史交替。

今天,Web 应用经常动态生成或加载内容,显示新应用状态而并不真正加载新文档。这样的应用必须自己管理历史记录,才能让用户直观地通过 后退前进 按钮从应用的一个状态导航到另一个状态。

使用 hashchange 事件管理历史

第一种管理浏览历史的技术是使用 location.hashhashchange 事件:

  • location.hash 属性用于设置 URL 的片段标识符,通常用于指定要滚动到的文档区域的 ID。但 location.hash 不一定必须是元素 ID,也可以将它设置为任意字符串。只要不是某个元素碰巧有该字符串 ID,浏览器就不会在设置 hash 属性时滚动
  • 设置 location.hash 属性会更新地址栏中显示的 URL,而且更重要的是,还会在浏览器历史列表中添加一条记录
  • 只要文档的片段标识符改变,浏览器就会在 Window 对象上触发 hashchange 事件,显式设置 location.hash 也会触发 hashchange 事件

因此,只要你可以为应用的每个可能的状态创建唯一的片段标识符,“hashchange 事件就能够在用户向后或向前导航浏览历史时给你发送通知:

  • 如果用户的交互会导致应用进入新状态,不要直接渲染新状态。而要先把新状态编码为一个字符串,并将 location.hash 设置为该字符串
  • 在触发 hashchange 事件后,在事件处理程序将会显示该新状态
  • 使用这种迂回技术可以保证新状态被插入浏览历史,因而 后退前进 按钮继续有效

使用 pushState() 管理历史

管理历史的第二种技术建立在 history.pushState() 方法和 popstate 事件 基础上的:

  • 当 Web 应用进入一个新状态时,它会调用 history.pushState(),向浏览器历史中添加一个表示该状态的对象
  • 如果用户单击 后退 按钮,浏览器会触发携带该保存的状态对象的 popstate 事件,应用使用该对象重建其之前的状态
  • 除了保存的状态对象,应用也可以为每个状态都保存一个 URL,这样可以方便用户将 URL 加入书签和分享应用内部状态的链接

pushState() 的第一个参数是一个对象,包含恢复当前文档状态所需的全部状态信息,这个对象使用 HTML 的结构化克隆算法保存,第二个参数应该是与状态对应的标题字符串(绝大多数浏览器不支持,直接传空即可),第三个参数是一个可选的 URL,给每个状态都关联一个 URL 可以让用户收藏应用的内部状态。

除了 pushState() 方法,History 对象也定义了 replaceState(),它接收相同的参数,但会替换当前历史状态,而不是向浏览历史中添加新状态。当使用 pushState() 的应用首次加载时,一般最好调用 replaceState() 为应用的初始状态定义一个状态对象。

在用户使用 后退前进 按钮导航到保存的历史状态时,浏览器会在 Window 对象上触发 popstate 事件。与之关联的事件对象有一个名为 state 的属性,其中包含当初你通过 pushState() 传入的状态对象的副本。

网络

每次我们打开一个网页时,浏览器都会(使用HTTP或HTTPS协议)发送网络请求,请求 HTML 文档,也请求该文档依赖的图片、字体、脚本和样式表。除了根据用户操作发送网络请求,浏览器也暴露了相关的 JavaScript API。

fetch

要发送简单的 HTTP 请求,使用 fetch() 只需三步:

  1. 调用 fetch(),传入要获取内容的 URL
  2. 在 HTTP 响应开始到达时取得第 1 步异步返回的响应对象,然后调用这个响应对象的某个方法,读取响应体
  3. 取得第 2 步异步返回的响应体,按需要处理它

fetch() API 完全是基于期约的,因为涉及两个异步环节,所以使用 fetch() 时通常要写两个 then() 或两个 await 表达式:

1
2
3
4
5
fetch("api/users/current")
.then(response => response.json())
.then(currentUser => {
displayUserInfo(currentUser);
});
1
2
3
4
5
async function isServiceReady() {
let response = await fetch("api/service/status");
let body = await response.json();
return body === "ready";
}

fetch() API 取代了复杂且名字误导人的 XMLHttpRequest API(其实跟 XML 没什么关系)​。在一些遗留代码中也许还能看到 XHR(通常用这个简写)的身影,但在新代码中则完全没有必要使用它了。

  • fetch() 返回的期约解决为一个 Response 对象。这个对象的 status 属性是 HTTP 状态码,另外,还存在一个更方便的 ok 属性:它在 status 为 200 或在 200 和 299 之间时是 true,在其他情况下是 false
  • 当服务器开始发送响应时,fetch() 只要一收到 HTTP 状态码和响应头就会解决它的期约,但此时通常还没收到完整的响应体
  • Response 对象的 headers 属性是一个 Headers 对象。使用它的 has() 方法可以测试某个头部是否存在,使用它的 get() 方法可以取得某个头部的值,Headers 对象也是一个可迭代对象
  • fetch() 只在自己根本联系不到服务器时才会拒绝自己返回的期约,因为这些情况对任何网络请求都可能发生,所以最好在任何 fetch() 调用后面都包含一个 .catch() 子句

有时候,除了设置 URL 参数,还需要为 fetch() 请求设置一些头部。为此,可以使用两个参数版的 fetch()。与以前一样,第一个参数还是一个用于指定 URL 的字符串或 URL 对象。第二个参数用于提供额外选项,包括请求头部。另一种替代给 fetch() 传两个参数的方法是把同样的两个参数传给 Request() 构造函数,然后再将创建的 Request 对象传给 fetch()

1
2
let request = new Request(URL, {headers});
fetch(request).then(response => ...);

除了 json()text(),Response 对象还有以下几个方法来获取获取响应体:

  • arrayBuffer():这个方法返回一个期约,解决为一个 ArrayBuffer。在响应包含二进制数据时可以使用这个方法
  • blob():这个方法返回一个期约,解决为一个 Blob 对象。Blob(Binary Large Object)在需要处理大量二进制数据的时候会用到。
  • formData():这个方法返回一个期约,解决为一个 FormData 对象。如果 Response 响应体是以 multipart/form-data 格式编码的,应该使用这个方法。这种编码格式常见于向服务器发送的 POST 请求中,在服务器响应中并不常见

除了以某种形式返回完整响应体的异步响应方法,还可以流式访问响应体。在需要分块处理通过网络接收到的响应时可以采取这种方式:

  • Response 对象的 body 属性是一个 ReadableStream 对象。
  • 如果已经调用了 text()json() 等读取、解析和返回响应体的方法,那么 bodyUsed 属性会变成 true,表示 body 流已经读完了
  • 如果 bodyUsed 属性是 false,那就意味着该流尚未被读取。此时,可以在 response.body 上调用 getReader() 获取流读取器对象,然后通过这个读取器对象的 read() 方法异步从流中读取文本块
  • 这个 read() 方法返回一个期约,解决为一个带有 done 和 value 属性的对象

目前,在每个 fetch() 的例子中我们发送的都是 HTTP(或 HTTPS)GET 请求,如果想使用不同的请求方法(如 POST、PUT 或 DELETE)​,可以直接使用两个参数版的 fetch(),传入带 method 参数的选项对象:

1
fetch(url, { method: "POST"}).then(r => r.json()).then(handleResponse);
  • 只要 method 方法不是 GET 或 HEAD(这两个方法不支持请求体)​,都可以在选项对象中设置 body 属性指定请求体
  • 在指定请求体时,浏览器会自动添加合适的 Content-Length 请求头
  • 对于 POST 请求,常见的做法是在请求体中传入一组名/值参数,而不是将它们编码后作为查询参数附在 URL 后面。有两种方法可以实现:
    • 可以通过 URLSearchParams 指定参数的名和值,然后把这个 URLSearchParams 对象作为 body 属性的值。这样做,请求体将被设置为一个类似 URL 查询参数的字符串,而 Content-Type 头部也会自动被设置为 application/x-www-form-urlencoded;charset=UTF-8
    • 如果使用 FormData 对象指定参数的名和值,则 Content-Type 也将被设置为 multipart/form-data; boundary=…
  • 从用户计算机向服务器上传文件是一个常见的任务,可以通过将 FormData 对象作为请求体来实现

多数情况下,我们在 Web 应用中都是使用 fetch() 从自己的服务器请求数据。这种请求也被称为同源请求,因为传给 fetch() 的 URL 与 包含发送请求脚本的文档 是同源的(协议、主机名及端口都相同)​。出于安全考虑,浏览器通常不允许跨源网络请求(当然跨源请求图片和脚本是例外)​。不过,利用 CORS(Cross-Origin Resource Sharing,跨源资源共享)可以实现安全的跨源请求:

  • 在通过 fetch( )请求跨源 URL 时,浏览器会为请求添加一个 Origin 头部(且不允许通过headers属性覆盖它的值)以告知服务器这个请求来自不同源的文档
  • 如果服务器对这个请求的响应中包含恰当的 Access-Control-Allow-Origin 头部,则请求可以继续
  • 如果服务器没有明确允许请求,则 fetch() 返回的期约会被拒绝

有时候我们可能想中断已经发出的 fetch() 请求,此时,fetch API 支持使用 AbortControllerAbortSignal 类来中断请求。这两个类定义了通用的中断机制,也能在其他 API 中使用。

我们知道可以给 fetch()(或者 Request() 构造函数)传第二个参数,也就是选项对象,用于指定请求方法、请求头或请求体。这个选项对象还支持其他一些选项:

  • cache:这个属性可以用来覆盖浏览器默认的缓存行为
  • redirect:这个属性控制浏览器如何处理服务器的重定向响应
  • referrer:这个属性是一个包含相对 URL 的字符串,用于指定 HTTP 的 Referer 头部

服务器发送事件

Web 所依赖的 HTTP 协议的一个核心特性是:客户端发起请求,服务器响应该请求。但是某些 Web 应用却需要在服务器发生事件时,接收来自服务器发送的通知。HTTP 天生并不具备这个特性,但随着技术的发展,客户端向服务器发送请求之后,两端都可以不关闭连接。此时一旦服务器有事情要通知客户端,就可以把数据写入这个连接并保持其打开。

由于这是一个有用的编程模式,客户端 Javascript 以 EventSource API 的形式对其给予支持。要创建与服务器间的这种长时间存在的请求连接,只要向 EventSource() 构造函数传入一个URL即可。当服务器将(适当格式化的)数据写入这个连接时,EventSource 对象会将它们转换为客户端能够监听到的事件:

1
2
3
4
let ticker = new EventSource("stockprices.php");
ticker.addEventListener("bid", (event) => {
displayNewBid(event.data);
});
  • 与消息事件关联的事件对象有一个 data 属性,保存着服务器针对这次事件发送过来的字符串
  • 与其他事件对象一样,这个事件对象也有一个 type 属性,指定了这个事件的名字。服务器确定生成的事件的类型,默认为 “message”

这种 SSE(Server-Sent Event,服务器发送事件)协议很好理解:

  • 客户端(在它创建 EventSource 对象时)发起对服务器的连接,服务器保持连接打开
  • 一旦有事件发生,服务器就向连接中写入几行文本,通过网络传送的消息格式大致如下:
1
2
3
event: bid
data: GOOG
data: 999
  • 这个协议还允许为事件指定一个 ID ,以便客户端重新建立连接时告诉服务器它上一次接收到的事件 ID 是什么,而服务器可以重新发送它错过的事件

SSE 的一个典型应用是类似在线聊天一样的多用户协作。聊天客户端可以使用 fetch() 把消息发送到聊天室,通过 EventSource 对象订阅聊天信息流。如下是一个示例:

客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Chat</title>
</head>
<body>
<input id="input" style="width: 100%; padding: 10px; border:solid black 2px" />

<script>
let nick = prompt("Enter your nickname");
let input = document.getElementById("input");
input.focus();

let chat = new EventSource("/chat");
chat.addEventListener("chat", function(event) {
let div = document.createElement("div");
div.append(event.data);
input.before(div);
input.scrollIntoView();
});

input.addEventListener("change", () => {
fetch("/chat", {
method: "POST",
body: nick + ": " + input.value,
})
.catch(e => console.error)
input.value = "";
});
</script>
</body>
</html>

而服务端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const http = require("http");
const fs = require("fs");
const url = require("url");

const clientHTML = fs.readFileSync("chatClient.html");

let clients = [];

let server = new http.Server();
server.listen(8081);

server.on("request", (request, response) => {
let pathname = url.parse(request.url).pathname;

if (pathname === "/") {
response.writeHead(200, {"Content-Type": "text/html"}).end(clientHTML);
} else if (pathname !== "/chat" ||
(request.method !== "GET" && request.method !== "POST")) {
response.writeHead(404).end();
} else if (request.method === "GET") {
acceptNewClient(request, response);
} else {
broadcastNewMessage(request, response);
}
});

function acceptNewClient(request, response) {
clients.push(response);

request.connection.on("end", () => {
clients.splice(clients.indexOf(response), 1);
response.end();
})

response.writeHead(200, {
"Content-Type": "text/event-stream",
"Connection": "keep-alive",
"Cache-Control": "no-cache",
});
response.write("event: chat\ndata: Connected\n\n");
}

async function broadcastNewMessage(request, response) {
request.setEncoding("utf8");
let body = "";
for await (let chunk of request) {
body += chunk;
}

response.writeHead(200).end();

let message = "data: " + body.replace("\n", "\ndata: ");
let event = `event: chat\n${message}\n\n`;
clients.forEach(client => client.write(event));
}
  • 当客户端请求根 URL / 时,这个服务器会返回客户端代码,即对应的 HTML 文件
  • 当客户端向 URL /chat 发送 GET 请求时,服务器会保存响应对象并保持连接打开
  • 当客户端向 URL /chat 发送 POST 请求时,它会把请求体作为聊天消息并对每个保存的响应对象使用 text/event-stream 格式

WebSocket

WebSocket API 是一个复杂、强大的网络协议对外暴露的简单接口。WebSocket 允许 JavaScript 代码在浏览器中与服务器方便地交换文本和二进制消息:

  • 与服务器发送事件 SSE 类似,客户端必须建立连接,而连接一旦建立,服务器就可以异步向客户端发送消息
  • 与 SSE 不同,WebSocket 支持二进制消息,而且消息可以双向发送,而不仅仅是从服务器向客户端发消息

支撑 WebSocket 的网络协议是一种扩展 HTTP。在使用 WebSocket 协议连接服务时,要通过 URL 指定该服务,就像使用 Web 服务一样。WebSocket URL 以 ws:// 而不是 https:// 开头(浏览器通常会限制只能在安全的 https:// 连接加载的页面中使用 WebSocket):

  • 要建立 WebSocket 连接,浏览器首先要建立一个 HTTP 连接,并向服务器发送 Upgrade: websocket 请求头,请求把连接从 HTTP 协议切换为 WebSocket 协议
  • 这意味着,要在客户端 JavaScript 中使用 WebSocket,服务器必须遵循 WebSocket 协议,按照该协议发送和接收数据

如果想与支持 WebSocket 的服务器通信,需要创建一个 WebSocket 对象,并通过 wss://URL 来指定要连接的服务:

1
let socket = new WebSocket("wss://example.com/stockticker");
  • 这个套接口对象的 readyState 属性表明了当前的连接状态
  • 当 WebSocket 的状态从 CONNECTING 转变为 OPEN 时,它会触发 open 事件。可以通过设置 WebSocket 对象的 onopen 属性或调用该对象的 addEventListener() 来监听这个事件
  • 如果 WebSocket 连接发生了协议错误或其他错误,WebSocket 对象会触发 error 事件
  • 在使用完 WebSocket 之后,可以调用 WebSocket 对象的 close() 方法关闭连接。当连接状态变成 CLOSED 时,WebSocket 对象会触发 close 事件

要向位于 WebSocket 连接另一端的服务器发送消息,调用 WebSocket 对象的 send() 方法。要通过 WebSocket 从服务器接收消息,需要注册 message 事件处理程序,可以设置 WebSocket 对象的 onmessage 属性,也可以调用 addEventListener()。与 message 事件关联的事件对象是 MessageEvent 的实例,其 data 属性包含服务器的消息。

WebSocket 协议支持文本和二进制消息交换,但并未规定这些消息的结构或含义。使用 WebSocket 的应用必须在其提供的简单消息交换机制基础上自行协商通信协议。可以基于 wss://URL 来区分不同的协议格式。WebSocket 协议和 API 也提供了应用级消息协商功能:

  • 在调用 WebSocket() 构造函数时,wss://URL 是第一个参数,但也可以传一个字符串数组作为第二个参数,传入这个参数后,就相当于把客户端能够处理的应用协议提供给服务器,由服务器从中选择一个协议
  • 连接建立以后,WebSocket 对象的 protocol 属性将保存服务器选择的子协议

存储

Web 应用可以使用浏览器 API 在用户计算机上本地存储数据。客户端存储的目的是让浏览器能够记住一些信息。客户端存储是按照来源隔离的,因此来自一个站点的页面不能读取来自另一个站点的页面存储的数据。但来自同一站点的两个页面可以共享存储的数据,并将其作为一种通信机制。

客户端存储分为如下几种形式:

  • Web Storage:Web Storage API 包含 localStoragesessionStorage 对象,本质上是映射字符串键和值的持久化对象。适合存储大量(不是巨量)的数据
  • cookie:cookie是一种古老的客户端存储机制,是专门为服务端脚本使用而设计的。浏览器也提供了 JavaScript API 可以在客户端操作 cookie。cookie 只适合保存少量数据。另外,保存在 cookie 中的数据也会随 HTTP 请求发送给服务器
  • IndexedDB:IndexedDB 是一种异步API,可以访问支持索引的对象数据库

这些客户端数据存储技术都不涉及加密,因此任何形式的客户端存储技术都不能用来保存敏感信息。

localStorage 和 sessionStorage

Window 对象的 localStorage 和 sessionStorage 属性引用的是 Storage 对象。Storage 对象与普通 JavaScript 对象非常类似,只不过:

  • Storage 对象的属性值必须是字符串,如果想存取其他类型的数据,必须自己编码和解码
  • Storage 对象中存储的属性是持久化的。如果你设置了 localStorage 对象的一个属性,然后用户刷新了页面,你的程序仍然可以访问在该属性中保存的值
1
2
3
4
5
let name = localStorage.username;
if (!name) {
name = prompt("What's your name?");
localStorage.username = name;
}
  • 可以使用 delete 操作符删除 localStorage 和 sessionStorage 的属性
  • 可以使用 for/in 循环或 Object.keys() 枚举 Storage 对象的属性
  • 可以使用 clear() 删除 Storage 对象的所有属性
  • Storage 对象也定义了 getItem()setItem()deleteItem() 方法,可以用来代替直接读写属性和 delete 操作符

localStorage 和 sessionStorage 的差异主要体现在生命期和作用域上

  • 通过 localStorage 存储的数据是永久性的,除非 Web 应用或用户通过浏览器删除,否则数据会永远保存在用户设备上
  • localStorage 的作用域为文档来源。文档来源由协议、域名和端口共同定义,所有同源文档都共享相同的 localStorage 数据(与实际访问 localStorage 的脚本的来源无关)。同源文档可以相互读取对方的数据,可以重写对方的数据。但非同源文档的数据相互之间是完全隔离的,既读不到也不能重写
  • 也需要注意,localStorage 的作用域也受浏览器实现的限制,两个不同的浏览器存储的数据无法互通
  • sessionStorage 数据的生命期与存储它的脚本所属的顶级窗口或浏览器标签页相同,窗口或标签页永远关闭后,通过 sessionStorage 存储的所有数据都会被删除(但是现代浏览器有能力再次打开最近关闭的标签页并恢复用户上次浏览的会话,因此之关联的sessionStorage的生命期有可能比看起来更长)
  • sessionStorage 的作用域与 localStorage 类似,都是文档来源。但是,sessionStorage 的作用域也在窗口间隔离。如果用户在两个浏览器标签页中打开了同一来源的文档,这两个标签页的 sessionStorage 数据也是隔离的

存储在 localStorage 中的数据每次发生变化时,浏览器都会在该数据可见的其他 Window 对象(不包括导致该变化的窗口)上触发 storage 事件。要注册 storage 事件,可以使用 window.onstorage 事件属性,或者调用 window.addEventListener() 并传入 storage

localStorage 和 storage 事件可以作为一种广播机制,即浏览器向所有当前浏览同一网站的窗口发送消息。

cookie 是浏览器为特定网页或网站保存的少量命名数据。cookie 是为服务端编程而设计的,cookie 数据会自动在浏览器与 Web 服务器之间传输,因此服务器端脚本可以读写存储在客户端的 cookie 值。

操作 cookie 的 API 很古老也很难用,因此没有涉及方法。查询、设置和删除 cookie,都是通过读写 Document 对象的 cookie 属性实现的,而且要使用特定格式的字符串。每个 cookie 的生命期和作用域可以通过 cookie 属性来单独指定。

document.cookie 属性返回一个包含与当前文档有关的所有 cookie 的字符串。这个字符串是一个分号和空格分隔的名/值对。为了使用 document.cookie 属性,通常必须调用 split() 方法把整个字符串拆分成单独的的 名/值对。从 cookie 属性中提取出某个 cookie 的值之后,必须根据 cookie 创建者的格式或编码来解释该值。

除了名字和值,每个 cookie 还有可选的属性,用于控制其生命期和作用域。

  • cookie 默认的生命期很短,它们存储的值只在浏览器会话期间存在,用户退出浏览器后就会丢失。如果想让 cookie 的生命期超过单个浏览会话,必须告诉浏览器你希望保存它们多长时间,为此要指定 cookiemax-age 属性。如果指定了这样一个生命期,浏览器将把 cookie 存储在一个文件中,等时间到了再把它们删除
  • cookie 的可见性由文档来源决定,但也由文档路径决定,即 cookie 的作用域通过 path 和 domain 属性来配置。默认情况下,cookie 关联着创建它的网页,以及与该网页位于相同目录和子目录下的其他网页,这些网页都可以访问它
  • 可以为 cookie 指定 path 属性,然后来自同一服务器的任何网页,只要其 URL 以你指定的路径前缀开头,就可以共享该 cookie
  • 默认情况下,cookie 的作用域按照文档来源区分。不过大网站可能需要跨子域名共享 cookie,这时候就要用到 domain 属性了,但是不能将 cookie 的域设置为服务器父域名之外的其他域名
  • secure 属性是一个布尔值,用于指定如何通过网络传输 cookie 值。默认可以在普通的不安全的 HTTP 连接上传输。如果把 cookie 设置为安全的,那么就只能在浏览器与服务器通过 HTTPS 或其他安全协议连接时传输 cookie

Cookie 主要用于为服务器端脚本存储少量数据,而且该数据在每次请求相关 URL 时都会发送给服务器。某些浏览器会限制每个 cookie 大小不超过 4kb。

要给当前文档关联一个短暂的 cookie,只要把 document.cookie 设置为 name=value 形式的字符串即可:

1
document.cookie = `version=${encodeURIComponent(document.lastModified)}`;
  • cookie值不能包含分号、逗号或空格。为此,可能需要使用核心 JavaScript 的全局函数 encodeURIComponent() 先对值进行编码,然后再把它保存到 cookie 中。
  • 如果进行了编码,那么在将来读取 cookie 值时还必须使用对应的 decodeURIComponent() 函数来解码
  • 可以在保存 cookie 属性的字符串中继续添加 max-agepathdomain 等属性。例如 name=value; path=value; domain=value; secure
  • 要删除 cookie,需要以相同的名字、路径和域名再设置一次,指定一个任意值(或空值)​,并将 max-age 属性指定为 0

IndexedDB

IndexedDB 是一个对象数据库,不是关系型数据库,比支持 SQL 查询的数据库更简单,而且比 localStorage 提供的键/值对存储机制更强大、高效和可靠:

  • IndexedDB 数据库的作用域限定为包含文档的来源。换句话说,两个同源的网页可以互相访问对方的数据,但不同源的网页则不能相互访问
  • 每个来源可以有任意数量的 IndexedDB 数据库。每个数据库的名字必须在当前来源下唯一
  • 在 IndexedDB API 中,数据库就是一个命名的对象存储集合
  • 对象存储中存储的是对象。对象会使用结构化克隆算法序列化为对象存储
  • 每个对象必须有一个键,可以用于排序和从存储中检索,键必须唯一。JavaScript 字符串、数值和 Date 对象都是有效的键
  • IndexedDB 数据库可以自动为插入数据库中的每个对象生成一个唯一的键
  • 通常插入对象存储中的对象都会有一个属性适合作为键。在这种情况下,可以在创建对象存储时为该属性指定一个 键路径,告诉数据库如何从对象中提取对象的键
  • 可以在对象存储上定义任意数量的索引,每个索引为存储的对象定义了一个次键

IndexedDB 提供了原子保证,即查询和更新数据库会按照事务进行分组,要么全部成功,要么全部失败,永远不会让数据库处于未定义、部分更新的状态。从概念上讲,IndexedDB API 非常简单:

  • 要查询或更新数据库,首先要打开对应的数据库(通过名字)​。然后,创建一个事务对象并使用该对象查找数据库中相应的对象存储(同样通过名字)​
  • 通过调用该对象存储的 get() 方法查询对象,或通过调用 put() 方法存储新对象
  • 如果想查询键在某个范围内的对象,需要创建一个 IDBRange 对象并指定范围的上、下边界,然后把它传给对象存储的 getAll()openCursor() 方法
  • IndexedDB API是异步的,而且 IndexedDB 是在期约得到广泛支持之前定义的,因此这个 API 是基于事件而非基于期约的
  • 在第一次打开一个数据库时,或者在增大一个已有数据库的版本号时,IndexedDB 会在调用 indexedDB.open() 返回的请求对象上触发 upgradeneeded 事件。这个 upgradeneeded 事件的处理程序要负责定义或更新这个新数据库的模式,这意味着创建对象存储和在这些对象存储上定义索引

工作线程与消息传递

单线程是 JavaScript 的一个基本特性,因此浏览器绝不会同时运行两个事件处理程序,也不会在一个事件处理程序运行的时候触发其他计时器,这样前端开发者就无需思考甚至理解并发编程了,但缺点是一个 JavaScript 函数不能运行太长时间,否则它们就会阻塞事件循环。事实上这也是 fetch() 被设计为异步函数的原因。

浏览器通过 Worker 类非常谨慎地放松了这种单线程的限制。这个类的实例代表与主线程和事件循环同时运行的线程:

  • Worker 运行于独立的运行环境,有着完全独立的全局对象,不能访问 Window 或 Document 对象
  • Worker 与主线程只能通过异步消息机制通信

这意味着并发修改 DOM 仍然是不可能的,但也意味我们可以写长时间运行的函数,而不会阻塞事件循环、卡死浏览器。工作线程适合执行计算密集型任务。创建新工作线程(worker)并不会像打开新浏览器窗口那么 重量,但也不是没有任何开销。复杂 Web 应用可能会创建几十个工作线程,但要创建几百或者几千个工作线程也是不切实际的。

与任何线程 API 一样,Worker API也有两部分。一部分是 Worker 对象,另一部分是 WorkerGlobalScope。前者是这个线程的外在部分,后者则是线程的内在部分。

Worker 对象

要创建新的工作线程,调用 Worker() 构造函数,传入一个 URL,这个 URL 用于指定线程要执行的 JavaScript 代码:

1
let dataCruncher = new Worker('utils/cruncher.js');
  • 创建 Worker 对象后,可以使用 postMessage() 方法向工作线程发送数据
  • 传给 postMessage() 的值会使用结构化克隆算法被复制,得到的副本会通过消息事件发送给工作线程
  • 通过监听 Worker 对象的 message 事件,可以从工作线程接收消息
  • Worker 对象也定义了标准的 addEventListener()removeEventListener() 方法,可以用它们代替 onmessage

工作线程中的全局对象

在通过 Worker() 构造函数创建新工作线程时,传入的 URL 指定的是一个 JavaScript 代码文件。其中的代码会在一个新的、干净的 JavaScript 执行环境中执行,与创建工作线程的脚本完全隔离。这个新执行环境中的全局对象是一个 WorkerGlobalScope 对象。

WorkerGlobalScope 对象也有 postMessage() 方法和 onmessage 事件处理程序,只是方向与 Worker 对象上的恰好相反:

  • 在工作线程内部调用 postMessage() 会在外部生成消息事件,而在工作线程外部发送的消息会转换为事件并发送给内部的 onmessage 事件处理程序
  • WorkerGlobalScope 是工作线程的全局对象,postMessage()onmessage 在工作线程的代码中看起来就像一个全局函数和一个全局变量
  • close() 函数可以让工作线程终止自己,效果与调用 Worker 对象的 terminate() 方法一样

由于 WorkerGlobalScope 是工作线程的全局对象,因此它拥有核心 JavaScript 全局对象的所有属性,如 JSON 对象、isNaN() 函数、Date() 函数。而且,WorkerGlobalScope 也拥有下列客户端 Window 对象的属性:

  • self 是对全局对象自身的引用
  • setTimeout()、clearTimeout()、setInterval()、clearInterval() 等定时器方法
  • location 属性描述传给 Worker() 构造函数的 URL,这个属性引用一个 Location 对象
  • navigator 属性引用的是一个类似 Window 的 Navigator 对象
  • 常用的事件目标方法 addEventListener()removeEventListener()
  • WorkerGlobalScope 对象还包含重要的客户端 JavaScript API,比如 Console对象、fetch() 函数和 IndexedDB API 等等。WorkerGlobalScope 也包含 Worker() 构造函数,这意味着工作线程也可以创建自己的工作线程

在工作线程中导入代码

浏览器支持 Worker 的时候 JavaScript 还不支持模块系统,因此工作线程有自己一套独特的系统用于导入外部代码:

  • importScripts() 接收一个或多个 URL 参数,每个 URL 引用一个 JavaScript 代码文件
  • importScripts() 按照传入顺序一个接一个地同步加载并执行这些文件
  • importScripts() 是同步函数,即它会在所有脚本都加载并执行完毕后返回
  • 为了在工作线程中使用模块,必须给 Worker() 构造函数传入第二个参数。这个参数必须是一个有 type 属性且值为 module 的对象。它表示应该将当前代码作为模块来解释,并允许使用 import 声明

工作线程执行模型

工作线程自上而下地同步运行自己的代码(和所有导入的脚本及模块)​,之后就进入了异步阶段,准备对事件和定时器作出响应:

  • 如果注册了 message 事件处理程序,只要有收到消息事件的可能,则工作线程就不会退出
  • 如果工作线程没有监听消息事件,它会运行直到没有其他待决的任务(如 fetch() 期约和定时器)​,且所有任务相关的回调都被调用,之后线程可以安全退出,而且是自动的
  • 工作线程也可以调用全局的 close() 函数显式将自己终止

如果工作线程中出现了异常,而且没有被 catch 子句捕获,则会在全局对象上触发 error 事件:

  • 如果这个事件有处理程序,而且处理程序调用了事件对象的 preventDefault(),则错误会停止传播
  • 否则,​error 事件会在 Worker 对象上触发

工作线程也可以注册一个事件处理程序,以便期约被拒绝又没有 .catch() 函数处理它时调用。可以在工作线程内定义一个 self.onunhandledrejection 函数,或者使用 addEventListener() 为全局事件 unhandledrejection 注册一个全局处理程序。

postMessage()、MessagePort 和 MessageChannel

Worker 对象的 postMessage() 方法和工作线程内部的全局 postMessage() 函数,都是通过调用在创建工作线程时一起创建的一对 MessagePort(消息端口)对象的 postMessage() 方法来实现通信的。客户端 JavaScript 无法直接访问这两个自动创建的 MessagePort 对象,但可以通过 MessageChannel() 构造函数创建一对新的关联端口:

1
2
3
4
5
let channel = new MessageChannel();
let myport = channel.port1;
let yourport = channel.port2;
myport.postMessage('Hello');
yourport.onmessage = (e) => console.log(e.data);

postMessage() 方法还接收可选的第二个参数,该参数是一个数组,数组的元素不是被复制到信道另一端,而是被转移到信道另一端。像这样可以转移而非复制的值包括 MessagePortArrayBuffer。如果 postMessage() 的第一个参数包含一个 MessagePort(嵌套在消息对象中某个地方)​,那么该 MessagePort 也必须出现在第二个参数中。这样一来,这个 MessagePort 将被转移到另一个线程,并在当前线程立即失效。

使用 MessageChannel 也可以实现两个工作线程间直接通信,从而避免通过主线程代为转发消息。

通过 postMessage() 跨源发送消息

在客户端 JavaScript 中,postMessage()方法还有另一个使用场景。这个场景涉及窗口而不是工作线程。如果文档中包含一个 <iframe> 元素,则该元素就像一个嵌入但独立的窗口。表示 <iframe> 的 Element对象有一个contentWindow 属性,也就是那个嵌套文档的 Window 对象。

  • 对于在这个嵌入窗格(iframe)中运行的脚本,window.parent 属性引用包含文档的 Window 对象
  • 当两个窗口显示的文档具有相同来源时,两个窗口中的脚本都拥有访问另一个窗口中内容的权限
  • 但是如果两个文档的来源不同,浏览器的同源策略将阻止两个窗口中的 JavaScript 相互访问对方的内容

对于窗口,postMessage() 也为两个独立的来源提供了安全交换消息的受控机制。即便同源策略阻止脚本访问另一个窗口的内容,仍然可以调用另一个窗口的 postMessage(),这样会触发该窗口的 message 事件,从而让该窗口脚本中的事件处理程序接收到。

Window 对象上的 postMessage() 方法有一点不同,其第二个参数是一个字符串,表示一个源,用于指定你希望谁接收这条消息。