翻譯自sitepoint的一篇文章,作者是Sebastian Seitz。雖然日常工作中很少再寫原生js來操作DOM了,大家可能都在用主流的前端框架,我也是,但是看到這篇很淺顯易懂的文章,還是忍不住想細讀一下,複習的同時也會有新的發現。
無論何時我們需要操作DOM的時候,我們都會很快去用jQuery。然而,原生的JavaScript DOM API其實以它自己的方式已經可以解決非常多的需求。因為11以下的IE版本已經被官方丟棄,我們可以沒有任何擔憂地使用它。
在這篇文章,我將展示如何用原生JavaScript來完成一些最普遍的DOM操作任務,即:
我將在最後展示給各位,如何來創建一個可以用在任何項目裡的你自己的超精簡DOM庫。與此同時,各位可以學到用原生JS操作DOM其實並不難,很多jQuery的方法事實上都有對等的native API。
那麼我們開始吧...
請注意:我不會詳細地講解原生DOM API的細節,只是停留在表面。在用例裡,你可能會遇到我並沒有清楚介紹的方法。這時你可以參考Mozilla Developer Network。
可以用.querySelector()
方法來查詢DOM。需要傳入任意的CSS選擇器作為參數:
const myElement = document.querySelector('#foo > div.bar')
這行代碼返回第一個匹配的元素(深度優先)。相反的,我們可以檢查一個元素是否匹配一個選擇器:
myElement.matches('div.bar') === true
如果我們想得到所有匹配元素,我們可以用:
const myElements = document.querySelectorAll('.bar')
如果我們已經得到一個父元素的引用,我們可以只查找它的子元素,而不是整個document。像這樣縮小查找範圍,我們可以簡化選擇器提高查找性能。
const myChildElemet = myElement.querySelector('input[type="submit"]')// Instead of// document.querySelector('#foo > div.bar input[type="submit"]')
那麼我們為什麼還要用其他的不那麼方便的方法呢?比如.getElementsByTagName()
?一個重要的區別是.querySelector()
的結果不是實時的,所以當我們動態地添加一個匹配該選擇器的元素(參考第三部分)的時候,元素集合不會更新。
const elements1 = document.querySelectorAll('div')const elements2 = document.getElementsByTagName('div')const newElement = document.createElement('div')document.body.appendChild(newElement)elements1.length === elements2.length // false
另一個原因是這樣的實時的元素集合不需要預先獲得所有的元素信息,而.querySelectorAll()
會立刻收集所有的信息到一個靜態的列表裡,因而會降低性能。
關於.querySelectorAll()
有兩個坑。一個是我們不能在結果集上調用Node方法從而獲得它的元素(像jQuery對象那樣用)。我們不得不明確地遍歷這些元素。另一個是返回的結果是一個NodeList,不是數組。也就是說只能直接調用數組的方法。NodeList自己有一些數組方法的實現,比如.forEach
,但是任何版本的IE瀏覽器都不支持。所以我們必須先把它轉換成數組,或者從Array原型上「借用」那些方法。
// Using Array.from()Array.from(myElements).forEach(doSomethingWithEachElement)// Or prior to ES6Array.prototype.forEach.call(myElements, doSomethingWithEachElement)// Shorthand:[].forEach.call(myElements, doSomethingWithEachElement)
每個元素都有一些非常語義化的只讀的屬性,都是實時更新的:
myElement.childrenmyElement.firstElementChildmyElement.lastElementChildmyElement.previousElementSiblingmyElement.nextElementSibling
因為Element接口繼承自Node接口,它也有以下的屬性:
myElement.childNodesmyElement.firstChildmyElement.lastChildmyElement.previousSiblingmyElement.nextSiblingmyElement.parentNodemyElement.parentElement
前一組屬性的值只可以是元素節點,而後一組屬性(除了.parentElement
)的值可以是任何節點,比如文本節點。我們可以像這樣檢查節點的類型:
myElement.firstChild.nodeType === 3 // this would be a text node
像任何對象那樣,我們可以用instanceof
操作符檢查節點的原型鏈:
myElement.firstChild.nodeType instanceof Text
修改元素的class像下面的代碼這樣簡單:
myElement.classList.add('foo')myElement.classList.remove('bar')myElement.classList.toggle('baz')
你可以在quick tip by Yaphi Berhanu讀到關於如何修改class的更深度的討論。元素屬性值可以像其他任何對象屬性一樣得到。
// Get an attribute valueconst value = myElement.value// Set an attribute as an element propertymyElement.value = 'foo'// Set multiple properties using Object.assign()Object.assign(myElement, { value: 'foo', id: 'bar'})// Remove an attributemyElement.value = null
注意還有.getAttibute()
, .setAttribute()
和.removeAttribute()
這三個方法。這些方法直接修改的是元素的HTML屬性(與DOM屬性相對),因此會使瀏覽器重新渲染(你可以用你的瀏覽器自帶的開發調試工具來檢查元素觀察它的變化)。瀏覽器重新渲染不僅比只是設置DOM屬性代價更高,而且還會產生不期望的後果。
作為一個小原則,除非你真的想對HTML「持久化」那些改變,你就只用上面的方法修改與DOM屬性不相關的HTML屬性(比如colspan
)。(比如當克隆一個元素或者修改它的父元素的.innerHTML
的時候想保持這些改變,參考第三部分)
CSS規則可以像其他屬性那樣設置。需要注意的是在JavaScript裡要寫成駝峰形式:
myElement.style.marginLeft = '2em'
如果我們想獲得CSS規則的值,我們可以通過.style
屬性。然而,通過它只能拿到我們明確設置過的樣式。想拿到計算後的樣式值,我們可以用.window.getComputedStyle()
。它可以拿到這個元素並返回一個CSSStyleDeclaration。這個返回值包括了這個元素自己的和繼承自父元素的全部樣式。
window.getComputedStyle(myElement).getPropertyValue('margin-left')
我們可以像下面這樣移動元素:
// Append element1 as the last child of element2element1.appendChild(element2)// Insert element2 as child of element 1, right before element3element1.insertBefore(element2, element3)
如果我們不想移動元素,而是插入一個拷貝,我們可以這樣克隆它:
// Create a cloneconst myElementClone = myElement.cloneNode()myParentElement.appendChild(myElementClone)
.cloneNode()
方法可選地接受一個boolean類型的參數;如果傳入的是true, 將會創建一個深拷貝,也就是它的所有子元素也會被克隆。
當然我們可以創建一個全新的元素或文本節點:
const myNewElement = document.createElement('div')const myNewTextNode = document.createTextNode('some text')
然後我們可以像上面展示的代碼那樣插入創建的元素。如果我們想刪除一個元素,我們不能直接刪除,而要採用從它的父元素刪除子元素的辦法來實現,像這樣:
myParentElement.removeChild(myElement)
這給了我們一個優雅的解決辦法,也就是可以通過它的父元素間接的刪除一個元素:
myElement.parentNode.removeChild(myElement)
每個元素都有.innerHTML
和.textContent
(還有.innerText
,跟.textContent
類似,但是有一些重要的區別。它們分別表示HTML內容和純文本內容。它們是可寫的屬性,也就是說我們可以直接修改元素和它們的內容:
// Replace the inner HTMLmyElement.innerHTML = ` <div> <h2>New content</h2> <p>beep boop beep boop</p> </div>`// Remove all child nodesmyElement.innerHTML = null// Append to the inner HTMLmyElement.innerHTML += ` <a href="foo.html">continue reading...</a> <hr/>`
像上面的代碼那樣向HTML添加標記是通常是一個不好的注意,因為這樣是丟失之前對影響元素的屬性做的修改(除非我們把那些修改作為HTML屬性而保留下來,參考第二部分)和已經綁定的事件監聽。設置.innerHTML
可以適合用在需要完全丟棄原來的而替換成新的標記的場景,比如服務端渲染。所以添加元素這樣做比較好:
const link = document.createElement('a')const text = document.createTextNode('continue reading...')const hr = document.createElement('hr')link.href = 'foo.html'link.appendChild(text)myElement.appendChild(link)myElement.appendChild(hr)
但是這個辦法會引起兩次瀏覽器的重新渲染-每次添加元素都會渲染一次-而用設置.innerHTML
的辦法的話只會重新渲染一次。我們可以先把所有的節點組合在一個DocumentFragment裡,然後把這一個片段添加到DOM裡,這樣可以解決這個性能問題。
const fragment = document.createDocumentFragment()fragment.appendChild(link)fragment.appendChild(hr)myElement.appendChild(fragment)
這可能是最知名的綁定事件監聽的方法:
myElement.onclick = function onclick (event) { console.log(event.type + ' got fired')}
但是這是通常應該避免採用的方法。這裡,.onclick
是一個元素的屬性,也就是說你可以修改它,但是你不能用它再綁定其他的監聽函數-你只能把新的函數賦給它,覆蓋掉舊函數的引用。
我們可以用更加強大的.addEventListener()
方法來盡情地添加各種類型的各種事件的監聽器。它接受三個參數:事件類型(比如click
),一個無論何時在這個綁定元素上該事件發生都會觸發的函數(這個函數會得到一個事件對象傳進去作為參數)和一個可選的配置參數,下面會更詳細的解釋。
myElement.addEventListener('click', function (event) { console.log(event.type + ' got fired')})myElement.addEventListener('click', function (event) { console.log(event.type + ' got fired again')})
在監聽函數內部,event.target
指向這個事件觸發的元素(this
也是,當然除非你用的是箭頭函數。譯者註:如果監聽函數是箭頭函數,裡面的this
指向的是window
對象,如果是普通的function
函數,裡面的this
指向的跟event.target
相同,都是該元素本身)。因此你可以輕鬆的拿到它的屬性:
// The `forms` property of the document is an array holding// references to all formsconst myForm = document.forms[0]const myInputElements = myForm.querySelectorAll('input')Array.from(myInputElements).forEach(el => { el.addEventListener('change', function (event) { console.log(event.target.value) })})
注意在監聽函數內部總是可以拿到event
,但是當需要的時候明確地傳入這個參數是一個好的實踐(當然參數名稱可以隨意設置)(譯者註:即使沒有明確地給監聽函數傳入任何參數,在內部仍然可以拿到原生event
對象,變量名就是event
)。先不詳細解釋Event接口,一個特別需要注意的方法是.preventDefault()
。它可以用來阻止瀏覽器的默認行為,比如跳轉鏈接。另一個常見的應用場景是當前端的表單校驗失敗的時候,可以根據判斷條件阻止表單提交。
myForm.addEventListener('submit', function (event) { const name = this.querySelector('#name') if (name.value === 'Donald Duck') { alert('You gotta be kidding!') event.preventDefault() }})
另一個重要的事件方法是.stopPropagation()
,它可以阻止事件冒泡。也就是說在一個子元素上綁定了阻止事件冒泡的點擊事件監聽函數,而在它的某一個父元素上也監聽了點擊事件,在子元素上觸發的點擊事件,不會觸發它的這個父元素的點擊事件監聽函數-否則,父子元素都會觸發。
現在我們看一下.addEventListener()
的可選的配置對象這個第三個參數,它可以有以下的布爾屬性(它們的默認值都是false
):
capture
: 這個事件會先在父元素觸發,然後再向下傳遞給它的子元素(關於事件捕獲和事件冒泡更詳細地解釋可以參考這裡)once
: 你已經猜到,這個屬性表示這個事件只會被觸發一次passive
: 它的意思是event.preventDefault()
會被忽略(通常在控制台都會打印一句警告)最常用的選項是.capture
;事實上,因為它非常常用,所以可以只傳入它的一個布爾值,而不必傳入整個配置對象:
myElement.addEventListener(type, listener, true)
事件監聽可以用.removeEventListener()
方法刪除。它接受事件類型和回調函數的引用兩個參數;例如,once
選項也可以像這樣實現:
myElement.addEventListener('change', function listener (event) { console.log(event.type + ' got triggered on ' + this) this.removeEventListener('change', listener)})
另一個有用的模式是事件委託:假如我們有一個表單,並且想給它的每一個input
元素綁定一個change
事件的監聽函數。一種方法是上面已經介紹過的那樣用myForm.querySelectorAll('input')
取到所有的input
元素,然後再通過遍歷綁定事件。然而,我們其實只需要給表單本身綁定這個事件監聽函數,然後檢查event.target
是否是input
元素就可以了。
myForm.addEventListener('change', function (event) { const target = event.target if (target.matches('input')) { console.log(target.value) }})
用這種模式的另一個優勢就是它對動態插入的子元素同樣有效,而不需要給每一個綁定新的監聽函數。
通常,最優雅的生成動畫的方式是結合transition
屬性用CSS的類,或者用CSS的@keyframes
。但是如果你需要更加靈活的方式(比如做遊戲),也可以用JavaScript。
簡單的方法就是有一個window.setTimeout()
函數,不斷地調用自己直到期望的動畫完成。然而,這會低效地強迫文檔進行迅速的重排;並且結構的抖動會很快使頁面卡頓,特別是在移動設備上。替代方案是,我們可以用window.requestAnimationFrame()
同步頁面的更新,把當前的所有改變安排到下一次瀏覽器重繪。它接受一個回調函數作為參數。這個回調函數會接收到當前的時間戳作為參數:
const start = window.performance.now()const duration = 2000window.requestAnimationFrame(function fadeIn (now)) { const progress = now - start myElement.style.opacity = progress / duration if (progress < duration) { window.requestAnimationFrame(fadeIn) }}
用這個方法我們可以得到非常流暢的動畫。想瞭解更加詳細的討論,可以參考Mark Brown寫的這篇文章。
確實,與jQuery簡潔的鏈式的$('.foo').css({color: 'red'})
表達式相比,總是要遍歷元素去做什麼可能是非常的繁瑣。所以為什麼我們不像下面這樣寫我們自己的快捷的方法呢?
const $ = function $ (selector, context = document) {const elements = Array.from(context.querySelectorAll(selector)) return { elements, html (newHtml) { this.elements.forEach(element => { element.innerHTML = newHtml }) return this }, css (newCss) { this.elements.forEach(element => { Object.assign(element.style, newCss) }) return this }, on (event, handler, options) { this.elements.forEach(element => { element.addEventListener(event, handler, options) }) return this } // etc. }}
因此我們有了一個沒有向下兼容負擔的只有我們需要的方法的超簡潔的DOM庫。儘管通常在元素的原型鏈上已經有了那些方法。這裡有一個gist(更加詳細深入一些),它展示了一些實現這些幫助函數的辦法。我們還可以這樣保持簡單:
const $ = (selector, context = document) => context.querySelector(selector)const $$ = (selector, context = document) => context.querySelectorAll(selector)const html = (nodeList, newHtml) => { Array.from(nodeList).forEach(element => { element.innerHTML = newHtml })}// And so on...
為使文章圓滿結束,下面的CodePen通過實現一個簡單的燈箱效果展示了上面提到的很多概念。我鼓勵你們花點時間去看一下源碼,如果你們有任何想法或者疑問請在下面評論來讓我知道。
CodePen上的Demo代碼
我希望我已經證明了用原生JavaScript來操作DOM並不是什麼高科技,而且事實上,很多jQuery裡的方法在原生DOM的API裡有直接對應的實現。這意味著在一些日常的應用場景裡(比如導航菜單或者是跳出的模態框),額外的加載過重的DOM庫是不合適的。
雖然一部分原生API確實繁瑣或是不方便(比如必須總是要手動遍歷節點列表),但是我們能夠非常輕鬆的把這些重複工作抽象出來寫成我們自己的短小的幫助函數。
但是現在輪到你了。你怎麼看?你更願意在你可以的地方避免使用第三方庫,還是使自己捲入根本不值得的認知開銷裡面?請在下面的評論中讓我知道。
联系客服