十四、文档对象模型
译者:飞龙
自豪地采用谷歌翻译
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》
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/14-0.jpg
当你在浏览器中打开网页时,浏览器会接收网页的 HTML 文本并进行解析,其解析方式与第 11 章中介绍的解析器非常相似。浏览器构建文档结构的模型,并使用该模型在屏幕上绘制页面。
JavaScript 在其沙箱中提供了将文本转换成文档对象模型的功能。它是你可以读取或者修改的数据结构。模型是一个所见即所得的数据结构,改变模型会使得屏幕上的页面产生相应变化。
文档结构
你可以将 HTML 文件想象成一系列嵌套的箱子。诸如<body>
和</body>
之类的标签会将其他标签包围起来,而包含在内部的标签也可以包含其他的标签和文本。这里给出上一章中已经介绍过的示例文件。
<!doctype html>
<html>
<head>
<title>My home page</title>
</head>
<body>
<h1>My home page</h1>
<p>Hello, I am Marijn and this is my home page.</p>
<p>I also wrote a book! Read it
<a href="http://eloquentjavascript.net">here</a>.</p>
</body>
</html>
该页面结构如下所示。
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/14-1.svg
浏览器使用与该形状对应的数据结构来表示文档。每个盒子都是一个对象,我们可以和这些对象交互,找出其中包含的盒子与文本。我们将这种表示方式称为文档对象模型(Document Object Model),或简称 DOM。
我们可以通过全局绑定document
来访问这些对象。该对象的documentElement
属性引用了<html>
标签对象。由于每个 HTML 文档都有一个头部和一个主体,它还具有head
和body
属性,指向这些元素。
树
回想一下第 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
)。
因此我们可以使用另一种方法来表示文档树:
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/14-2.svg
叶子节点是文本节点,而箭头则指出了节点之间的父子关系。
标准
并非只有 JavaScript 会使用数字代码来表示节点类型。本章随后将会展示其他的 DOM 接口,你可能会觉得这些接口有些奇怪。这是因为 DOM 并不是为 JavaScript 而设计的,它尝试成为一组语言中立的接口,确保也可用于其他系统中,不只是 HTML,还有 XML。XML 是一种通用数据格式,语法与 HTML 相近。
这就比较糟糕了。一般情况下标准都是非常易于使用的。但在这里其优势(跨语言的一致性)并不明显。相较于为不同语言提供类似的接口,如果能够将接口与开发者使用的语言进行适当集成,可以为开发者节省大量时间。
我们举例来说明一下集成问题。比如 DOM 中每个元素都有childNodes
属性。该属性是一个类数组对象,有length
属性,也可以使用数字标签访问对应的子节点。但该属性是NodeList
类型的实例,而不是真正的数组,因此该类型没有诸如slice
和map
之类的方法。
有些问题是由不好的设计导致的。例如,我们无法在创建新的节点的同时立即为其添加子节点和属性。相反,你首先需要创建节点,然后使用副作用,将子节点和属性逐个添加到节点中。大量使用 DOM 的代码通常较长、重复和丑陋。
但这些问题并非无法改善。因为 JavaScript 允许我们构建自己的抽象,可以设计改进方式来表达你正在执行的操作。 许多用于浏览器编程的库都附带这些工具。
沿着树移动
DOM 节点包含了许多指向相邻节点的链接。下面的图表展示了这一点。
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/14-3.svg
尽管图表中每种类型的节点只显示出一条链接,但每个节点都有parentNode
属性,指向一个节点,它是这个节点的一部分。类似的,每个元素节点(节点类型为 1)均包含childNodes
属性,该属性指向一个类数组对象,用于保存其子节点。
理论上,你可以通过父子之间的链接移动到树中的任何地方。但 JavaScript 也提供了一些更加方便的额外链接。firstChild
属性和lastChild
属性分别指向第一个子节点和最后一个子节点,若没有子节点则值为null
。类似的,previousSibling
和nextSibling
指向相邻节点,分别指向拥有相同父亲的前一个节点和后一个节点。对于第一个子节点,previousSibling
是null
,而最后一个子节点的nextSibling
则是null
。
也存在children
属性,它就像childNodes
,但只包含元素(类型为 1)子节点,而不包含其他类型的子节点。 当你对文本节点不感兴趣时,这可能很有用。
处理像这样的嵌套数据结构时,递归函数通常很有用。 以下函数在文档中扫描包含给定字符串的文本节点,并在找到一个时返回true
:
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
属性保存它所表示的文本字符串。
查找元素
使用父节点、子节点和兄弟节点之间的连接遍历节点确实非常实用。但是如果我们只想查找文档中的特定节点,那么从document.body
开始盲目沿着硬编码的链接路径查找节点并非良策。如果程序通过树结构定位节点,就需要依赖于文档的具体结构,而文档结构随后可能发生变化。另一个复杂的因素是 DOM 会为不同节点之间的空白字符创建对应的文本节点。例如示例文档中的body
标签不止包含 3 个子节点(<h1>
和两个<p>
元素),其实包含 7 个子节点:这三个节点、三个节点前后的空格、以及元素之间的空格。
因此,如果你想获取文档中某个链接的href
属性,最好不要去获取文档body
元素中第六个子节点的第二个子节点,而最好直接获取文档中的第一个链接,而且这样的操作确实可以实现。
let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);
所有元素节点都包含getElementsByTagName
方法,用于从所有后代节点中(直接或间接子节点)搜索包含给定标签名的节点,并返回一个类数组的对象。
你也可以使用document.getElementById
来寻找包含特定id
属性的某个节点。
<p>My ostrich Gertrude:</p>
<p><img id="gertrude" src="https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/ostrich.png"></p>
<script>
let ostrich = document.getElementById("gertrude");
console.log(ostrich.src);
</script>
第三个类似的方法是getElementsByClassName
,它与getElementsByTagName
类似,会搜索元素节点的内容并获取所有包含特定class
属性的元素。
修改文档
几乎所有 DOM 数据结构中的元素都可以被修改。文档树的形状可以通过改变父子关系来修改。 节点的remove
方法将它们从当前父节点中移除。appendChild
方法可以添加子节点,并将其放置在子节点列表末尾,而insertBefore
则将第一个参数表示的节点插入到第二个参数表示的节点前面。
<p>One</p>
<p>Two</p>
<p>Three</p>
<script>
let paragraphs = document.body.getElementsByTagName("p");
document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>
每个节点只能存在于文档中的某一个位置。因此,如果将段落Three
插入到段落One
前,会将该节点从文档末尾移除并插入到文档前面,最后结果为Three/One/Two
。所有将节点插入到某处的方法都有这种副作用——会将其从当前位置移除(如果存在的话)。
replaceChild
方法用于将一个子节点替换为另一个子节点。该方法接受两个参数,第一个参数是新节点,第二个参数是待替换的节点。待替换的节点必须是该方法调用者的子节点。这里需要注意,replaceChild
和insertBefore
都将新节点作为第一个参数。
创建节点
假设我们要编写一个脚本,将文档中的所有图像(<img>
标签)替换为其alt
属性中的文本,该文本指定了图像的文字替代表示。
这不仅涉及删除图像,还涉及添加新的文本节点,并替换原有图像节点。为此我们使用document.createTextNode
方法。
<p>The <img src="https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/cat.png" alt="Cat"> in the
<img src="https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/hat.png" alt="Hat">.</p>
<p><button onclick="replaceImages()">Replace</button></p>
<script>
function replaceImages() {
let images = document.body.getElementsByTagName("img");
for (let i = images.length - 1; i >= 0; i--) {
let image = images[i];
var image = images[i];
if (image.alt) {
let text = document.createTextNode(image.alt);
image.parentNode.replaceChild(text, image);
}
}
}
</script>
给定一个字符串,createTextNode
为我们提供了一个文本节点,我们可以将它插入到文档中,来使其显示在屏幕上。
该循环从列表末尾开始遍历图像。我们必须这样反向遍历列表,因为getElementsByTagName
之类的方法返回的节点列表是动态变化的。该列表会随着文档改变还改变。若我们从列表头开始遍历,移除掉第一个图像会导致列表丢失其第一个元素,第二次循环时,因为集合的长度此时为 1,而i
也为 1,所以循环会停止。
如果你想要获得一个固定的节点集合,可以使用数组的Array.from
方法将其转换成实际数组。
let arrayish = {0: "one", 1: "two", length: 2};
let array = Array.from(arrayish);
console.log(array.map(s => s.toUpperCase()));
// → ["ONE", "TWO"]
你可以使用document.createElement
方法创建一个元素节点。该方法接受一个标签名,返回一个新的空节点,节点类型由标签名指定。
下面的示例定义了一个elt
工具,用于创建一个新的元素节点,并将其剩余参数当作该节点的子节点。接着使用该函数为引用添加来源信息。
<blockquote id="quote">
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.
</blockquote>
<script>
function elt(type, ...children) {
let node = document.createElement(type);
for (let child of children) {
if (typeof child != "string") node.appendChild(child);
else node.appendChild(document.createTextNode(child));
}
return node;
}
document.getElementById("quote").appendChild(
elt("footer", "—",
elt("strong", "Karl Popper"),
", preface to the second editon of ",
elt("em", "The Open Society and Its Enemies"),
", 1950"));
</script>
属性
我们可以通过元素的 DOM 对象的同名属性去访问元素的某些属性,比如链接的href
属性。这仅限于最常用的标准属性。
HTML 允许你在节点上设定任何属性。这一特性非常有用,因为这样你就可以在文档中存储额外信息。你自己创建的属性不会出现在元素节点的属性中。你必须使用getAttribute
和setAttribute
方法来访问这些属性。
<p data-classified="secret">The launch code is 00000000.</p>
<p data-classified="unclassified">I have two feet.</p>
<script>
let paras = document.body.getElementsByTagName("p");
for (let para of Array.from(paras)) {
if (para.getAttribute("data-classified") == "secret") {
para.remove();
}
}
</script>
建议为这些组合属性的名称添加data-
前缀,来确保它们不与任何其他属性发生冲突。
这里有一个常用的属性:class
。该属性是 JavaScript 中的保留字。因为某些历史原因(某些旧版本的 JavaScript 实现无法处理和关键字或保留字同名的属性),访问class
的属性名为className
。你也可以使用getAttribute
和setAttribute
方法,使用其实际名称class
来访问该属性。
布局
你可能已经注意到不同类型的元素有不同的布局。某些元素,比如段落(<p>
)和标题(<h1>
)会占据整个文档的宽度,并且在独立的一行中渲染。这些元素被称为块(Block)元素。其他的元素,比如链接(<a>
或<strong>
元素则与周围文本在同一行中渲染。这类元素我们称之为内联(Inline)元素。
对于任意特定文档,浏览器可以根据每个元素的类型和内容计算其尺寸与位置等布局信息。接着使用布局来绘制文档。
JavaScript 中可以访问元素的尺寸与位置。
属性offsetWidth
和offsetHeight
给出元素的起始位置(单位是像素)。像素是浏览器中的基本测量单元。它通常对应于屏幕可以绘制的最小的点,但是在现代显示器上,可以绘制非常小的点,这可能不再适用了,并且浏览器像素可能跨越多个显示点。
同样,clientWidth
和clientHeight
向你提供元素内的空间大小,忽略边框宽度。
<p style="border: 3px solid red">
I'm boxed in
</p>
<script>
let para = document.body.getElementsByTagName("p")[0];
console.log("clientHeight:", para.clientHeight);
console.log("offsetHeight:", para.offsetHeight);
</script>
getBoundingClientRect
方法是获取屏幕中某个元素精确位置的最有效方法。该方法返回一个对象,包含top
、bottom
、left
和right
四个属性,表示元素相对于屏幕左上角的位置(单位是像素)。若你想要知道其相对于整个文档的位置,必须加上其滚动位置,你可以在pageXOffset
和pageYOffset
绑定中找到。
我们还需要花些力气才能完成文档的排版工作。为了加快速度,每次你改变它时,浏览器引擎不会立即重新绘制整个文档,而是尽可能等待并推迟重绘操作。当一个修改文档的 JavaScript 程序结束时,浏览器会计算新的布局,并在屏幕上显示修改过的文档。若程序通过读取offsetHeight
和getBoundingClientRect
这类属性获取某些元素的位置或尺寸时,为了提供正确的信息,浏览器也需要计算布局。
如果程序反复读取 DOM 布局信息或修改 DOM,会强制引发大量布局计算,导致运行非常缓慢。下面的代码展示了一个示例。该示例包含两个不同的程序,使用X
字符构建一条线,其长度是 2000 像素,并计算每个任务的时间。
<p><span id="one"></span></p>
<p><span id="two"></span></p>
<script>
function time(name, action) {
let start = Date.now(); // Current time in milliseconds
action();
console.log(name, "took", Date.now() - start, "ms");
}
time("naive", () => {
let target = document.getElementById("one");
while (target.offsetWidth < 2000) {
target.appendChild(document.createTextNode("X"));
}
});
// → naive took 32 ms
time("clever", function() {
let target = document.getElementById("two");
target.appendChild(document.createTextNode("XXXXX"));
let total = Math.ceil(2000 / (target.offsetWidth / 5));
target.firstChild.nodeValue = "X".repeat(total);
});
// → clever took 1 ms
</script>
样式
我们看到了不同的 HTML 元素的绘制是不同的。一些元素显示为块,一些则是以内联方式显示。我们还可以添加一些样式,比如使用<strong>
加粗内容,或使用<a>
使内容变成蓝色,并添加下划线。
<img>
标签显示图片的方式或点击标签<a>
时跳转的链接都和元素类型紧密相关。但元素的默认样式,比如文本的颜色、是否有下划线,都是可以改变的。这里给出使用style
属性的示例。
<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>
样式属性可以包含一个或多个声明,格式为属性(比如color
)后跟着一个冒号和一个值(比如green
)。当包含更多声明时,不同属性之间必须使用分号分隔,比如color:red;border:none
。
文档的很多方面会受到样式的影响。例如,display
属性控制一个元素是否显示为块元素或内联元素。
This text is displayed <strong>inline</strong>,
<strong style="display: block">as a block</strong>, and
<strong style="display: none">not at all</strong>.
block
标签会结束其所在的那一行,因为块元素是不会和周围文本内联显示的。最后一个标签完全不会显示出来,因为display:none
会阻止一个元素呈现在屏幕上。这是隐藏元素的一种方式。更好的方式是将其从文档中完全移除,因为稍后将其放回去是一件很简单的事情。
JavaScript 代码可以通过元素的style
属性操作元素的样式。该属性保存了一个对象,对象中存储了所有可能的样式属性,这些属性的值是字符串,我们可以把字符串写入属性,修改某些方面的元素样式。
<p id="para" style="color: purple">
Nice text
</p>
<script>
let para = document.getElementById("para");
console.log(para.style.color);
para.style.color = "magenta";
</script>
一些样式属性名包含破折号,比如font-family
。由于这些属性的命名不适合在 JavaScript 中使用(你必须写成style["font-family"]
),因此在 JavaScript 中,样式对象中的属性名都移除了破折号,并将破折号之后的字母大写(style.fontFamily
)。
层叠样式
我们把 HTML 的样式化系统称为 CSS,即层叠样式表(Cascading Style Sheets)。样式表是一系列规则,指出如何为文档中元素添加样式。可以在<style>
标签中写入 CSS。
<style>
strong {
font-style: italic;
color: gray;
}
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>
所谓层叠指的是将多条规则组合起来产生元素的最终样式。在示例中,<strong>
标签的默认样式font-weight:bold
,会被<style>
标签中的规则覆盖,并为<strong>
标签样式添加font-style
和color
属性。
当多条规则重复定义同一属性时,最近的规则会拥有最高的优先级。因此如果<style>
标签中的规则包含font-weight:normal
,违背了默认的font-weight
规则,那么文本将会显示为普通样式,而非粗体。属性style
中的样式会直接作用于节点,而且往往拥有最高优先级。
我们可以在 CSS 规则中使用标签名来定位标签。规则.abc
指的是所有class
属性中包含abc
的元素。规则#xyz
作用于id
属性为xyz
(应当在文档中唯一存在)的元素。
.subtle {
color: gray;
font-size: 80%;
}
#header {
background: blue;
color: white;
}
/* p elements with id main and with classes a and b */
p#main.a.b {
margin-bottom: 20px;
}
优先级规则偏向于最近定义的规则,仅在规则特殊性相同时适用。规则的特殊性用于衡量该规则描述匹配元素时的准确性。特殊性取决于规则中的元素数量和类型(tag
、class
或id
)。例如,目标规则p.a
比目标规则p
或.a
更具体,因此有更高优先级。
p>a
这种写法将样式作用于<p>
标签的直系子节点。类似的,p a
应用于所有的<p>
标签中的<a>
标签,无论是否是直系子节点。
查询选择器
本书不会使用太多样式表。尽管理解样式表对浏览器程序设计至关重要,想要正确解释所有浏览器支持的属性及其使用方式,可能需要两到三本书才行。
我介绍选择器语法(用在样式表中,确定样式作用的元素)的主要原因是这种微型语言同时也是一种高效的 DOM 元素查找方式。
document
对象和元素节点中都定义了querySelectorAll
方法,该方法接受一个选择器字符串并返回类数组对象,返回的对象中包含所有匹配的元素。
<p>And if you go chasing
<span class="animal">rabbits</span></p>
<p>And you know you're going to fall</p>
<p>Tell 'em a <span class="character">hookah smoking
<span class="animal">caterpillar</span></span></p>
<p>Has given you the call</p>
<script>
function count(selector) {
return document.querySelectorAll(selector).length;
}
console.log(count("p")); // All <p> elements
// → 4
console.log(count(".animal")); // Class animal
// → 2
console.log(count("p .animal")); // Animal inside of <p>
// → 2
console.log(count("p > .animal")); // Direct child of <p>
// → 1
</script>
与getElementsByTagName
这类方法不同,由querySelectorAll
返回的对象不是动态变更的。修改文档时其内容不会被修改。但它仍然不是一个真正的数组,所以如果你打算将其看做真的数组,你仍然需要调用Array.from
。
querySelector
方法(没有All
)与querySelectorAll
作用相似。如果只想寻找某一个特殊元素,该方法非常有用。该方法只返回第一个匹配的元素,如果没有匹配的元素则返回null
。
位置与动画
position
样式属性是一种强大的布局方法。默认情况下,该属性值为static
,表示元素处于文档中的默认位置。若该属性设置为relative
,该元素在文档中依然占据空间,但此时其top
和left
样式属性则是相对于常规位置的偏移。若position
设置为absolute
,会将元素从默认文档流中移除,该元素将不再占据空间,而会与其他元素重叠。其top
和left
属性则是相对其最近的闭合元素的偏移,其中position
属性的值不是static
。如果没有任何闭合元素存在,则是相对于整个文档的偏移。
我们可以使用该属性创建一个动画。下面的文档用于显示一幅猫的图片,该图片会沿着椭圆轨迹移动。
<p style="text-align: center">
<img src="https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/cat.png" style="position: relative">
</p>
<script>
let cat = document.querySelector("img");
let angle = Math.PI / 2;
function animate(time, lastTime) {
if (lastTime != null) {
angle += (time - lastTime) * 0.001;
}
lastTime = time;
cat.style.top = (Math.sin(angle) * 20) + "px";
cat.style.left = (Math.cos(angle) * 200) + "px";
requestAnimationFrame(newTime => animate(newTime, time));
}
requestAnimationFrame(animate);
</script>
我们的图像在页面中央,position
为relative
。为了移动这只猫,我们需要不断更新图像的top
和left
样式。
脚本使用requestAnimationFrame
在每次浏览器准备重绘屏幕时调用animate
函数。animate
函数再次调用requestAnimationFrame
以准备下一次更新。当浏览器窗口(或标签)激活时,更新频率大概为 60 次每秒,这种频率可以生成美观的动画。
若我们只是在循环中更新 DOM,页面会静止不动,页面上也不会显示任何东西。浏览器不会在执行 JavaScript 程序时刷新显示内容,也不允许页面上的任何交互。这就是我们需要requestAnimationFrame
的原因,该函数用于告知浏览器 JavaScript 程序目前已经完成工作,因此浏览器可以继续执行其他任务,比如刷新屏幕,响应用户动作。
我们将动画生成函数作为参数传递给requestAnimationFrame
。为了确保每一毫秒猫的移动是稳定的,而且动画是圆滑的,它基于一个速度,角度以这个速度改变这一次与上一次函数运行的差。如果仅仅每次走几步,猫的动作可能略显迟钝,例如,另一个在相同电脑上的繁重任务可能使得该函数零点几秒之后才会运行一次。
我们使用三角函数Math.cos
和Math.sin
来使猫沿着圆弧移动。你可能不太熟悉这些计算,我在这里简要介绍它们,因为你会在这本书中偶尔遇到。
Math.cos
和Math.sin
非常实用,我们可以利用一个 1 个弧度,计算出以点(0,0
为圆心的圆上特定点的位置。两个函数都将参数解释为圆上的一个位置,0 表示圆上最右侧那个点,一直逆时针递增到2π
(大概是 6.28),正好走过整个圆。Math.cos
可以计算出圆上某一点对应的x
坐标,而Math.sin
则计算出y
坐标。超过2π
或小于 0 的位置(或角度)都是合法的。因为弧度是循环重复的,a+2π
与a
的角度相同。
用于测量角度的单位称为弧度 - 一个完整的圆弧是2π
个弧度,类似于以角度度量时的 360 度。 常量π
在 JavaScript 中为Math.PI
。
https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/14-4.svg
猫的动画代码保存了一个名为angle
的计数器,该绑定记录猫在圆上的角度,而且每当调用animate
函数时,增加该计数器的值。我们接着使用这个角度来计算图像元素的当前位置。top
样式是Math.sin
的结果乘以 20,表示圆中的垂直弧度。left
样式是 Math.cos 的结果乘以200
,因此圆的宽度大于其高度,导致最后猫会沿着椭圆轨迹移动。
这里需要注意的是样式的值一般需要指定单位。本例中,我们在数字后添加px
来告知浏览器以像素为计算单位(而非厘米,ems
,或其他单位)。我们很容易遗漏这个单位。如果我们没有为样式中的数字加上单位,浏览器最后会忽略掉该样式,除非数字是 0,在这种情况下使用什么单位,其结果都是一样的。
本章小结
JavaScript 程序可以通过名为 DOM 的数据结构,查看并修改浏览器中显示的文档。该数据结构描述了浏览器文档模型,而 JavaScript 程序可以通过修改该数据结构来修改浏览器展示的文档。
DOM 的组织就像树一样,DOM 根据文档结构来层次化地排布元素。描述元素的对象包含很多属性,比如parentNode
和childNodes
这两个属性可以用来遍历 DOM 树。
我们可以通过样式来改变文档的显示方式,可以直接在节点上附上样式,也可以编写匹配节点的规则。样式包含许多不同的属性,比如color
和display
。JavaScript 代码可以直接通过节点的style
属性操作元素的样式。
习题
创建一张表
HTML 表格使用以下标签结构构建:
<table>
<tr>
<th>name</th>
<th>height</th>
<th>place</th>
</tr>
<tr>
<td>Kilimanjaro</td>
<td>5895</td>
<td>Tanzania</td>
</tr>
</table>
<table>
标签中,每一行包含一个<tr>
标签。<tr>
标签内部则是单元格元素,分为表头(<th>
)和常规单元格(<td>
)。
给定一个山的数据集,一个包含name
,height
和place
属性的对象数组,为枚举对象的表格生成 DOM 结构。 每个键应该有一列,每个对象有一行,外加一个顶部带有<th>
元素的标题行,列出列名。
编写这个程序,以便通过获取数据中第一个对象的属性名称,从对象自动产生列。
将所得表格添加到id
属性为"mountains"
的元素,以便它在文档中可见。
当你完成后,将元素的style.textAlign
属性设置为right
,将包含数值的单元格右对齐。
<h1>Mountains</h1>
<div id="mountains"></div>
<script>
const MOUNTAINS = [
{name: "Kilimanjaro", height: 5895, place: "Tanzania"},
{name: "Everest", height: 8848, place: "Nepal"},
{name: "Mount Fuji", height: 3776, place: "Japan"},
{name: "Vaalserberg", height: 323, place: "Netherlands"},
{name: "Denali", height: 6168, place: "United States"},
{name: "Popocatepetl", height: 5465, place: "Mexico"},
{name: "Mont Blanc", height: 4808, place: "Italy/France"}
];
// Your code here
</script>
通过标签名获取元素
document.getElementsByTagName
方法返回带有特定标签名称的所有子元素。实现该函数,这里注意是函数不是方法。该函数的参数是一个节点和字符串(标签名称),并返回一个数组,该数组包含所有带有特定标签名称的所有后代元素节点。
你可以使用nodeName
属性从 DOM 元素中获取标签名称。但这里需要注意,使用tagName
获取的标签名称是全大写形式。可以使用字符串的toLowerCase
或toUpperCase
来解决这个问题。
<h1>Heading with a <span>span</span> element.</h1>
<p>A paragraph with <span>one</span>, <span>two</span>
spans.</p>
<script>
function byTagName(node, tagName) {
// Your code here.
}
console.log(byTagName(document.body, "h1").length);
// → 1
console.log(byTagName(document.body, "span").length);
// → 3
let para = document.querySelector("p");
console.log(byTagName(para, "span").length);
// → 2
</script>
猫的帽子
扩展一下之前定义的用来绘制猫的动画函数,让猫和它的帽子沿着椭圆形轨道边(帽子永远在猫的对面)移动。
你也可以尝试让帽子环绕着猫移动,或修改成其他有趣的动画。
为了便于定位多个对象,一个比较好的方法是使用绝对(absolute
)定位。这就意味着top
和left
属性是相对于文档左上角的坐标。你可以简单地在坐标上加上一个固定数字,以避免出现负的坐标,它会使图像移出可见页面。
<style>body { min-height: 200px }</style>
<img src="https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/cat.png" id="cat" style="position: absolute">
<img src="https://raw.githubusercontent.com/wizardforcel/eloquent-js-3e-zh/master/img/hat.png" id="hat" style="position: absolute">
<script>
let cat = document.querySelector("#cat");
let hat = document.querySelector("#hat");
let angle = 0;
let lastTime = null;
function animate(time) {
if (lastTime != null) angle += (time - lastTime) * 0.001;
lastTime = time;
cat.style.top = (Math.sin(angle) * 40 + 40) + "px";
cat.style.left = (Math.cos(angle) * 200 + 230) + "px";
// Your extensions here.
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
</script>