DOM编程之节点(一)

《DOM编程艺术》这本书短小精悍,值得一看,入门必备呀!囫囵吞枣看了两遍,补充了一点笔记,主要是记录了元素节点的相关知识。

<!--more-->

更新于2017-3-6,在阅读了《JavaScript高级程序设计》和《JavaScript权威指南》这两本书后,又回到了这本带领我进入前端大门的《DOM编程艺术》,重新整理关于DOM中元素节点的相关知识,而不仅限于只是这本书的读书笔记啦(所以顺带把标题都改了)。

1. DOM概念

DOM(Document Object Model),书面语称为文档对象模型,是一套HTML(和XML)文档内容进行抽象和概念化的方法。换句话讲,它提供了一系列的接口,方便我们对文档进行操作。

浏览器负责发起请求并解析HTML文档,然后渲染样式和执行脚本。这都是建立在HTML文档的基础上的,DOM中的D,表示的就是HTML文档。

我们知道,每一个Web浏览器窗口都由一个window对象所表示,而每一个window对象都有一个document属性,引用了Document对象,Document对象的主要功能就是处理网页内容。DOM中的O,表示的就是Document对象。

DOM中的M,是一个抽象的概念,它把整个文档抽象为一棵节点树,文档由节点组成,每个节点都可以通过某条联系找到该页面中的其他任何节点,对整个文档的操作可以具体到对某个节点的操作。具体的文档树这里就不放图了,

尽管节点的类型很多(11种),但最常用的节点是元素节点,每一个元素节点都是一个Element对象,该对象定义了相关的属性和方法,用于操作该节点,某些特定的元素节点还具有特定的属性和方法。

2. 获取元素节点

操作元素节点,首先需要得到该节点的Element对象。

2.1. 通过document的方法

首先,document对象提供了一系列的方法用于获取元素节点。

// 根据id返回单个元素节点
document.getElementById("id");

// 根据标签名或类名返回全部的元素节点列表(是一个HTMLCollection类数组对象,犀牛书上说的是返回Nodelist)
// 即使只有一个元素节点也必须通过下标访问
document.getElementsByTagName("ul");
document.getElementsByClassName("head");

// 根据标签name属性选择元素
document.getElementsByName("tel");

上面这些获取元素节点的方法都是由document对象提供的,需要注意的是,所有的元素节点都继承了上面的这些方法,因此,可以通过这个方法查找对应元素下的元素节点,缩小查找范围。另外,在CSS3标准化同时新增了一组“选择器API”,用于增强筛选节点的能力

document.querySelector(selector);
document.querySelectorAll(selector);

根据《JavaScript权威指南》的讲解,NodeList类数组对象并不是历史文档的一个静态快照,而是实时的,即文档变化时他们所包含的元素列表能随之改变,但是选择器API所返回的对象并不是事实的,他包含的只是在调用适合选择器所匹配到的元素,但并不更新后续文档的变化(这个不是NodeList和HTMLCollection的区别,而是选择器API自身的特性),下面是测试例子

<ul>
    <li></li>
    <li></li>
    <li></li>
</ul>
<script type="text/javascript">
    var oUl = document.getElementsByTagName("ul")[0];
    var aLi = document.getElementsByTagName("li");
    var aLi2 = document.querySelectorAll("li");
    console.log(aLi); // HTMLCollection[3]
    console.log(aLi2); // NodeList[3]

    var node = document.createElement("li");
    oUl.appendChild(node);
    console.log(aLi); // HTMLCollection[4]
    console.log(aLi2); // NodeList[3]
</script>

2.2. id的另外一种用法

每个HTML元素都可以有一个id属性,这个id属性在整个文档中必须是唯一的。如果声明的id并没有被window对象使用的话,那么任何有id属性的html元素都会成为window对象的属性(全部变量)。也就是说,可以直接使用id引用对应元素Element对象,这是一个十分酷的使用方法

// html
<div id="test"></div>

// js
console.log(test); // <div id="test"></div>

但是,如果声明了同名的全局变量(不论是在该元素节点之前还是之后进行的声明),就会覆盖掉该元素的引用,因此,使用这种方法存在着潜在的风险。

2.3. name的另外一种用法

HTML的name属性最开始打算为表单元素分配名字的,在表单元素提交到服务器时使用该属性的值。与id类似,如果声明的name值没有被window对象使用,则该元素也会称为window对象的属性。但是与id不同的是:

  • 多个元素可以具有相同的name值,此时会组成一个类数组对象(但是如果只有一个则是单一的Element对象而非长度为1的类数组对象,很智能吧)
  • 只有少部分HTML标签的name属性符合这种情况,包括a,iframe,img,form
<form action="" name="test">
    <input type="radio" name="user">
    <input type="radio" name="tel">
</form>
console.log(test); // form
console.log(user); // 报错,input元素的name值不具备这种规则
console.log(test.user) // input,很神奇吧,这就是特殊的元素具有特定的属性和方法,牢记这一点十分重要

这么做的弊端也十分明显,只有少部分标签支持这个特性。而上面所提到的document的方法,是没有这些限制的。此外,为iframe元素指定的name值,表示的是这个子窗口的window对象而不是Element对象。

3. 操作元素节点

3.1. 属性节点

属性节点用来对元素节点做更具体的描述,由于属性节点总是被放在起始标签内部,因此属性节点总是位于元素节点内。获取元素节点上的属性节点十分简单:

var oTest = document.querySelector("#test");
oTest.getAttribute("title"); //获取title属性的值,若无返回null

设置属性节点类似

//为titile属性设置hello的值
oTest.setAttribute("title","hello")

需要注意的是这种方式会覆盖原本的属性值。比如如果需要添加一个类名而不是覆盖类名,需要先将之前的class属性保存起来然后重新赋值。 也可以删除某个属性节点

oTest.removeAttribute("title")

此外如果需要简单判断元素是否含有对应属性,则使用hasAttribute(attr)方法。

HTML5新增了自定义属性,使用data-*进行作为属性名,用于保存我们自定义的属性,同时提供了一个dataset属性用于访问自定义属性。

<div data-demo="this is demo" id="test"></div>
// 忽略"data-"的前缀,需要注意属性名的大小写问题哦
console.log(test.dataset.demo); //  this is demo

3.2. 文本节点

文本节点总是被包含在元素节点的内部(尽管某些元素节点并没有文本节点)。我们通过innerHTML属性操作文本节点,如果为其赋值则表示设置文本内容,如果直接返回则显示该元素节点中的文本内容。

<div id="test">Haha <a href="#">link</a></div>
// 获取文本节点
var htm = oTest.innerHTML; // Haha <a href="#">link</a>

// 设置文本节点
oTest.innerHTML = "Hello World";

该属性会注意会返回或修改对应元素节点下的所有标签代码与文本内容,如果只希望获取纯文本,则使用innerText属性

var txt = oTest.innerText; // Haha link

此外还有一个textContent的属性,该属性简单地将所有后代文本节点的内容串联在一起然后返回。在大多数情况下可以与innerText属性互换,但是innerText属性不返回script元素的内容,它忽略多余的空白并试图保留表格格式,而textContent则保留全部的空白和换行等文本格式。

3.3. 元素节点的遍历

一旦从文档中选取了某个元素,有时候需要查找文档中与之在结构上相关的元素。我们可以通过文档树来遍历找到对应的元素,幸好Documen对象和Element对象定义了一些相关的属性来遍历文档树

元素的父结点 parentNode属性返回当前结点的父节点,如果是ducument对象则返回null。 offsetParent属性返回的是离当前结点最近的有定位的父节点,这对绝对定位的元素十分有用。

元素的子节点 childNodes属性返回当前结点的全部子节点。该属性只统计他自己的子节点而不计算其子节点的子节点,空格与换行都算是文本节点;此外在不同浏览器下会返回不同的节点总数,可以通过节点的nodeType属性查看其具体属性,其中:

  • 1表示元素节点
  • 2表示属性节点
  • 3表示文本节点,
  • 共有12种节点类型,其他节点用的很少。

该属性返回的是一个节点列表,那么:

  • 可以使用firstChild属性代替childNodes[0]
  • 可以使用lastChild属性代替childNodes[length-1]

由于我们更希望得到元素的子元素节点而不是元素的全部子节点,因此一般使用children属性,该属性返回整个子元素节点列表,与之相关的还有一个childElementCount属性表示子元素节点的个数

  • 可以使用firstElementChild来表示元素的第一个子元素节点children[0]
  • 可以使用lastElementChild来表示元素的最后一个子元素节点children[childElementCount-1]

需要注意的是,firstElementChild在标准下获取第一个元素类型的节点,但是非标准的ie不支持,在非标准的ie下firstChild获取到的就是第一个元素节(真是坑啊)。

元素的兄弟节点 具有相同的父元素的节点称为兄弟节点,使用nextSiblingpreviousSibling属性来访问相邻的兄弟节点。 同上,我们更希望获得相邻的兄弟元素节点过滤掉其他的节点,因此可以使用和nextElementSibling和previousSibling来访问相连的兄弟元素节点。

4. 操作CSS样式

CSS描述页面内容应该如何呈现,一般单独编写在样式表中并在加载HTML文档时由浏览器负责解析,但是某些时候也需要使用JavaScript来动态操作元素的样式,这是通过修改元素节点的style属性来实现的。

4.1. 设置行内样式

查询元素的style属性返回的是一个叫做CSSStyleDeclaration对象而不是简单的字符串,该对象包含了元素的样式(用谷歌浏览器测试有373条属性,火狐下有698条~~)。如果我们需要为元素设置样式,则可以:

// html
<h1 id="test">123</h1>

// js
test.style.color = "red";

打开开发者工具就可以看见,元素上多了一条行内样式(这里需要记住,所有的DOM操作设置的样式都是行内样式,因此其样式权重值非常非常高)。此外,也可以直接通过style对象获取元素的已经声明的行内样式:

<h1 id="test" style="color: green;">123</h1>

test.style.color; // green

由于-是JavaScript的元素符,因此,如果所需的CSS属性中包含-连字符,比如font-size等,需要转换成fontSize驼峰形式的才行。

4.2. 查询渲染样式

但是,一定要记住,通过这种方式只能获得元素的行内样式,而无法获得样式表和<style>标签中声明的样式。这个原因是:为了显示文档,浏览器必须组合元素的样式属性,包括该元素的所有选择器所匹配到的样式,最终计算出的结果才是用于实际显示元素的样式值,这也被称为"计算属性"。要获得最终的渲染样式,应当使用getComputedStyle方法

// css
h1 {
    color: red;
}

// html
<h1 id="test">123</h1>

//js
var color = window.getComputedStyle(test).color;
console.log(test.style.color); // ""
console.log(color); // rgb(255, 0, 0)

// 低版本的IE下存在着兼容
// 不过我在IE edge上测试貌似也只支持上面那种形式了
var color2 = test.currentStyle.color;

计算属性具有下面几个特点:

  • 计算属性的值是绝对值,类似于百分比和点之类的相对的单位将全部转换为绝对值,开发者工具提供的Elements面板的Computed选项卡,表示的就是计算属性的结果。
  • 计算样式的属性是只读的,也就是说我们无法通过改变计算属性来改变元素的样式
  • 计算属性不包括符合属性,比如margin会被拆分为marginLeftmarginTop等。

因此,我们在需要设置行内样式的使用,使用style对象;而在需要获取元素的渲染样式的时候,使用计算属性getComputedStyle(注意兼容)。

function getStyle(obj, attr) {
    return obj.currentStyle ? obj.currentStyle[attr] : getComputedStyle(obj)[attr];
}

虽然通过style对象能精确地操作元素的样式,但是,频繁地修改元素的单条样式所引起的浏览器渲染重绘代价是十分昂贵的,还需要在JS代码中插入大量的样式字符串。实际上,由于样式表是响应的,因此更通用的做法是通过切换类来达到修改元素样式的目的。

4.3. 动态设置样式类

通过切换元素的样式类,设置动态样式需要我们提前定义好相关的样式,这种方式是通过className属性来实现的,className属性代表的是元素的class属性值的一个字符串(因为class是JavaScript中的保留字),我们可以通过设置className来修改元素的所有样式类

<h1 class="text-red test" id="test">123</h1>

// 相当于getAttribute("class")
console.log(test.className); // text-red test

// 相当于setAttribute("class",newClassName)
test.className = "text-green";

由于className返回的是一个字符串,如果需要单独修改某个类就,就需要拆字符串了:

// 移除test类
test.className = test.className.replace("test","")

这么做貌似比较麻烦,幸好HTML5为元素节点提供了一个classList的属性,该属性是一个只读的类数组对象(无法被覆盖),包含了该元素节点的单独类名,

  • 由于该对象是只读的,需要通过add(classname)remove(classname)来向元素节点添加和删除类
  • 为了便于切换某个类,该对象提供了toggle(classname)的方法
  • 为了检测元素是否包含类,该对象提供了contains(classname)的方法(跟jquery很相似)
  • 该对象还有一个length属性(类名数量)和value属性(与className相同的全部类名字符串)

4.4. 定时器与动画

定时器是window全局对象的方法,每隔一段时间就调用指定函数,完成动画效果。比如一个移动的动画,其本质效果是改变元素的定位属性值,因此需要预先设定元素的定位方式。

使用DOM来操作CSS是一个很庞大的概念,这里暂时就了解到这里,关于制作动画等方面,可能需要另外再写一篇了。

5. 构造元素节点

上面提到了如何查找元素节点,以及在元素节点上进行的一些操作。事实上这是大部分JavaScript函数的工作原理,HTML文档的结构由标记负责创建,JavaScript函数只用来改变某些细节而不是底层的网页结构(这个成本会比较大)。但是,在某些时候动态创建节点也是十分有必要的。

5.1. 拼接HTML字符串

我们可以直接向文档中添加标记,让浏览器重新解析文档并生成节点。可以使用的是document.write()方法和node.innerHTML属性。

// 向文档中追加内容
document.write("<span></span>");

// 改变test节点的文本节点
test.innerHTML = "<span></span>";
// 由于使用innerHTML会替换元素的全部子节点,因此如果只是追加元素,则需要
var htm = test.innerHTML;
text.innerHTML = htm + "<span></span>";

innerHTML可以向某个具体的元素节点中输入内容;而document.write()可以看作是向<body>标签中输出内容,这里还有一个需要注意的地方:如果文档还没有解析完成,则使用write方法会向文档中追加内容;如果当文档已经解析完成(触发onload事件)后直接调用write方法,会覆盖整个文档!!

window.onload = function(){
     document.write("<p>clear</p>")
}

这是因为文档解析完毕之后,文档流已经关闭了,这个时候执行write(方法会自动调用document.open()方法来创建一个新的文档流,并写入新的内容,再通过浏览器展现,这样就会覆盖原来的内容 且会导致整个浏览器的重绘。即使在文档解析时使用write方法不会覆盖整个文档,也可能影响后续文档的生成,因此这也是为什么浏览器加载,解析和执行<script>标签时会阻塞后续文档解析的一个原因。

另外,使用上述两种方式插入元素节点,需要在JS代码中拼接大量的HTML字符串,这个看起来不是很美观,违背了“行为,样式和结构分离”的知道思想。(然而,作者肯定没想到现在流行的前端框架VueReact等,貌似又回到了样式结构和行为融合为一体的情形,哈哈)。

5.2. 生成元素节点

除了上面通过拼接HTML文档字符串生成节点,我们也可以使用Node对象的方法来创建一个节点。

// 创建对应标签的元素节点并返回一个ElementNode对象
document.createElement(tagname);

然后通过操作这个元素节点,设置其属性节点,添加样式及子元素,就可以构造一个真正的元素节点,就向是我们从DOM树上获取得到的一样。然后只需要通过某种办法将他重新挂到DOM树上就可以了。

除了生成元素节点,还可以生成其他节点,不过使用的比较少。

// 创建内容为text的文本节点
document.createTextNode(text);

// 创建评论节点
document.createComment(text);

另外,每个节点还有一个cloneNode()的方法,来返回该节点的一个全新副本,如果向该方法传入true则会递归复制该节点的所有子节点(但是不会复制绑定的事件)。通过克隆节点可以很快速的生成一个相同的节点。

5.3. 插入节点

动态创建的节点,保存在内存,需要将创建的节点插入到节点树中才会渲染出来。将节点插入DOM树,首先需要确定插入点,这里可以根据父节点确定,也可以通过兄弟节点确定。

// 将动态创建的节点插入到父节点中
parent.appendChild(node);

// 将动态创建的节点插入到兄弟节点前,注意这个方法也是在父节点上进行的
parentUl.insertBefore(oLi, siblingLi);

但是需要注意的是并没有insertAfter方法(尽管这个需求十分常见),需要自定义一个。

function insertAfter(parent, sibling, node){
    var children = parent.children;

    if (children[children.length - 1] == sibling){
        parent.appendChild(node);
    }else {
        var next = sibling.nextElementSibling;
        console.log(next);
        parent.insertBefore(node, next);
    }
}

HTML5引进了一个十分强大的插入节点的API,叫做innerAdjacentHTML(),该方法接收两个参数:

  • 第一个参数是"beforebegin"(起始标签前,即作为当前结点前一个兄弟插入),"afterbegin"(起始标签后,即作为当前结点的第一个子元素插入),"beforeend"(闭合标签前,即作为当前结点的最后一个元素插入),"afterend"(闭合标签后,即作为当前结点后一个兄弟插入)
  • 第二个参数是一个HTML文档字符串。用于生成的节点。

可以很方便地使用这个方法插入节点,只需要找到参考节点,再根据该节点传入对应的插入位置就可以了。

siblingLi.insertAdjacentHTML("afterEnd","<li>hell</li>");

对于非闭合标签(如img等标签)来说,使用afterbeginbeforeend的参数可能会无法显示插入的节点,浏览器会尝试将非闭合标签转变成闭合标签,然后将元素正确插入,然而,一个使用<img></img>包围的元素是无效的HTML文档,浏览器并不会正常解析(这个是我根据测试情况猜的~)

5.4. 删除和替换子节点

如果需要删除元素的子节点,除了暴力使用innerHTML进行覆盖之外,也可以调用removeChild方法来删除某个子节点;如果需要使用另外一个元素节点替换该节点(所谓替换就是移除旧节点然后在原来的位置插入新节点),可以使用replaceNode方法

// 移除子节点
parentUl.removeChild(siblingLi)

// 使用oLi新节点替换子节点
 parentUl.replaceChild(oLi, siblingLi)

6. 总结

看完这本书,我仍旧有一些疑惑:我所理解的W3C标准就是“结构,表现,行为三者分离”,但是随着逐步深入,发现HTML,CSS,DOM之间都有互相叠加的区域,可以在HTML中设置样式与事件响应,可以在CSS中通过伪类达成动态交互,可以在DOM中创建节点及设置样式,三者并不冲突,而具体使用则需要仔细斟酌。随着CSS3和HTML5的深入,发现这三者越来越融合的感觉,至于具体的方面,还需要进一步的学习才行。

更新于2017-3-8 《DOM编程艺术》这本书是16年五月份看的,距今已经大半年了,这本书给我的印象是循序渐进,结构合理,对于当时刚想进入前端大门的来说真是太友好了。书中不仅介绍了关于DOM的知识,还介绍了“平稳退化与渐进增强”以及“性能优化”等方面的东西,在后面的工作中,才发现这些建议真的是太重要了,尽管我现在对于兼容性的处理还是菜的不行。 这次笔记的更新,主要是重新整理了关于元素节点操作的知识,至于事件和Ajax等方面的知识,也需要重新学习整理才行。