DOM 高級工程師不徹底指南

本文乾貨部分翻譯自: Use the DOM like a Pro
譯者:kyrieliu(劉凱里)

「前端框架真的太香了,香到我都不敢徒手撕 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

來,衝!node

獲取 DOM 元素

獲取單個元素

向 document.querySelector 中傳入任何有效的 css 選擇器,便可選中單個 DOM 元素:數組

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。app

那麼問題來了,如何將一個僞數組轉化爲數組呢?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 樹

上述內容的主題是查找 DOM 元素,這是一個自上而下的過程:從父元素向其包含的子元素髮起查詢。

但沒有一個 API 能夠幫助開發者藉由子元素向父元素髮起查詢。

迷惑之際,MDN 給我提供了一個寶藏方法:closest 。

Starting with the Element itself, the closest() method traverses parents (heading toward the document root) of the Element until it finds a node that matches the provided selectorString. Will return itself or the matching ancestor. If no such element exists, it returns null.

也就是說,closest 方法能夠從特定的 HTMLElement 向上發起查詢,找到第一個符合指定 css 表達式的父元素(也能夠是元素自身),若是找到了文檔根節點尚未找到目標時,就會返回 null 。

添加 DOM 元素

若是用原生 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': 元素以前
  • 'afterbegin': 元素內,位於現存的第一個子元素以前
  • 'beforeend': 元素內,位於現存的最後一個子元素以後
  • 'afterend': 元素以後
<!-- beforebegin -->
<div>
    <!-- afterbegin -->
  <span></span>
     <!-- beforeend -->
</div>
<!-- afterend -->

舒服了呀。

更舒服的是,它還有兩個好兄弟,讓開發者能夠快速地插入 HTML 元素和字符串:

// 插入 HTML 元素
document.body.insertAdjacentElement(
    'beforeend',
    document.createElement('a')
)

// 插入文本
document.body.insertAdjacentText('afterbegin', 'cool!')

移動 DOM 元素

上面提到的兄弟方法 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>

替換 DOM 元素

replaceChild? 這是幾年前的作法了,每當開發者須要替換兩個 DOM 元素,除了須要拿到這必須的兩個元素以外,還須要獲取他們的直接父元素:

parentNode.replaceChild(newNode, oldNode)

而現在,開發者們可使用 replaceWith 就能夠完成兩個元素之間的替換了:

oldElement.replaceWith(newElement)

從用法上來講,要比前者清爽一些。

須要注意的是:

  1. 若是傳入的 newElement 已經存在於文檔中,那麼方法的執行結果將是 newElement 被移動並替換掉 oldElement
  2. 若是傳入的 newElement 是一個字符串,那麼它將做爲一個 TextNode 替換掉原有的元素

移除 DOM 元素

和替換元素的老方法相同,移除元素的老方法一樣須要獲取到目標元素的直接父元素:

const target = document.querySelector('#target')
target.parentNode.removeChild(target)

如今只須要在目標元素上執行一次 remove 方法就 ok 了:

const target = document.querySelector('#target')
target.remove()

用 HTML 字符串建立 DOM 元素

細心的你必定發現了,上文提到的 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 的小能手

標準的 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

compareDocumentPosition 是一個強大的 API ,它能夠快速判斷出兩個 DOM 元素的位置關係,諸如:先於、跟隨、是否包含。它返回一個整數,表明了兩個元素之間的關係。

// 仍是用上面的例子哈
container.compareDocumentPosition(h1)   // 20
h1.compareDocumentPosition(container)   // 10
h1.compareDocumentPosition(h2)          // 4
h2.compareDocumentPosition(h1)          // 2

標準語句:

element.compareDocumentPosition(otherElement)

返回值定義以下:

  • 1: 兩個元素再也不同一個文檔內
  • 2: otherElement 在 element 以前
  • 4: otherElement 在 element 以後
  • 8: otherElement 包含 element
  • 16: otherElement 被 element 所包含

那麼問題來了,爲何上面例子中第一行的結果是20、第二行的結果是10呢?

由於 h1 同時知足「被 container 所包含(16)」 和 「在 container 以後」,因此語句的執行結果是 16+4=20,同理可推出第二條語句的結果是 8+2=10。

DOM 觀察者:MutationObserver

在處理用戶交互的時候,當前頁面的 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

儘管大部分 DOM API 的名字都很長(寫起來很麻煩),但它們都是很是強大而且通用的。這些 API 每每旨在爲開發者提供底層的構建單元,以便在此之上創建更爲通用和簡潔的抽象邏輯,所以從這個角度出發,它們必須提供一個完整的名稱以變得足夠明確和清晰。

只要能發揮出這些 API 本應該發揮出的潛能,多敲幾下鍵盤又何妨呢?

DOM 是每一個 JavsScript 開發者必不可少的知識,由於咱們幾乎天天都在使用它。莫怕,大膽激發本身操做 DOM 的洪荒之力吧,儘早成爲一個 DOM 高級工程師。

最後

掃碼捕獲一隻還算有趣的前端er
掃碼捕獲一隻還算有趣的前端er

相關文章
相關標籤/搜索