文档对象模型

文档对象模型

太糟糕了!又是老一套!一旦你盖好了房子,你才发现自己意外学到了一些真正应该在动工前就知道的东西。

——弗里德里希·尼采,《善恶的彼岸》

(插图:一棵树上挂着字母、图片和齿轮)

当你打开一个网页时,浏览器会获取页面的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="http://eloquentjavascript.net">here</a>.</p>
  </body>
</html>

这个页面的结构如下:

(图示:HTML文档被表示为一组嵌套的盒子。最外层的盒子标有“html”,包含两个标有“head”和“body”的盒子。在这些盒子内部还有更多的盒子,一些最内层的盒子包含文档的文本。)

浏览器用来表示文档的数据结构遵循这种形状。对于每个盒子,都有一个对象,我们可以通过与这些对象交互来了解诸如它代表什么HTML标签、包含哪些盒子和文本等信息。这种表示形式被称为文档对象模型,简称DOM。

全局变量document让我们能够访问这些对象。它的documentElement属性指向代表<html>标签的对象。由于每个HTML文档都有一个head和一个body,所以document对象也有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文档被表示为一棵树,箭头从父节点指向子节点)

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

标准

用晦涩的数字代码表示节点类型并不是JavaScript的风格。在本章后面,我们还会看到DOM接口的其他部分也显得繁琐且格格不入。这是因为DOM接口并非专为JavaScript设计,而是试图成为一种与语言无关的接口,不仅可以用于HTML,还可以用于XML(一种具有类HTML语法的通用数据格式),并能在其他系统中使用。

这很遗憾。标准通常是有用的,但在这种情况下,跨语言一致性带来的好处并不那么显著。一个与所用语言深度集成的接口,往往比一个跨语言通用的接口更能节省时间。

举个接口设计不佳的例子:DOM中元素节点的childNodes属性。它的值是一个类数组对象,有length属性,也可以通过数字索引访问子节点,但它是NodeList类型的实例,而非真正的数组,因此没有slicemap等方法。

但还有一些问题纯粹是由糟糕的设计导致的。例如,你无法在创建新节点的同时直接为其添加子节点或属性。相反,你必须先创建节点,然后通过副作用操作逐一添加子节点和属性。大量与DOM交互的代码往往会变得冗长、重复且杂乱。

不过这些缺陷并非致命。由于JavaScript允许我们创建自己的抽象层,因此我们完全可以设计出更简洁的方式来表达这些操作。许多面向浏览器编程的库都提供了这类工具。

遍历树结构

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

1

尽管图示中每种类型只展示了一个链接,但实际上每个节点都有一个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>标签不仅有3个子节点(<h1>和两个<p>元素),实际上有7个:这3个元素节点,加上它们前后以及之间的空白文本节点。

如果我们想获取文档中链接的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 属性中的文本——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 实现无法处理与关键字同名的属性),访问这个属性的 DOM 属性名为 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,会强制浏览器进行大量布局计算,导致运行速度极慢。以下代码就是一个例子,它包含两个程序,均用于生成一行宽 2000 像素的 “X” 字符,并测量各自的执行时间。

<p><span id="one"></span></p>
<p><span id="two"></span></p>

<script>
  function time(name, action) {
    let start = Date.now(); // 当前时间(毫秒)
    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>

style 属性可以包含一个或多个声明,每个声明由属性(如 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>.

display: 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(层叠样式表,Cascading Style Sheets)。样式表是一组定义文档元素样式的规则,可以放在 <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” 的元素(id 在文档中应唯一)。

.subtle {
  color: gray;
  font-size: 80%;
}
#header {
  background: blue;
  color: white;
}
/* 同时具有 id "main" 和类 "a"、"b" 的 p 元素 */
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"));           // 所有 <p> 元素
  // → 4
  console.log(count(".animal"));     // class 为 animal 的元素
  // → 2
  console.log(count("p .animal"));   // <p> 内部的 animal 元素
  // → 2
  console.log(count("p > .animal")); // <p> 的直接子元素中 class 为 animal 的元素
  // → 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>

图片在页面中居中显示,并设置了 position: relative。我们会反复更新图片的 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 获取常数π的值。

2

以下是三个练习的解决方案:

1. 构建表格(Build a table)

根据MOUNTAINS数组动态生成HTML表格,包含表头和数据行,并对数值列设置右对齐。

<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"}
  ];

  // 创建表格元素
  const table = document.createElement('table');
  
  // 生成表头
  const headerRow = document.createElement('tr');
  const keys = Object.keys(MOUNTAINS[0]); // 获取属性名(name, height, place)
  keys.forEach(key => {
    const th = document.createElement('th');
    th.textContent = key; // 设置表头文本
    headerRow.appendChild(th);
  });
  table.appendChild(headerRow); // 添加表头到表格
  
  // 生成数据行
  MOUNTAINS.forEach(mountain => {
    const row = document.createElement('tr');
    keys.forEach(key => {
      const td = document.createElement('td');
      td.textContent = mountain[key]; // 设置单元格内容
      // 若内容为数字,设置右对齐
      if (typeof mountain[key] === 'number') {
        td.style.textAlign = 'right';
      }
      row.appendChild(td);
    });
    table.appendChild(row); // 添加行到表格
  });
  
  // 将表格添加到页面
  document.getElementById('mountains').appendChild(table);
</script>

2. 通过标签名查找元素(Elements by tag name)

实现byTagName函数,递归查找指定节点下所有标签名匹配的后代元素(忽略大小写)。

<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) {
    const result = [];
    const targetTag = tagName.toUpperCase(); // 统一转为大写,忽略大小写
    
    // 检查当前节点是否为元素节点且标签名匹配
    if (node.nodeType === 1 && node.nodeName === targetTag) {
      result.push(node);
    }
    
    // 递归遍历所有子节点
    for (const child of node.childNodes) {
      // 合并子节点的查询结果
      result.push(...byTagName(child, tagName));
    }
    
    return result;
  }

  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>

3. 猫的帽子(The cat’s hat)

扩展猫的动画,让帽子与猫在椭圆轨道的相反方向运动(对称位置)。

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

    // 猫的位置:基于椭圆轨道(宽200px,高40px)
    cat.style.top = (Math.sin(angle) * 40 + 100) + "px"; // 垂直位置(+100避免超出页面)
    cat.style.left = (Math.cos(angle) * 200 + 300) + "px"; // 水平位置(+300避免超出页面)

    // 帽子的位置:与猫在椭圆轨道的相反方向(角度+π)
    hat.style.top = (Math.sin(angle + Math.PI) * 40 + 100) + "px"; 
    hat.style.left = (Math.cos(angle + Math.PI) * 200 + 300) + "px";

    requestAnimationFrame(animate);
  }
  requestAnimationFrame(animate);
</script>

说明:猫和帽子的角度相差Math.PI(180度),因此会在椭圆轨道的两端对称运动,形成“相对环绕”的效果。+100+300是为了将轨道中心移到页面可见区域内。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容