# DOM操作常用API总结

前段时间工作中开发的项目,没有用到三大框架,也没有引入jQuery,全是原生的JS,其中涉及到了一些DOM操作,因此这两天趁着空闲的时间把DOM操作中的API回顾了一下.

这里主要将DOM操作分为了增删改查四部分.当然了,划分的可能不是那么准确.文末第二个链接里面的老哥对于类型的划分还是挺不错的,有兴趣的可以点进去看看.

# 查找操作

# getElementById

该方法返回一个匹配特定id(区分大小写)的元素,而又因为一个id一般在一个页面中是唯一的.因此这是一个高效查找特定元素的方法.但是如果同一个页面中有多个相同的id,那么只会返回第一个而忽略后面.

<div id="div1">div1 - 1</div>    
<div id="div1">div1 - 2</div>    
1
2
console.log(document.getElementById('div1').textContent)  // div1 - 1
1

# getElementsByTagName

该方法返回一个动态的包含所有指定标签名的元素的HTML集合 HTMLCollection .所谓的动态的或者即时的意思就是它会随着DOM树的变化自动更新自身.该方法可以在任何元素中查找,即可以给它一个确定的查找范围,而不仅仅只限于 document 对象.例如下面的代码中,我们就是在id为div1的div元素中寻找p标签.当然,标签名可以传入 * 代表所有元素.

<div id="div1">
    <p class="p1">1</p>
    <p class="p1">2</p>
</div>
1
2
3
4
console.log(document.getElementById('div1').getElementsByTagName('p'))  // HTMLCollection(2) [p.p1, p.p1]
1

# getElementsByClassName

该方法返回一个即时的 HTMLCollection ,包含所有指定 class 的子元素.该方法可以在任何元素中查找,即可以给它一个确定的查找范围,而不仅仅只限于 document 对象.

<div id="div0">
    <div class="div2">0-2</div>
</div>
<div id="div1">
    <div class="div2">1-2</div>
</div>
1
2
3
4
5
6
let ele = document.getElementById('div1').getElementsByClassName('div2')  // 我们将class为div2的元素的查询范围限制在id为div1的元素中
console.log(ele.length)  // 1
console.log(ele[0].textContent)  // 1-2
1
2
3

该方法在IE9以下浏览器不支持,但是可以通过已有的方法来封装.下面是一段兼容新老浏览器的写法,代码来自于《DOM编程艺术》这本书.

function getElementsByClassName(node, className){
    if(node.getElementsByClassName){
        return node.getElementsByClassName(className)
    }else{
        var results = new Array()
        var elems = node.getElementsByTagName('*')
        for(var i = 0; i < elems.length; i++){
             if(elems[i].className.indexOf(className) != -1){
                results[results.length] = elems[i]
             }
        }
        return results
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# querySelector

该方法返回文档中与指定选择器或选择器组匹配的第一个html元素 Element ,若找不到则返回 null.即它里面的参数为一个 css选择器 形式的DOM字符串.

<div id="div1">
    <p class="p1">1</p>
    <p class="p1">2</p>
</div>
1
2
3
4
let div1 = document.querySelector('#div1')
let p1 = document.querySelector('#div1>.p1')
1
2

上面的第一句代码是获取id为div1的元素,我们传入其中的参数为 #div1 ,比 getElementById 的参数多了一个 # 号.而第二句代码则是获取id为div1的元素中的class为p1的元素,这就是一个很典型的 css选择器 形式的写法.那么它与 getElementById, getElementsByClassName 这些有什么区别呢? 前面提到了 getElementsByClassName 返回的是一个即时的 HTMLCollection,即它返回一个动态集合,而 querySelector 获取返回的是一个静态集合.

# querySelectorAll

该方法返回与指定的选择器组匹配的文档中的元素列表,是一个 NodeList .它的参数形式和 querySelector 一样,只是它能返回所有的符合条件的元素.同样它返回的也是一个静态集合,而 getElementsByClassName 返回的是动态集合. 我们通过代码来具体了解一下两者区别.

<ul id="lists">
    <li class="item">1</li>
    <li class="item">2</li>
</ul>
1
2
3
4
let lists = document.getElementById('lists')
let items = lists.getElementsByClassName('item')
console.log(items)  // HTMLCollection(2) [li.item, li.item]
console.log(items.length)  // 2
for(let i = 3; i < 5; i++){
    let li = document.createElement('li')
    li.classList.add('item')
    li.innerText = i
    lists.appendChild(li)
}
console.log(items.length)  // 4


let lists2 = document.querySelector('#lists2')
let items2 = lists2.querySelectorAll('.item')
console.log(items2)  // NodeList(2) [li.item, li.item]
console.log(items2.length)  // 2
for(let i = 3; i < 5; i++){
    let li = document.createElement('li')
    li.classList.add('item')
    li.innerText = i
    lists.appendChild(li)
}
console.log(items2.length)  // 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

前一大段代码在获取class为item的li的个数后再次添加,长度会跟着动态变化,而后一大段代码中li的个数则是不变的.

# parentNode

parentNode 返回指定节点的父节点

<div id="parent">
    <div id="children"></div>
</div>
1
2
3
let parent = document.querySelector('#parent')
let children = document.querySelector('#children')
console.log(parent === children.parentNode)  // true
1
2
3

# childNodes

返回一个包含指定节点的即时更新的子节点集合, ModeList 类型. 我们看 parentNode 中的代码

<div id="parent">
    <div id="children"></div>
</div>
1
2
3
let parent = document.querySelector('#parent')
console.log(parent.childNodes)  // NodeList(3) [text, div#children, text]
1
2

可以看到返回的结果中,有好几个子节点,这是因为 childNodes 会返回文本节点.

# previousSibling 和 nextSibling

这两个方法分别返回指定节点的前一个兄弟节点和后一个兄弟节点,若不存在则返回null.

<ul>
    <li class="item1">1</li><li class="item2">2</li><li class="item3">3</li>
</ul>
1
2
3
let items1 = document.querySelector('.item1')
let items2 = document.querySelector('.item2')
let items3 = document.querySelector('.item3')
console.log(items2.previousSibling === items1)  // true
console.log(items2.nextSibling === items3)  // true
1
2
3
4
5

注意上面的代码中,三个 li 标签之间没有空隙,否则在使用 previousSiblingnextSibling 的时候可能会获取到空白文本节点.

# 创建和新增操作

# createELement

createELement 通过传入一个指定的标签名来创建一个元素节点,即使传入的不是一个标准的标签名,也会创建一个自定义标签.

let ele = document.createElement('div')
let ele2 = document.createElement('div2')  // 会创建一个 div2 标签
1
2

# createTextNode

createELement 通过传入一个文本字符来创建一个文本节点

let text = document.createTextNode('created')
1

但是上面两种节点创建后都是独立的节点,即都还不属于html文档.我们可以使用appendChildinsertBefore 等方法将其添加到html文档中.

let ele = document.createElement('div')
let text = document.createTextNode('created')
ele.appendChild(text)
document.body.appendChild(ele)
1
2
3
4

# cloneNode

cloneNode 该方法返回一个调用该方法的节点的副本,即谁调用了 cloneNode ,就返回'谁'的一个复制品.它接受一个Boolean类型的参数,表示是否复制这个'谁'的子元素.为true则表示连子节点一起复制了,为false则表示只复制当前的这个'谁',而不去管它的子节点. 注意了,这里复制出来的副本节点还是一个不存在于html文档树中的独立节点.同样的,我们可以使用 appendChild 来将其添加到文档树中.

<div id="app">
    <p class="p1">1</p>
</div>
1
2
3
let ele = document.querySelector('#app')
let p1 = document.querySelector('.p1')
let newNode1 = p1.cloneNode(true) 
let newNode2 = p1.cloneNode(false)

p1.onclick = function(){
    console.log('click event')
}
p1.addEventListener('click', function(){
    console.log('addEventListener click')
})

app.appendChild(newNode1)
app.appendChild(newNode2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在上面的代码中,我们生成了两个节点,即 newNode1newNode2.我们可以在控制台查看页面结构,发现 newNode2 生成的节点是没有子节点的,而 newNode1 则是有的(文本节点即是它的子节点).这里最好将这个Boolean类型的参数填入,以免不同浏览器会有不同的处理表现. 并且在上面的代码中,我们不但给 p1 节点添加了 click 事件,并且还给它注册了监听器.这里简单的介绍下这两者之间的区别,onclick 事件在同一时间同一对象只能绑定一个,绑多了则是后面的会覆盖前面的.而 addEventListener 则是可以给一个事件注册多个监听器,可以绑定好多个.通过点击生成的节点我们可以发现,不管是通过 onclick 还是 addEventListener 绑定事件,在副本节点中都不会将该绑定事件复制过来.那有没有什么办法可以将事件行为也一起复制过来呢?有的,假如我们使用html标签内联绑定的方式,那么副本节点也能触发事件了.

<div id="app">
    <p class="p1" onclick="log()">1</p>
</div>
1
2
3
let ele = document.querySelector('#app')
let p1 = document.querySelector('.p1')
let newNode1 = p1.cloneNode(true)
function log(){
    console.log('bind html')
}
app.appendChild(newNode1)
1
2
3
4
5
6
7

# createDocumentFragment

createDocumentFragment 可以用来创建一个文档片段,用来存储临时的节点以便后面统一添加到DOM文档树中.一般在添加大量节点的时候,我们选择使用这个方法来优化性能.我们先来看下面的代码,循环渲染10个元素,并将其添加到文档中.

let app = document.querySelector('#app')
for(let i = 0; i < 10; i++){
    let ele = document.createElement('div')
    ele.innerText = `new node ${i}`
    app.appendChild(ele)
}
1
2
3
4
5
6

上面的代码能够实现我们的需求,但是会有性能问题.因为浏览器内核可以分成两部分,渲染引擎JS引擎 .其中渲染引擎负责渲染页面,JS引擎负责JavaScript脚本的解释与执行.又因为JS是单线程的,所以它需要不停的在两个引擎之间来回切换.这个过程的耗时是比较久的,所以这就是为啥操作DOM会消耗性能的原因. 现在我们每循环一次,都会创建一个新的元素,当它被塞进DOM文档树的时候,会引起浏览器的回流.这里简单介绍下浏览器的回流重绘.关于它们具体的定义我这里就不讲了.来举个简单的例子告诉大家什么时候会回流,什么时候会重绘.当我们改变一个元素的背景颜色时,会引起重绘,因为这个过程并没有实际改变元素的位置等布局信息.当我们改变一个元素的高度时,浏览器需要重新计算每个元素的位置,那么此时引起的就是回流.它们之间的包含关系是重绘不一定会引起回流,但是回流一定会引起重绘.既然我们知道了引起性能问题的原因,那么我们就用 createDocumentFragment 来优化它.

let app = document.querySelector('#app')
let tmp = document.createDocumentFragment()
for(let i = 0; i < 10; i++){
    let ele = document.createElement('div')
    ele.innerText = `new node ${i}`
    tmp.appendChild(ele)
}
app.appendChild(tmp)
1
2
3
4
5
6
7
8

我们通过 createDocumentFragment 方法生成的对象不是文档树中的一部分,它保存在内存中.因此在进行循环生成的时候,不会造成频繁的浏览器回流.当循环结束的时候,再一次性将全部节点添加到DOM中.

# appendChild

appendChild 这个方法在前面的各种例子中其实已经出现了好多次了.前面说到了好几个创建节点的方法,但都只是创建节点,并没有真的将节点塞进DOM树中,而 appendChild 方法则是替我们干了这个事情. appendChild 方法将一个节点附加到指定的父节点的原有最后一个子节点之后,若被插入的节点原先已经存在于此文档的文档树中,则它会自动从原先的位置移动到新位置.所以可以说这既是一个增的方法,也是一个改的方法.

<div id="app">
    <p class="p1">1</p>
    <p class="p2">2</p>
    <p class="p3">3</p>
</div>
1
2
3
4
5
let app = document.querySelector('#app')
let p2 = document.querySelector('.p2')
app.appendChild(p2)
1
2
3

# insertBefore

appendChild 是将节点插入到最后,而 insertBefore 可以帮我们将节点准确的插入我们想要的位置. insertBefore 的语法如下: parentNode.insertBefore(newNode, referenceNode) .其中 referenceNode 是一个指定节点,表明我们想要将新节点插入到它之前.若新节点已经存在于文档树中,则它会自动从原先的位置移动到新位置,这点和 appendChild 是类似的.

<div id="app">
    <p class="p1">1</p>
    <p class="p2">2</p>
    <p class="p3">3</p>
</div>
1
2
3
4
5
let app = document.querySelector('#app')
let p2 = document.querySelector('.p2')
let el = document.createElement('p')
el.classList.add('p1-2')
el.innerText = '1-2'
app.insertBefore(el, p2)
1
2
3
4
5
6

# 删除操作

# removeChild

removeChild 用于删除指定元素的某个子节点,若子节点不存在则返回null

<ul>
    <li class="list">1</li>
    <li class="list">2</li>
    <li class="list">3</li>
</ul>
1
2
3
4
5
let lists = document.querySelectorAll('.list')
let list2 = document.querySelectorAll('.list')[1]
let deleteLi = lists[0].parentNode.removeChild(list2)
console.log(deleteLi)  // <li class="list">2</li>
lists[0].parentNode.appendChild(deleteLi)
1
2
3
4
5

结果页面的顺序是 1 3 2,结合上面的 console.log(deleteLi) 结果,可以得出结论:被删除的节点虽然不在文档树中,但是却还在内存中,我们仍然可以对其进行操作. 此外若删除的节点不是其子节点的话,则会报错:Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node. 修改如下以确保不会报错:

if(node.parentNode){
    node.parentNode.removeChild(node)
}
1
2
3

# remove

上面我们说到了通过 removeChild 来删除元素节点的时候,需要先找到自身元素的父节点,然后再找到父节点下面的子元素.这里我们来说一个可以直接删除自身节点的方法 remove.

<ul>
    <li class="li1">1</li>
    <li class="li2">2</li>
</ul>
1
2
3
4
let li1 = document.querySelector('.li1')
li1.remove()  // <li class="li1">1</li> 这个元素被删除掉了
1
2

但此方法的兼容性不是很好,在IE中无法使用,但是可以通过Polyfill来使IE9及以上的浏览器兼容.但是其实Polyfill的原理也还是使用 removeChild 来模拟实现 remove 的功能.

# 修改操作

# replaceChild

replaceChild 方法使用一个指定的节点替换当前节点的一个子节点,并返回被替换掉的节点.其语法结构为: parentNode.replaceChild(newChild, oldChild)

<div id="app">
    <p class="p1">1</p>
    <p class="p2">2</p>
    <p class="p3">3</p>
</div>
1
2
3
4
5
let app = document.querySelector('#app')
let p2 = document.querySelector('.p2')
let el = document.createElement('p')
el.classList.add('p4')
el.innerText = '4'
let r = app.replaceChild(el, p2)
console.log(r)  // <p class="p2">2</p>
1
2
3
4
5
6
7

# 总结:

参考链接:

DOM