46. DOM 之旅

  • 打印

引言

在网上很难找得到不与 HTML 有任何形式的交互的实用 JavaScript 代码示例。一般而言,我们的代码需要从页面上读取数据,以某种方式处理这些数据,然后以可见的页面变化或通知信息的形式生成输出结果。为了给页面和应用程序创建反应灵敏的界面,本文和下一篇文章引入了文档对象模型( Document Object Model),该模型提供了对我们所创建的语义性和表象性层进行检查操作的机制。

学完本文后,你就能对什么是 DOM 有深刻的理解,本文还会教你如何用 DOM 来遍历 HTML 页面,以定位要获取数据或进行改动的位置。本系列教程的下一篇文章(创建和修改 HTML )着重讲述我们该如何操作页面上的数据,修改数值或创建全新的元素和属性。

 

本文结构如下:

播种

正如你可能会根据文档对象模型这个名字推断出来的那样, DOM 是一种在加载 Web 页面时由浏览器创建的 HTML 文档模型。 JavaScript 可以访问这个模型中的所有信息。现在我们稍稍回顾一下,想想我们到底对什么建模。

当创建一个页面的时候,我的目标是通过将原始内容映射到的 HTML 标签来为其添加意义:这一小段内容是一个段落,因此我用 p 标签来标记它;下一段是一个链接,因此我用 a 标签,等等。我还会对元素之间的关系进行编码:每个 input 域都有一个 label,并且可能全都位于一个 fieldset 内部。此外,我还会做一点额外的事情,在适当的地方添加idclass属性,来给该页面加入更多的结构,这样我就可以对这些结构进行样式化或其它操作了。 HTML 框架建立好之后,我就会用 CSS 来给这些纯语义的结构装扮出时尚的外观。这样,我们就创建了一个能令用户欣喜的页面。

但这些都还不是全部。我所创建的是一个充满了可供我用 JavaScript 操作的元信息的文档。我可以查找到特定元素或元素组,并根据用户定义的变量来对它们进行删除,添加和修改操作;我可以查找到外观信息(CSS)并在运行中对样式进行修改;我还可以校验用户输入到表单中的信息;以及进行各种其它操作。为了用 JavaScript 来完成这些操作,它就必须能获得并操作所需的信息,而 DOM 为 JavaScript 提供了所需的一切。

还有一件值得注意的事:结构良好的 HTML 和CSS是一颗种子,从这颗种子就能长出该页面的 JavaScript 模型来。结构不良的文档的生成的 DOM 跟你所期待的相差甚远,并且在不同的浏览器上其行为表现也不一致。为了确保最终 JavaScript 所访问的模型跟你所期待的完全一致,你的 HTML 和 CSS 都应该是良构而且能通过检验的,这一点至关重要。

生长

在创建好你的网页文档,并对其进行了样式化之后,接下来要做的就是将其提交给浏览器以显示给你的用户了。这下就该轮到 DOM 上场了,浏览器会通读你编写的网页文档,并动态地生成 DOM ,以供你在脚本程序中使用。具体来讲, DOM 将 HTML 页面表示为一棵,就像你描绘的“家谱树”一样。页面上的每个元素在 DOM 中都是一个节点,每个节点都带有分枝,连到自己直接包含的元素(其子元素)以及直接包含自己的元素(其父元素)。下面我们通过一个简单的 HTML 文档来对这些关系加以说明:

< HTML >
  <head>
    <title>This is a Document!</title>
  </head>
  <body>
    <h1>This is a header!</h1>
    <p id="excitingText">
      This is a paragraph! <em>Excitement</em>!
    </p>
    <p>
      This is also a paragraph, but it's not nearly as exciting as the last one.
    </p>
  </body>
</ HTML >

如你所见,整个文档都包含在一个 HTML 元素中。该元素直接包含另外两个元素:head body 。这两个元素在我们的模型中就是 HTML 元素的子元素,而它们又分别指向自己的父元素 HTML 。依此类推,在整个文档层级中,每个元素都指向自己的直接派生元素,即子元素,以及自己的直接祖先,即父元素:

  • title head 的子元素。
  • body 有三个子元素 — 两个 p 元素和一个 h1 元素。
  • id="excitingTextp 元素的子元素是一个 em 元素。
  • 元素的纯文本内容(比如“This is a Document!”)在 DOM 中被表示为文本节点。文本节点没有子元素,但会指向自己的包含元素,即父元素。

因此,如果将前面的 HTML 文档的 DOM 层级用可视化的图形描绘出来的话,就如图1所示:

A visual DOM tree representation of an HTML document

图1:将前面的 HTML 文档可视化地描述成一个 DOM 树。

从 HTML 文档到这个树状结构的映射是很简单的,该图简洁地抓住了页面上的元素之间的直接关系,清楚地阐述了文档元素的层级。不过,你可能会注意到,我在 HTML 节点之上添加了一个 document 节点。这就是该文档的根节点,也是 JavaScript 操作该树状结构的入口。

节点

在我开始在这棵树上晃荡,并从一个分枝荡到另一个分枝之前,我们先来花点时间详细查看一下节点。

DOM 树上的每个节点都是一个对象 ,代表了该页面上的某个元素。每个节点都知道自己与其它那些跟自己直接相邻的节点之间的关系,而且还包含着关于自身的大量信息。就像一个孩子在后院的橡树上从某根枝条爬到最近的另一根枝条上一样,我也可以从一个节点收集到所需的一切信息,以到达其父元素或子元素。

就像你可能会预料到的那样, JavaScript 是面向对象的,我们可以通过节点对象的属性来获得要查找的信息。具体来说就是 parentNodechildNodes 属性。由于页面上每个元素最多只有一个父元素,parentNode 属性很好理解:通过它可以获取当前节点的父节点。然而,每个节点可有任意数量的子节点,因此 childNodes 属性实际上是一个数组。该数组中的每个元素都指向一个子节点,其排列顺序跟这些节点在文档中的顺序一样。因此我们的示例文档的 body 元素的 childNodes 数组就依次包含着h1,第一个 p,然后是第二个 p

当然,上面所说的并没有包含所有节点属性。但这是个不错的起始点。那么,首先我该用什么样的代码来找到这些节点呢?我该从哪里开始我的 DOM 之旅呢?

从一个分枝到另一个分枝

最好的出发点就是该文档的根节点,通过那个命名很有创意的 document 的对象就可以访问该节点。由于 document 是根节点,它没有 parentNode ,但有一个唯一的子节点: HTML 元素节点,我们可以通过 document childNodes 数组来访问该节点:

var theHTMLNode = document.childNodes[0];

这一行代码创建了一个名叫 theHTMLNode 的新变量,并将它的值赋为document对象的第一个子节点(别忘了 JavaScript 数组的下标是从0开始的,而不是1)。通过检查the HTML NodenodeName属性可以确认自己得到的确实是 HTML 节点,该属性所提供的是你正在处理的节点类型信息:

alert( "the HTML Node is a " + the HTML Node.nodeName + " node!" );

这段代码会弹出一个“the HTML Node is a HTML node!”的对话框。很好!nodeName 属性为你提供了该节点的类型。对于元素节点来说,该属性包含的是大写的标签名:此处是“ HTML ”;对于链接来说就是“A”,对于段落来说就是“P”,等等。文本节点的 nodeName 属性值是“#text”,而 document nodeName 值就是“#document”。

我们还可以看到,theHTMLNode 应当包含对自己的父节点的引用。下面这个测试可以确认这一点:

if ( theHTMLNode.parentNode == document ) {
  alert( "Hooray!  The  HTML  node's parent is the document object!" );
}

它的确是按照我们的预期来运行的。利用这个信息,我们可以编写一些代码,来获取示例文档的 body 中的第一个段落的引用。该段落是 body元 素的第二个子节点,body 元素又是 HTML 元素的第二个子节点,而 HTML 元素又是 document 对象的第一个子节点。汗~~

var the HTML Node = document.childNodes[0];
var theBodyNode = the HTML Node.childNodes[1];
var theParagraphNode = theBodyNode.childNodes[1];
alert( "theParagraphNode is a " + theParagraphNode.nodeName + " node!" );

太好了。这段代码完全实现了我们想要的效果。不过它也实在是有点太长了;幸好我们有更好的方式来写这段代码。在 JavaScript 对象一文中,我们知道可以将对象引用链接在一起;此处我们也可以这么做,像下面这样写就可以免掉中间变量了:

var theParagraphNode = document.childNodes[0].childNodes[1].childNodes[1];
alert( "theParagraphNode is a " + theParagraphNode.nodeName + " node!" );

这样就清爽多了,而且还可以让你少写一点代码。

每个节点的第一个子节点无一例外的都是 node.childNodes[0],而某个节点的最后一个子节点也总是 node.childNodes[node.childNodes.length - 1]。我经常需要访问这些节点,但重复上面代码有点麻烦。考虑到可能经常都要用到这两种表达方式, DOM 为我们提供了易于理解的快捷方式:分别是 .firstChild .lastChild 。由于 HTML 节点是 document 对象的第一个子节点,而 body 节点是 HTML 节点的最后一个子节点,我们可以更简便地重写前面那段代码:

var theParagraphNode = document.firstChild.lastChild.childNodes[1];
alert( "theParagraphNode is a " + theParagraphNode.nodeName + " node!" );

上述的一系列节点导航的方法很有用,可以让你到达文档中任何你想要去的地方,但它们也是比较麻烦的。即便是在上面简单的例子中,你也可以看出从根节点导航至所需节点有多费劲。一定还有什么办法能更好地解决这个问题!

直接访问

要显式地指定页面上每个感兴趣的元素的路径是很困难的。而且如果你所面对的页面是由动态方式生成的话(比如用 PHP 或 ASP.NET 之类的服务器端语言来生成的),显式指定路径就完全不可能了。因为你不可能保证,比如说,你要找的段落始终是 body 节点的第二个子节点。因此我们需要一个更好的方法来到达某个特定元素,同时又不用明确知道其周围的环境如何。

回顾一下前面的例子中的 HTML 文档,可以看到在其中的段落元素有个 id 属性。这个 id 是唯一的,标志着该文档中的特定位置,这样你就可以通过利用 document 对象的 getElementById 方法来代替使用显式的路径。这个方法可以准确地实现我们想要的效果。如果你想让 JavaScript 查找一个在该页面上并不存在的 id 的话,该方法会返回 null ,如果存在的话,则返回该元素节点。为了试验该方法,我们比较一下新方法与老方法的结果:

var theParagraphNode = document.getElementById('excitingText');
if ( document.firstChild.lastChild.childNodes[1] == theParagraphNode ) {
  alert( "theParagraphNode is exactly what we expect!" );
}

这段代码会弹出一个确认信息,证明这两种方法具有同样的效果。getElementById 是最高效的获取页面上特定元素引用的方法:如果你要对某个页面上的某处进行一些处理的话(尤其是如果你不确定它到底是在哪个地方的话),在适当的地方添加一个 id 属性就可以为你节约不少时间。

DOM 的 getElementsByTagName 方法也很有用,该方法会返回页面上某个特定类型的元素的集合。举例来说,你可以通过 JavaScript 来显示出页面上所有的 p 元素。下面例子显示出一个精彩的段落,以及它的稍显逊色的同辈们:

var allParagraphs = document.getElementsByTagName('p');

最好是通过 for 循环来处理存储在 allParagraphs 集合中的结果:你可以像使用数组一样来使用它:

for (var i=0; i < allParagraphs.length; i++ ) {
  //  此处是我们要进行的操作的代码,我们通过
  //  "allParagraphs[i]"来引用集合中的当前元素

        
  alert( "This is paragraph " + i + "!" );
}

对于更复杂的文档来说,返回所有指定类型的元素可能很耗时。你很可能只要对某个特定部分的 div 进行操作,而不是处理某个庞大页面上的全部200个 div 。在这种情况下你就可以将这两种方法联合使用,来过滤自己的结果:通过其 id 来获取某个元素,然后获取该元素中包含的所有特定类型的元素。比如说,我可以通过下面的代码抓取前面那个"精彩"段落中的所有 em 元素:

document.getElementById('excitingText').getElementsByTagName('em')

总结

在网络中 DOM 几乎是 JavaScript 实现一切效果的基础。它是我们与自己的页面内容进行交互的接口,了解如何在该模型中进行遍历是非常必要的。

本文提供了更进一步学习所需的基本知识。现在你可以通过 document 获取 DOM 根节点,通过 childNodes parentNode 来在 DOM 树中跳转到该节点的直系亲属节点,从而轻松地在 DOM 树中漫游了。你还可以通过 getElementById getElementsByTagName 来创建自己的快捷方式,从而避开中间变量和硬编码的又长又麻烦的路径。

能遍历 DOM 树只是第一步。从逻辑上来讲,下一步就可以开始用你的 JavaScript 所返回的结果来实现有趣的效果了。你需要获取数据来为自己的脚本提供动力,并且对页面上的数据进行操作,以创建精彩的用户界面。我们将在下一篇文章探讨这些问题,下一篇文章还会告诉你如何运用 DOM 提供的方法来与节点及其属性进行交互,以及将该交互编写进你今后要创建的脚本和接口中去。

练习题

  • 参考本文中的示例,写出三种不同的到达head元素的路径。别忘了你可以随心所欲地将 childNodesparentNode 链接起来。
  • 给定一个任意节点,你该如何测定其类型?
  • 给定一个任意节点,你该怎样回到 document 对象?提示:别忘了 document 对象的 parentNode 属性将返回 null