现已推出第四版点击此处阅读

第 14 章文档对象模型

太糟糕了!老故事重演!当你完成了建房,你才发现你意外地学到了一些东西,而这些东西你本应该在开始之前就知道的。

弗里德里希·尼采,善恶的彼岸
Picture of a tree with letters and scripts hanging from 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>

此页面的结构如下

HTML document as nested boxes

浏览器用来表示文档的数据结构遵循这种形状。对于每个盒子,都存在一个对象,我们可以与它交互以查找诸如它表示的 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)。

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

HTML document as a tree

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

标准

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

这很不幸。标准通常很有用。但在这种情况下,这种优势(跨语言一致性)并不那么引人注目。拥有与你使用的语言正确集成的接口,比拥有跨语言的熟悉接口更能节省你的时间。

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

然后是纯粹的设计缺陷问题。例如,没有办法创建一个新节点并立即向它添加子节点或属性。相反,你必须先创建它,然后使用副作用一个接一个地添加子节点和属性。与 DOM 密切交互的代码往往变得冗长、重复且丑陋。

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

遍历树

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

Links between DOM nodes

虽然图中只显示了一种类型的链接,但每个节点都有一个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>

一个节点只能在文档中存在一个位置。因此,在段落One之前插入段落Three,将首先将其从文档末尾删除,然后将其插入开头,从而得到Three/One/Two。所有在某个位置插入节点的操作都会作为副作用导致节点从其当前位置删除(如果它有当前位置)。

replaceChild 方法用于用另一个节点替换子节点。它以两个节点作为参数:一个新节点和要替换的节点。要替换的节点必须是调用该方法的元素的子节点。请注意,replaceChildinsertBefore 都将节点作为第一个参数。

创建节点

假设我们要编写一个脚本,用它们alt属性中包含的文本替换文档中的所有图像(<img> 标签),该属性指定图像的替代文本表示。

这不仅包括删除图像,还包括添加一个新的文本节点来替换它们。文本节点使用document.createTextNode 方法创建。

<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);
  console.log("offsetHeight:", para.offsetHeight);
</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的形式提供。

Using cosine and sine to compute coordinates

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

请注意,样式通常需要*单位*。在本例中,我们必须将"px"附加到数字后面,以告诉浏览器我们正在使用像素计数(而不是厘米、“em”或其他单位)。这很容易忘记。使用没有单位的数字会导致你的样式被忽略——除非数字为 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来查找具有正确id属性的节点。

按标签名查找元素

document.getElementsByTagName方法返回具有给定标签名的所有子元素。实现自己的版本,该函数接受一个节点和一个字符串(标签名)作为参数,并返回一个数组,其中包含具有给定标签名的所有后代元素节点。

要查找元素的标签名,请使用它的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)来获得相反的角度。这对于将帽子放在轨道的另一侧很有用。