第 3 版现已发布。 点击此处阅读

第 13 章
文档对象模型

当您在浏览器中打开网页时,浏览器会检索页面的 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="https://eloquent.javascript.ac.cn">here</a>.</p>
  </body>
</html>

此页面具有以下结构

HTML document as nested boxes

浏览器用来表示文档的数据结构遵循此形状。对于每个框,都存在一个对象,我们可以与之交互以找出诸如它表示的 HTML 标签以及它包含的框和文本等信息。此表示称为文档对象模型,简称 DOM。

全局变量 document 使我们可以访问这些对象。它的 documentElement 属性引用表示 <html> 标签的对象。它还提供 headbody 属性,它们包含这些元素的对象。

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

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

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

典型的树具有不同类型的节点。Egg 语言 的语法树具有变量、值和应用节点。应用节点始终具有子节点,而变量和值是叶子,即没有子节点的节点。

DOM 也一样。用于表示 HTML 标签的常规元素的节点确定文档的结构。这些节点可以有子节点。例如,document.body 就是这样一个节点。其中一些子节点可以是叶子节点,例如文本片段或注释(注释在 HTML 中用 <!----> 编写)。

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

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

HTML document as a tree

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

标准

使用神秘的数字代码来表示节点类型不是一件非常 JavaScript 式的事情。在本节后面,我们将看到 DOM 界面中的其他部分也感觉笨拙且陌生。这样做的原因是 DOM 不是专门为 JavaScript 设计的。相反,它试图定义一个与语言无关的界面,该界面也可以在其他系统中使用,不仅包括 HTML,还包括 XML,这是一种具有类似 HTML 语法的通用数据格式。

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

例如,考虑 DOM 中元素节点具有的 childNodes 属性。此属性保存一个类似数组的对象,具有 length 属性和通过数字标记的属性以访问子节点。但它是一个 NodeList 类型的实例,而不是一个真正的数组,因此它没有 sliceforEach 等方法。

然后是一些纯粹是设计不良的问题。例如,没有办法创建一个新节点并立即向其添加子节点或属性。相反,您必须先创建它,然后一个接一个地添加子节点,最后使用副作用一个接一个地设置属性。大量与 DOM 交互的代码往往变得很长、重复且难看。

但这些缺陷并不致命。由于 JavaScript 允许我们创建自己的抽象,因此很容易编写一些辅助函数,这些函数允许您以更清晰、更简洁的方式表达正在执行的操作。事实上,许多针对浏览器编程的库都带有这样的工具。

遍历树

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

Links between DOM nodes

虽然该图只显示了每种类型的链接,但每个节点都具有一个 parentNode 属性,该属性指向包含它的节点。同样,每个元素节点(节点类型 1)都具有一个 childNodes 属性,该属性指向保存其子节点的类似数组的对象。

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

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

function talksAbout(node, string) {
  if (node.nodeType == document.ELEMENT_NODE) {
    for (var 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

文本节点的 nodeValue 属性引用它表示的文本字符串。

查找元素

导航这些父、子节点和兄弟节点之间的链接通常很有用,就像在前面的函数中,该函数遍历整个文档。但是,如果我们想在文档中找到一个特定的节点,从 document.body 开始并盲目地沿着硬编码的链接路径前进并不是一个好主意。这样做会将关于文档精确结构的假设(我们可能以后想更改的结构)嵌入到我们的程序中。另一个复杂因素是,即使对于节点之间的空格也会创建文本节点。示例文档的 body 标签不仅有三个子节点(<h1> 和两个 <p> 元素),实际上有七个:那三个加上它们之前、之后和之间的空格。

因此,如果我们想获取该文档中链接的 href 属性,我们不想说“获取文档正文的第六个子节点的第二个子节点”。如果我们可以说“获取文档中的第一个链接”,那就更好了。我们可以。

var 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>
  var ostrich = document.getElementById("gertrude");
  console.log(ostrich.src);
</script>

第三种类似的方法是 getElementsByClassName,它与 getElementsByTagName 一样,搜索元素节点的内容并检索所有在其 class 属性中具有给定字符串的元素。

更改文档

关于 DOM 数据结构的几乎所有内容都可以更改。元素节点具有一系列方法,可用于更改其内容。removeChild 方法从文档中删除给定的子节点。要添加子节点,我们可以使用 appendChild(它将子节点放在子节点列表的末尾)或 insertBefore(它将作为第一个参数给出的节点插入到作为第二个参数给出的节点之前)。

<p>One</p>
<p>Two</p>
<p>Three</p>

<script>
  var paragraphs = document.body.getElementsByTagName("p");
  document.body.insertBefore(paragraphs[2], paragraphs[0]);
</script>

一个节点只能在一个地方存在于文档中。因此,将段落“三”插入到段落“一”之前,将首先将其从文档末尾删除,然后将其插入到前面,从而导致“三/一/二”。所有插入节点到某处且副作用会导致节点从其当前位置(如果存在)删除的操作。

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

创建节点

在以下示例中,我们想编写一个脚本,将文档中的所有图像(<img> 标签)替换为其 alt 属性中包含的文本,该属性指定图像的替代文本表示。

这不仅涉及删除图像,还涉及添加一个新的文本节点来替换它们。为此,我们使用 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() {
    var images = document.body.getElementsByTagName("img");
    for (var i = images.length - 1; i >= 0; i--) {
      var image = images[i];
      if (image.alt) {
        var text = document.createTextNode(image.alt);
        image.parentNode.replaceChild(text, image);
      }
    }
  }
</script>

给定一个字符串,createTextNode 会为我们提供一个类型为 3 的 DOM 节点(文本节点),我们可以将其插入到文档中以使其显示在屏幕上。

遍历图片的循环从节点列表的末尾开始。这是必要的,因为像getElementsByTagName(或像childNodes这样的属性)这样的方法返回的节点列表是动态的。也就是说,它会在文档发生变化时更新。如果我们从前面开始,删除第一个图像会导致列表丢失它的第一个元素,因此循环第二次重复时,i为 1,它将停止,因为集合的长度现在也为 1。

如果你想要一个静态的节点集合,而不是动态的,你可以通过调用集合的slice方法将其转换为一个真正的数组。

var arrayish = {0: "one", 1: "two", length: 2};
var real = Array.prototype.slice.call(arrayish, 0);
real.forEach(function(elt) { console.log(elt); });
// → one
//   two

要创建常规的元素节点(类型 1),可以使用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) {
    var node = document.createElement(type);
    for (var i = 1; i < arguments.length; i++) {
      var child = arguments[i];
      if (typeof child == "string")
        child = document.createTextNode(child);
      node.appendChild(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>

属性

某些元素属性,例如链接的href,可以通过元素的 DOM 对象上的同名属性进行访问。这对有限数量的常用标准属性有效。

但是 HTML 允许你设置节点上的任何属性。这很有用,因为它允许你将额外信息存储在文档中。但是,如果你自己发明了属性名,那么这些属性就不会作为元素节点上的属性存在。相反,你必须使用getAttributesetAttribute方法来处理它们。

<p data-classified="secret">The launch code is 00000000.</p>
<p data-classified="unclassified">I have two feet.</p>

<script>
  var paras = document.body.getElementsByTagName("p");
  Array.prototype.forEach.call(paras, function(para) {
    if (para.getAttribute("data-classified") == "secret")
      para.parentNode.removeChild(para);
  });
</script>

我建议在这些自定义属性的名称前加上data-前缀,以确保它们不会与任何其他属性冲突。

作为一个简单的例子,我们将编写一个“语法高亮器”,它查找具有data-language属性的<pre>标签(“预格式化”,用于代码和类似纯文本),并粗略地尝试突出显示该元素包含的编程语言的关键字。

function highlightCode(node, keywords) {
  var text = node.textContent;
  node.textContent = ""; // Clear the node

  var match, pos = 0;
  while (match = keywords.exec(text)) {
    var before = text.slice(pos, match.index);
    node.appendChild(document.createTextNode(before));
    var strong = document.createElement("strong");
    strong.appendChild(document.createTextNode(match[0]));
    node.appendChild(strong);
    pos = keywords.lastIndex;
  }
  var after = text.slice(pos);
  node.appendChild(document.createTextNode(after));
}

函数highlightCode接受一个<pre>节点和一个正则表达式(带有“全局”选项打开),该表达式匹配元素中包含的编程语言的关键字。

textContent属性用于获取节点中的所有文本,然后将其设置为一个空字符串,这将清空节点的效果。我们遍历关键字表达式的所有匹配项,将它们之间的文本作为普通文本节点追加,并将匹配的文本(关键字)作为包装在<strong>(粗体)元素中的文本节点追加。

我们可以通过遍历所有具有data-language属性的<pre>元素,并对每个元素调用highlightCode,使用该语言的正确正则表达式,来自动突出显示页面上的所有程序。

var languages = {
  javascript: /\b(function|return|var)\b/g /* … etc */
};

function highlightAllCode() {
  var pres = document.body.getElementsByTagName("pre");
  for (var i = 0; i < pres.length; i++) {
    var pre = pres[i];
    var lang = pre.getAttribute("data-language");
    if (languages.hasOwnProperty(lang))
      highlightCode(pre, languages[lang]);
  }
}

这是一个例子

<p>Here it is, the identity function:</p>
<pre data-language="javascript">
function id(x) { return x; }
</pre>

<script>highlightAllCode();</script>

有一个常用的属性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>
  var 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) {
    var start = Date.now(); // Current time in milliseconds
    action();
    console.log(name, "took", Date.now() - start, "ms");
  }

  time("naive", function() {
    var target = document.getElementById("one");
    while (target.offsetWidth < 2000)
      target.appendChild(document.createTextNode("X"));
  });
  // → naive took 32 ms

  time("clever", function() {
    var target = document.getElementById("two");
    target.appendChild(document.createTextNode("XXXXX"));
    var total = Math.ceil(2000 / (target.offsetWidth / 5));
    for (var i = 5; i < total; i++)
      target.appendChild(document.createTextNode("X"));
  });
  // → 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">
  Pretty text
</p>

<script>
  var 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的规则适用于所有在它们类属性中包含"abc"的元素。#xyz的规则适用于具有"xyz"id属性的元素(这在文档中应该是唯一的)。

.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;
}

优先级规则有利于最后定义的规则,只有当这些规则具有相同的特异性时才会成立。规则的特异性是对其描述匹配元素的精确程度的度量,由其所需的元素方面(标签、类或 ID)的数量和类型决定。例如,针对p.a的规则比针对p或仅.a的规则更具体,因此将优先于它们。

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

查询选择器

我们在这本书中不会过多地使用样式表。尽管理解样式表对于浏览器编程至关重要,但要正确解释它们支持的所有属性以及这些属性之间的交互作用需要两到三本书。

我介绍选择器语法的主要原因——样式表中用来确定哪些元素应用一组样式的符号——是我们可以使用这种相同的小型语言作为一种有效的方式来查找 DOM 元素。

querySelectorAll 方法,它在 document 对象和元素节点上都有定义,接受一个选择器字符串,并返回一个类似数组的对象,其中包含它匹配的所有元素。

<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 返回的对象不是实时的。当你改变文档时,它不会改变。

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>
  var cat = document.querySelector("img");
  var angle = 0, lastTime = null;
  function animate(time) {
    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(animate);
  }
  requestAnimationFrame(animate);
</script>

这幅图片居中于页面,并设置了 positionrelative。我们将重复更新该图片的 topleft 样式,以便移动它。

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

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

我们的动画函数将当前时间作为参数传递给它,它将当前时间与它之前看到的时间(lastTime 变量)进行比较,以确保每毫秒的猫的运动是稳定的,并且动画平稳移动。如果它只是每步移动一个固定量,那么如果例如,同一台计算机上运行的另一个繁重的任务阻止该函数运行一小部分秒,则运动会卡顿。

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

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

Using cosine and sine to compute coordinates

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

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

总结

JavaScript 程序可以通过一个称为 DOM 的数据结构来检查和干预浏览器正在显示的当前文档。这个数据结构代表了浏览器对文档的模型,JavaScript 程序可以修改它来改变可见的文档。

DOM 被组织成树状结构,其中元素根据文档的结构进行层次化排列。表示元素的对象具有 parentNodechildNodes 等属性,可以用来在树中进行导航。

文档的显示方式可以通过样式来影响,既可以通过将样式直接附加到节点,也可以通过定义与特定节点匹配的规则来影响。有许多不同的样式属性,例如 colordisplay。JavaScript 可以通过元素的 style 属性直接操作元素的样式。

练习

构建表格

我们在第 6 章中构建了纯文本表格。HTML 使表格的布局变得容易得多。HTML 表格使用以下标签结构构建

<table>
  <tr>
    <th>name</th>
    <th>height</th>
    <th>country</th>
  </tr>
  <tr>
    <td>Kilimanjaro</td>
    <td>5895</td>
    <td>Tanzania</td>
  </tr>
</table>

对于每一<table> 标签包含一个 <tr> 标签。在这些 <tr> 标签内部,我们可以放置单元格元素:标题单元格 (<th>) 或普通单元格 (<td>)。

第 6 章中使用的相同源数据再次在沙盒中的 MOUNTAINS 变量中可用。它也可以从网站上下载

编写一个名为 buildTable 的函数,它接受一个对象数组(这些对象都具有相同的属性集),构建一个表示表格的 DOM 结构。表格应该有一个标题行,其中属性名称用 <th> 元素包裹,并且应该为数组中的每个对象有一行,其属性值在 <td> 元素中。

Object.keys 函数,它返回一个包含对象所具有的属性名称的数组,在这里可能会有所帮助。

一旦你完成了基本功能,通过将它们的 style.textAlign 属性设置为 "right" 来右对齐包含数字的单元格。

<style>
  /* Defines a cleaner look for tables */
  table  { border-collapse: collapse; }
  td, th { border: 1px solid black; padding: 3px 8px; }
  th     { text-align: left; }
</style>

<script>
  function buildTable(data) {
    // Your code here.
  }

  document.body.appendChild(buildTable(MOUNTAINS));
</script>

使用 document.createElement 创建新的元素节点,使用 document.createTextNode 创建文本节点,使用 appendChild 方法将节点放入其他节点中。

你应该循环遍历键名一次以填充顶行,然后为数组中的每个对象再次循环以构建数据行。

不要忘记在函数结束时返回包含的 <table> 元素。

按标签名查找元素

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

要查找元素的标签名,请使用它的 tagName 属性。但请注意,这将以全大写形式返回标签名。使用 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
  var para = document.querySelector("p");
  console.log(byTagName(para, "span").length);
  // → 2
</script>

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

你可以递归调用 byTagname 本身,将结果数组连接起来以生成输出。为了提高效率,定义一个内部函数,该函数递归调用自身,并且可以访问外部函数中定义的数组变量,它可以向其中添加找到的匹配元素。不要忘记从外部函数中调用一次内部函数。

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

猫的帽子

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

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

为了使定位多个对象更容易,最好切换到绝对定位。这意味着 topleft 相对于文档的左上角进行计数。为了避免使用负坐标,你只需在位置值中添加固定数量的像素。

<img src="img/cat.png" id="cat" style="position: absolute">
<img src="img/hat.png" id="hat" style="position: absolute">

<script>
  var cat = document.querySelector("#cat");
  var hat = document.querySelector("#hat");
  // Your code here.
</script>