Skip to content

Latest commit

 

History

History
850 lines (551 loc) · 42.3 KB

14_dom.md

File metadata and controls

850 lines (551 loc) · 42.3 KB

文档对象模型

{{quote {author: "弗德里克·尼采", title: "善恶的彼岸", chapter: true}

糟糕透了!又是老掉牙的那一套!盖完自家房子之后,发现不经意间学到的一点东西,其实是 —— 在动工之前就应该知晓的。

quote}}

{{figure {url: "img/chapter_picture_14.jpg", alt: "Picture of a tree with letters and scripts hanging from its branches", chapter: "framed"}}}

{{index drawing, parsing}}

当你在浏览器中打开一个网页时,浏览器会获取该网页的 ((HTML)) 文本并对其进行解析,就像 第十二章 提到的解析器解析程序那样。浏览器会构建一个文档的 ((结构)) 的模型,并用该模型在屏幕上渲染出页面。 {{index "live data structure"}}

这种对((文档))的表示是 JavaScript 程序在其 ((沙盒)) 中已有的工具之一。它是你可以读取或更改的 ((数据结构)),而且该数据结构是 动态 的:当它被修改时,屏幕上的页面会被更新,将变化反映出来。

文档结构

{{index [HTML, structure]}}

你可以将 HTML 文档想像成一些嵌套的((盒子))。诸如 <body></body> 之类的标签会将其他 ((标签)) 包围起来,而被包起来的标签内部还可以包含其他标签或 ((文本))。以下是 上一章 出现过的示例文档:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>我的主页</title>
  </head>
  <body>
    <h1>我的主页</h1>
    <p>你好,我是 Marijn,这是我的主页哦。</p>
    <p>我还写了一本书!来
      <a href="http://eloquentjavascript.net">这里</a>阅读吧!</p>
  </body>
</html>

该页面的结构如下所示:

{{figure {url: "img/html-boxes.svg", alt: "HTML document as nested boxes", width: "7cm"}}}

{{indexsee "Document Object Model", DOM}}

浏览器用于表示文档的数据结构有这样的形状。每一个盒子对应一个对象,我们可以和这些对象交互并找出它们所表示的 HTML 标签以及其包含的盒子和文本。这种表示方法被称之为 文档对象模型(Document Object Model),或简称 ((DOM))。

{{index "documentElement property", "head property", "body property", "html (HTML tag)", "body (HTML tag)", "head (HTML tag)"}}

名为 document 的全局绑定让我们得以访问这些对象。这个绑定的 documentElement 属性引用了表示 <html> 标签的对象。由于每份 HTML 文档都有一个头部和主体,该对象也有 headbody 属性,分别指向这些元素。

{{index [nesting, "of objects"]}}

让我们回顾一下 第十二章 中的((句法树))。句法树的结构与浏览器文档的结构非常相似,每个 ((节点)) 可以引用其他作为其 children 的节点,而这些节点也可能有它们自己的子节点。这是在嵌套结构中很典型的树形状,元素可以包含与自身相似的子元素。

{{index "documentElement property", [DOM, tree]}}

如果一种数据结构有分杈结构、没有((环路))(一个节点不能直接或间接地包含它本身),且有一个单一的、定义明确的 ((根节点)),那么我们将其称之为树。就 DOM 而言,document.documentElement 就是它的根节点。

{{index sorting, ["data structure", "tree"], "syntax tree"}}

树在计算机科学中相当常见。树不仅被用于表示诸如 HTML 文档和程序之类的递归结构,它们还常被用于维护数据的有序((集合)),因为在树中查找和插入元素通常比在平铺的数组中更有效率。

{{index "leaf node", "Egg language"}}

典型的树结构中有不同种类的 ((节点))。Egg 语言 的句法树拥有标识器节点、值节点、应用节点。应用节点可能含有子节点,而标识器节点和值节点是 ,即没有子节点的节点。

{{index "body property", [HTML, structure]}}

在 DOM 中也是如此。用于表示 HTML 标签的 ((元素)) 节点决定了文档的结构。它们可以拥有((子节点))。这类节点中的一个例子是 document.body。该元素节点的一些子节点可以是 ((叶节点)),比如说((文本))段落或((注释))节点。

{{index "text node", element, "ELEMENT_NODE code", "COMMENT_NODE code", "TEXT_NODE code", "nodeType property"}}

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

我们可以用下列视觉的方法来呈现文档((树)):

{{figure {url: "img/html-tree.svg", alt: "HTML document as a tree",width: "8cm"}}}

叶子表示文本节点,而箭头指明了节点之间的父子关系。

{{id standard}}

标准

{{index "programming language", [interface, design], [DOM, interface]}}

使用号码来表示节点类型在 JavaScript 中并不常见。在这章节的稍后内容中,我们会看到 DOM 接口其他不常见的部分。造成这种局面的原因在于 DOM 并不是专为 JavaScript 而设计的。相反地,它想要成为也能被其他系统使用的语言中立的接口 —— 并不只适用于 HTML, 也适用于 ((XML)),XML 是句法类似 HTML 的泛型((数据格式))。

{{index consistency, integration}}

这样的局面有点尴尬,因为标准通常是有益的。但是在 DOM 这里,它的优势(跨语言一致性)并不是非常打动人。相较于为多种语言提供相似的接口,拥有一个与你正在使用的语言集成完善的接口会为你节省更多的时间。

{{index "array-like object", "NodeList type"}}

举一个体现这种糟糕集成的例子,就拿 DOM 中元素节点所拥有的 childNodes 属性来说,这个属性含有一个类数组对象、 length 属性,还有可访问子节点的数字标签属性。然而,它是一个 NodeList 类型的实例,并不是真的数组,所以没有诸如 slicemap 这样的方法。

{{index [interface, design], [DOM, construction], "side effect"}}

然后,有的问题单纯是出于糟糕的设计。比如说,我们无法在创建新节点的同时马上为其添加子节点或((属性))。相反地,你必须先创建节点,然后使用副作用往里面逐个添加子节点和属性。大量与 DOM 进行交互的代码通常会变得很长、重复而且不美观。

{{index library}}

但这些问题并不致命。由于 JavaScript 允许我们创建自己的((抽象)),我们可以设计出表示对 DOM 操作的更好的方式。许多适用于浏览器编程的库都含有这样的工具。

顺树而行

{{index pointer}}

DOM 节点含有好多通往其他邻近节点的((链接))。如下列图案所示:

{{figure {url: "img/html-links.svg", alt: "Links between DOM nodes",width: "6cm"}}}

{{index "child node", "parentNode property", "childNodes property"}}

虽然在图案中每种链接只显示了一次,每个节点其实都有一个 parentNode 属性,指向当前节点所属的父节点(如果有的话)。与之类似的是,每一个(节点类型号码为 1 的)元素节点都有名为 childNodes 的属性,该属性指向一个含有其子节点的((类数组的对象))。

{{index "firstChild property", "lastChild property", "previousSibling property", "nextSibling property"}}

从理论上来说,你可以单单使用这些父链接和子链接移动到树的任何部位。然而 JavaScript 为此提供了一些额外的易用链接。名为 firstChildlastChild 的属性分别指向首个子节点和最后一个子节点,在没有子节点的情况下值为 null。同样,名为 previousSiblingnextSibling 的属性分别指向一个节点的前后相邻节点,即拥有相同父节点的前一个节点和后一个节点。对于第一个子节点来说,previousSibling 的值是 null。对于最后一个子节点来说,nextSibling 的值是 null

{{index "children property", "text node", element}}

文档对象模型中也存在 children 属性,该属性与 childNodes 相像,但是只包含元素(类型号码为1)子节点,不含其他类型的子节点。当你不需要搜寻文本节点的时候,该属性可能很有用。

{{index "talksAbout function", recursion, [nesting, "of objects"]}}

通常在处理像这样的嵌套数据结构时,递归函数会很有用。下列函数在文档中扫描含有指定字符串的((文本节点)),并且在找到的时候返回 true

{{id talksAbout}}

function talksAbout(node, string) {
  if (node.nodeType == Node.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 == Node.TEXT_NODE) {
    return node.nodeValue.indexOf(string) > -1;
  }
}

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

{{index "childNodes property", "array-like object", "Array.from function"}}

由于 childNodes 并不是真正的数组,我们不能以 for 或者 of 遍历它,而是需要用常规的 for 循环或 Array.from 来遍历索引范围。

{{index "nodeValue property"}}

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

查找元素

{{index [DOM, querying], "body property", "hard-coding", [whitespace, "in HTML"]}}

在父节点、子节点以及相邻节点的((链接))中游走通常是实用的。然而,当我们想要在文档中找寻一个特定的节点的时候,以 document.body 为起点根据属性沿着固定的路线找寻实非良策。因为这么做的前提是假设我们已经知道了文档的准确结构 —— 文档的结构随后可能发生改变。另一个使之复杂化的因素在于,DOM 会为不同节点之间的空白字符也创建文本节点。之前的示例文档可不单有三个子节点(一个 <h1> 和两个 <p> 元素)而已, 而是有七个子节点:这三个子节点,以及它们前后及元素之间的空格。

{{index "search problem", "href attribute", "getElementsByTagName method"}}

因此,如果我们想要获取该文档中链接的 href 属性,我们并不想说 “获取文档主体元素的第六个子节点的第二个子节点”。更好的方法是直接说 “获取文档中的第一个链接”,而我们确实可以这样做。

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

{{index "child node"}}

所有的元素节点都有名为 getElementsByTagName 的方法,该方法从所有的子嗣节点(直接或间接的子节点)收集含有给定标签名的节点,并且将这些收集到的节点以 ((类数组的对象)) 形式返回。

{{index "id attribute", "getElementById method"}}

要想找寻一个特定的 单一 节点,你也可以赋予该节点 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>

{{index "getElementsByClassName method", "class attribute"}}

第三个类似的方法是 getElementsByClassName,就像 getElementsByTagName 那样,该方法搜索一个元素节点的内容,并获取所有在其 class 属性中含有给定字符串的元素。

更改文档

{{index "side effect", "removeChild method", "appendChild method", "insertBefore method", [DOM, construction], [DOM, modification]}}

几乎 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>

一个节点在文档中只能存在于一个位置,因此,若将 段落 3 插入到 段落 1 的前面,首先会将其从文档末尾移除,然后再进行前插,最后结果为 段落 3/段落 1/段落 2。所有插入节点的操作都会有这样的 ((副作用)),即将其从当前位置移除(如果当前有位置的话)。

{{index "insertBefore method", "replaceChild method"}}

名为 replaceChild 的方法用于将子节点替换为另一个节点,该方法接受两个参数:新节点以及被替换的节点。被替换的节点必须是该方法调用的元素的子节点之一。需要注意的是,replaceChild 方法和 insertBefore 方法都将 节点作为它们的第一个参数。

创建节点

{{index "alt attribute", "img (HTML tag)"}}

假设我们想要写一个将文档中所有((图像))(<img> 标签)都替换为它们 alt 属性中持有的文本, 这些文本是对图像的描述。

{{index "createTextNode method"}}

这个操作不仅需要将图像移除,还涉及添加新的文本节点以替换图像。为此我们使用 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>

{{index "text node"}}

给定一字符串,使用 createTextNode 方法可以让我们将该字符串生成文本节点,然后将其插入到文档中,以在屏幕上显示出来。

{{index "live data structure", "getElementsByTagName method", "childNodes property"}}

遍历图像的循环以节点列表的尾端为起点。这是有必要的,因为诸如 getElementsByTagName 这样的方法(或者类似 childNodes 的属性)返回的节点列表是 动态的 ,意味着文档的改变会让列表也随之改变。倘若我们从节点列表的开端进行遍历,移除第一个图像会导致列表失去第一个元素。第二次循环的时候 i 是 1,而集合的长度也是 1,于是该循环会终止。

{{index "slice method"}}

如果你想要一个 固定的 节点的集合,而不是动态的,你可以调用 Array.from 将其转换为真正的数组的集合。

let arrayish = {0: "one", 1: "two", length: 2};
let array = Array.from(arrayish);
console.log(array.map(s => s.toUpperCase()));
// → ["ONE", "TWO"]

{{index "createElement method"}}

你可以使用 document.createElement 来创建((元素))节点。该方法接收一个标签名作为参数,并返回相应类型的新的空节点。

{{index "Popper, Karl", [DOM, construction], "elt function"}}

{{id elt}}

下列示例定义了一个名为 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>

{{if book

处理之后的文档看起来是这样的:

{{figure {url: "img/blockquote.png", alt: "A blockquote with attribution",width: "8cm"}}}

if}}

属性

{{index "href attribute", [DOM, attributes]}}

某些元素的 ((属性)) 可以被元素的 ((DOM)) 对象含有的相同的属性名所访问,比如用于链接的 href。对于最常用的标准属性来说都是如此。

{{index "data attribute", "getAttribute method", "setAttribute method", attribute}}

然而,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- 前缀,以确保它们不会和任何其他属性产生冲突。

{{index "getAttribute method", "setAttribute method", "className property", "class attribute"}}

class 是一个很常用的属性,它是 JavaScript 语言中的一个 ((关键词))。基于某些历史原因 ———— 某些旧有的 JavaScript 实现无法处理与关键词同名的属性名,因此用于访问 class 的属性名为 className。你亦可以通过 getAttributesetAttribute 方法使用其真名 "class" 对其进行访问。

布局

{{index layout, "block element", "inline element", "p (HTML tag)", "h1 (HTML tag)", "a (HTML tag)", "strong (HTML tag)"}}

你可能已经注意到不同类型的元素被摆放的布局是不同的。有些元素占据整个文档的宽度,诸如段落 (<p>) 和标题 (<h1>),它们被渲染于单独的一行。这些被称为 元素。而另一些元素与其周围的文本在同一行渲染,诸如链接 (<a>) 和 <strong> 元素。这类元素被称为 内联 元素。

{{index drawing}}

对于任意一个指定的文档,浏览器能够计算出一个布局,该布局根据每个元素的类型及内容给元素分配尺寸及位置。最终用于绘制文档的就是这个计算出来的布局。

{{index "border (CSS)", "offsetWidth property", "offsetHeight property", "clientWidth property", "clientHeight property", dimensions}}

一个元素的尺寸和位置可以通过 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>

{{if book

Giving a paragraph a border causes a rectangle to be drawn around it.

{{figure {url: "img/boxed-in.png", alt: "A paragraph with a border",width: "8cm"}}}

if}}

{{index "getBoundingClientRect method", position, "pageXOffset property", "pageYOffset property"}}

{{id boundingRect}}

要想找到屏幕上某个元素的精确位置,使用 getBoundingClientRect 方法最为高效。该方法返回一个含有 topbottomleftright 四个属性的对象,表示元素边框相对于屏幕左上角的像素位置。倘若你想相对于整个文档的像素位置,你得加上当前的滚动位置,即用 pageXOffsetpageYOffset 绑定获取相应的值。

{{index "offsetHeight property", "getBoundingClientRect method", drawing, laziness, performance, efficiency}}

给文档进行布局所需的工作量可不小。为了考虑速度,浏览器引擎不会在你每次修改文档布局的时候立刻重新绘制文档,而是尽可能地推迟重绘。当一个修改文档的 JavaScript 程序运行完毕,浏览器必须计算出新的布局以将修改后的文档绘于屏幕。当一个程序通过读取诸如 offsetHeight 属性或调用 getBoundingClientRect 方法来 查询 某个元素的位置或尺寸时,为了能提供正确的信息,浏览器也需要计算 ((布局))。

{{index "side effect", optimization, benchmark}}

一个程序如果反复读取 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"));
    }
  });
  // → 天真的方法用时 32 毫秒

  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);
  });
  // → 聪明的方法用时 1 毫秒
</script>

样式

{{index "block element", "inline element", style, "strong (HTML tag)", "a (HTML tag)", underline}}

我们已经了解到不同的 HTML 在页面上的绘制效果是不同的。有些元素显示为块,另一些则与其他元素显示在同一行。有些元素会增添样式 —— 比如 <strong> 会 ((加粗)) 内容, <a> 使内容变蓝并且添加下划线。

{{index "img (HTML tag)", "default behavior", "style attribute"}}

一个 <img> 标签会显示图片,一个 <a> 标签使得一个链接在被点击时可以被访问 —— 元素的行为与其类型紧密相关。然而我们可以改变与元素相关联的样式,比如说文本颜色或下划线。下列代码是使用 style 属性的示例:

<p><a href=".">Normal link</a></p>
<p><a href="." style="color: green">Green link</a></p>

{{if book

第二个链接会变成绿色,而非默认的链接颜色。

{{figure {url: "img/colored-links.png", alt: "A normal and a green link",width: "2.2cm"}}}

if}}

{{index "border (CSS)", "color (CSS)", CSS, "colon character"}}

样式属性可能含有一个或多个 ((声明)),其格式为属性 (如 color) 后面跟着冒号和一个值 (如 green)。当声明的数量多于一个时,它们必须被 ((分号)) 隔开,比如 "color: red; border: none"

{{index "display (CSS)", layout}}

文档的很多方面都被样式所影响。举例来说,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>.

{{index "hidden element"}}

由于 ((块元素)) 不会与周遭的文本内联显示,上述代码中含有 block 标签的部分会显示在单独的一行。最后一个标签根本不会显示 —— display: none 会防止一个元素被显示在屏幕上。这是隐藏元素的一种方式。这种方式通常比将元素从文档中完全删除要更常见,因为稍后将元素重新显示出来是一件简单的事情。

{{if book

{{figure {url: "img/display.png", alt: "Different display styles",width: "4cm"}}}

if}}

{{index "color (CSS)", "style attribute"}}

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>

{{index "camel case", capitalization, "hyphen character", "font-family (CSS)"}}

有的样式属性名含有连字符,比如 font-family。然而这样的属性名在 JavaScript 里使用起来会有点尴尬(你得写成 style["font-family"])。鉴于此,style 对象中诸如此类的属性名的连字符会被移除,并将尾随连字符的字母变成大写 (style.fontFamily)。

层叠样式

{{index "rule (CSS)", "style (HTML tag)"}}

{{indexsee "Cascading Style Sheets", CSS}} {{indexsee "style sheet", CSS}}

HTML 的样式系统名为 ((CSS)),即 层叠样式表。一份 样式表 是定义如何为文档中的元素添加样式的规则的集合。这些规则可以在 <style> 标签内部被定义。

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

{{index "rule (CSS)", "font-weight (CSS)", overlay}}

((层叠)),顾名思义,指的是元素的最终样式是由多条规则组合而成。在上述示例中,<strong> 标签的默认样式 (font-weight: bold) 被 <style> 标签里的规则覆盖了,该规则为其样式添加了 font-stylecolor 属性。

{{index "style (HTML tag)", "style attribute"}}

倘若有多条规则定义了同一属性,那么最新被读取的规则会获取更高的 ((优先级)) 并胜出。因此,如果在 <style> 标签中给 <strong> 标签定义包含 font-weight: normal 的规则,该规则与默认的 font-weight 规则相冲突,最终的文本显示出来是普通样式,而非 粗体。直接作用于节点的 style 属性中的样式拥有最高优先级,总是会在规则的角逐中胜出。

{{index uniqueness, "class attribute", "id attribute"}}

在 CSS 规则中我们不仅仅可以使用 ((标签)) 名称来择定元素。名为 .abc 的规则用于针对所有 class 属性有 "abc" 的元素。名为 #xyz 的规则作用于 id 属性为 "xyz" (应独一无二存在于文档中) 的元素。

.subtle {
  color: gray;
  font-size: 80%;
}
#header {
  background: blue;
  color: white;
}
/* 针对 id 为 main 以及 class 为 a 或 b 的 段落元素 */
p#main.a.b {
  margin-bottom: 20px;
}

{{index "rule (CSS)"}}

只有在多条规则有相同的 ((具体性)) 之时,((优先级)) 才会偏向最新定义的规则。一条规则的具体性是对其描述匹配元素的精确程度的衡量,具体性取决于规则所择定的元素数量及类型 (tag、class 或 id)。比方说,目标为 p.a 的规则比目标为 p.a 的规则要更具体,所以优先级更高。

{{index "direct child node"}}

p > a {…} 这样的写法会将给定的样式作用于 <p> 标签的直系子节点中的所有 <a> 标签。与之类似的是,p a {…} 会作用于 <p> 标签里的所有 <a> 标签,不论是否为直系子节点。

查询选择器

{{index complexity, CSS}}

我们不会在这本书里过多地使用样式表。理解样式表有助于在浏览器中编程,但是它们足够复杂,需要另外写一整本书来讲解。

{{index "domain-specific language", [DOM, querying]}}

我介绍 ((选择器)) 句法 —— 在样式表里用于定义样式作用于哪些元素的写法 —— 的主要原因在于,我们可以使用同样的微型编程语言来有效查找 DOM 元素。

{{index "querySelectorAll method", "NodeList type"}}

名为 document 的对象以及众元素节点都定义了 querySelectorAll 方法,该方法接受一个选择器字符串作为输入,并返回一个包含匹配到的所有元素的 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>

{{index "live data structure"}}

getElementsByTagName 这类的方法所返回的对象不同,由 querySelectorAll 返回的对象 不是 动态的。当你更改文档时,该对象并不会被更改。由于它不是一个真正的数组,如果你向将其作为数组进行交互的话,需要调用 Array.from 方法。

{{index "querySelector method"}}

名为 querySelector (不含 All) 的方法的作用与上述方法类似。如果你只想找一个具体的、单个元素,那么这个方法会很有用。该方法只会返回第一个匹配到的元素,如果没有匹配到任何元素则返回 null。

{{id animation}}

定位与动画

{{index "position (CSS)", "relative positioning", "top (CSS)", "left (CSS)", "absolute positioning"}}

position 样式属性对页面布局有强大的影响。该属性的默认值为 static,表示文档中的元素在常规的静态位置上。当该属性被设置为 relative 时,元素仍然会占用文档的空间,然而此时 topleft 样式属性可用于将元素相对于常规位置进行移动。当 position 属性设置为 absolute 时,元素会从常规的文档流中被移除,也就是说它不再占据空间,可能会与其他元素重叠。并且,如果存在 position 属性不为 static 的闭合元素,那么属性为 absolute 的元素的 topleft 属性可用于将其相对于最近的闭合元素的左上角进行绝对定位 ———— 如果不存在这样的闭合元素,则相对于文档的左上角进行绝对定位。

{{index [animation, "spinning cat"]}}

我们可以使用该属性的特点来创建动画。以下文档显示一只沿 ((椭圆形)) 轨迹移动的小猫的图像:

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

{{if book

The gray arrow shows the path along which the image moves.

{{figure {url: "img/cat-animation.png", alt: "A moving cat head",width: "8cm"}}}

if}}

{{index "top (CSS)", "left (CSS)", centering, "relative positioning"}}

该图像位于页面中央,其 position 的值为 relative。我们将不断更新图像的 topleft 样式来移动这只小猫。

{{index "requestAnimationFrame function", drawing, animation}}

{{id animationFrame}}

以上脚本使用 requestAnimationFrame 方法在每次浏览器准备重新绘制屏幕的时候调用 animate 方法。为了准备下次更新,animate 方法本身会再次调用 requestAnimationFrame。当浏览器窗口 (或标签页) 处于活跃状态,图像的更新频率是大约每秒 60 次,这样的做法可以通常生成美观的动画。

{{index timeline, blocking}}

倘若我们仅仅用一个循环来更新 DOM,页面会卡住,也不会显示任何内容。由于浏览器在 JavaScript 程序运行的时候不会更新屏幕,也不允许在此期间对页面有任何交互,所以我们才需要 requestAnimationFrame 方法 —— 该方法让浏览器知晓我们的程序目前告一段落,使得它继续做浏览器该做的事情,比如更新屏幕,以及响应用户的行为。

{{index "smooth animation"}}

我们的动画函数接收当前的 ((时间)) 作为参数。为了确保小猫每毫秒的动作稳定,函数让移动速度基于函数这次运行和上次运行的时间差所计算出的角度。倘若函数每次运行以固定的角度移动图像,小猫的动作可能会显得迟钝,比如当下同一台电脑上有繁重的任务在运行,以至于该函数的运行被终止了零点几秒。

{{index "Math.cos function", "Math.sin function", cosine, sine, trigonometry}}

{{id sin_cos}}

我通过使用三角函数 Math.cosMath.sin 来让这只猫的图案绕 ((圆圈))。针对不熟悉三角函数的读者,我会简要地做一些介绍,因为我们在这本书里偶尔会用到这些函数。

{{index coordinates, pi}}

当我们要找到在半径为 1 且原点位于 (0,0) 的圆上的任意点的时候,Math.cosMath.sin 函数相当有用。这两个函数都将其参数解释为圆上的位置,就角度而言,以 0 表示圆最右端的点与圆心构成的角度,然后逆时针旋转递增至 2π (大约 6.28) 正好绕了该圆一整圈。 Math.cos 告诉你圆上指定点的横坐标的值,而 Math.sin 给出纵坐标的值。大于 2π 或小于 0 的位置 (或角度) 是有效的 ———— 旋转的过程是可重复的,因此如果 a 表示某一角度的话, a+2π 与 a 的 ((角度)) 相同。

{{index "PI constant"}}

如此衡量角度的单位被称为 ((弧度)) ———— 一整圈是 2π 弧度,用度衡量的话则是 360 度。常数 π 在 JavaScript 里可以用 Math.PI 获取。

{{figure {url: "img/cos_sin.svg", alt: "Using cosine and sine to compute coordinates",width: "6cm"}}}

{{index "counter variable", "Math.sin function", "top (CSS)", "Math.cos function", "left (CSS)", ellipse}}

小猫动画的代码里有一个名为 angle 的计数器,用于绑定当前动画的角度,并在每次 animate 函数被调用时进行递增。之后我们使用这个角度来计算图像元素当前的位置。top 样式的值由通过 Math.sin 函数计算的结果乘以 20,即我们椭圆形的垂直半径。left 样式的值则是基于 Math.cos 算出的结果乘上 200, 因此椭圆形的宽比高要长许多。

{{index "unit (CSS)"}}

值得注意的是,样式的值通常需要指定 单位。在这个例子中,我们需要在数字后添加 "px" 来告知浏览器应以 ((像素)) 为单位 (而不是厘米、"ems" 或其他单位)。这一点很容易被忘记。使用没有单位的值会导致你的样式被浏览器忽视 ———— 除非值是 0,这时无论什么单位结果都一样。

摘要

JavaScript 程序可以通过名为 DOM 的数据结构对浏览器所显示的文档进行检视和交互。该数据结构表示浏览器针对文档而建立的模型,并且 JavaScript 程序可以通过更改该模型而改变可见的文档。

DOM 的组织是树状的,其中的元素根据文档结构依层级排列。表示元素的对象拥有诸如 parentNodechildNodes 这样的属性,这些属性可以被用于在 DOM 树里游走。

我们可以用 样式 来影响文档被显示的方式。有两种做法,一种是直接在节点上添加样式,另一种是定义能匹配特定节点的规则。不同的样式属性有很多,比如 colordisplay。 JavaScript 代码可以通过一个元素的 style 属性直接操纵其样式。

练习题

{{id exercise_table}}

构建一个表格

{{index "table (HTML tag)"}}

以下是一个 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>

{{index "tr (HTML tag)", "th (HTML tag)", "td (HTML tag)"}}

<table> 标签针对每一 ((行)) 都有一个 <tr> 标签。在这些 <tr> 标签里,我们可以放置单元格元素: 表头单元格 (<th>) 或常规单元格 (<td>)。

现在给定一个关于山峰的数据集,即一个包含对象的数组,对象有 nameheightplace 属性。生成一个可以枚举这些对象的表格的 DOM 结构。该结构每列一个属性,每行一个对象,再加上顶部含有 <th> 元素、指明列名的标题行。

写出这样的结构,以至于列名都是通过获取数据中第一个对象的属性名自动导入的。

给最终的表格元素加上值为 "mountains"id 属性,这样一来该元素在文档就是可见的。

{{index "right-aligning", "text-align (CSS)"}}

当你搞定了表格之后,可以通过将这些单元格的 style.textAlign 属性设置成 "right" 从而将含有数字的单元格向右对齐。

{{if interactive

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

if}}

{{hint

{{index "createElement method", "table example", "appendChild method"}}

你可以使用 document.createElement 方法来创建新的元素节点,使用 document.createTextNode 创建文本节点,并使用 appendChild 方法来将节点置入其他节点。

{{index "Object.keys function"}}

你可以尝试遍历对象的键名来填充标题行,然后遍历每一个对象来构建数据行。为了从第一个对象获取键名的数组,Object.keys 方法会很实用。

{{index "getElementById method", "querySelector method"}}

你可以使用 document.getElementByIddocument.querySelector 方法来查找含有 id 属性的节点,然后将表格作为子节点添加进去。

hint}}

根据标签名获取元素

{{index "getElementsByTagName method", recursion}}

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

{{index "nodeName property", capitalization, "toLowerCase method", "toUpperCase method"}}

我们可以用元素的 nodeName 属性来查找其标签名。需要注意的是,该属性返回的标签名都是大写的。使用 toLowerCasetoUpperCase 字符串方法来解决这个问题。

{{if interactive

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

if}}

{{hint

{{index "getElementsByTagName method", recursion}}

最显而易见的解法是使用递归函数,可参照本章节中早先出现的 talksAbout 函数

{{index concatenation, "concat method", closure}}

你可以递归调用 byTagname 方法自身,拼接结果数组以产生输出。或者你可以创建一个递归调用其自身的内部函数,该函数能访问外部函数中定义的数组绑定,一旦找到匹配的元素就向数组绑定中添加。别忘了一开始先在外部函数中调用一次 ((内部函数)),以启动内部函数的递归过程。

{{index "nodeType property", "ELEMENT_NODE code"}}

递归函数必须检查节点的类型,在这题中我们只关注类型号码为 1 (Node.ELEMENT_NODE) 的节点。针对这些节点,我们需要遍历其子节点。对于每一个子节点,查看其是否匹配我们对元素标签的查询,同时递归调用自身,以对子节点的子节点进行同样的匹配。

hint}}

猫之帽

{{index "cat's hat (exercise)", [animation, "spinning cat"]}}

扩展在本章早先定义过的小猫的动画,以至于小猫和它的帽子 (<img src="img/hat.png">) 朝着椭圆上相反的轨道移动。

或者使得帽子围绕着小猫移动,亦可以改成其他有趣的动画。

{{index "absolute positioning", "top (CSS)", "left (CSS)", "position (CSS)"}}

在这种情况下使用绝对定位是个好主意,以便于定位多个对象。这意味着 topleft 的计算是相对于文档左上角而言。负坐标会导致图像移出可见页面,为了避免这种情况发生,你可以给用于定位的属性加上固定的像素值。

{{if interactive

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

if}}

{{hint

Math.cosMath.sin 方法以弧度衡量角度,一整圈是 2π 弧度。针对一个给定的角度,你可以通过加上半弧 (Math.PI) 的方式得到对角。这样可以帮助我们将帽子置于轨道的对侧。

hint}}