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