第 12 章: 文档对象模型

第 11 章 中,我们看到了 JavaScript 对象引用了 HTML 文档中的 forminput 标签。这些对象是称为 文档对象模型 (DOM) 的结构的一部分。文档中的每个标签都在此模型中都有表示,并且可以查找和交互。

HTML 文档具有所谓的层次结构。除顶层 <html> 标签之外的每个元素(或标签)都包含在另一个元素中,即其父元素。该元素又可以包含子元素。您可以将其可视化为一种家族树

文档对象模型基于对文档的这种视图。请注意,该树包含两种类型的元素:节点,显示为蓝色框,以及简单的文本片段。文本片段,正如我们将在后面看到的那样,与其他元素的工作方式有所不同。首先,它们永远不会有子元素。

打开文件 example_alchemy.html,它包含图片中显示的文档,并将控制台附加到该文档。

attach(window.open("example_alchemy.html"));

文档树根部的对象,即 html 节点,可以通过 document 对象的 documentElement 属性访问。大多数情况下,我们需要访问文档的 body 部分,它位于 document.body 中。


这些节点之间的链接作为节点对象的属性提供。每个 DOM 对象都有一个 parentNode 属性,它引用包含它的对象(如果有)。这些父节点也包含指向其子节点的链接,但由于可能存在多个子节点,因此这些链接存储在一个称为 childNodes 的伪数组中。

show(document.body);
show(document.body.parentNode);
show(document.body.childNodes.length);

为了方便起见,还存在名为 firstChildlastChild 的链接,它们指向节点内部的第一个和最后一个子节点,或者当没有子节点时指向 null

show(document.documentElement.firstChild);
show(document.documentElement.lastChild);

最后,存在名为 nextSiblingpreviousSibling 的属性,它们指向位于节点“旁边”的节点——这些节点是同一父节点的子节点,位于当前节点之前或之后。同样,当没有这样的兄弟节点时,这些属性的值为 null

show(document.body.previousSibling);
show(document.body.nextSibling);

要确定节点是否表示一个简单的文本片段还是一个实际的 HTML 节点,我们可以查看其 nodeType 属性。它包含一个数字,对于常规节点为 1,对于文本节点为 3。实际上,还有其他类型的对象具有 nodeType,例如 document 对象,其值为 9,但此属性最常见的用途是区分文本节点和其他节点。

function isTextNode(node) {
  return node.nodeType == 3;
}

show(isTextNode(document.body));
show(isTextNode(document.body.firstChild.firstChild));

常规节点具有一个名为 nodeName 的属性,它指示它们表示的 HTML 标签类型。另一方面,文本节点具有一个 nodeValue,它包含其文本内容。

show(document.body.firstChild.nodeName);
show(document.body.firstChild.firstChild.nodeValue);

nodeName 始终大写,如果您需要将其与任何内容进行比较,则需要考虑这一点。

function isImage(node) {
  return !isTextNode(node) && node.nodeName == "IMG";
}

show(isImage(document.body.lastChild));

示例 12.1

编写一个名为 asHTML 的函数,该函数在给定一个 DOM 节点时,会生成一个表示该节点及其子节点的 HTML 文本的字符串。您可以忽略属性,只显示节点为 <nodename>。来自 第 10 章escapeHTML 函数可用于正确转义文本节点的内容。

提示:递归!

function asHTML(node) {
  if (isTextNode(node))
    return escapeHTML(node.nodeValue);
  else if (node.childNodes.length == 0)
    return "<" + node.nodeName + "/>";
  else
    return "<" + node.nodeName + ">" +
           map(asHTML, node.childNodes).join("") +
           "</" + node.nodeName + ">";
}

print(asHTML(document.body));

实际上,节点已经有类似于 asHTML 的东西。它们的 innerHTML 属性可用于检索节点内部的 HTML 文本,不包括节点本身的标签。某些浏览器还支持 outerHTML,它确实包含节点本身,但不包含所有浏览器。

print(document.body.innerHTML);

这些属性中的一些也可以修改。设置节点的 innerHTML 或文本节点的 nodeValue 将更改其内容。请注意,在第一种情况下,给定的字符串被解释为 HTML,而在第二种情况下,它被解释为纯文本。

document.body.firstChild.firstChild.nodeValue =
  "Chapter 1: The deep significance of the bottle";

或者...

document.body.firstChild.innerHTML =
  "Did you know the 'blink' tag yet? <blink>Joy!</blink>";

我们一直在通过一系列 firstChildlastChild 属性来访问节点。这可以工作,但它冗长且容易出错——如果我们在文档开头添加另一个节点,document.body.firstChild 将不再引用 h1 元素,而假设它这样做的代码将出错。最重要的是,某些浏览器会为标签之间的空格和换行符添加文本节点,而另一些浏览器则不会,因此 DOM 树的精确布局可能会因浏览器而异。

另一种方法是为需要访问的元素添加 id 属性。在示例页面中,图片的 id 为 "picture",我们可以使用它来查找它。

var picture = document.getElementById("picture");
show(picture.src);
picture.src = "img/ostrich.png";

在键入 getElementById 时,请注意最后一个字母是小写。另外,在反复键入时,请注意腕管综合征。由于 document.getElementById 对于一项非常常见的操作来说是一个非常长的名称,因此 JavaScript 程序员之间已经形成了一种习惯,那就是将其缩写为 $。如您所知,$ 被 JavaScript 视为一个字母,因此它是一个有效的变量名称。

function $(id) {
  return document.getElementById(id);
}
show($("picture"));

DOM 节点还有一个方法 getElementsByTagName(另一个简洁的名称),它在给定一个标签名称时,会返回调用该方法的节点中包含的所有该类型节点的数组。

show(document.body.getElementsByTagName("BLINK")[0]);

我们还可以对这些 DOM 节点进行的操作是创建新的节点。这使得可以根据需要向文档添加片段,这可以用来创建一些有趣的效果。不幸的是,用于执行此操作的接口非常笨拙。但这可以通过一些辅助函数来弥补。

document 对象具有 createElementcreateTextNode 方法。第一个用于创建常规节点,第二个顾名思义,用于创建文本节点。

var secondHeader = document.createElement("H1");
var secondTitle = document.createTextNode("Chapter 2: Deep magic");

接下来,我们希望将标题名称放入 h1 元素中,然后将该元素添加到文档中。最简单的方法是使用 appendChild 方法,该方法可以被每个(非文本)节点调用。

secondHeader.appendChild(secondTitle);
document.body.appendChild(secondHeader);

通常,您还需要为这些新节点设置一些属性。例如,如果没有 src 属性来告诉浏览器应该显示哪个图像,img(图像)标签就毫无用处。大多数属性可以直接作为 DOM 节点的属性访问,但还有 setAttributegetAttribute 方法,它们用于以更通用的方式访问属性

var newImage = document.createElement("IMG");
newImage.setAttribute("src", "img/Hiva Oa.png");
document.body.appendChild(newImage);
show(newImage.getAttribute("src"));

但是,当我们想要构建多个简单的节点时,使用 document.createElementdocument.createTextNode 调用来创建每个单独的节点,然后逐个添加其属性和子节点,会让人感到非常厌烦。幸运的是,编写一个函数来完成大部分工作并不困难。在我们这样做之前,需要处理一个细节——setAttribute 方法虽然在大多数浏览器上都能正常工作,但在 Internet Explorer 上并不总是能正常工作。一些 HTML 属性的名称在 JavaScript 中已经具有特殊含义,因此相应的对象属性名称进行了调整。具体来说,class 属性变为 classNamefor 变为 htmlForchecked 被重命名为 defaultChecked。在 Internet Explorer 上,setAttributegetAttribute 也使用这些调整后的名称,而不是原始的 HTML 名称,这可能会造成混淆。最重要的是,style 属性(以及 class 属性,将在本章后面讨论)无法使用该浏览器上的 setAttribute 设置。

一个解决方法看起来像这样

function setNodeAttribute(node, attribute, value) {
  if (attribute == "class")
    node.className = value;
  else if (attribute == "checked")
    node.defaultChecked = value;
  else if (attribute == "for")
    node.htmlFor = value;
  else if (attribute == "style")
    node.style.cssText = value;
  else
    node.setAttribute(attribute, value);
}

在 Internet Explorer 偏离其他浏览器的每种情况下,它都会执行在所有情况下都能正常工作的事情。不要担心细节——这是一种我们宁愿不需要的丑陋技巧,但非标准浏览器强迫我们编写它。有了它,就可以编写一个简单的函数来构建 DOM 元素。

function dom(name, attributes) {
  var node = document.createElement(name);
  if (attributes) {
    forEachIn(attributes, function(name, value) {
      setNodeAttribute(node, name, value);
    });
  }
  for (var i = 2; i < arguments.length; i++) {
    var child = arguments[i];
    if (typeof child == "string")
      child = document.createTextNode(child);
    node.appendChild(child);
  }
  return node;
}

var newParagraph = 
  dom("P", null, "A paragraph with a ",
      dom("A", {href: "http://en.wikipedia.org/wiki/Alchemy"},
          "link"),
      " inside of it.");
document.body.appendChild(newParagraph);

dom 函数创建一个 DOM 节点。它的第一个参数给出节点的标签名称,它的第二个参数是一个包含节点属性的对象,或者当不需要属性时为 null。之后,可以跟进任意数量的参数,这些参数将作为子节点添加到该节点中。当字符串出现在这里时,它们首先被放入文本节点中。


appendChild 不是将节点插入另一个节点的唯一方法。当新节点不应该出现在其父节点的末尾时,可以使用 insertBefore 方法将其放置在另一个子节点之前。它将新节点作为第一个参数,将现有子节点作为第二个参数。

var link = newParagraph.childNodes[1];
newParagraph.insertBefore(dom("STRONG", null, "great "), link);

如果一个已经具有 parentNode 的节点被放置在某个位置,它将自动从其当前位置移除——节点不能在文档中的多个位置存在。

当一个节点必须被另一个节点替换时,使用 replaceChild 方法,它将新节点作为第一个参数,将现有节点作为第二个参数。

newParagraph.replaceChild(document.createTextNode("lousy "),
                          newParagraph.childNodes[1]);

最后,还有 removeChild 用于移除子节点。请注意,它是对要移除节点的父节点进行调用,并传递该子节点作为参数。

newParagraph.removeChild(newParagraph.childNodes[1]);

示例 12.2

编写一个名为 removeElement 的便捷函数,该函数会将其作为参数传入的 DOM 节点从其父节点中移除。

function removeElement(node) {
  if (node.parentNode)
    node.parentNode.removeChild(node);
}

removeElement(newParagraph);

在创建新节点和移动节点时,需要了解以下规则:不允许将节点从创建它们的文档插入到另一个文档中。这意味着如果您打开了额外的框架或窗口,您不能从一个文档中获取一部分并将其移动到另一个文档,并且使用一个document对象上的方法创建的节点必须保留在该文档中。一些浏览器,特别是 Firefox,没有执行此限制,因此违反此限制的程序在这些浏览器中可以正常运行,但在其他浏览器中会崩溃。


使用此dom函数可以做的一件有用的事情是,编写一个程序,该程序接受 JavaScript 对象并将它们汇总成一个表格。在 HTML 中,表格是用一组以t开头的标签创建的,如下所示

<table>
  <tbody>
    <tr> <th>Tree </th> <th>Flowers</th> </tr>
    <tr> <td>Apple</td> <td>White  </td> </tr>
    <tr> <td>Coral</td> <td>Red    </td> </tr>
    <tr> <td>Pine </td> <td>None   </td> </tr>
  </tbody>
</table>

每个tr元素都是表格的一行。thtd元素是表格的单元格,td是普通数据单元格,th单元格是“标题”单元格,它们将以更突出的方式显示。当以 HTML 形式编写表格时,不必包含tbody(表格主体)标签,但从 DOM 节点构建表格时应该添加它,因为 Internet Explorer 拒绝显示没有tbody的表格。


示例 12.3

函数makeTable接受两个数组作为参数。第一个包含它应该汇总的 JavaScript 对象,第二个包含字符串,这些字符串命名表格的列以及应该在这些列中显示的对象的属性。例如,以下将生成上面的表格

makeTable([{Tree: "Apple", Flowers: "White"},
           {Tree: "Coral", Flowers: "Red"},
           {Tree: "Pine",  Flowers: "None"}],
          ["Tree", "Flowers"]);

编写此函数。

function makeTable(data, columns) {
  var headRow = dom("TR");
  forEach(columns, function(name) {
    headRow.appendChild(dom("TH", null, name));
  });

  var body = dom("TBODY", null, headRow);
  forEach(data, function(object) {
    var row = dom("TR");
    forEach(columns, function(name) {
      row.appendChild(dom("TD", null, String(object[name])));
    });
    body.appendChild(row);
  });

  return dom("TABLE", null, body);
}

var table = makeTable(document.body.childNodes,
                      ["nodeType", "tagName"]);
document.body.appendChild(table);

不要忘记在将值从对象添加到表格之前将它们转换为字符串——我们的dom函数只理解字符串和 DOM 节点。


与 HTML 和文档对象模型密切相关的是样式表主题。这是一个很大的主题,我不会完全讨论它,但对于许多有趣的 JavaScript 技术来说,需要了解一些关于样式表的知识,因此我们将讨论基础知识。

在传统的 HTML 中,更改文档中元素的外观方式是为它们提供额外的属性或将它们包装在额外的标签中,例如center将它们水平居中,或font更改字体样式或颜色。大多数情况下,这意味着如果您希望文档中的段落或表格以某种方式显示,您必须向每个段落或表格添加一堆属性和标签。这会很快为这些文档添加很多噪音,并使它们通过手工编写或更改变得非常痛苦。

当然,人们是富有创造力的猴子,有人想出了一个解决方案。样式表是一种可以做出诸如“在此文档中,所有段落都使用 Comic Sans 字体,并且是紫色的,所有表格都有一个粗绿色的边框”的声明的方式。您只需在文档顶部或单独的文件中指定它们一次,它们就会影响整个文档。例如,这里有一个样式表,它可以使标题大小为 22 点,并且居中,并使段落使用前面提到的字体和颜色,当它们属于“丑陋”类时。

<style type="text/css">
  h1 {
    font-size: 22pt;
    text-align: center;
  }

  p.ugly {
    font-family: Comic Sans MS;
    color: purple;
  }
</style>

类是与样式相关的概念。如果您有不同类型的段落,例如丑陋的和漂亮的段落,那么设置所有p元素的样式并不是您想要的,因此类可用于区分它们。上面的样式仅适用于这样的段落

<p class="ugly">Mirror, mirror...</p>

这也就是className属性的含义,它在setNodeAttribute函数中简要提及过。style属性可用于直接向元素添加一段样式。例如,这将为我们的图像提供一个 4 像素(“px”)宽的实线边框。

setNodeAttribute($("picture"), "style",
                 "border-width: 4px; border-style: solid;");

样式还有更多内容:某些样式会从父节点继承到子节点,并以复杂而有趣的方式相互干扰,但对于 DOM 编程而言,最重要的一点是,每个 DOM 节点都有一个style属性,该属性可用于操纵该节点的样式,并且存在几种类型的样式,可用于使节点执行非凡的操作。

style属性引用一个对象,该对象具有所有可能的样式元素的属性。例如,我们可以使图片的边框变为绿色。

$("picture").style.borderColor = "green";
show($("picture").style.borderColor);

请注意,在样式表中,单词用连字符分隔,如border-color,而在 JavaScript 中,大写字母用于标记不同的单词,如borderColor

一种非常实用的样式是display: none。这可用于暂时隐藏节点:当style.display"none"时,即使元素确实存在,它也不会出现在文档的查看者面前。稍后,可以将display设置为空字符串,元素将重新出现。

$("picture").style.display = "none";

并且,为了取回我们的图片

$("picture").style.display = "";

另一组可以以有趣的方式被滥用的样式类型是与定位相关的样式。在简单的 HTML 文档中,浏览器负责确定所有元素的屏幕位置——每个元素都放置在前面的元素旁边或下方,并且节点(通常)不会重叠。

当节点的position样式设置为"absolute"时,该节点将从正常的文档“流”中移出。它不再占用文档中的空间,而是漂浮在文档之上。然后,lefttop样式可用于影响其位置。这可以用于各种目的,从使节点令人讨厌地跟随鼠标光标到使“窗口”打开在文档的其余部分之上。

$("picture").style.position = "absolute";
var angle = 0;
var spin = setInterval(function() {
  angle += 0.1;
  $("picture").style.left = (100 + 100 * Math.cos(angle)) + "px";
  $("picture").style.top = (100 + 100 * Math.sin(angle)) + "px";
}, 100);

如果您不熟悉三角学,那就相信我,余弦和正弦的东西用于构建位于圆周轮廓上的坐标。每秒十次,更改放置图片的角度,并计算新的坐标。在设置此类样式时,一个常见的错误是忘记将"px"附加到您的值。在大多数情况下,将样式设置为没有单位的数字不起作用,因此您必须为像素添加"px",为百分比添加"%",为“em”添加"em"M字符的宽度),或为点添加"pt"

(现在让图像再次休息...)

clearInterval(spin);

这些位置的 0,0 位置取决于节点在文档中的位置。当它放置在另一个具有position: absoluteposition: relative的节点内部时,将使用该节点的左上角。否则,您将获得文档的左上角。


DOM 节点的最后一个有趣方面是它们的大小。存在称为widthheight的样式类型,可用于设置元素的绝对大小。

$("picture").style.width = "400px";
$("picture").style.height = "200px";

但是,当您需要精确设置元素的大小时,需要考虑一个棘手的问题。某些浏览器在某些情况下,将这些尺寸解释为对象的外部尺寸,包括任何边框和内部填充。其他浏览器在其他情况下,使用对象内部空间的尺寸,并且不计算边框和填充的宽度。因此,如果您设置了一个具有边框或填充的对象的大小,它并不总是显示相同的大小。

幸运的是,您可以检查节点的内部和外部大小,当您确实需要精确地调整某些东西的大小,可以使用它来弥补浏览器行为。offsetWidthoffsetHeight属性为您提供元素的外部尺寸(它在文档中占用的空间),而clientWidthclientHeight属性提供内部空间(如果有)的大小。

print("Outer size: ", $("picture").offsetWidth,
      " by ", $("picture").offsetHeight, " pixels.");
print("Inner size: ", $("picture").clientWidth,
      " by ", $("picture").clientHeight, " pixels.");

如果您已经按照本章中的所有示例操作,并且可能自己做了一些额外的事情,您将完全毁坏了我们开始使用的那个可怜的小文档。现在让我讲点道德,告诉你,你不想对真正的页面这样做。添加各种移动的 bling-bling 的诱惑有时会很强烈。抵抗它,否则您的页面肯定无法阅读,或者如果您走得太远,甚至会导致偶尔的发作。