如何优雅地操作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') // 选择所有标签为 
  • 的元素
  • 如果要使用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')
    

    添加元素

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

    Home
    

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

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

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

    // 一句就能搞定
    $('body').append('Home')
    

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

    document.body.insertAdjacentHTML('beforeend', 'Home')
    

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

    • beforebegin 插入某个元素之前
    • afterbegin 插入到第一个子元素之前
    • beforeend 插入到最后一个子元素之后
    • afterend 插入到元素之后
    
    
    content

    通过这个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', '')
    

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

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

    插入文本

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

    移动元素

    上面介绍的insertAdjacentElement也可以移动文档流上的一个元素,假如有这样的HTML片段:

    Title

    Subtitle

    我们需要把h2标签插入到h1标签下面

    // 分别获取两个元素
    const h1 = document.querySelector('h1')
    const h2 = document.querySelector('h2')
    
    // 指定把 h2 插入到 h1 下面
    h1.insertAdjacentElement('afterend', h2)
    

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

    Title

    Subtitle

    元素替换

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

    someElement.replaceWith(otherElement)
    
    
    

    Title

    Subtitle

    // 选择 h1 和 h2
    const h1 = document.querySelector('h1')
    const h2 = document.querySelector('h2')
    
    // 用 h2 替换掉 h1
    h1.replaceWith(h2)
    
    
    

    Subtitle

    移除一个元素

    只需要调用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('Home')
    

    元素匹配

    matches

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

    Hello world

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

    contains

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

    Foo

    Bar

    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包裹

    Foo

    Bar

    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 结构有更清晰的认识,更好地发挥它们的潜力,优雅地实现各种效果。

    参考文档:

    • MutationObserver
    • MutationRecord
    • Using the DOM like a Pro

    你可能感兴趣的:(如何优雅地操作DOM)