## 十四、文档对象模型 > Too bad! Same old story! Once you've finished building your house you notice you've accidentally learned something that you really should have known—before you started. > > Friedrich Nietzsche,《Beyond Good and Evil》 当你在浏览器中打开网页时,浏览器会接收网页的HTML文本并进行解析,其解析方式与第11章中介绍的解析器非常相似。浏览器构建文档结构的模型,并使用该模型在屏幕上绘制页面。 JavaScript在其沙箱中提供了将文本转换成文档对象模型的功能。它是你可以读取或者修改的数据结构。模型是一个所见即所得的数据结构,改变模型会使得屏幕上的页面产生相应变化。 ### 13.1 文档结构 你可以将HTML文件想象成一系列嵌套的箱子。诸如<body>和</body>之类的标签会将其他标签包围起来,而包含在内部的标签也可以包含其他的标签和文本。这里给出上一章中已经介绍过的示例文件。 ```html
Hello, I am Marijn and this is my home page.
I also wrote a book! Read it here.
``` 该页面结构如下所示。  浏览器使用与该形状对应的数据结构来表示文档。每个盒子都是一个对象,我们可以和这些对象交互,找出其中包含的盒子与文本。我们将这种表示方式称为文档对象模型(Document Object Model),或简称DOM。 我们可以通过全局绑定document来访问这些对象。该对象的documentElement属性引用了表示<html>标签的对象。由于每个 HTML 文档都有一个头部和一个主体,它还具有`head`和`body`属性,指向这些元素。 ### 13.2 树 回想一下第12章中提到的语法树。其结构与浏览器文档的结构极为相似。每个节点可以使用children引用其他节点,而每个子节点又有各自的children。其形状是一种典型的嵌套结构,每个元素可以包含与其自身相似的子元素。 如果一个数据结构有分支结构,而且没有任何回环(一个节点不能直接或间接包含自身),并且有一个单一、定义明确的“根节点”,那么我们将这种数据结构称之为树。就DOM来讲,document.documentElement就是其根节点。 在计算机科学中,树的应用极为广泛。除了表现诸如HTML文档或程序之类的递归结构,树还可以用于维持数据的有序集合,因为在树中寻找或插入一个节点往往比在数组中更高效。 一棵典型的树有不同类型的节点。Egg 语言的语法树有标识符、值和应用节点。应用节点常常包含子节点,而标识符、值则是叶子节点,也就是没有孩子的节点。 DOM中也是一样。元素(表示 HTML 标签)的节点用于确定文档结构。这些节点可以包含子节点。这类节点中的一个例子是document.body。其中一些子节点可以是叶子节点,比如文本片段或注释。 每个DOM节点对象都包含nodeType属性,该属性包含一个标识节点类型的代码(数字)。元素的值为1,DOM也将该值定义成一个常量属性document.ELEMENT_NODE。文本节点(表示文档中的一段文本)代码为3(document.TEXT_NODE)。注释的代码为8(document.COMMENT_NODE)。 因此我们可以使用另一种方法来表示文档树:  叶子节点是文本节点,而箭头则指出了节点之间的父子关系。 ### 13.3 标准 并非只有JavaScript会使用数字代码来表示节点类型。本章随后将会展示其他的DOM接口,你可能会觉得这些接口有些奇怪。这是因为DOM并不是为JavaScript而设计的,它尝试成为一组语言中立的接口,确保也可用于其他系统中,不只是HTML,还有XML。XML是一种通用数据格式,语法与HTML相近。 这就比较糟糕了。一般情况下标准都是非常易于使用的。但在这里其优势(跨语言的一致性)并不明显。相较于为不同语言提供类似的接口,如果能够将接口与开发者使用的语言进行适当集成,可以为开发者节省大量时间。 我们举例来说明一下集成问题。比如DOM中每个元素都有childNodes属性。该属性是一个类似于数组的对象,有length属性,也可以使用数字标签访问对应的子节点。但该属性是NodeList类型的实例,而不是真正的数组,因此该类型没有诸如slice和map之类的方法。 有些问题是由不好的设计导致的。例如,我们无法在创建新的节点的同时立即为其添加孩子和属性。相反,你首先需要创建节点,然后使用副作用,将子节点和属性逐个添加到节点中。大量使用DOM的代码通常较长、重复和丑陋。 但这些问题并非无法改善。因为JavaScript允许我们构建自己的抽象,可以设计改进方式来表达您正在执行的操作。 许多用于浏览器编程的库都附带这些工具。 ### 13.4 通过树结构访问节点 DOM节点包含了许多指向相邻节点的链接。下面的图表展示了这一点。  尽管图表中每种类型的节点只显示出一条链接,但每个节点都有parentNode属性,指向一个节点,它是这个节点的一部分。类似的,每个元素节点(节点类型为1)均包含childNodes属性,该属性指向一个类似于数组的对象,用于保存其子节点。 理论上,你可以通过父子之间的链接移动到树中的任何地方。但JavaScript也提供了一些更加方便的额外链接。firstChild属性和lastChild属性分别指向第一个孩子和最后一个孩子,若没有孩子则值为null。类似的,previousSibling和nextSibling指向相邻节点,分别指向拥有相同父亲的前一个节点和后一个节点。对于第一个孩子,previousSibling是null,而最后一个孩子的nextSibling则是null。 也存在`children`属性,它就像`childNodes`,但只包含元素(类型为 1)的子节点,而不包含其他类型的子节点。 当您对文本节点不感兴趣时,这可能很有用。 处理像这样的嵌套数据结构时,递归函数通常很有用。 以下函数在文档中扫描包含给定字符串的文本节点,并在找到一个时返回`true`: ```html function talksAbout(node, string) { if (node.nodeType == document.ELEMENT_NODE) { for (let i = 0; i < node.childNodes.length; i++) { if (talksAbout(node.childNodes[i], string)) { return true; } } return false; } else if (node.nodeType == document.TEXT_NODE) { return node.nodeValue.indexOf(string) > -1; } } console.log(talksAbout(document.body, "book")); // → true ``` 因为`childNodes`不是真正的数组,所以我们不能用`for/of`来遍历它,并且必须使用普通的`for`循环遍历索引范围。 文本节点的`nodeValue`属性保存它所表示的文本字符串。 ### 13.5 查找元素 使用父节点、子节点和兄弟节点之间的连接遍历节点确实非常实用。但是如果我们只想查找文档中的特定节点,那么从document.body开始盲目沿着硬编码的链接路径查找节点并非良策。如果程序通过树结构定位节点,就需要依赖于文档的具体结构,而文档结构随后可能发生变化。另一个复杂的因素是DOM会为不同节点之间的空白字符创建对应的文本节点。例如示例文档中的body标签不止包含3个孩子(<h1>和两个<p>元素),其实包含7个孩子:这三个节点、三个节点前后的空格、以及元素之间的空格。 因此,如果你想获取文档中某个链接的href属性,最好不要去获取文档body元素中第六个孩子的第二个孩子,而最好直接获取文档中的第一个链接,而且这样的操作确实可以实现。 ```html let link = document.body.getElementsByTagName("a")[0]; console.log(link.href); ``` 所有元素节点都包含getElementsByTagName方法,用于从所有后代节点中(直接或间接子节点)搜索包含给定标签名的节点,并返回一个类数组的对象。 你也可以使用document.getElementById来寻找包含特定id属性的某个节点。 ```htmlMy ostrich Gertrude:
One
Two
Three
``` 每个节点只能存在于文档中的某一个位置。因此,如果将段落Three插入到段落One前,会将该节点从文档末尾移除并插入到文档前面,最后结果为“Three/One/Two”。所有将节点插入到某处的方法都有这种副作用——会将其从当前位置移除(如果存在的话)。 replaceChild方法用于将一个子节点替换为另一个子节点。该方法接受两个参数,第一个参数是新节点,第二个参数是待替换的节点。待替换的节点必须是该方法调用者的子节点。这里需要注意,replaceChild和insertBefore都将新节点作为第一个参数。 ### 13.7 创建节点 假设我们要编写一个脚本,将文档中的所有图像(`The in the
.
No book can ever be finished. While working on it we learn just enough to find it immature the moment we turn away from it.``` ### 13.8 属性 我们可以通过元素的DOM对象的同名属性去访问元素的某些属性,比如链接的href属性。这仅限于最常用的标准属性。 HTML允许你在节点上设定任何属性。这一特性非常有用,因为这样你就可以在文档中存储额外信息。你自己创建的属性不会出现在元素节点的属性中。你必须使用getAttribute和setAttribute方法来访问这些属性。 ```html
The launch code is 00000000.
I have two feet.
``` 建议为这些组合属性的名称添加`data-`前缀,来确保它们不与任何其他属性发生冲突。 这里有一个常用的属性:class。该属性是JavaScript中的保留字。因为某些历史原因(某些旧版本的JavaScript实现无法处理和关键字或保留字同名的属性),访问class的属性名为className。你也可以使用getAttribute和setAttribute方法,使用其实际名称“class”来访问该属性。 ### 13.9 布局 你可能已经注意到不同类型的元素有不同的布局。某些元素,比如段落(<p>)和标题(<h1>)会占据整个文档的宽度,并且在独立的一行中渲染。这些元素被称为块(Block)元素。其他的元素,比如链接(<a>或<strong>元素则与周围文本在同一行中渲染。这类元素我们称之为内联(Inline)元素。 对于任意特定文档,浏览器可以根据每个元素的类型和内容计算其尺寸与位置等布局信息。接着使用布局来绘制文档。 JavaScript中可以访问元素的尺寸与位置。 属性offsetWidth和offsetHeight给出元素的起始位置(单位是像素)。像素是浏览器中的基本测量单元。它通常对应于屏幕可以绘制的最小的点,但是在现代显示器上,可以绘制非常小的点,这可能不再适用了,并且浏览器像素可能跨越多个显示点。 同样,`clientWidth`和`clientHeight`向你提供元素内的空间大小,忽略边框宽度。 ```htmlI'm boxed in
``` getBoundingClientRect方法是获取屏幕中某个元素精确位置的最有效方法。该方法返回一个对象,包含top、bottom、left和right四个属性,表示元素相对于屏幕左上角的位置(单位是像素)。若你想要知道其相对于整个文档的位置,必须加上其滚动位置,你可以在pageXOffset和pageYOffset绑定中找到。 我们还需要花些力气才能完成文档的排版工作。为了加快速度,每次你改变它时,浏览器引擎不会立即重新绘制整个文档,而是尽可能等待并推迟重绘操作。当一个修改文档的JavaScript程序结束时,浏览器会计算新的布局,并在屏幕上显示修改过的文档。若程序通过读取offsetHeight和getBoundingClientRect这类属性获取某些元素的位置或尺寸时,为了提供正确的信息,浏览器也需要计算布局。 如果程序反复读取DOM布局信息或修改DOM,会强制引发大量布局计算,导致运行非常缓慢。下面的代码展示了一个示例。该示例包含两个不同的程序,使用X字符构建一条线,其长度是2000像素,并计算每个任务的时间。 ```html
``` ### 13.10 样式 我们看到了不同的HTML元素会有不同的显示效果。一些元素显示为块,一些则是以内联方式显示。我们还可以添加一些样式,比如使用<strong>加粗内容,或使用<a>使内容变成蓝色,并添加下划线。 <img>标签显示图片的方式或点击标签<a>时跳转的链接都和元素类型紧密相关。但元素的默认样式,比如文本的颜色、是否有下划线,都是可以改变的。这里给出使用style属性的示例。 ```html ``` 样式属性可以包含一个或多个声明,格式为属性(比如color)后跟着一个冒号和一个值(比如green)。当包含更多声明时,不同属性之间必须使用分号分隔,比如“color:red;border:none”。 样式会受到很多因素的影响。例如,display属性控制一个元素是否显示为块元素或内联元素。 ```html This text is displayed inline, as a block, and not at all. ``` 标签block会结束其所在的那一行,因为块元素是不会和周围文本内联显示的。最后一个标签完全不会显示出来,因为display:none会阻止一个元素呈现在屏幕上。这是隐藏元素的一种方式。更好的方式是将其从文档中完全移除,因为随后将其放回去是一件很简单的事情。 JavaScript代码可以通过节点的style属性操作元素的样式。该属性保存了一个对象,对象中存储了所有可能的样式属性,这些属性的值是字符串,我们可以把字符串写入属性,修改某些方面的元素样式。 ```html
Pretty text
``` 一些样式属性名包含破折号,比如font-family。由于这些属性的命名不适合在JavaScript中使用(你必须写成style[“font-family”]),因此在JavaScript中,样式对象中的属性名都移除了破折号,并将破折号之后的字母大写(style.fontFamily)。 ### 13.11 层叠样式 我们把HTML的样式化系统称为CSS,即层叠样式表(Cascading Style Sheets)。样式表是一系列规则,指出如何为文档中元素添加样式。可以在<style>标签中写入CSS。 ```htmlNow strong text is italic and gray.
``` 所谓层叠指的是将多条规则组合起来产生元素的最终样式。在上面的示例中,<strong>标签的默认样式font-weight:bold,会被<style>标签中的规则覆盖,并为<strong>标签样式添加font-style和color属性。 当多条规则重复定义同一属性时,最近的规则会拥有最高的优先级。因此如果<style>标签中的规则包含font-weight:normal,与默认的font-weight规则冲突,那么文本将会显示为普通样式,而非粗体。属性style中的样式会直接作用于节点,而且往往拥有最高优先级。 我们可以在CSS规则中使用标签名来定位标签。规则.abc指的是所有class属性中包含“abc”的元素。规则#xyz作用于id属性为“xyz”(应当在文档中唯一存在)的元素。 ```css .subtle { color: gray; font-size: 80%; } #header { background: blue; color: white; } /* p elements, with classes a and b, and id main */ p.a.b#main { margin-bottom: 20px; } ``` 优先级规则(偏向于最近定义的规则)只有在规则特殊性相同的情况下有效。规则的特殊性用于衡量该规则描述匹配元素时的准确性。特殊性取决于规则中的元素数量和类型(tag、class或id)。例如,目标规则p.a比目标规则p或.a更具体,因此有更高优先级。 p>a{….}这种写法将样式作用于<p>标签的直系孩子。类似的,p a{…}应用于所有的<p>标签中的<a>标签,无论是否是直系孩子。 ### 13.12 查询选择器 本书不会使用太多样式表。尽管理解样式表对浏览器程序设计至关重要,想要正确解释所有浏览器支持的属性及其使用方式,可能需要两到三本书才行。 我介绍选择器语法(用在样式表中,确定样式作用的元素)的主要原因是这种微型语言同时也是一种高效的DOM元素查找方式。 document对象和元素节点中都定义了querySelectorAll方法,该方法接受一个选择器字符串并返回类似于数组的对象,返回的对象中包含所有匹配的元素。 ```htmlAnd if you go chasing rabbits
And you know you're going to fall
Tell 'em a hookah smoking caterpillar
Has given you the call
``` 与getElementsByTagName这类方法不同,由querySelectorAll返回的对象不是动态变更的。修改文档时其内容不会被修改。 querySelector方法(没有All)与querySelectorAll作用相似。如果只想寻找某一个特殊元素,该方法非常有用。该方法只返回第一个匹配元素,如果不存在则返回null。 ### 13.13 位置与动画 position样式属性是一种强大的布局方法。默认情况下,该属性值为static,表示元素处于文档中的默认位置。若该属性设置为relative,该元素在文档中依然占据空间,但此时其top和left样式属性则是相对于默认位置的偏移。若position设置为absolute,会将元素从默认文档流中移除,该元素将不再占据空间,而会与其他元素重叠。其top和left属性则是相对其最近的封闭元素的偏移,其中position属性的值不是static。如果没有任何封闭元素存在,则是相对于整个文档的偏移。 我们可以使用该属性创建一个动画。下面的文档用于显示一幅猫的图片,该图片会沿着椭圆轨迹移动。 ```html
name | height | country |
---|---|---|
Kilimanjaro | 5895 | Tanzania |
A paragraph with one, two spans.
``` #### 13.15.3 猫的帽子 扩展一下之前定义的用来绘制猫的动画函数,让猫和它的帽子沿着椭圆形轨道边(帽子永远在猫的对面)移动。 你也可以尝试让帽子环绕着猫移动,或修改成其他有趣的动画。 为了便于定位多个对象,一个比较好的方法是使用绝对(absolute)定位。这就意味着top和left属性是相对于文档左上角的坐标。你可以简单地在坐标上加上一个固定数字,以避免出现负的坐标。 ```html