第十五章 浏览器中的JavaScript

第15章 浏览器中的JavaScript

15.1 Web 编程基础

15.1.1 HTML < script>标签中的JavaScript

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

JavaScript代码可以出现在HTML文件的< script>与< /script>标签之间,也就是嵌入HTML中。

虽然JavaScript代码可直接嵌入

<script src="scripts/digital_clock.js"></script>

JavaScript文件只含纯JavaScript代码,不包含< script>或其他标签。按照约定,JavaScript代码文件以.js结尾。

包含src属性的< script>标签就如同指定JavaScript文件的内容直接出现在< script>和< /script>标签之间一样。注意,即便指定了src属性,后面的标签也是HTML文件必需的,HTML不支持< script/>标签。

使用src有如下优点:

-简化HTML文件,因为可以把大段地JavaScript代码从中移走。换句话说,这样可以实现内容与行为分离。

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

10.3节讲解了 JavaScript模块,介绍了 import和export指令。如果你用模块写了一个 JavaScript程序(且没有使用代码打包工具把所有模块都整合到一个非JavaScript模块文件中),那必须使用一个带有type="module"属性的〈script〉标签来加载这个程序的顶级模块。这样,浏览器会加载你指定的模块,并加载这个模块导入的所有模块,以及(递归地)加载所有这些模块导入的模块。完整的细节可以参考10.3.5节。

指定脚本类型

在Web的早期,人们认为浏览器将来有一天可能实现JavaScript以外的语言。为此,程序员需要给< script>标签添加language="javascript"或type="application/javascript"属性。这些是完全没有必要的。JavaScript本来就是Web的默认(也是唯一)语言。因此language属性被废弃了,而type属性也只有两个使用场景:

  • 用于指定脚本是模块
  • 在网页中嵌入数据但不会显示
脚本运行时机:async和defer

在浏览器中引入JavaScript语言之初,还没有任何API可以遍历和操作已经渲染好的文档或内容。JavaScript代码能够影响文档内容的唯一方式,就是在浏览器加载文档的过程中动态生成内容,为此,要使用doncument.write()方法在脚本所在的位置向HTML中注入文本。

虽然现在已经不再提倡使用document.write()生成内容了,但由于还存在这种可能,浏览器在解析遇到的< script>元素时的默认行为必需要运行脚本,就是为了确保不漏掉脚本可能输出的HTML内容,然后才能继续解析和渲染文档。这有可能严重拖慢网页的解析和渲染过程。

好在默认的这种同步或阻塞式脚本并非唯一选项。< script>标签也支持defer和async属性,这两个属性会导致脚本以不同的方式执行。这两个是布尔值属性,没有值,因此只要它们出现在< script>标签上就会生效。但要注意,这两个属性只对使用src属性的< script>标签起作用:

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

defer和async属性都会明确告诉浏览器,当前链接的脚本中没有使用document.write()生成HTML输出。因此浏览器可以在下载脚本的同时继续解析和渲染文档。其中,defer属性会让浏览器把脚本的执行推迟到文档完全加载和解析之后,此时已经可以操作文档了。而async属性会让浏览器尽早运行脚本,但在脚本下载期间同样不会阻塞文档解析。如果< script>标签上同时存在两个属性,则async属性起作用。

有一点要注意,推迟(defer)的脚本会按照它们在文档中出现的顺序运行。因为异步(async)脚本会在它们加载完毕后运行,所以其运行顺序无法预测。

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

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

按需加载脚本

有时,文档在刚刚加载完成时可能并不需要某些JavaScript代码,只有当用户执行了某些操作,比如单击某个按钮或打开某个菜单时才需要。如果你的代码是以模块形式写的,则可以使用import()来按需加载,具体可以参考10.3.6节。

如果没有使用模块,可以通过向文档中动态添加

//异步加载和执行指定URL的脚本
//返回期约,脚本加载完毕后解决
function importScript(url){
    return new Promise((resolve,reject)=>{
        let s=document.createElement("script"); //创建一个<script>元素
        s.onload=()=>{resolve();};    //加载后解决期约
        s.onerror=(e)=>{reject(e);};  //失败时拒绝期约
        s.src=url;                    //设置脚本的URL
        document.head.append(s);      //把<script>添加到文档
    });
}

15.1.2 文档对象类型

客户端JavaScript编程中最重要的一个对象就是Document对象,它代表浏览器窗口或标签页中显示的HTML文档。用于操作HTML文档的API被称作文档对象模型(Document Object Model,DOM),将在15.3节详细讲解。但DOM对于客户端JavaScript编程实在太重要了,因此有必要先在这里介绍一下。

HTML文档包含一组相互嵌套的HTML元素,构成了一棵树。
在这里插入图片描述
每个HTML标签都有一个与之对应的JavaScript类,而文档中出现的每个标签在JavaScript中都有对应类的一个实例表示。例如,< body>标签由HTMLBodyElement的实例表示,而< table>标签则由HTMLTableElement的实例表示。JavaScript中这些元素对象都有与之相应属性的值。在JavaScript中修改这个属性的值,也会改变HTML属性的值(并导致浏览器加载和显示新图片)。多数JavaScript元素类都只是镜像HTML标签的属性,但有些也定义了额外的方法。比如,HTMLAudioElement和HTMLVideoElement类都定义了play()和pause()方法,用于控制音频和视频文件的回放。

15.1.3 浏览器中的全局对象

每个浏览器窗口或标签页都有一个全局对象(参见3.7节)。在一个窗口中运行的所有JavaScript代码(不包括在工作线程中运行的代码,参见15.13节)都共享一个全局对象。无论文档中包含多少脚本或模块,这个事实都不会改变:文档中的所有脚本和模块共享同一个全局对象,如果由脚本在该对象上定义了一个属性,则该属性也将对其所有其他脚本可见。

全局对象上定义了JavaScript标准库,比如parseInt()函数、Math对象、Set类等。在浏览器中,全局对象也包含各种Web API的主入口。例如,document属性表示当前显示的文档,fetch()方法用于发送HTTP网络请求,而Audio()构造函数允许JavaScript程序播放声音。

在浏览器中,全局对象具有双重角色。它既是定义JavaScript语言内置类型和函数的地方,也代表当前浏览器窗口定义了history(表示浏览器浏览历史,参加15.10.2节)和innerWidth(表示窗口的像素宽度)等Web API的属性。全局对象的属性中有一个属性叫window,它的值就是全局对象本身。这意味着在客户端代码中可以直接通过window引用全局对象。在使用窗口特定的功能时,最好加上window前缀。比如,写windoe.innerWidth比只写innerWidth更明确。

15.1.4 脚本共享一个命名空间

在模块中,定义在模块顶级(即位于任何函数或类定义之外的常量、变量、函数和类是模块私有的,除非它们被明确地导出。被导出时,这些模块可以被其他模块有选择地导入(注意,模块的这个性质使用代码打包工具时也得到了维护)。

不过在非模块脚本中,情况完全不同。如果在顶级脚本中定义了一个常量、变量、函数或类,则该声明将对同一文档中的所有脚本可见。如果一个脚本定义了函数f(),另一个脚本类定义了类C,第三个脚本无须采取任何导入操作即可调用和实例化该类。因此如果没有使用模块,同一文档中共享同一个命名空间的独立脚本就如同它们是一个更大脚本的组成部分一样。这对于小程序或许会很方便,但在大型程序中避免命名冲突则会变成一件麻烦事,特别是在某些脚本还是第三方库的情况下。

这个共享的命名空间在运行时有一些历史遗留问题。比如,顶级的var和function声明会在共享的全局对象上创建属性。如果一个脚本定义了顶级函数f(),那么同一个文档中的另一个脚本可以用f()或者window.f()调用该函数。而使用ES6中const、let和class的顶级声明则不会在全局对象上创建属性。但是,它们仍然会定义在一个共享的命名空间内。如果一个脚本定义了类c,另一个脚本也可以通过new C()(但不能通过new window.C())创建该类的实例。

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

15.1.5 JavaScript程序的执行

客户端JavaScript中没有程序的正式定义,但我们可以说JavaScript程序由文档中包含和引用的所有JavaScript代码组成。这些分开的代码共享同一个全局Window对象,它们可以通过这个对象访问表示HTML文档的同一个底层Document对象。不是模块的脚本还额外共享同一个顶级命名空间。

如果网页中包含嵌入的窗格(< iframe>元素),被嵌入文档与嵌入它的文档中的JavaScript代码拥有不同的全局对象和Document对象,可以看成两个不同的JavaScript程序。但要记住,关于JavaScript程序的边界在哪里并没有正式的定义。如果包含文档和被包含文档是从同一个服务器加载的,则一个文档中的代码就能够与另一个文档中的代码交互。此时,如果你愿意,可以把它们看成一个程序整体的两个互相操作的部分。15.13.5节将解释JavaScript程序如何与在中运行的JavaScript代码相互发送和接收消息。

我们可以把JavaScript程序的执行想象成发生在两个阶段。在第一阶段,文档内容加载完成, < script >元素指定的(内部和外部)代码运行。脚本通常按照它们在文档中出现的顺序依次执行,不过也可以使用前面介绍过的async和defer属性来修改。任何一个脚本中的JavaScript代码都自上而下运行,当然还要服从JavaScript的条件、循环和其他控制语句。有的脚本在这个阶段并不真正做任何事,仅仅是定义第二阶段使用的函数和类。而有的脚本在第一阶段可能会做很多重要的事情,而在第二阶段则什么也不做。想象一下在文档中最末尾有一个脚本,它会找到文档中所有的< h1 >和< h2 >标签,然后修改文档,在开头的地方插入一个目录。这件事完全可以在第一阶段完成(15.3.6节恰好有一个为文档插入目录的示例)。

当文档加载完毕且所有脚本都运行之后,JavaScript执行就进入了第二阶段。这个阶段是异步的、事件驱动的。如果脚本要在第二阶段执行,那么它在第一阶段必需要做一件事,就是至少要注册一个将被异步调用的事件处理程序或其他回调函数。在事件驱动的第二阶段,作为对异步事件的回应,浏览器会调用事件程序程序或其他回调。事件处理程序通常是为响应用户操作(如鼠标点击、敲击键盘等)而被调用的,但也可能会被网络活动、文档和资源加载事件、流逝的事件或者JavaScript代码中的错误触发。事件和事件处理程序在15.2节有详细的讲解。

事件驱动阶段发生的第一批事件主要有“DOMContentLoaded”和“load”。“DOMContentLoaded”在HTML文档完全加载和解析后触发。JavaScript程序经常使用这两个事件作为触发器或启动信号。经常可以看到某些程序的脚本定义了一些函数,但除了注册会被事件驱动阶段开始时的“load”事件触发的事件处理程序之外,其他什么也不做。而负责操作文档、执行程序预定逻辑的正是这个“load”事件处理程序。注意,在JavaScript编程中,类似这里所说的“load”事件处理程序再去注册其他事件处理程序也是很常见的。

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

客户端JavaScript的线程模型

JavaScript是单线程的语言,而单线程执行让编程更容易:你可以保证自己写的两个事件程序程序永远不会同时运行。在操作文档内容时,你敢肯定不会有别的线程会同时去修改它,而且,在写JavaScript代码时,你永远不需要关心锁、死锁或者资源争用。

单线程意味着浏览器会在脚本和事件执行期间停止响应用户输入。JavaScript程序员为此有责任确保JavaScript脚本处理程序保护长时间运行。如果脚本执行计算量大的任务,就会导致文档加载延迟,用户在脚本执行结束前将看不到文档内容。如果事件处理程序执行计算密集任务,浏览器就会变得没有响应有可能导致用户以为程序已经崩溃了。

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

客户端JavaScript时间线

前面介绍了JavaScript程序会从脚本执行阶段开始,然后过渡到事件处理阶段。这两个阶段可以进一步分成下列步骤。

  1. 浏览器插件Document对象并开始解析网页,随着对HTML元素及其文本内容的解析,不断向文档中添加Element对象喝Text节点。此时,document.readyState属性的值是“loading”。
  2. HTML解析器在碰到一个没有async、defer或type="module"属性的< script >标签时,会把标签添加到文档中,然后执行其中的脚本。脚本是同步执行的,而且在脚本下载(如果需要)和运行期间,HTML解析器会暂停。类似这样的脚本可以使用document.write()向输入流中插入文本,而该文本在解析器恢复时将成为文档的一部分。类似这样的脚本经常只会定义函数和注册事件处理程序,以便后面使用,但它也可以遍历和操作当时已经存在的文档树。换句话说,不带async或defer属性的非模块脚本可以看到它自己的< script >标签及该标签之前的文档内容。
  3. 解析器在碰到一个有async属性集的< script>元素时,会开始下载该脚本的代码(如果该脚本是模块,也会递归地下载模块的所有依赖)并继续解析文档。脚本在下载完成后会尽快执行,但解析器不会停下来等待它下载。异步脚本必须不使用document.write()方法。它们可以看到自己的< script>标签及该标签之前的文档内容,同时也有可能访问更多文档内容。
  4. 当文档解析完成后,document.readState属性变成“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元素上单击鼠标(或点击触屏),或在HTML< textarea >元素中输入文本。15.2节将讲解JavaScript程序如何响应类似的用户事件。
  • 当前显示文档的URL可以在客户端JavaScript中通过document,URL读到。如果把这个字符串传给URL构造函数(参见11.9节),则可以方便地取到URL的路径、查询字符串和片段值。
  • HTTP“Cookie”请求头的内容在客户端代码中可以通过document.cookie读到。Cookie通常被服务器端代码用来维持用户会话,但需要客户端代码也可以读取(和写入)Cookie。更多内容可以参见15.12.2节。
  • 全局navigate属性暴露了关于浏览器、操作系统以及它们能力的信息。例如,navigator.userAgent是标识浏览器身份的字符串,navigator.language是用户偏好的语言,而navigator.hardwareConrrency返回浏览器可用的逻辑CPU的个数。类似地,全局screen属性暴露了用户显示器尺寸地信息,比如screen.width和screen.height分别是显示器地宽度和高度。从某种意义上看,这些navigator和screen的值对浏览器而言就相当于Node程序中的环境变量。

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

15.1.7 程序错误

与直接运行在操作系统上的应用程序(例如Node应用程序)不同,在浏览器中运行的JavaScript程序不会真正“崩溃”。如果JavaScript程序在运行期间出现异常,且代码中没有catch语句处理它,开发者控制台将会显示一条错误信息,但任何已经注册的事件处理程序照样会继续运行和响应事件。

如果你想定义一个终极错误处理程序,希望在出现这种未捕获异常时调用,那可以把Window对象的onerror属性设置为一个错误处理函数。但未捕获异常沿调用栈一路向上传播,错误消息即将显示在开发者控制台时,window.onerror收到的第一个参数是描述错误的消息。第二个参数是一个字符串,包含导致错误的JavaScript代码的URL。第三个参数是文档中发生错误的行号。如果onerror处理程序返回true,意味着通知浏览器它已经处理了这个错误,不需要进一步行动了,换句话说,也就是浏览器不应该再显示自己的错误消息了。

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

虽然定义了onerror和onhandledrejection处理程序经常不是必需的,但如果你想知道用户浏览器中发生了哪些意外错误,则作为一种“遥感”机制,可以利用它们把客户端错误上报给服务器(比如使用fetch()函数发送HTTP POST请求)。

15.1.8 Web安全模型

由于网页可以在你的私人设备上执行任意JavaScript代码,因此存在明显的安全隐患。浏览器厂商一直在努力平衡相互制约的目标:

  • 定义强大的客户端API,让Web应用用途更广。
  • 防止恶意代码读取或修改用户数据、侵犯用户隐私、欺诈用户或浪费用户的时间。
JavaScript不能做什么

浏览器对恶意代码的第一道防线就是不支持某些能力。例如,客户端JavaScript不能向客户端计算机中写入或删除任何文件,也不能展示任意目录的内容。这意味着JavaScript程序不能删除数据,也不能植入病毒。

类似地,客户端JavaScript没有通用网络能力。客户端JavaScript程序可以发送HTTP请求(参见15.11.1节)。而另一个标准,即WebSocket(参见15.11.3节),定义了一套类似套接口的API。用于跟特定的服务器通信,但这些API都无法随意访问任意服务器,使用客户端JavaScript写不出通用互联网客户端和服务器。

同源策略

同源策略指的是对JavaScript代码能够访问和操作什么Web内容的一整套限制。通常在页面包含< iframe >元素时就会涉及同源策略。此时,同源策略控制着一个窗格中的JavaScript与另一个窗格中的JavaScript的交互。比如,脚本只能读取与包含它的文档同源的Window和Document对象的属性。

文档的源就是文档URL的协议、主机和端口。从不同服务器加载的文档是不同源的,从相同主机的不同端口加载的文档也是不同源的。而且,对于通过http:协议加载的文档与通过https:协议加载的文档来说,即便它们来自同一台服务器,也是不同源的。浏览器通常把每个file:URL看成一个独立的源,这意味着如果你写的程序会显示同一台服务器上的多个文档,则可能无法使用file:URL在本地测试它,而必须在开发期间运行一个静态Web服务器。

有一点非常重要,就是应该知道脚本自身的源与同源策略不相关,相关的是包含脚本的文档的源。比如,假设主机A上有一个脚本,而主机B上的一个网页(使用< script >元素的src属性)包含了这个脚本。则该脚本的源是主机B,且该脚本对包含它的文档具有完全访问权。如果文档中嵌入的< iframe >包含另一个来自主机B的文档,则该脚本同样拥有对这个文档的完全访问权。但是,如果顶级文档包含另一个< iframe >,其中显示的文档来自主机C(或者甚至来自主机A),则同源策略就会起作用,并阻止该脚本访问这个嵌入的文档。

同源策略对使用多子域的大型网站造成了麻烦。比如,来自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”。

第二章缓解同源策略的技术是跨源资源共享(Cross-Origin Resouse Sharing, CORS),它允许服务器决定对哪些资源提供服务。CORS扩展了HTTP,增加了一个新的Origin:请求头和一个新的Origin:请求头和一个新的Access-Control-Allow-Origin响应头。服务器可以使用这个头部明确列出对哪些源提供服务,或者使用通配符标识可以接收任何网站的请求。浏览器会根据这些CORS头部的有无决定是否放松同源限制。

同源策略

同源策略是指的是对JavaScript代码能够访问和操作什么Web内容的一整套限制。通常是在页面中包含< iframe >元素时就会涉及同源策略。此时,同源策略控制着一个窗格中的JavaScript与另一个窗格中的JavaScript的交互。比如,脚本只能读取与包含它的文档同源的Window和Document对象的属性。

文档的源就是文档URL的协议、主机和端口。从不同服务器加载的文档是不同源的,从相同主机的不同端口加载的文档也是不同源的。而且,对于http:协议加载的文档与通过https:协议加载的文档加载的文档来说,即便它们来自同一台服务器,也是不同源的。浏览器通常把美国个file:URL看成一个独立的源,这意味着如果你写的程序会显示同一台服务器上的多个文档,则可能无法使用file:URL在本地测试它,而必须在开发期间运行一个静态Web服务器。

有一点非常重要,就是应该知道脚本自身的源与同源策略不相关,相关的是包含脚本的文档的源。比如,假设主机A上有一个脚本,而主机B上的一个网页(使用< script >的元素的src属性)包含了这个脚本。则该脚本的源是主机B,且该脚本对包含它的文档具有完全访问权。如果文档中嵌入的< iframe >包含另一个来自主机B的文档,则该脚本同样拥有对这个文档的完全访问权。但是,如果顶级文档包含另一个< iframe >,其中显示的文档来自主机C(或者甚至来自主机A),则同源策略就会起作用,并阻止该脚本访问这个嵌入的文档。

同源策略也会应用到脚本发起的HTTP请求(参见15.11.1节)中。JavaScript代码可以向托管其包含文档的服务器发送任意HTTP请求,但不能与其他服务器通信(除非那些服务器开启了后面介绍的CORS)。

同源策略对使用多子域的大型网站造成了麻烦。比如,来自orders.example.com的脚本可能需要读取example.com上文档的属性。为了支持这种多子域名网站,脚本可以通过把document.domain设置为一个域名来修改自己的源。因此,源为https://orders.example.com的脚本通过把document.domain设置为“orders.example”“ample.com”或“com”。

第二种缓解同源策略的技术是跨源资源共享(Cross-Origin Resourse Sharing, CORS),它允许服务器决定对哪些源提供服务。CORS扩展了HTTP,增加了一个新的Origin:请求头和一个新的Access-Content-Allow-Origin响应体。服务器可以使用这个头部明确列出哪些源提供服务,或者使用通配符可以接收任何网站的请求。浏览器会根据这些这些CORS头部的有无决定是否放松同源限制。

跨站点脚本

跨站点脚本(Cross-Site Scripting , XSS)是一种攻击方式,指攻击者向目标网站注入HTML标签或脚本。客户端JavaScript程序员必须了解并防范跨站点脚本。

如果网页的内容是动态生成的,比如根据用户提交的数据生成内容,但却没有提前对那些数据“消毒”(删除嵌入的HTML标签等),那可能成为跨站点脚本的攻击目标。下面看一个非常简单的示例,这个示例使用JavaScript根据用户输入的名字给出问候:

<acsipt>
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/great.html?name=David

对于这个URL,网页会显示文本“Hello David”。但是,如果查询参数是下面这样的,会发生什么呢?

name=%3Cimg%20ssrc=%22x.png%22%20onload=%22alert(%27hacked%27)%22/%3E

把这个经过URL转义的参数解码后,就会导致下面的HTML被注入文档:

Hello <img src="x.png" onload="alert('hacked')"/>

于是,在图片加载后,onload属性中的JavaScript字符串就会执行。全局alert()函数将显示一个模态对话框。显示一个对话框没什么大不了,但这演示了在这个网站上显示未经处理的HTML会导致任意代码执行的可能性。

之所以称其为跨站点脚本攻击,是因为会涉及不止一个网站。网站B包含一个特殊编制的链接(类似前面示例中的URL),指向网站A。如果网站B能够说服用户点击该链接,用户就会导航到网站A,但网站A此时会运行来自网站B的代码。该代码可能会破坏网站A的页面。或者导致它功能失效。更危险的是,恶意代码可能读取网站A存储的cookie(可能包含个人账号或其他用户身份信息)并将该数据发送回网站B。这种注入的代码甚至可以跟踪用户的键盘输入,并将该数据发送回网站B。

一般来说,防止XSS攻击的办法是从不可信数据中删除HTML标签,然后再用它去动态参见文档内容。对于前面展示的greet.html,可以通过把不可信输入中的特殊HTML字符替换成等价的HTML实体来解决问题:

name=name
   .replace(/&/g,"%amp;")
   .replace(/</g,"&lt;")
   .replace(/>/g,"&gt;")
   .replace(/"/g,"quot;")
   .replace(/'/g,"&#x27;")
   .replace(/\//g,"&#x27;")

应对XSS攻击的另一个思路是让自己的Web应用始终在一个< iframe >中显示不可信内容,并将这个< iframe >的sandbox属性设置为禁用脚本和其他能力。

跨站点脚本作为一种有害的漏洞,其根源可以追溯到Web的架构设计。深入了解这个漏洞是非常有必要的。

15.2 事件

客户端JavaScript程序使用异步事件驱动的编程模型。在这种编程风格下,浏览器会在文档、浏览器或者某些元素与之关联的对象发生某些值得关注的事件时生成事件。例如,浏览器会在它加载完文档时生成事件,在用户把鼠标移到超链接上时生成事件,也会在用户敲击键盘上的键时生成事件。如果JavaScript应用关注特定类型的事件,那它可以注册一个或多个函数,让这些函数在该类型事件发生时被调用。注意,这并非Web编程的专利,任意具有图形界面的应用都是这样设计的。换句话说,界面就在那里等待用户与之交互(也就是说,它们在等待事件发生),然后给出响应。

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

事件类型

事件类似是一个字符串,表示发生了什么事件。例如,“mousemove”表示用户移动了鼠标,“keydown”表示用户按下了键盘上的某个键,而“load”表示文档(或其他资源)已经通过网络加载完成。因为事件类型是字符串,所以有时也称它为事件名。我们确实要使用这个名称来谈论某种事件。

事件目标

事件目标是一个对象,而事件就发生在该对象或者事件与该对象有关。说到某个事件,必须明确它的类型和目标。比如,Window对象上发生了加载事件,或者一个< button >元素上发生了单击事件。Window、Document和Element对象是客户端JavaScript应用中最常见的事件目标,不过也有一些事件会在其他对象上发生。例如,Worker对象(15.13节介绍的一种线程)是“message”事件的目标,这种事件在工作线程发消息时发生。

事件处理程序或事件监听器

事件处理程序或事件监听器是一个函数,复制处理或响应事件。应用通过浏览器注册自己的事件处理程序,指定事件类型和事件目标。当事件目标上发生指定类型的事件时,浏览器就会调用这个处理程序。当事件处理程序在某个对象上被调用时,我们说浏览器“触发”“派发”或“分派”了该事件。注册事件处理程序员有不同的方式。

事件对象

事件对象是与特定事件关联的对象,包含有关该事件的细节。事件对象作为事件处理程序的参数传入。所有事件对象都有type和target属性,分别表示事件类型和事件目标。每种事件类型都为相关的事件对象定义了一组属性。比如,与鼠标事件相关的事件对象包含鼠标指针的坐标,与键盘事件相关的事件包含与被按下的键以及按住不放的修饰键的信息。很多事件类型只定义几个标准属性(包括type和target),并没有其他有用信息。对这些事件,重要的是它们发生了,而不是事件的细节。

事件传播

事件传播是一个过程,浏览器会决定在这个过程中那些对象触发事件处理程序。对于Window对象上的“load”或Worker对象上的“message”等特定于一个对象的事件,不需要传播。但对于发生在HTML文档中的某些事件,则会“冒泡”到文档根元素。如果用户在一个超链接上移动鼠标,这个鼠标首先会在定义该超链接的< a >元素上触发,然后在包含元素上触发,可能经过一个< p >元素、一个< section >元素,然后到达文档对象本身。有时,只给文档或包含元素注册一个事件处理程序,比给你关心的每个元素都注册一个处理程序更方便。事件处理程序可以阻止事件传播,从而让事件不再冒泡,也就不会在包含元素上触发处理程序。为此,事件处理程序需要调用事件对象上的一个方法。在另外一种事件传播形式,即事件捕获中,注册包含在元素上的处理程序在事件被发送到实际目标之前,有机会先拦截(或捕获)事件。事件冒泡和捕获将在15.2.4节详细介绍。
有些事件有与子关联的默认动作。比如,单击一个超链接,默认动作是让浏览器跟随链接,加载一个新页面。事件处理程序可以通过调用事件对象的一个方法来阻止这个默认动作。对此,我们有时也称为“取消”事件。

15.2.2 注册事件处理程序

有两种注册事件处理程序的方式,第一种是Web早期就有的,即设置作为事件目标的对象或文档元素的一个属性。第二种(更新也更常用)是吧处理程序传给这个对象或元素的addEvenLitstener()方法。

设置事件处理程序属性:JavaScript

注册事件处理程序最简单的方式就是把事件目标的一个属性设置为关联的程序函数。按照惯例,事件处理程序属性的名字都由“on”和事件名称组成,比如:onclick、onchange、onload、onmouseover,等等。注意,这些属性名是区分大小写的,必须全部小写,即便事件类型包含多个单词(如“mousedown”)。以下代码包含两个以这种方式组成事件处理程序的地方:

//设置Window对象的onload属性为一个函数
//这个函数是事件处理程序:它会在文档加载完成时被调用
window.onload=function(){
    //查找一个<form>元素
    let form=document.querySelector("form#shopping");
    //在这个表单上注册一个事件处理程序,在表单被提交之前
    //会调用这个函数。假设其他地方已经定义了isFormValid()
    form.onsubmit=function(event){   //若用户提交表单时
        if(!isFormValid(this)){      //检查表单是否有效
            event.preventDefault();  //若无效,则阻止提交
        }
    };
}

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

设置事件处理程序属性:HTML

文档元素的事件处理程序属性也可以直接在HTML文件中作为对应HTML标签的属性来定义(在JavaScript中注册的Window元素上的处理程序在HTML可以定义为< body >标签的属性)。

在使用HTML属性定义事件处理程序时,属性的值应该是一段JavaScript代码字符串。这段代码应该是事件处理程序函数的函数体,不是完整的函数声明。换句话说,HTML事件处理程序的代码应该没有外围的大括号,前面也没有function关键字。例如:

<button onclick="console.log('Thank you');">Please Click</button>

如果一个HTML属性定义事件处理程序时,则必须用分号来分隔这些语句,或者用回车把这个属性值分成多行。

在给HTML事件处理程序属性指定JavaScript代码字符串时,浏览器会把这个字符串转换为一个函数,这个函数类似如下所示:

function(event){
    with(document){
       with(this.form||{}){
            with(this){
               /* 你的代码在这里 */
            }
       }
    }
}

这个event参数意味着你的处理程序代码可以通过它引用当前的事件对象。而with语句意味着你的处理程序可以直接引用目标对象、外层< form >(如果有),乃至Document对象的属性,就像它们都是作用域内的变量一样。严格模式下是禁止使用with语句的,但HTML属性中的JavaScript代码没有严格这一说。这样定义的事件处理程序将在某一个可能存在意外变量的环境中执行,因此可能是一些讨厌的bug的来源,也是避免在HTML中编写事件处理程序的一个充分理由。

addEvenListener()

任何可以作为事件目标的对象(包括Window和Document对象以及所有文档元素),都定义了一个名为addEvenListener()的方法,可以使用它来注册目标为调用对象的事件处理程序。addEvenListener()接收3个参数。第一个参数是注册处理程序的事件类型。事件类型(或名称)是一个字符串,不包含作为HTML元素属性使用时的前缀“on”。第二个参数是当指定类型的事件发生时调用的函数。第三个参数是可选的,下面会介绍。

以下代码在一个< button >元素上位“click”事件注册了两个事件处理程序。注意这里使用的两种技术的差异:

<button id="mybutton">Click me</button>
<script>
let b=document.querySelector("#mybutton");
b.onclick=function(){ console.log("Thanks for clicking me!"); }
b.addEventListener("click",()=>{ console.log("Thanks  
 again!");});
</script>

以“click”作为第一参数调用addEvenListener()不会影响onclick属性的值。在这段代码中,单击一次按钮会在开发者控制台打印两条消息。如果我们先调用addEvenLitstener(),然后设置onclick,那么仍然会看到两条消息,只是顺序相反。更重要的是,可以多次调用addEvenListnener()在同一个对象上为同一个事件类型注册多个处理程序。当对象上发生该事件时,所有为这个事件而注册的处理程序都会按照注册它们的顺序被调用。在同一个对象上以相同的参数多次调用addEvenListener()没有作用,同一个处理程序只能注册一次,重复调用不会改变处理程序被调用的顺序。

与addEvenListener()对应的是removeEvenListener()方法,它们的前两个参数是一样的(第三个参数也是可选的),只不过是用来从同一个对象上移除而不是添加事件处理程序。有时,临时注册一个事件处理程序,然后很快移除它是有用的。比如,在“mousedown”事件发生时,可以为“mousemove”和“mouseup”事件注册临时事件处理程序,以便知道用户是否拖动鼠标。然后,在“mouseup”事件发生时移除这两个处理程序。

addEvenListener()可选的第三个参数是一个布尔值或对象。如果传入true,函数就会被注册为捕获事件处理程序,从而在事件派发的另一个阶段调用它。15.2.4节将介绍事件捕获。如果在注册事件监听器时给第三个参数传了true,那么要移除该事件处理程序,必须在调用removeEvenListener()时也传入了true作为第三个参数。

注册捕获事件处理程序只是addEvenListener()支持的3个选项之一。如果要传入其他选项,可以给第三个参数传一个对象,显式指定这些选项:

dicument.addEvenListener("click",handleClick,{
    capture:true,
    once:true,
    passive:true
});

如果这个Options(选择)对象的capture属性为true,那么函数会被注册为捕获处理程序。如果这个属性为false或省略该属性,那么处理程序就不会注册到捕获阶段。

如果选项对象有once且为true,那么监听器在被触发一次后会主动移除。如果这个属性为false或忽略该属性,那么处理程序永远不会被自动移除。

如果选项对象有passive属性且值为true,那么事件监听器永远不调用prevent Default()取消默认动作。这对于移动设备上的触摸事件特别重要。如果“touchmove”事件可以阻止浏览器的默认滚动动作,那浏览器就不能实现平滑滚动。passive属性提供了一种机制,即在注册一个可能存在破坏性操作的事件处理程序时,让浏览器知道可以在事件处理程序运行的同时安全地开始其默认行为(如滑动)。平滑滚动对保证良好的用户体验非常重要,因此Firefox和Chrome都默认把“touchmove”和“touchwheel”设置为“被动式”。如果确实想为这两个事件注册一个会调用preventDefault()的事件处理程序,应该显式地将passive属性设置为false。

可以把选项对象传给removeEventListener(),但其中只有capture属性才是有用地。换句话说,移除监听器时不需要指定once或passive,指定了也会忽略。

15.2.3 调用事件处理程序

注册事件处理程序后,浏览器会在指定对象发生指定事件时自动调用它。边界介绍调用事件处理程序的细节,解释事件处理程序的参数,调用上下文(this值)和事件处理程序返回值的含义。

事件处理程序的参数

事件处理程序被调用时会接收到一个EventListener对象作为唯一的参数。这个Event对象的属性提供了事件的详细信息。

type

发生事件的类型

target

发生事件的对象

currentTarget

对于传播的事件,这个属性是注册当前处理程序的对象。

timeStamp

表示发生事件的时间戳(毫秒),不是绝对时间。可以用第二个事件的时间戳减去第一个事件的时间戳来计算两个事件相隔多久时间。

isTrusted

如果时间由浏览器自身派发,这个属性为true;如果事件由JavaScript代码派发,这个属性为false。

事件处理的上下文

在通过设置属性注册事件处理程序时,看起来就像为目标对象定义了一个新方法:

target.onclick=function{  /* 处理程序的代码 */};

因此,没有意外,这个事件处理程序加个作为它所在对象的方法杯调用。换句话说,在事件处理程序的函数中,this关键字引用的是注册事件处理程序的对象。

即便使用addEventLiatener()注册,处理程序在杯调用时也会以目标作为其this值。不过,这不适用于牵头函数形式的处理程序。箭头函数中this的值始终等于定义它的作用域的this值。

处理程序的返回值

在现代JavaScript中,事件处理程序不应该返回值。在比较老的代码中,我们还可以看到返回值的事件处理器,而且返回的值通常用于告诉浏览器不要执行与事件相关的默认动作。比如,如果一个表单Submit按钮的onclick处理程序返回false,浏览器将不会提交表单(通常因为事件处理程序确定用户输入未能通过客户端验证)。

阻止浏览器执行默认动作的标准且推荐的方式,是调用Event对象的preventDefault()方法。

调用顺序

一个事件目标可能会成为一种事件再次多个处理程序。当这种事件发生时,浏览器会按照注册处理程序的顺序调用它们。有意思的是,即便混合使用addEventListener()注册的事件处理程序和在对象属性onclick上首次的事件处理程序,结果仍是如此。

15.2.4 事件传播

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

注册在目标元素上的事件处理程序被调用后,多数事件都会沿DOM树向上“冒泡”。目标父元素的事件处理程序会被调用。然后注册在目标元素上的事件处理程序会被调用。就这样一直向上到Document对象,然后到Window对象。由于事件冒泡,我们可以不给个别元素注册很多个事件处理程序,而是只在他它们的公共祖先元素上注册一个事件处理程序,然后在其中处理事件。比如,可以在< form >元素上注册一个“change”事件处理元素,而不是在表单的每个元素上都注册一个“change”事件处理程序。

多数在文档元素上发生的事件都会冒泡。明显的例外的“focus”“blur”和“scroll”事件。文档元素的“load”事件冒泡,但到Document对象就会停止冒泡,不会传播到Window对象(Window对象的“load”事件处理程序只会在整个文档加载完毕后才被触发)。

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

事件捕获提供了把事件发送到目标之前先行处理的机会。捕获事件处理程序可用于调试,或者使用下一节介绍的事件取消技术过滤事件,让目标事件处理程序永远不会被调用。事件捕获最常见的用途是处理鼠标拖动,因为鼠标运动事件需要被拖动的对象来处理,而不是让位于其上的文档元素来处理。

事件捕获提供了把事件发送到目标之前先行处理的机会。捕获事件处理程序可用于调试,或者使用下一节介绍的事件取消技术过滤事件,让目标事件处理程序永远不会被调用。事件捕获最常见的用途是处理鼠标拖动,因为鼠标运动事件需要被拖动的对象来处理,而不是让位于其上的文档元素来处理。

15.2.5 事件取消

浏览器对很多用户事件都会做出响应,无论你是否在代码中指定。比如,用户在一个链接上单击鼠标,浏览器就会跟随链接。如果一个HTML文本输入元素获得了键盘焦点,而且用户按了某个键,浏览器就会打出用户的输入。如果用户在触摸屏上滑动手指,浏览器就会滚动。如果你为这些事件注册了事件处理程序,那么就可以阻止浏览器执行其默认动作,为此要调用事件对象的preventDefault()方法(除非你注册处理程序时传入了passive选项,该选项会导致preventDefault()无效)。

取消与事件关联的默认动作只是事件取消的一种情况。除此之外,还可以调用事件对象的stopPropagation()方法,取消事件传播。如果同一对象上也注册了其他处理程序,则这些处理程序仍然会被调用。但是,在这个对象上调用stopPropagation()方法之后,其他对象上的事件处理程序都不会再被调用。stopPropagation()可以在捕获阶段、在事件目标本身,以及在冒泡阶段起作用。stopImmediatePropagation()与stopPropagation()类似,只不过它也会阻止在同一个对象上注册的后续事件处理程序的执行。

15.2.6 派发自定义事件

客户端JavaScript事件API相对比较强大,可以使用它定义和派发自己的事件。比如,假设你的程序需要周期性地执行耗时计算或者发送网络请求,而在执行此操作期间,不能执行其他操作。你想在此时显示一个转轮图标,它只派发一个事件,显示自己正忙,然后在自己不忙的时候再派发另一个事件即可。UI模块可以为这两个事件处理程序,然后在适当的方式在UI上告知用户即可。

如果一个JavaScript对象有addEventListener()方法,那它就是一个“事件目标”。这意味着该对象也有一个dispatchEvent()方法。可以通过CustomEvent()构造函数创建自定义事件对象,然后再把它传给dispatchEvent()。CustomEvent()的第一个参数是一个字符串,表示事件类型;第二个参数是一个对象,用于指定事件对象的属性。可以将这个对象的detail属性设置为一个字符串、对象或其他值,表示事件的上下文。如果你想子啊一个文档元素上派发自己的事件,并希望它沿文档树向上冒泡,则要在第二个参数中添加bubbles:true。下面看一个例子:

//派发一个自定义事件,通知UI自己正在忙
document.dispatchEvent(new CustomEvent("busy",{detai: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();
    }
});

15.3 操作DOM

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

每个Window对象都有一个document属性,引用一个Document对象。这个Document对象代表窗口的内容,也是本节的主题。不过,Document对象并不是孤立存在的,它是DOM中表示和操作文档内容的核心对象。

15.3.1 选择Document元素

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

通过CSS选择符选择元素

CSS样式有一个非常强大的语法,就是它的选择符。选择符用来描述文档中元素或元素的集合。DOM方法querySelector()和querySelectorAll()让我们能够在文档中找到指定选择符匹配的元素。

querySelector()方法接收一个CSS选择符作为参数,返回它在文档中找到的第一个匹配的元素;如果没有找到,则返回null:

//查找文档中所有HTML标签包含属性id="spinner"的元素
let spinner=document.querySelector("#spinner")

querySelectorAll()也类似,只不过防护文档中所有的匹配元素,而不是只返回第一个:

//查找所有< h1 >、< h2 >和< h3 >标签的Element对象
let titles=document.querySelectorAll("h1,h2,h3");

querySelectorAll()的返回值不是Element对象的数组,而是一个类似数组的NodeList对象。NodeList对象有一个length属性,可以像数组一样通过索引访问,因此可以使用传统的for循环遍历。NodeList也是可迭代对象,因此也可以在for/of循环中使用它们。如果想把NodeList转换为真正的数组,只要把它传给Array.from()即可。

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

我们找到,CSS也定义了::first-line和::first-letter伪元素。在CSS中,它们只匹配文本节点的一部分,而不匹配实际的元素。在querySelector()或querySelectoeAll()中使用它们什么也找不到。而且,很多浏览器也拒绝对:link和:visited伪类返回匹配结果,因为这有可能暴露用户的浏览历史。

还有一个基于CSS的元素选择方法:closest()。这个方法是Element定义的,以一个选择符作为唯一参数。如果选择符匹配那个调用它的元素,则返回该元素;否则,就返回与选择符匹配的最先祖先元素;如果没有匹配,则返回null。某种意义上来看,closest()是querySelector()的逆向操作:closest()从当前元素开始,沿DOM树向上匹配;而querySelector()则从当前元素开始,沿DOM树向下匹配。如果你在文档树种某个高层注册了事件处理程序,closest()通常能派上用场。比如,在处理一个单击事件时,你可能想知道该事件是否发生在一个超链接上。事件对象会告诉你事件目标,但该目标也许是超链接的文本而非< a >本身。为此,可以让事件处理对象想这样查找最近的超链接:

// 查找有href属性的最近的外圈< a >标签
let hyperlink= event.target.closest("a[href]");

下面是使用closest()的另一个例子

//如果e被包含在一个HTML列表元素内则返回true
function inside(e){
     return e.closest("ul,ol,dl") !== null;
}

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

//如果e是一个HTML标题则返回true
function isHeading(e){
   return e.matches("h1,h2,h3,h4,h5,h6");
}
其他选择元素的方法

处理querySelector()和querySelectorAll(),DOM也定义了一些老式的元素选择方法。如今,这些方法多多少少已经被废弃了。不过,实际开发中仍然可能会用到其中某些方法(特别是getElementById()):

//通过id属性查找元素。参数就是id属性的值,不包含CSS选择符前缀
//类似于document.querySelectorAll("#sect1")
let setc1=document.getElementById("sect1");

//查找具有name="color"属性的所有元素(如表单的复选框)
//类似于document.querySelectorAll('*[name="color"]')
let colors=document.getElementsByName("color");

//查找文档中所有的<h1>元素
//类似于document.querySelectorAll("h1")
let headings=document.getElementByTagName("h1");

//getElementsByTagName()在Element对象上也有定义
//取得sect1的后代中所有<h2>元素
let subheads=sect1.getElementByTagName("h2");

//查找所有类名中包含"tooltip"的元素
//类似于document.querySelectorAll(".tooltip")
let tooltips=document.getElementsByClassName("tooltip")

//查找sect1的后代中所有类名包含"sidebar"的元素
//类似于sect1.querySelectorAll(".sidebar")
let sidebars=setc1.getElementsByClassName("sidebar")

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

由于历史原因,Document类定义了一些快捷属性,可以通过它们直接访问某种节点。例如,通过Images、forms和links属性可以直接访问文档中的< img >、< form >和 < a >元素(但只有< a >标签有href属性)。这些属性引用的是HTMLCollection对象,与NodeList对象非常相似,只是还可以通过元素ID或名字来索引其中的元素。例如,使用document.forms属性,可以下面这样访问< form id=“address”>标签:

document.forms.address

还有一个更古老元素的API,即document.all属性。这个属性引用的对象类似于HTMLCollection,包含文档中的所有元素。document.call已经被废弃,因此实际开发中不应该再使用了。

15.3.2 文档结构与遍历

从Document中选择一个Element之后,常常还需要查找文档结构中相关的部分(父亲、同辈、孩子)。如果我们只关心文档中的Element而非其中的文本(以及元素间的空白,其实也是文本),有一个遍历API可以让我们把文档作为一颗Element对象树,树中不包含同样术语文档的Text节点。这里遍历API不涉及任何方法,而只是Element对象上的一组属性。使用这些属性可以引用当前元素的父亲、孩子和同辈:

parentNode

这个属性引用元素的父节点,也就是另一个Element对象,或者Document对象。

children

这个属性是NodeList,包含元素的所有子元素,不含非Element节点,如Text节点(也不含Comment节点)。

firstElementChild、lastElementChild

这两个属性分别引用元素的第一个元素和最后一个子元素。如果没有子元素,它们的值为null。

previousElementSibling、nextElementSibling

这两个属性分别引用元素左侧紧邻的同辈元素和右侧紧邻的同辈元素,如果没有相应的同辈元素则为null。

使用这些Element属性,可以用下面任意一个表达式引用Document第一个元素的第二个子元素:

document.children[0].children[1]
=><body style="zoom:​ 1;​">​…​</body>​
document.firstElementChild.firstElementChild.nextElementSibling
=><body style="zoom:​ 1;​">​…​</body>

在标准HTML文档中,这两个表达式引用的都是文档的< body >标签。

下面这两个函数演示了如何使用这些属性对文档执行深度优先的遍历,并对文档的每个元素都调用一次指定的函数:

//递归调用Document或Element e
//在e和美国个后代元素上调用函数f
function traverse(e,f){
    f(e);
    for(let child of e.children){
        traverse(child,f);
    }
}

function traverse2(e,f){
    f(e);                //在e上调用f()
    let child = e.firstElementChild;  链表式迭代孩子
    while(child !== null){
        traverse2(child,f);
        child=child.nextElementSibling;
    }
}
作为节点树的文档

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

所有Node对象都定义了以下属性:

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

不过要注意,这套API对于文档中文本的变化极为敏感。如果在上例文档的< html >和 < head >之间插入一个换行符,则表示该换行符的Text节点就会成为第一个子节点的第一个子节点,而第二个子节点就变成< head >元素而不是< body >元素。

为理解这套基于Node的遍历API,可以看看下面这个返回元素或文档中所有文本的函数:

//返回元素e的纯文本内容,递归包含子元素
//这个方法类似元素的textContent属性
function textContent(e){
    let s="";                     //在这里累计文本
    for(let child=e.firstChild;child!==null;child=child.nextSibling){
    let type=child.nodeType;
    if(type===3){               //如果是Text节点
       s+=child.nodeValue;      //把文本内容追加到字符串
    }else if(type===1)          //而如果是Element节点
       s+=textContent(child);   //则递归
    }
  }
  return s;
}

这个函数仅仅是为演示而写的,实践中可以直接通过e.textContent取得元素e的文本内容。

15.3.3 属性

HTML元素由标签名和一组称为属性的名/值对构成。比如,< a >元素定义一个超链接,使用其href属性的值作为链接的目标。

Element类定义了通用的getAttribute()、setAttribute()、hasAttribute()和removeAttribute()方法,用于查询、设置、检测和删除元素的属性。但HTML元素的属性(值所有标准HTML元素的标准属性)同时也在表示这些元素的HTMLElement对象上具有相应的属性。而作为JavaScript属性来存取它们,通常要比调用getAttribute()及其他方法来得更便捷。

作为元素属性的HTML属性

表示HTML文档中元素的Element对象通常会定义读/写属性,镜像该元素的HTML属性。HTMLElement为通用HTML属性(如id、title、lang和dir)和事件处理程序属性(如onclick)定义了属性。特定的Element子类型则定义了特定于相应元素的属性。例如,要查询图片的URL,可以使用表示< img >元素的HTMLImageElement的src属性:

let image=document.querySelector("#main_image");
let url=image.src;          //src属性是图片的URL
image.id===="main_image"    //=>true;我们通过id找到了图片

类似地,可以使用如下代码设置< form >元素地表单提交属性:

let f=document.querySelector("form");       //文档中第一个<form>
f.action="https://www.example.com/submit";  //设置要提交给哪个URL
f.method="POST"                             //设置HTTP请求类型

对于某些元素(比如< input >),有的HTML属性名会映射到不同地JavaScript属性。比如,< input >元素在HTML中的value属性是由JavaScript的defaultValue属性镜像的。JavaScript的value属性包含的是用户当前在< input >元素中输入的值。但是系应该这个value属性,疾病和影响JavaScript的defaultValue属性,也不会影响HTML的value属性。

HTML属性是不区分大小写的,但JavaScript属性名区分大小写。要把HTML属性名转换为JavaScript属性名,全部小写即可。如果HTML属性名包含多个单词,则从第二个单词开始,每个单词的首字母修大写。比如,defaultCheked和tableIndx。不过,事件处理程序是意外,比如onclick,需要全部小写。

有些HTML属性名是JavaScript中的保留字。对于这些属性,通用规则是对应的JavaScript属性包含前缀“html”。比如,< label >元素在HTML中的for属性,变成了JavaScript的htmlFor属性。“class”也是JavaScript的保留字,但这个非常重要的HTML class属性是个例外,它在JavaScript代码中会变成className。

JavaScript中表示HTML属性的这些属性通常都是字符串值。但是当HTML属性是布尔值或数字值时(如< input >元素的defaultChecked和maxLength属性),相应的JavaScript属性则是布尔值或数值,不是字符串。事件处理程序属性的值则始终是函数(或null)。

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

class 属性

由于class在JavaScript中是保留字,所以这个HTML属性是通过Element对象上的className属性反映出来的。className属性可用于设置或返回HTML中class属性的字符串值。但class属性这个名字并不恰当,因为它的值是一个CSS类名的列表。在这个列表中添加或删除某个类名(而不是把列表作为整个字符串来操作)在客户端JavaScript编程中非常常见。

为此,Element对象定义了classList属性,支持将class属性作为一个列表来操作。classList属性的值是一个可迭代的类数组对象。虽然这个属性的名字叫classList,但它的行为更像类名的集合,而且定义了add()、remove()、contains()和toggle()方法:

//在想让用户找到现在正在忙的时候,就显示一个
//转轮图标。为此必须删除hidden类,添加
//animated类(假设样式表有正确的配置)
let spinner=document.querySelector("#spinner");
spinner.classList.remove("hidden");
spinner.classList.add("animated");
dataset 属性

有时候在HTML元素上附加一些信息很有用,因为JavaScript代码在选择并操作相应的元素时可以使用这些信息。在HTML中,任何以前缀“data-”开头的小写属性都被认为是有效的,可以将它们用于任何目的。这些“数据集”属性不影响它们所在的元素的展示,在保证文档准确性的前提下定义了一种附加额外数据的标准方式。

在DOM中,Element对象有一个dataset属性,该属性引用的对象包含与HTML中的data- 属性对应的属性,但不带这个前缀。也就是说,dataset.x保存的是HTML中data-x属性的值。连字符分隔的属性将映射为驼峰式属性名:HTML中的data-section-number会变成JavaScript中的dataset.sectionNumber。

假设某HTML文档中包含以下内容:

<h2 id="title: data-section-number="16.1">Attributes</h2>

那么可以使用以下JavaScript访问其中的节号(section number):

let number = document.querySelector("#title").dataset.sectionNumber;

15.3.4 元素内容

现在看一下图15-1所示的文档树,问问自己< p >元素包含哪些“内容”?这个问题有两个答案。

  • 它的内容是HTML字符串“This is a simple document”。
  • 它的内容是纯文本字符串“This is a simple document”。

这两个答案都是正确的,而且每个答案都有自己适用的场景。接下来几小节介绍如何操作元素内容的HTML表示和纯文本表示。

作为HTML的内容

读取一个Element的innerHTML属性会返回该元素内容的标记字符串。在元素上设置这个属性会调用浏览器的解析器,并以新字符串解析后的表示替换元素当前的内容。可以打开开发者控制台,运行下面的代码试试效果:

document.body.innerHTML="<h1>Oops</h1>";

你会发现整个网页都不见了,取而代之的是一个标题“Oops”。浏览器非常擅长解析HTML,设置innerHTML通常效率很高。不过要注意,通过+=操作符给innerHTML追加成本的效率不高。因为这个操作既会涉及序列化操作,也会涉及解析操作:先把元素内容转换为字符串,再把新字符串转换回元素内容。

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

另一个相关的Element方法是insertAdjacentHTML(),用于插入与指定元素“相邻”的任意HTML标记字符串。要插入的标签作为第二参数传入,而“相邻”的精确含义取决于第一个参数的值。第一个参数可以是以下字符串值中的一个:“before-begin”“afterbegin”“beforeend”“afterend”。图15-2展示了这几个值对应的插入位置。
在这里插入图片描述

作为纯文本的内容

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

let para=document.querySelector("p");   //文档中的第一个<p>
let text=para.textContent;              //取得该段落的文本
para.textContent="Hello world";         //修改该段落的文本

这个textContent属性是由Node类定义的,因此在Text节点和Element节点上都可以使用。对于Element节点,它会找到并返回元素所有后代中的文本。

Element类定义了一个innerText属性,与textContent类似。但innerText有一些少见和复杂的行为,如试图阻止表格格式化。这个属性的定义不严谨,浏览器间的实现也存在兼容问题,因此不应该再使用了。

15.3.5 创建、插入和删除节点

我们已经知道了如何获取及使用HTML和纯文本字符串修改文档内容。也知道了可以遍历Document,查找构成文档的个别Element和Text节点。当然,在个别节点的层级修改文档也是可能的。Document类定义了创建Element对象的方法,而Element和Text对象拥有在树中插入、删除和替换节点的方法。

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

let paragraph=document.createElement("p");   //创建一个空的<p>元素
let emphasis=document.createElement("em");   //创建一个空的<em>元素
emphasis.append("World");                    //向<em>元素中添加文本
paragraph.append("Hello ",emphasis,"!");     //在<p>中添加文本和<em>
paragraph.prepend("i");                      //在<p>的开头再添加文本
paragraph.innerHTML                       
=>"iHello <em>World</em>!"

append()和prepend()接收任意多个参数,这些参数可以是Node对象或字符串或字符串。字符串参数会自动转换为Text节点(也可以使用document.createTextNode()来创建Text节点,但很少需要这样做)。append()把参数添加到孩子列表的末尾。prepend()把参数追加到孩子列表的开头。

如果想在包含元素的孩子列表中间插入Element或Text节点,那append()和prepend()都派不上用场。这时候,应该先获取对一个同辈节点的引用,然后调用before()在该同辈前面插入新内容,或调用after()在该同辈后面插入新内容。例如:

//找到class="greetings"的标题元素
let greetings=document.querySelector("h2.greetings");

//在这个标题后面插入新创建的paragraph和一条水平线
greetings.after(paragraph,document.createElement("hr"));

与append()和prepend()类似,after()和before()也接收任意个数的字符串和元素参数,在将字符串转换为Text节点后把它们全部插入文档中。append()和prepend()只在Element对象上有定义,但after()和before()同时存在于Element和Text节点上,因此可以使用它们相对于Text节点插入内容。

要注意的是,元素只能插入到文档中的一个地方。如果某个元素已经在文档中了,你又把它插入到了其他地方,那它会转移到新位置,而不会复制一个新的过去:

//刚才我们在这个元素后面插入了paragraph
//但现在又把它转移到了元素的前面
greetings.before(paragraph)

要注意的是,元素只能被插入一个地方。如果某个元素已经文档中了,你又把它插入到了其他地方,那它会转移到新位置,而不会复制一个新的过去:

//刚才我们在这个元素后面传入了paragraph
//但现在又把它转移到了元素的前面
greetings.before(paragraph)

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

//创建paragraph的一个副本,再把它插入到greetings元素后面
greetings.after(paragraph.cloneNode(true));

调用remove()方法可以把Element或Text节点从文档中删除,或者可以调用replaceWith()替换它。remove()不接收如何参数,replaceWith()和after()一样,接收任意个数的字符串和元素:

//从文中删除greetings元素,并代之以paragraph元素(如果
//paragraph已经再文档中了,则把它从当前位置移走
greetings.replaceWith(paragraph);

//删除paragraph元素
paragraph.remove();

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

document.addEventListener("DOMContentLoaded",()=>{
    //查找TOC日期元素
    //如果没找到,则在文档开头创建一个
    let toc=document.querySelector("#TOC");
    if(!toc){
        toc=document.createElement("div");
        toc.id="TOC"
        document.body.prepend(toc);
    }
    //查找所有节标题元素。这里假设文档的标题
    //使用<h1>,文档中的各节使用<h2>到<h6>
    let headings=document.querySelectorAll("h2,h3,h4,h5,h6");
    
    //数组化一个数组,用来跟踪节号
    let sectionNumbers=[0,0,0,0,0];
    
    //遍历我们找到的节标题元素
    for(let heading of headings){
        //如果标题位于TOC容器中则跳过
        if(heading.parentNode===toc){
            continue;
        }
        
        //确定标题的级别
        //减1,因为<h2>算1级标题
        let level=parseInt(heading.tagName.charAt(1))-1;
        
        //递增这个标题级别的节号
        //并把所有低级编号重置为0
        sectionNumbers[level-1]++;
        for(let i=level;i<sectionNumbers.length;i++){
            sectionNumbers[i]=0;
        }
        
        //现在组合所有标题级别的节号
        //以产生类似2.3.1这样的节号
        let sectionNumber=sectionNumbers.slice(0,level).join(".");
        
        //把节号添加到节标题中
        //把编号放在<span>中方便添加样式
        let span=document.createElement("span");
        span.className="TOCSectNum";
        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}`;    //链接目标
        
        //把标题文本复制到链接中。此时可以放心使用
        //innerHTML,因为没有插入如何不可信字符串
        link.innerHTML=heading.innerHTML;
        
        //把链接放到一个div中,以便根据级别添加样式
        let entry=document.createElement("div");
        entry.classList.add("TOCEntry",`TOCLevel${level}`);
        entry.append(link);
        
        //把div添加到TOC容器中
        toc.append(entry);
    }
});
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值