文档对象模型

太糟糕了!老故事!一旦你建好房子,你就会发现自己无意中学习了一些你本应该在开始之前就应该知道的东西。

弗里德里希·尼采,《善恶的彼岸》
Illustration showing a tree with letters, pictures, and gears hanging on its branches

当你打开一个网页时,你的浏览器会检索网页的 HTML 文本并解析它,就像我们来自 第 12 章 的解析器解析程序一样。浏览器构建了文档结构的模型,并使用该模型在屏幕上绘制页面。

这种文档的表示是 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="https://eloquent.javascript.ac.cn">here</a>.</p>
  </body>
</html>

此页面具有以下结构

Diagram showing an HTML document as a set of nested boxes. The outer box is labeled 'html' and contains two boxes labeled 'head' and 'body'. Inside those are further boxes, with some of the innermost boxes containing the document's text.

浏览器用来表示文档的数据结构遵循这种形状。对于每个盒子,都存在一个对象,我们可以与它交互以了解例如它代表的 HTML 标签以及它包含的盒子和文本。这种表示被称为文档对象模型,简称DOM

全局绑定 document 使我们能够访问这些对象。它的 documentElement 属性引用表示 <html> 标签的对象。由于每个 HTML 文档都有一个头部和一个主体,它也具有指向这些元素的 headbody 属性。

回想一下来自 第 12 章 的语法树。它们的结构与浏览器的文档结构惊人地相似。每个节点可以引用其他节点,即子节点,而子节点反过来可能有自己的子节点。这种形状是嵌套结构的典型特征,其中元素可以包含类似于自身本身的子元素。

当数据结构具有分支结构、没有循环(节点可能不包含自身,直接或间接)并且具有一个明确定义的时,我们称其为。在 DOM 的情况下,document.documentElement 充当根。

树在计算机科学中经常出现。除了表示 HTML 文档或程序之类的递归结构外,它们还经常用于维护排序的数据集,因为与平面数组相比,元素通常可以在树中更有效地找到或插入。

典型的树具有不同类型的节点。用于 Egg 语言 的语法树有标识符、值和应用节点。应用节点可能具有子节点,而标识符和值是叶子,或没有子节点的节点。

DOM 也是如此。表示 HTML 标签的元素节点决定了文档的结构。这些可能具有子节点。此类节点的一个示例是 document.body。这些子节点中的一些可以是叶子节点,例如文本片段或注释节点。

每个 DOM 节点对象都有一个 nodeType 属性,它包含一个代码(数字),用于标识节点的类型。元素具有代码 1,该代码也定义为常量属性 Node.ELEMENT_NODE。表示文档中一段文本的文本节点获得代码 3 (Node.TEXT_NODE)。注释具有代码 8 (Node.COMMENT_NODE)。

另一种可视化文档树的方法如下

Diagram showing the HTML document as a tree, with arrows from parent nodes to child nodes

叶子是文本节点,箭头表示节点之间的父子关系。

标准

使用神秘的数字代码来表示节点类型并不是 JavaScript 喜欢的做法。在本章的后面,我们将看到 DOM 接口的其他部分也让人感觉笨拙和陌生。这是因为 DOM 接口并非专为 JavaScript 设计。相反,它试图成为一个语言中立的接口,也可以在其他系统中使用——不仅适用于 HTML,也适用于 XML,XML 是一种具有 HTML 类语法的通用数据格式。

这是不幸的。标准通常很有用。但在这种情况下,优势(跨语言一致性)并没有那么引人注目。拥有一个与您使用的语言完全集成的接口将比拥有一个跨语言的熟悉接口为您节省更多时间。

作为这种糟糕集成的示例,请考虑 DOM 中元素节点具有的 childNodes 属性。此属性保存一个类似数组的对象,该对象具有 length 属性以及由数字标记的属性来访问子节点。但它是 NodeList 类型的实例,而不是真正的数组,因此它没有 slicemap 这样的方法。

然后是仅仅由设计不良引起的错误。例如,没有办法创建一个新节点并立即向其添加子节点或属性。相反,您必须先创建它,然后使用副作用逐个添加子节点和属性。大量与 DOM 交互的代码往往会变得冗长、重复且难看。

但是这些缺陷并非致命。由于 JavaScript 允许我们创建自己的抽象,因此可以设计出表达我们正在执行的操作的改进方法。许多旨在用于浏览器编程的库都附带了这些工具。

遍历树

DOM 节点包含大量指向附近其他节点的链接。下图说明了这些

Diagram that shows the links between DOM nodes. The 'body' node is shown as a box, with a 'firstChild' arrow pointing at the 'h1' node at its start, a 'lastChild' arrow pointing at the last paragraph node, and 'childNodes' arrow pointing at an array of links to all its children. The middle paragraph has a 'previousSibling' arrow pointing at the node before it, a 'nextSibling' arrow to the node after it, and a 'parentNode' arrow pointing at the 'body' node.

尽管图中仅显示了一种类型的链接,但每个节点都有一个 parentNode 属性,它指向节点所在的节点(如果有)。同样,每个元素节点(节点类型 1)都有一个 childNodes 属性,它指向一个包含其子节点的类似数组的对象。

理论上,您可以仅使用这些父级和子级链接在树中移动到任何地方。但 JavaScript 也让您可以访问许多其他便利链接。firstChildlastChild 属性分别指向第一个和最后一个子元素,或者对于没有子元素的节点,其值为 null。类似地,previousSiblingnextSibling 指向相邻节点,这些节点与同一父级节点具有相同的父级节点,并出现在节点本身的紧前方或紧后方。对于第一个子节点,previousSibling 将为 null,而对于最后一个子节点,nextSibling 将为 null。

还有 children 属性,它类似于 childNodes,但仅包含元素(类型 1)子节点,而不是其他类型的子节点。当您对文本节点不感兴趣时,这很有用。

处理这种嵌套数据结构时,递归函数通常很有用。以下函数扫描文档以查找包含给定字符串的文本节点,并在找到一个节点时返回 true

function talksAbout(node, string) {
  if (node.nodeType == Node.ELEMENT_NODE) {
    for (let child of node.childNodes) {
      if (talksAbout(child, string)) {
        return true;
      }
    }
    return false;
  } else if (node.nodeType == Node.TEXT_NODE) {
    return node.nodeValue.indexOf(string) > -1;
  }
}

console.log(talksAbout(document.body, "book"));
// → true

文本节点的 nodeValue 属性保存它表示的文本字符串。

查找元素

导航这些父级、子级和兄弟姐妹之间的链接通常很有用。但是,如果我们想在文档中找到一个特定的节点,从 document.body 开始并遵循一组固定的属性路径来访问它,这是一个糟糕的主意。这样做会将有关文档精确结构的假设烘焙到我们的程序中——这是一种您可能希望稍后更改的结构。另一个复杂因素是,即使对于节点之间的空格,也会创建文本节点。示例文档的 <body> 标签不仅有三个子节点(<h1> 和两个 <p> 元素),而是有七个:这三个,加上它们之前的空格、之后的空格和它们之间的空格。

如果我们想获取该文档中链接的 href 属性,我们不想说诸如“获取文档主体第六个子节点的第二个子节点”之类的话。如果我们能说“获取文档中的第一个链接”,那就更好了。我们可以做到。

let link = document.body.getElementsByTagName("a")[0];
console.log(link.href);

所有元素节点都有一个 getElementsByTagName 方法,该方法会收集作为该节点的后代(直接或间接子节点)的所有具有给定标签名称的元素,并将它们作为类似数组的对象返回。

若要查找特定的单个节点,您可以为其提供一个 id 属性,并使用 document.getElementById

<p>My ostrich Gertrude:</p>
<p><img id="gertrude" src="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 方法用于用另一个节点替换子节点。它以两个节点作为参数:一个新节点和要替换的节点。被替换的节点必须是调用该方法的元素的子节点。请注意,replaceChildinsertBefore 都将节点作为第一个参数。

创建节点

假设我们要编写一个脚本,将文档中的所有图像(<img> 标签)替换为其 alt 属性中保存的文本,该属性指定了图像的替代文本表示。这不仅涉及移除图像,还涉及添加新的文本节点来替换它们。

<p>The <img src="img/cat.png" alt="Cat"> in the
  <img src="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];
      if (image.alt) {
        let text = document.createTextNode(image.alt);
        image.parentNode.replaceChild(text, image);
      }
    }
  }
</script>

给定一个字符串,createTextNode 将提供一个文本节点,我们可以将其插入文档中,使其显示在屏幕上。

遍历图像的循环从列表的末尾开始。这是必要的,因为像 getElementsByTagName(或像 childNodes 这样的属性)这样的方法返回的节点列表是实时的。也就是说,它会在文档更改时更新。如果我们从开头开始,删除第一个图像会导致列表失去第一个元素,因此循环第二次重复时,i 为 1,它将停止,因为集合的长度现在也是 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 edition of ",
        elt("em", "The Open Society and Its Enemies"),
        ", 1950"));
</script>

属性

某些元素属性,例如链接的 href,可以通过元素的 DOM 对象上的同名属性访问。对于大多数常用的标准属性来说,情况都是如此。

HTML 允许您在节点上设置任何您想要的属性。这很有用,因为它允许您在文档中存储额外的信息。要读取或更改自定义属性(不可作为常规对象属性使用),必须使用 getAttributesetAttribute 方法。

<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 实现无法处理与关键字匹配的属性名——用于访问此属性的属性称为 className。您还可以使用 getAttributesetAttribute 方法以其真实名称 "class" 访问它。

布局

您可能已经注意到,不同类型的元素的布局方式不同。有些元素,例如段落(<p>)或标题(<h1>),占据整个文档宽度,并在单独的行上呈现。这些称为块级元素。其他元素,例如链接(<a>)或 <strong> 元素,与其周围的文本在同一行上呈现。这样的元素被称为内联元素。

对于任何给定的文档,浏览器都能够计算出布局,它根据元素的类型和内容为每个元素提供大小和位置。然后使用此布局来实际绘制文档。

可以从 JavaScript 中访问元素的大小和位置。offsetWidthoffsetHeight 属性以像素为单位提供元素所占用的空间。像素是浏览器中的基本度量单位。它传统上对应于屏幕可以绘制的最小的点,但在现代显示器上,可以绘制非常小的点,这可能不再是这种情况,并且浏览器像素可能跨越多个显示点。

类似地,clientWidthclientHeight 提供元素内部的空间大小,不包括边框宽度。

<p style="border: 3px solid red">
  I'm boxed in
</p>

<script>
  let para = document.body.getElementsByTagName("p")[0];
  console.log("clientHeight:", para.clientHeight);
  // → 19
  console.log("offsetHeight:", para.offsetHeight);
  // → 25
</script>

查找元素在屏幕上精确位置的最有效方法是 getBoundingClientRect 方法。它返回一个带有 topbottomleftright 属性的对象,这些属性指示元素相对于屏幕左上角的边的像素位置。如果想要相对于整个文档的像素位置,则必须添加当前滚动位置,您可以在 pageXOffsetpageYOffset 绑定中找到它。

布局文档可能需要相当多的工作。为了提高速度,浏览器引擎不会在每次更改文档时立即重新布局文档,而是尽可能长时间地等待,然后再进行布局。当更改了文档的 JavaScript 程序完成运行时,浏览器将不得不计算新的布局,以便将更改后的文档绘制到屏幕上。当程序请求某些内容的位置或大小时,通过读取 offsetHeight 等属性或调用 getBoundingClientRect 来提供该信息,也需要计算布局。

一个反复在读取 DOM 布局信息和更改 DOM 之间切换的程序会导致大量的布局计算发生,因此运行速度会非常慢。以下代码是一个示例。它包含两个不同的程序,它们构建了一行 2,000 像素宽的X 字符,并测量每个程序所需的时间。

<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"]),所以 style 对象中这些属性的属性名将删除连字符并将连字符后的字母大写(style.fontFamily)。

层叠样式

HTML 的样式系统称为CSS,代表层叠样式表样式表是一组规则,用于描述如何设置文档中元素的样式。它可以在 <style> 标签中给出。

<style>
  strong {
    font-style: italic;
    color: gray;
  }
</style>
<p>Now <strong>strong text</strong> is italic and gray.</p>

名称中的层叠是指多个这样的规则被组合起来生成元素的最终样式。在示例中,<strong> 标签的默认样式,它为它们提供 font-weight: bold,被 <style> 标签中的规则覆盖,该规则添加了 font-stylecolor

当多个规则为同一个属性定义值时,最后读取的规则具有更高的优先级,并且获胜。例如,如果 <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;
}

有利于最后定义的规则的优先级规则仅适用于规则具有相同特异性的情况。规则的特异性是衡量它描述匹配元素的精确度的指标,由它要求的元素方面的数量和种类(标签、类或 ID)决定。例如,定位 p.a 的规则比定位 p 或仅 .a 的规则更具体,因此将优先于它们。

p > a {…} 表示法将给定的样式应用于所有<p> 标签的直接子级 <a> 标签。类似地,p a {…} 应用于<p> 标签内所有 <a> 标签,无论它们是直接子级还是间接子级。

查询选择器

在本书中,我们不会过多地使用样式表。理解它们在浏览器编程中很有帮助,但它们足够复杂,需要单独一本书来介绍。我介绍选择器语法(样式表中用于确定一组样式应用于哪些元素的表示法)的主要原因是,我们可以使用这种简短的语言作为查找 DOM 元素的有效方法。

querySelectorAll 方法(在 document 对象和元素节点上均定义)接受一个选择器字符串,并返回一个包含所有匹配元素的 NodeList

<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 部分)的工作方式类似。当您想要一个特定的单一元素时,此方法很有用。它只返回第一个匹配元素,如果没有任何元素匹配,则返回 null

定位和动画

position 样式属性以强大的方式影响布局。它的默认值为 static,这意味着元素位于文档中的正常位置。当它设置为 relative 时,元素仍然在文档中占用空间,但现在可以使用 topleft 样式属性相对于该正常位置移动它。当 position 设置为 absolute 时,元素将从文档的正常流中移除——也就是说,它不再占用空间,并且可能与其他元素重叠。可以使用 topleft 属性相对于最近的 position 属性不是 static 的封闭元素的左上角进行绝对定位,如果不存在这样的封闭元素,则相对于文档进行定位。

我们可以使用它来创建动画。以下文档显示了一张在椭圆中移动的猫的图片

<p style="text-align: center">
  <img src="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;
    }
    cat.style.top = (Math.sin(angle) * 20) + "px";
    cat.style.left = (Math.cos(angle) * 200) + "px";
    requestAnimationFrame(newTime => animate(newTime, time));
  }
  requestAnimationFrame(animate);
</script>

我们的图片位于页面中心,并被赋予 positionrelative。我们将反复更新该图片的 topleft 样式以移动它。

该脚本使用 requestAnimationFrame 来安排 animate 函数在浏览器准备重新绘制屏幕时运行。animate 函数本身再次调用 requestAnimationFrame 来安排下次更新。当浏览器窗口(或选项卡)处于活动状态时,这将导致更新以大约每秒 60 次的速率发生,这往往会产生外观良好的动画。

如果我们只是在循环中更新 DOM,页面会冻结,屏幕上不会出现任何内容。浏览器在 JavaScript 程序运行时不会更新其显示,也不允许任何与页面的交互。这就是我们需要 requestAnimationFrame 的原因——它让浏览器知道我们现在完成了,并且可以继续执行浏览器所做的事情,例如更新屏幕并响应用户操作。

动画函数以当前时间作为参数传递给它。为了确保每毫秒猫的运动稳定,它根据函数上次运行时间与当前时间之间的差值来确定角度变化的速度。如果它只是每步移动固定数量的角度,当例如同一台计算机上运行的其他繁重任务阻止函数运行几分之一秒时,运动会卡顿。

圆形移动是使用三角函数 Math.cosMath.sin 完成的。对于那些不熟悉这些函数的人,我将简要介绍它们,因为我们偶尔会在本书中使用它们。

Math.cosMath.sin 用于查找位于以 (0, 0) 为中心、半径为 1 的圆上的点。这两个函数都将它们的实参解释为圆上的位置,其中 0 表示圆最右边的点,顺时针移动直到 2π(约 6.28)将我们带到整个圆的周围。Math.cos 会告诉你对应于给定位置的点的 x 坐标,Math.sin 会给出 y 坐标。大于 2π 或小于 0 的位置(或角度)是有效的——旋转重复,因此a+2π 指的是与a相同的角度。

这种用于测量角度的单位称为弧度——一个完整的圆周为 2π 弧度,类似于用度数测量时为 360 度。常数 π 在 JavaScript 中可用作 Math.PI

Diagram showing the use of cosine and sine to compute coordinates. A circle with radius 1 is shown with two points on it. The angle from the right side of the circle to the point, in radians, is used to compute the position of each point by using 'cos(angle)' for the horizontal distance from the center of the circle and sin(angle) for the vertical distance.

猫动画代码保存一个计数器 angle,用于表示动画的当前角度,并在每次调用 animate 函数时递增它。然后它可以使用此角度来计算图像元素的当前位置。top 样式使用 Math.sin 计算,并乘以 20,这是我们椭圆的垂直半径。left 样式基于 Math.cos 并乘以 200,因此椭圆的宽度远大于高度。

注意,样式通常需要单位。在本例中,我们必须将 "px" 附加到数字,以告知浏览器我们正在使用像素进行计数(而不是厘米、“em”或其他单位)。这一点很容易忘记。使用没有单位的数字会导致你的样式被忽略——除非该数字是 0,无论其单位如何,0 始终表示相同的意思。

总结

JavaScript 程序可以通过称为 DOM 的数据结构来检查和干扰浏览器正在显示的文档。该数据结构表示浏览器对文档的模型,JavaScript 程序可以修改它以更改可见的文档。

DOM 就像一棵树一样组织,其中元素根据文档的结构按层次结构排列。表示元素的对象具有诸如 parentNodechildNodes 之类的属性,这些属性可用于浏览这棵树。

文档的显示方式可以通过样式来影响,既可以通过将样式直接附加到节点,也可以通过定义匹配某些节点的规则。存在许多不同的样式属性,例如 colordisplay。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>)。

给定一个山脉数据集,一个包含 nameheightplace 属性的对象数组,生成一个枚举这些对象的表格的 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.createElement 创建新的元素节点,使用 document.createTextNode 创建文本节点,并使用 appendChild 方法将节点放入其他节点中。

您需要循环遍历键名一次来填充顶行,然后再次循环遍历数组中的每个对象来构建数据行。要从第一个对象中获取键名数组,Object.keys 会很有用。

要将表格添加到正确的父节点,可以使用 document.getElementByIddocument.querySelector 以及 "#mountains" 来查找节点。

按标签名查找元素

document.getElementsByTagName 方法返回所有具有给定标签名的子元素。实现您自己的版本,该版本接受节点和字符串(标签名)作为参数,并返回一个包含所有具有给定标签名的后代元素节点的数组。您的函数应该遍历文档本身。它可能不会使用 querySelectorAll 等方法来完成工作。

要查找元素的标签名,请使用其 nodeName 属性。但请注意,这将以全大写形式返回标签名。使用 toLowerCasetoUpperCase 字符串方法来弥补这一点。

<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>
显示提示...

该解决方案最容易用递归函数表示,类似于本章前面定义的talksAbout 函数

您可以递归地调用 byTagname 本身,将结果数组连接起来以产生输出。或者,您可以创建一个内部函数,该函数递归地调用自身,并且可以访问外部函数中定义的数组绑定,它可以在其中添加找到的匹配元素。不要忘记从外部函数中调用一次内部函数来启动该过程。

递归函数必须检查节点类型。在这里,我们只对节点类型 1 (Node.ELEMENT_NODE) 感兴趣。对于此类节点,我们必须循环遍历它们的子节点,并针对每个子节点,查看子节点是否与查询匹配,同时对其进行递归调用以检查其自己的子节点。

猫的帽子

扩展之前定义的猫动画动画,使猫和它的帽子 (<img src="img/hat.png">) 在椭圆的相对两侧绕轨道运行。

或者让帽子绕着猫转圈。或者以其他有趣的方式改变动画。

为了使定位多个对象更容易,您可能需要切换到绝对定位。这意味着 topleft 是相对于文档的左上角计算的。为了避免使用负坐标(这会导致图像移出可见页面),您可以向位置值添加固定数量的像素。

<style>body { min-height: 200px }</style>
<img src="img/cat.png" id="cat" style="position: absolute">
<img src="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>
显示提示...

Math.cosMath.sin 以弧度为单位测量角度,其中一个圆周为 2π。对于给定的角度,您可以通过添加其一半(即 Math.PI)来获取相反的角度。这对于将帽子放在轨道相反侧很有用。