“前端框架真的太香了,我都不敢徒手撕 DOM 了!”javascript
虽然绝大多数前端er都有这样的困扰,但本着基础为大的原则,手撕 DOM 应当是一个前端攻城狮的必备技能,这正是本文诞生的初衷 —— DOM 并无那么难搞,若是能去充分利用它,那么你离爱上它就不远了。css
三年前我初入前端坑的时候,发现了一个叫作 jQuery 的宝贝,她有一个神奇的 $ 函数,可让我快速选中某一个或一组 DOM 元素,并提供链式调用以减小代码的冗余。虽然如今提到 jQuery 这个名词,你会以为老土,“都 9102 年了你跟我说 Nokia?”。**土归土,但也是真的香。**尽管这几年风生水起的 Vue、React 加重了 jQuery 的没落,但全世界仍有超过 6600 万个网站在使用 jQuery,占全球全部网站数量的 74%。html
jQuery 也给业界留下了产生深远影响的“遗产”,W3C 就仿照其 $ 函数实现了 querySelector 和 querySelectorAll。而讽刺的是,也正是这两个原生方法的出现,大大加快了 jQuery 的没落,由于它们取代了前者最经常使用的功能 —— 快捷的选择 DOM 元素。前端
虽然这两个新方法写起来有点长(问题不大,封装一哈),可是它们是真的贼好用。java
接下来我会以如今已经很经常使用了的 querySelector 为起点,介绍一波很好用的 DOM api。
来,冲!node
向 document.querySelector 中传入任何有效的 css 选择器,便可选中单个 DOM 元素:api
document.querySelector('.element')
document.querySelector('#element')
document.querySelector('div')
document.querySelector('[name="username"]')
document.querySelector('div + p > span')
复制代码
若是页面上没有指定的元素时,返回 null数组
使用 document.querySelectorAll 能够获取一个元素集合,它的传参和 document.querySelector 一毛同样。它会返回一个**静态的 NodeList **,若是没有元素被查找到,则会返回一个空的 NodeList 。浏览器
NodeList 是一个可遍历的对象(aka:伪数组),虽然和数组很像,但它确实不是数组,虽然能够利用 forEach 遍历它,但它并不具有数组的一些方法,好比 map、reduce、find。前端框架
那么问题来了,**如何将一个伪数组转化为数组呢?**ES6 为开发者提供了两个便利的选择:
const arr = [...document.querySelectorAll('div')]
// or
const alsoArr = Array.from(document.querySelectorAll('div'))
复制代码
远古时代,开发者们经常使用 getElementsByTagName 和 getElementsByClassName 去获取元素集合,但不一样于 querySelectorAll,它们获取的是一个动态的 HTMLCollection,这就意味着,它的结果会一直随着 DOM 的改变而改变。
当须要查找元素时,不必定每次都基于 document 去查找。开发者能够在任何 HTMLElement 上进行 DOM 元素的局部搜索:
const container = document.querySelector('#container')
container.querySelector('#target')
复制代码
事实证实,每一个优秀的开发者都是很懒的。为了减小对宝贝键盘的损耗,我通常会这么干:
const $ = document.querySelector.bind(document)
复制代码
保护机械键盘,从我作起。
上述内容的主题是查找 DOM 元素,这是一个自上而下的过程:从父元素向其包含的子元素发起查询。
但没有一个 API 能够帮助开发者借由子元素向父元素发起查询。
迷惑之际,MDN 给我提供了一个宝藏方法:closest 。
Starting with the
Element
itself, theclosest()
method traverses parents (heading toward the document root) of theElement
until it finds a node that matches the provided selectorString. Will return itself or the matching ancestor. If no such element exists, it returnsnull
.
也就是说,closest 方法能够从特定的 HTMLElement 向上发起查询,找到第一个符合指定 css 表达式的父元素(也能够是元素自身),若是找到了文档根节点尚未找到目标时,就会返回 null 。
若是用原生 JavaScript 向 DOM 中添加一个或多个元素,通常开发者的心里都是抗拒的,为啥呢?假设向页面添加一个 a 标签:
<a href="/home" class="active">首页</a>
复制代码
正常状况下,须要写出以下的代码:
const link = document.createElement('a')
link.setAttribute('href', '/home')
link.className = 'active'
link.textContent = '首页'
// finally
document.body.appendChild(link)
复制代码
真的麻烦。
而老大哥 jQuery 能够简化为:
$('body').append('<a href="/home" class="active">首页</a>')
复制代码
但,各位观众,现在原生 JavaScript 也能够实现这一操做了:
document.body.insertAdjacentHTML(
'beforeend',
'<a href="/home" class="active">首页</a>'
)
复制代码
这个方法容许你将任何有效的 HTML 字符串插入到一个 DOM 元素的四个位置,这四个位置由方法的第一个参数指定,分别是:
<!-- beforebegin -->
<div>
<!-- afterbegin -->
<span></span>
<!-- beforeend -->
</div>
<!-- afterend -->
复制代码
舒服了呀。
更舒服的是,它还有两个好兄弟,让开发者能够快速地插入 HTML 元素和字符串:
// 插入 HTML 元素
document.body.insertAdjacentElement(
'beforeend',
document.createElement('a')
)
// 插入文本
document.body.insertAdjacentText('afterbegin', 'cool!')
复制代码
上面提到的兄弟方法 insertAdjacentElement 也能够用来对已存在的元素进行移动,换句话说:当传入该方法的是已存在于文档中的元素时,该元素仅仅只会被移动(而不是复制并移动)。
若是你有如下 HTML:
<div class="first">
<h1>Title</h1>
</div>
<div class="second">
<h2>Subtitle</h2>
</div>
复制代码
而后操做一下,把 <h2>
搞到 <h1>
的后面去:
const h1 = document.querySelector('h1')
const h2 = document.querySelector('h2')
h1.insertAdjacentElement('afterend', h2)
复制代码
因而咱们就获得了这样的结果:
<div class="first">
<h1>Title</h1>
<h2>Subtitle</h2>
</div>
<div class="second">
</div>
复制代码
replaceChild? 这是几年前的作法了,每当开发者须要替换两个 DOM 元素,除了须要拿到这必须的两个元素以外,还须要获取他们的直接父元素:
parentNode.replaceChild(newNode, oldNode)
复制代码
而现在,开发者们可使用 replaceWith 就能够完成两个元素之间的替换了:
oldElement.replaceWith(newElement)
复制代码
从用法上来讲,要比前者清爽一些。
须要注意的是:
和替换元素的老方法相同,移除元素的老方法一样须要获取到目标元素的直接父元素:
const target = document.querySelector('#target')
target.parentNode.removeChild(target)
复制代码
如今只须要在目标元素上执行一次 remove 方法就 ok 了:
const target = document.querySelector('#target')
target.remove()
复制代码
细心的你必定发现了,上文提到的 insertAdjacent 方法容许开发者直接将一段 HTML 插入到文档当中,若是咱们此刻只想生成一个 DOM 元素以备未来使用呢?
DOMParser 对象的 parseFromString 方法便可知足这样的需求。该方法能够实现将一串 HTML 或 XML 字符串转化为一个完整的 DOM 文档,也就是说,当咱们须要得到预期的 DOM 元素时,须要从方法返回的 DOM 文档中获取这个元素:
const createSingleElement = (domString) => {
const parser = new DOMParser()
return parser.parseFromString(domString, 'text/html').body.firstChild
}
// usage
const element = createSingleElement('<a href="./home">Home</a>')
复制代码
标准的 DOM API 为开发者们提供了不少便利的方法去检查 DOM 。好比,matches 方法能够判断出一个元素是否匹配一个肯定的选择器:
// <div class="say-hi">Hello DOM!</div>
const div = document.querySelector('div')
div.matches('div') // true
div.matches('.say-hi') // true
div.matches('#hi') // false
复制代码
contains 方法能够检测出一个元素是否包含另外一个元素(或者:一个元素是不是另外一个元素的子元素):
// <div><h1>Title</h1></div>
// <h2>Subtitle</h2>
const $ = document.querySelector.bind(document)
const div = $('div')
const h1 = $('h1')
const h2 = $('h2')
div.contains(h1) // true
div.contains(h2) // false
复制代码
compareDocumentPosition 是一个强大的 API ,它能够快速判断出两个 DOM 元素的位置关系,诸如:先于、跟随、是否包含。它返回一个整数,表明了两个元素之间的关系。
// 仍是用上面的例子哈
container.compareDocumentPosition(h1) // 20
h1.compareDocumentPosition(container) // 10
h1.compareDocumentPosition(h2) // 4
h2.compareDocumentPosition(h1) // 2
复制代码
标准语句:
element.compareDocumentPosition(otherElement)
复制代码
返回值定义以下:
那么问题来了,为何上面例子中第一行的结果是20、第二行的结果是10呢?
由于 h1 同时知足“被 container 所包含(16)” 和 “在 container 以后”,因此语句的执行结果是 16+4=20,同理可推出第二条语句的结果是 8+2=10。
在处理用户交互的时候,当前页面的 DOM 元素一般会发生不少变化,而有些场景须要开发者们监听这些变化并在触发后执行相应的操做。MutationObserver 是浏览器提供的一个专门用来监听 DOM 变化的接口,它强大到几乎能够观测到一个元素的全部变化,可观测的对象包括:文本的改变、子节点的添加和移除和任何元素属性的变化。
如同往常同样,若是想构造任何一个对象,那就 new 它的构造函数:
const observer = new MutationObserver(callback)
复制代码
传入构造函数的是一个回调函数,它会在被监听的 DOM 元素发生改变时执行,它的两个参数分别是:包含本次全部变动的列表 MutationRecords 和 observer 自己。其中,MutationRecords 的每一条都是一个变动记录,它是一个普通的对象,包含以下经常使用属性:
- type: 变动的类型,attributes / characterData / childList
- target: 发生变动的 DOM 元素
- addedNodes: 新增子元素组成的 NodeList
- removedNodes: 已移除子元素组成的的 NodeList
- attributeName: 值发生改变的属性名,若是不是属性变动,则返回 null
- previousSibling: 被添加或移除的子元素以前的兄弟节点
- nextSibling: 被添加或移除的子元素以后的兄弟节点
根据目前的信息,能够写一个 callback 函数了:
const callback = (mutationRecords, observer) => {
mutationRecords.forEach({
type,
target,
attributeName,
oldValue,
addedNodes,
removedNodes,
} => {
switch(type) {
case 'attributes':
console.log(`attribute ${attributeName} changed`)
console.log(`previous value: ${oldValue}`)
console.log(`current value: ${target.getAttribite(attributeName)}`)
break
case 'childList':
console.log('child nodes changed')
console.log('added: ${addedNodes}')
console.log('removed: ${removedNodes}')
break
// ...
}
})
}
复制代码
至此,咱们有了一个 DOM 观察者 observer,也有了一个完整可用的 DOM 变化后的回调函数 callback,就差一个须要被观测的 DOM 元素了:
const target = document.querySelector('#target')
observer.observe(target, {
attributes: true,
attributeFilter: ['class'],
attributesOldValue: true,
childList: true,
})
复制代码
在上面的代码中,咱们经过调用观察者对象的 observe 方法,对 id 为 target 的 DOM 元素进行了观测(第一个参数就是须要观测的目标元素),而第二个元素,咱们传入了一个配置对象:开启对属性的观测 / 只观测 class 属性 / 属性变化时传递属性旧值 / 开启对子元素列表的观测。
配置对象支持以下字段:
- attributes: Boolean,是否监听元素属性的变化
- attributeFilter: String[],须要监听的特定属性名称组成的数组
- attributeOldValue: Boolean,当监听元素的属性发生变化时,是否记录并传递属性的上一个值
- characterData: Boolean,是否监听目标元素或子元素树中节点所包含的字符数据的变化
- characterDataOldValue: Boolean,字符数据发生变化时,是否记录并传递其上一个值
- childList: Boolean,是否监听目标元素添加或删除子元素
- subtree: Boolean,是否扩展监视范围到目标元素下的整个子树的全部元素
当再也不监听目标元素的变化时,调用 observer 的 disconnect 方法便可,若是须要的话,能够先调用 observer 的 takeRecords 方法从 observer 的通知队列中删除全部待处理的通知,并将它们返回到一个由 MutationRecord 对象组成的数组当中:
const mutationRecords = observer.takeRecords()
callback(mutationRecords)
observer.disconnect()
复制代码
尽管大部分 DOM API 的名字都很长(写起来很麻烦),但它们都是很是强大而且通用的。这些 API 每每旨在为开发者提供底层的构建单元,以便在此之上创建更为通用和简洁的抽象逻辑,所以从这个角度出发,它们必须提供一个完整的名称以变得足够明确和清晰。
只要能发挥出这些 API 本应该发挥出的潜能,多敲几下键盘又何妨呢?
DOM 是每一个 JavsScript 开发者必不可少的知识,由于咱们几乎天天都在使用它。莫怕,大胆激发本身操做 DOM 的洪荒之力吧,尽早成为一个 DOM 高级工程师。