JavaScript学习笔记:动态集合

发布于 大漠

DOM是JavaScript中重要部分之一,在DOM中有一个动态集合。这个动态集合包含节点的集合(NodeList)、元素属性的集合(NamedNodeMap)和HTML元素的集合(HTMLCollection)。这三个对象都是类数组(Array-like),具有像数组一样的特性。更为重要的是,它们都是动态的,是有有生命有呼吸的对象,会实时更新查询DOM结构。今天我们学习的目标就是深究这三个动态集合之间的用法和联系以及区别

类数组

文章开头就提到了,DOM中的动态集合都是一个对象,而且是一个类数组。那么什么是类数组呢?

对于类数组,简单的描述:

JavaScript中的对象看起来像却又不是数组的对象。

JavaScript的一个类数组对象有两个典型的特性:

  • 具有:指向对象元素的数字索引下标以及length属性告诉我们对象的元素个数
  • 不具有:不具有诸如push()forEach()以及indexOf()等数组对象具有的方法

JavaScript中所说的这些类数组对象有一些,其中包括argumentsarguments是一个很特殊的变量,在所有的函数体内都可以访问到。比如:

let testFun = function () {
    console.log(arguments)
    console.log(arguments.length)
}

但如果我们在控制器中输入arguments.shift()是将会报错:

Uncaught TypeError: arguments.shift is not a function

shift()是数组的一个函数。我们在尝试一下,在函数体内打印arguments.constructor[].constructor,分别会打印出Object()Array[]

let testFun = function () {
    console.log(arguments)
    console.log(arguments.length)
    console.log(arguments.constructor)
    console.log([].constructor)
}

从结果上看,是不是觉得很奇怪?

这不仅局限于argumetns,在DOM中的很多集合都会返回这种对象(类数组对象),比如document.getElementsByTagName()document.imagesdocument.childNodes等。

这里也提到了,假如我们在操作DOM的时候,使用了document.getElementsByTagName()得到的是一个类数组对象,要操作DOM,又避免不了对这个类数组对象进行操作。那么问题又来了,类数组对象是不具备数组中的方法。这样一来,就需要让类数组对象转换为一个数组。

将类数组对象转换为数组最经典的一个方法就是使用Arrayslice()方法:

var arr = Array.prototype.slice.call(arguments);

// 等同于

var arr = [].slice.call(arguments)

另外在ES6中,可以使用Array.from()方法:

var arr = Array.from(arguments);

只要有length属性的对象,都可以应用这个方法转换成为数组。除此之外,还可以使用ES6中的扩展运算符...将某些数据结构转换成数组,这种数据结构必须有遍历器接口。

var args = [...arguments];

DOM中的动态集合

为了更好的阐述后面的内容,我们之后的示例,都会用到下面这个HTML结构:

<!DOCTYPE html>
<html>
    <head>
        <title>DOM Tree Sample Document</title>
    </head>
    <body>
        <div id="box">
            <!-- 标题 -->
            <div>Title</div>
            <div class="item">Item1</div>
            <div class="item">Item2</div>
            <div class="item">Item3</div>
            <div class="item">Item4</div>
            <div class="item">Item5</div>
        </div>
    </body>
</html>

NodeList集合

在《初识JavaScript的DOM》一节中,我们知道了DOM将HTML页面解析成一个由多层次节点构成的结构。节点是页面结构的基础,而所有节点继承自NOde类型,因此所有节点共享着基本的属性和方法。

其中NodeListnode节点的集合,用于保存一组有序的节点,可以通过节点的位置访问这些节点。而且NodeList是一种类数组对象。Node类型有一个childNodes属性,通过这个属性可以得到一个保存着本节点的子点节点组成的NodeList对象。除此之外,还可以使用querySelectorAll()方法返回值中保存着NodeList对象。

比如上面的示例代码,先看childNodes属性中的NodeList对象:

let box = document.getElementById('box')
let children = box.childNodes;

console.log(children, children.length)
console.log(children instanceof NodeList)

输出的结果如下:

再来看document.querySelectAll()方法返回值中的NodeList对象:

let divs = document.querySelectorAll('div')
console.log(divs, divs.length)
console.log(divs instanceof NodeList)

NodechildNodesdocument.querySelectAll()对应的NodeList是有所不同的,前者是动态的,后者是静态的。比如:

甚至什么是动态NodeList,什么是静态NodeList?后续会阐述。这里暂时不深究。

NodeList可以通过[]表达式来访问,也可以通过item()方法来访问。而且它也有length属性,可以访问元素个数。虽然JavaScript中的数组可以修改length属性,但NodeList是一个类数组,而且它是页面一片区域的DOM结构映射。所以不要修改NodeList对象的length

console.log('First Child:', children[0])
console.log('Second Child:', children.item(1))
console.log('Last Child:', children[children.length - 1])

HTMLCollection集合

HTMLCollection对象与NodeList对象类似,都是节点的集合,返回的都是类数组对象。但也有其不同之处,其中NodeList集合包含着node节点中12种节点,而HTMLCollection仅包含elements元素节点的集合。

HTMLCollection的集合可以通过getElementsByTagName()getElementsByName()document.anchorsdocument.formsdocument.imagesdocumnet.links等方式来获取。比如:

// 获取NodeList
let nodeList = document.getElementById('box').childNodes
console.log(nodeList, nodeList.length)

// 获取HTMLCollection
let htmlCollectionList = document.getElementsByTagName('div')
console.log(htmlCollectionList, htmlCollectionList.length)

HTMLCollectionNodeList类似,都是类数组,同样可以使用[]或者item()来访问。

console.log('First Element:', htmlCollectionList[0])
console.log('Last Element:', htmlCollectionList.item(htmlCollectionList.length - 1))

HTMLCollectionNodeList都是DOM的节点集合;但是它们两个能够包含的元素是不太一样的,HTMLCollection只可以包含HTML元素(Element)集合,NodeList可以包含任意的节点类型,就是说NodeList不仅可以包含HTML元素集合,也可以包含像文字节点,注释节点等类型的节点集合。

从上图可以看到,就上例而言,nodeList是一个NodeList集合,它包含了8text节点(TEXT_NODE = 3),一个comment节点(COMMENT_NODE = 8)和6div元素节点(ELEMENT_NODE = 1);htmlCollectionList是一个HTMLCollection集合,它只包含了7div元素(ELEMENT_NODE = 1)。

HTMLCollectionNodeList还有一个不同之处就是多一个namedItem方法,其它的方法它们两个都相同的。有关于这两者更深入的介绍,可以查阅下面的资料:

NameNodeMap集合

DOM中的Element节点是唯一拥有attributes属性的一种节点类型。而attributes属性中就包含NamedNodeMap集合。NamedNodeMap集合的元素拥有nodeNamenodeValue属性,分别表示元素节点名称和值。

三者的异同

虽然NodeListHTMLCollectionNamedNodeMap都是DOM的动态集合,但三者之间也有差异。先来看三者相同之处:

  • 三者都具有length属性
  • 三者都有item()方法
  • 三者都是动态的,如果对NodeListHTMLCollection中的元素进行操作都会直接反映到DOM中,因此如果一次性直接在集合中进行DOM操作,开销非常大

另外三者也有不同之处:

  • NodeList里面包含了所有的节点类型
  • HTMLCollection里面只包含元素节点
  • NamedNodeMap里面包含了Attribute的集合,例如idtitleclass等,集合中的每一个元素都是attr类型
  • 三者所提供的方法也有不同,例如HTMLCollection中提供了namedItem(),而NodeListNamedNodeMap两个集合中没有namedItem()方法

将动态集合类数组转换为数组

文章开头了解arguments对象时都知道它是一个类数组对象,有数组的表达式,但没有数组方法。而DOM的三个动态集合HTMLCollectionNodeListNodedNameMaparguments对象一样,也是类数组。因此必须将类数组转换为DOM元素的数组。拿NodeList为例:

const nodeList = document.querySelectorAll('div');
const nodeListToArray = Array.apply(null, nodeList);

//之后 ..
nodeListToArray.forEach(...);
nodeListToArray.map(...);
nodeListToArray.slice(...);

apply方法可以在指定this时以数组形式向方法传递参数。MDN规定apply可以接受类数组对象,恰巧就是querySelectorAll方法所返回的内容。如果我们不需要指定方法内this的值时传null0即可。返回的结果即包含所有数组方法的DOM元素数组。

另外你可以使用Array.prototype.slice结合Function.prototype.callFunction.prototype.apply, 将类数组对象当做this传入:

const nodeList = document.querySelectorAll('div');
const nodeListToArray = Array.prototype.slice.call(nodeList); 
// 等价于 
// const nodeListToArray = Array.prototype.slice.apply(nodeList);

//之后 ..
nodeListToArray.forEach(...);
nodeListToArray.map(...);
nodeListToArray.slice(...);

如果你正在用ES6你可以使用展开运算符 ...

// 返回一个真正的数组
const nodeList = [...document.querySelectorAll('div')]; 

//之后 ..
nodeList.forEach(...);
nodeList.map(...);
nodeList.slice(...);

为了方便操作或者之后更易复用,可以写一个转换函数convertToArray()

function convertToArray(nodes) {
    var array = null

    try {
        array = nodes.prototype.slice.call(nodes, 0)
    } catch {
        array = new Array()
        for (let i = 0, len = nodes.length; i < len; i++) {
            array.push(nodes[i])
        }
    }
}

动态NodeList和静态NodeList

前面提到过,getElementsByTagName()方法返回一个动态(live)的NodeList,而querySelectorAll()返回的是一个静态(static)的NodeList。那么什么是动态的NodeList,什么又是静态的NodeList,他们有何区别呢?接下来,花点时间了解一下。

动态NodeList

动态的NodeList是DOM中的一个大坑。NodeList对象以及HTMLCollection对象是一种特殊类型的对象。DOM3规范HTMLCollection对象的描述如下:

DOM中的NodeListNamedNodeMap对象是动态的;也就是说,对底层文档结构的修改会动态地反映到相关的集合NodeListNamedNodeMap中。例如,如果先获取了某个元素(Element)的子元素的动态集合NodeList对象,然后又在其他地方顺序添加更多子元素到这个DOM父元素中(可以说添加、修改、删除子元素等操作),这些更改将自动反射到NodeList,不需要手动进行其他调用。同样地,对DOM树上某个Node节点的修改,也会实时影响引用了该节点的NodeListNamedNodeMap对象。

上面的大概意思就是说,DOM中的NodeList是一种特殊的对象,它是实时更新的,就是你对这个NodeList中的任何一个元素进行的一些操作,都会实时的更新到这个NodeList对象上面。比如下面这个例子:

let box = document.getElementById('box')
let liveNodeList = document.getElementsByTagName('div')

console.log(liveNodeList, liveNodeList.length)

let newEle = document.createElement('div')
newEle.textContent = '新创建的div元素'

box.appendChild(newEle)

console.log(liveNodeList, liveNodeList.length)

上图已经很允分的说明了liveNodeList是一个动态的NodeList或者说HTMLCollection。第一次打印出liveNodeList的时候,它的length值为7,也就是说,这个时候这个集合里面有七个元素;但经过后面的操作,添加了一个新的div元素,这个操作会实时的反映到这个对象身上。然后就会出现了上面的那种情况。

上面示例中getElementsByTagName()方法返回对应在标签名的元素的一个动态集合,只要document发生了变化,就会自动更新对应的元素。那么一不小心就会进入一个死循环。比如:

var liveNodeList = document.getElementsByTagName('div')
var i = 0

while(i < liveNodeList.length) {
    document.getElementById('box').appendChild(document.createElement('div'))
    i++
}

死循环的原因是每次循环都会重新计算 liveNodeList.length。 每次迭代都会添加一个新的 <div>, 所以每次 i++ ,对应的 liveNodeList.length 也在增加, 所以 i 永远比liveNodeList.length小, 循环终止条件也就不会触发(例外的情况是DOM中没有div,不进入循环)。

你可能会觉得这种动态集合是个坏主意, 但通过动态集合可以保证某些使用非常普遍的对象在各种情况下都是同一个, 如 document.images , document.forms, 以及其他类似的 pre-DOM集合。

静态NodeList

前面提到过querySelectorAll()方法将会返回一个静态的NodeList

W3C规范是这样描述静态NodeList的:

querySelectorAll()方法返回的NodeList对象必须是静态的,而不能是动态的。后续对底层document的更改不能影响到返回的这个NodeList对象。这意味着返回的对象将包含在创建列表那一刻匹配的所有元素节点。

上面的大概意思就是说,通过使用querySelectorAll()方法返回的NodeList集合必须是静态的,就是一旦获取到这个结果;那么这个结果不会因为后面再对这个集合中元素进行的操作而进行改变。我们可以改变一下上面的例子:

let box = document.getElementById('box')
let liveNodeList = document.querySelectorAll('div')

console.log(liveNodeList, liveNodeList.length)

let newEle = document.createElement('div')
newEle.textContent = '新创建的div元素'

box.appendChild(newEle)

console.log(liveNodeList, liveNodeList.length)

liveNodeList = document.querySelectorAll('div')
console.log(liveNodeList, liveNodeList.length)

上面这张图片展示的结果跟我们的预期是一样的,也就是说,静态的NodeList集合,一旦获取到结果,就不会再次因为这个集合中的元素发生变化而发生改变。

所以即便是让 querySelectorAll()getElementsByTagName() 具有相同的参数和行为, 他们也是有很大的不同点。 在前一种情况下, 返回的 NodeList 就是方法被调用时刻的文档状态的快照, 而后者总是会随时根据document的状态而更新。 下面的代码就不会是死循环:

var liveNodeList = document.querySelectorAll("div"),
    i=0;

while(i < liveNodeList.length){
    let newEle = document.createElement('div')
    newEle.textContent = 'new ele' + i
    document.getElementById('box').appendChild(newEle)

    i++;
}

在这种情况下没有死循环, liveNodeList.length的值永远不会改变, 所以循环实际上就是将 <div> 元素的数量增加一倍, 然后就退出循环。

为什么动态NodeList比静态NodeList更快

动态 NodeList 对象在浏览器中可以更快地被创建并返回,因为他们不需要预先获取所有的信息, 而静态 NodeList 从一开始就需要取得并封装所有相关数据. 再三强调要彻底了解这一点, WebKit 的源码中对每种 NodeList 类型都有一个单独的源文件: DynamicNodeList.cppStaticNodeList.cpp。两种对象类型的创建方式是完全不同的。

DynamicNodeList 对象通过在cache缓存中 注册它的存在 并创建。 从本质上讲, 创建一个新的 DynamicNodeList 是非常轻量级的, 因为不需要做任何前期工作。 每次访问 DynamicNodeList 时, 必须查询 document 的变化, length 属性 以及 item() 方法证明了这一点(使用中括号的方式访问也是一样的)。

相比之下, StaticNodeList 对象实例由另一个文件创建,然后循环填充所有的数据 。 在 document 中执行静态查询的前期成本上比起 DynamicNodeList 要显著提高很多倍。

如果真正的查看WebKit的源码,你会发现他为 querySelectorAll() 明确地 创建一个返回对象 ,在其中又使用一个循环来获取每一个结果,并创建最终返回的一个 NodeList

可以这样来理解:

因为通过getElementsByTagName()获取到的NodeList是一个实时的集合,这种动态的集合,是不需要在一开始的时候就获取到所有的信息的;然而通过querySelectorAll()方法获取到的的NodeList集合是一个静态的集合,这个集合相当于一个快照,就是在这个方法运行的那个时间,它所要获取的集合元素的一个快照,所以这个集合要保存大量的信息,速度自然会慢下来。

也就是说,

使用getElementsByTagName()方法我们得到的结果就像是一个对象的索引,而通过querySelectorAll()方法我们得到的是一个对象的克隆;所以当这个对象数据量非常大的时候,显然克隆这个对象所需要花费的时间是很长的。

在以后需要用到获取元素集合的方法的时候,我们就要根据不同的场景来选择使用不同的方法了。如果你不需要一个快照,那就选择使用getElementsByTagName()方法,如果你需要一个快照来进行复杂的CSS查询,或者复杂的DOM操作的话,那就选择使用querySelectorAll()方法。

这也就是为什么说getElementsByTagName()在所有浏览器上都比querySelectorAll()要快好多倍。

总结

DOM中有三个动态集合,它们分别是NodeListHTMLCollectionNamedNodeMap,而这三个集合都是类数组对象。具有数组的表现方式,但没有不具备数组的方法。在实际使用时,需要将类数组转换为数组。更为重要的是,它们都是动态的,是有有生命有呼吸的对象,会实时更新查询DOM结构。除此之外,动态集合将会有**动态NodeList静态NodeList**之分,并且动态NodeList要比静态NodeList要快。其根本原因在于两者对象不同。这也是为什么说getElementsByTagName() 速度比 querySelectorAll() 快的根本原因所在。

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《图解CSS3:核心技术与案例实战》。

如需转载,烦请注明出处:https://www.fedev.cn/javascript/dom-dynamic-collection.htmlSneaker Podcast