如何优雅地操作DOM

在以前,操作DOM是一件非常麻烦的事情,虽然现在已经有类似React、Vue、Angular等框架帮助我们更容易地构建界面。但是我们仍然有必要学习原生DOM的操作方式来扩展我们的知识面,并且可以来应对一些不使用框架的场景,经过长时间的发展,现在的DOM API也变得更加优雅简洁了。

元素选择

单个元素

// 返回一个 HTMLElement
document.querySelector(selectors)

它提供类似jQuery的$()选择器方法,非常方便,我们可以这样使用它:

document.querySelector('.class-name') // 根据 class 选择
document.querySelector('#id') // 根据 id 选择   
document.querySelector('div') // 根据 标签 选择
document.querySelector('[data-test="input"]') // 根据属性来选择
document.querySelector('div + p > span')  // 多重选择器

多个元素

// 返回一个 NodeList
document.querySelectorAll('li') // 选择所有标签为 <li> 的元素

如果要使用Array的数组方法,需要先转成普通数组,可以这样做:

// 使用扩展运算符
const arr = [...document.querySelectorAll('li')]

// 使用 Array.from 方法
const arr = Array.from(document.querySelectorAll('li'))

但是它和getElementsByTagNamegetElementsByClassName是有区别的,getElementsByTagNamegetElementsByClassName返回的是一个HTMLCollection,它是动态的,比如当我们移除掉document中被选取的某个li标签,所返回的HTMLCollection中相应的li标签也会被移除,它具有实时性

querySelectorAll是静态的,移除document文档流中被选取的某个li标签,不会影响返回的NodeList,它没有实时性

HTMLCollection 和 NodeList 的异同

  • HTMLCollection是元素的集合(只包含元素)
  • NodeList是文档节点的集合(包含元素也包含其它节点)
  • HTMLCollection动态集合,节点变化会反映到返回的集合中
  • NodeList静态集合,节点的变化不会影响返回的集合
  • HTMLCollection实例对象可以通过idname属性引用节点元素
  • NodeList只能使用数字索引引用

选择范围

我们可以限制选择的范围,而不至于每次都在document上进行选择,可以这样做:

// 只获取 #container 下的所有 li 标签
const container = document.querySelector('#container')
container.querySelectorAll('li')

进一步封装

我们可以封装成类似jQuery的写法,用$进行选择:

const $ = document.querySelector.bind(document)
$('#container')

const $$ = document.querySelectorAll.bind(document)
$$('li')

这里注意,我们需要使用bindthis的指向绑定到document上,否则直接把函数赋值给变量获取到的是一个普通函数,会导致this指向window

向上选择DOM

我们还可以获取某个Element的最近父元素,通过使用closest方法

// 获取距离 li 标签最近的上级 div 标签
document.querySelector('li').closest('div')

// 再更上一层,获取最近的上级名为 content 的元素
document.querySelector('li').closest('div').('.content')

添加元素

这里假设我们要添加这样一个元素

<a href="/home" class="active">Home</a>

在过去,我们需要这样来添加元素

const link = document.createElement('a')
a.setAttribute('href', '/home')
a.className = 'active'
a.textContent = 'Home'
document.body.appendChild(link)

在有了jQuery后,我们可以这样来添加元素

// 一句就能搞定
$('body').append('<a href="/home" class="active">Home</a>')

现在,我们可以借助insertAdjacentHTML来实现类似jQuery的方法

document.body.insertAdjacentHTML('beforeend', '<a href="/home" class="active">Home</a>')

这里需要传入两个参数,第一个参数是插入的位置,第二参数是插入的HTML片段,位置可选参数如下:

  • beforebegin 插入某个元素之前
  • afterbegin 插入到第一个子元素之前
  • beforeend 插入到最后一个子元素之后
  • afterend 插入到元素之后
<!-- beforebegin -->
<div>
  <!-- afterbegin -->
  content
  <!-- beforeend -->
</div>
<!-- afterend -->

通过这个API,可以更方便地指定插入位置。假如要把a标签插入到div之前,我们以前需要这样做:

const link = document.createElement('a')
const div = document.querySelector('div')
div.parentNode.insertBefore(link, div)

而现在直接指定位置就可以了

const div = document.querySelector('div')
div.insertAdjacentHTML('beforebegin', '<a></a>')

还有两个相似的方法,但第二个元素传入的不是HTML字符串,而是传一个元素或文本

const link = document.createElement('a')
const div = document.querySelector('div')
// 插入元素
div.insertAdjacentElement('beforebegin', link)

插入文本

// 插入文本
div.insertAdjacentText('afterbegin', 'content')

移动元素

上面介绍的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')

// 指定把 h2 插入到 h1 下面
h1.insertAdjacentElement('afterend', h2)

注意,这是移动,而非拷贝,此时的HTML变成:

<div class="first">
  <h1>Title</h1>
  <h2>Subtitle</h2>
</div>
<div class="second">
  <!-- h2 标签被移动了  -->
</div>

元素替换

我们可以直接使用replaceWith方法,通过这个方法,可以创建一个元素来进行替换,也可以选择一个已有元素进行替换,后者会移动被选择的元素,而非拷贝。

someElement.replaceWith(otherElement)
<!-- 替换前 -->
<div class="first">
  <h1>Title</h1>
</div>
<div class="second">
  <h2>Subtitle</h2>
</div>
// 选择 h1 和 h2
const h1 = document.querySelector('h1')
const h2 = document.querySelector('h2')

// 用 h2 替换掉 h1
h1.replaceWith(h2)
<!-- 替换后 -->
<div class="first">
  <!-- h1 被 h2 替换 -->
  <h2>Subtitle</h2>
</div>
<div class="second">
  <!-- h2 被移动 -->
</div>

移除一个元素

只需要调用remove()方法就可以了

const container = document.querySelector('#container')
container.remove()
// 以前的移除方法
const container = document.querySelector('#container')
container.parentNode.removeChild(container)

使用原生HTML片段创建元素

从上面可以了解到insertAdjacentHTML方法可以帮助我们插入HTML字符串到指定的位置,假如我们要先创建元素,而不是需要立即插入。

这时就需要借助DomParser对象,它可以解析HTMLXML来创建一个DOM元素,它提供了parseFromString方法进行创建并返回解析后的元素。

const createElement = domString => new DOMParser().parseFromString(domString, 'text/html').body.firstChild
const a = createElement('<a href="/home" class="active">Home</a>')

元素匹配

matches

matches可以帮助我们判断某个元素是否和选择器相匹配。

<p class="foo">Hello world</p>
const p = document.querySelector('p')
p.matches('p')     // true
p.matches('.foo')  // true
p.matches('.bar')  // false, 不存在 class 名为 bar

contains

也可以使用contains方法判断是否包含某个子元素:

<div class="container">
  <h1 class="title">Foo</h1>
</div>
<h2 class="subtitle">Bar</h2>
const container = document.querySelector('.container')
const h1 = document.querySelector('h1')
const h2 = document.querySelector('h2')
container.contains(h1)  // true
container.contains(h2)  // false

compareDocumentPosition

使用node.compareDocumentPosition(otherNode)方法可以帮助我们确定某个元素的确切位置,它会返回数字来指示位置,返回值的意思如下,如果满足多个条件,会返回相加值:

  • 1: 比较的元素不在同一个document
  • 2: otherNodenode之前
  • 4: otherNodenode之后
  • 8: otherNode包裹node
  • 16: otherNodenode包裹
<div class="container">
  <h1 class="title">Foo</h1>
</div>
<h2 class="subtitle">Bar</h2>
const container = document.querySelector('.container')
const h1 = document.querySelector('h1')
const h2 = document.querySelector('h2')
// 20: h1 被 container 所包裹,并且在 container 之后 16 + 4 = 20
container.compareDocumentPosition(h1) 
// 10: container 包裹 h1,并且在 h1 之前 8 + 2 = 10
h1.compareDocumentPosition(container)
// 4: h2 在 h1 的后面
h1.compareDocumentPosition(h2)
// 2: h1 在 h2 的前面
h2.compareDocumentPosition(h1)

MutationObserver

我们还可以使用MutationObserver来监听DOM树的变动

// 当监听到元素的变动就会调动 callback 方法
const observer = new MutationObserver(callback)

然后我们需要使用observer方法监听某个node的变化,否则不会监听,它接收两个参数,第一个参数是监听目标,第二个参数是监听选项。

const target = document.querySelector('#container')
const observer = new MutationObserver(callback)
observer.observe(target, options)

当发生变化时,就会调用callback方法,此时,我们就可以在callback中监听变化,并监听callbackmutations类型进行相应地处理:

具体的配置及其含义可以参考文档MutationObserver

// step1: 获取元素
const target = document.querySelector('#container')

// step2: 编写回调函数,处理逻辑
const callback = (mutations, observer) => {
  mutations.forEach(mutation => {
    switch (mutation.type) {
      case 'attributes':
        // 通过 mutation.attribute 获取改变的 attribute
        // 通过 mutation.oldValue 获取旧值
        break
      case 'childList':
        // 通过 mutation.addedNodes 获取添加的节点
        // 通过 mutation.removedNodes 获取移除的节点
        break
    }
  })
}

// step3: 传入 callback 并实例化
const observer = new MutationObserver(callback)

// step4: 开始监听并根据需求设置监听选项
observer.observe(target, {
  attributes: true, // 监听 attribute 变化
  attributeFilter: ['foo'], // 只监听属性包含 foo,需要先把 attribute 设置为 true
  attributeOldValue: true,  // 发生改变时,记录 attribute 之前的值
  childList: true // 监听元素的添加和删除
})

当完成监听时,可以通过observer.disconnect()方法来中止监听,并且可以在之前通过takeRecords()来处理未传递的MutationRecord

const mutations = observer.takeRecords()
callback(mutations)
observer.disconnect()

小结

通过上述这些强大的API,可以非常方便地对 DOM 进行操作,满足各种不同的需求,此外,还有一些没有介绍到的,比如IntersectionObserver可以监听目标元素和文档视窗的交叉状态来实现图片懒加载。所以,在使用框架进行开发时,我们也需要深入理解 DOM,这样才可以对整个 DOM 结构有更清晰的认识,更好地发挥它们的潜力,优雅地实现各种效果。

参考文档:

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  •   DOM(文档对象模型)是针对 HTML 和 XML 文档的一个 API(应用程序编程接口)。   DOM 描绘...
    霜天晓阅读 3,563评论 0 7
  • 前言:尽管现在有很多优秀的框架,大大简化了我们的DOM操作,但是我们仍然要学好DOM知识来写原生JS,从根本上去理...
    长鲸向南阅读 1,790评论 0 0
  • 基本概念 DOM DOM 是 JavaScript 操作网页的接口,全称为“文档对象模型”(Document Ob...
    许先生__阅读 854评论 0 1
  • 一、基本概念 1.1、DOM DOM是JS操作网页的接口,全称为“文档对象模型”(Document Object ...
    周花花啊阅读 3,027评论 0 6
  • 用多了 jQuery 也会有点忘记 原生JavaScript 是如何操作 DOM 的,在此总结: 什么是DOM?如...
    蓝醇阅读 579评论 0 0