DOM操作が必要なとき、真っ先にjQueryを使うことを考えます。しかし、素のJavaScriptのDOM APIだけでも、実はかなりのことができるのです。また、IE10以下のサポートが終了したため、今後は素のJavaScriptによるDOM操作を心配なく使えます。
記事では、素のJavaScriptで一般的なDOM操作をする方法について説明します。具体的には以下のとおりです。
- DOMの取得と変更
- クラスと属性の変更
- イベントのリッスン
- アニメーション
記事の最後に、どのようなプロジェクトにも使える独自の超軽量DOMライブラリーの作り方を説明します。記事の最後までに、素のJavaScriptによるDOM操作は決して高度な技術ではないこと、多くのjQueryメソッドとまったく同じ機能が実はネイティブAPIにもあることが理解できるはずです。
それでは始めます。
DOM操作:DOMの取得
この記事ではJavaScriptのDOM APIについて大まかに説明していきますが、詳しい説明は省略します。使用例には紹介していないメソッドが含まれているかもしれませんが、そのような場合はMozilla Developer Networkを参照し詳細を確認してください。
DOMは.querySelector()メソッドで取得できます。メソッドは任意の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()の結果はライブではないことが挙げられます。従って、セレクターに一致する要素を動的に追加しても、取得した要素コレクションは更新されません(詳しくはあとで説明する「DOMの変更」を参照)。
const elements1 = document.querySelectorAll('div')
const elements2 = document.getElementsByTagName('div')
const newElement = document.createElement('div')
document.body.appendChild(newElement)
elements1.length === elements2.length // false
もう1つの重要な違いは、こうしたライブな要素コレクションはあらかじめすべての情報を取得しておく必要がないということです。一方、.querySelectorAll()はあらゆる情報を即座に静的リストに集めるため、パフォーマンスの低下を引き起こします。
Nodelistを扱う
.querySelectorAll()にはよく知られた問題が2つあります。1つ目は、結果に対してNodeオブジェクトのメソッドを呼び出し、各要素にまとめて処理を適用する方法(jQueryオブジェクトで使い慣れているような方法)が使えないことです。代わりに、各要素に対する処理を1つ1つ繰り返さなければなりません。2つ目は、戻り値がArrayではなくNodeListであることです。つまり、通常のArrayメソッドを直接使えません。NodeListには.forEachなど類似の方法がいくつかありますが、どのバージョンのIEも依然として.forEachをサポートしていません。従って、最初にリストを配列に変換するか、これらのメソッドをArrayのプロトタイプから「借りる」必要があります。
// Using Array.from()
Array.from(myElements).forEach(doSomethingWithEachElement)
// Or prior to ES6
Array.prototype.forEach.call(myElements, doSomethingWithEachElement)
// Shorthand:
[].forEach.call(myElements, doSomethingWithEachElement)
それぞれの要素は「家族」を参照する読み取り専用プロパティをいくつか持っています。プロパティはすべて生きており、名称から内容が一目瞭然です。
myElement.children
myElement.firstElementChild
myElement.lastElementChild
myElement.previousElementSibling
myElement.nextElementSibling
ElementインターフェイスはNodeインターフェイスを継承しているので、以下のプロパティも利用できます。
myElement.childNodes
myElement.firstChild
myElement.lastChild
myElement.previousSibling
myElement.nextSibling
myElement.parentNode
myElement.parentElement
前者は要素を参照するだけですが、後者(.parentElementを除く)は、たとえば、テキストノードなど、あらゆる種類のノードが考えられます。ノードの種類は次のようにして確認できます。
myElement.firstChild.nodeType === 3 // this would be a text node
どのようなオブジェクトでも、instanceof演算子を使えばノードのプロトタイプチェーンを確認できます。
myElement.firstChild.nodeType instanceof Text
クラスと属性の変更
以下のように、要素のクラスは簡単に変更できます。
myElement.classList.add('foo')
myElement.classList.remove('bar')
myElement.classList.toggle('baz')
クラスの変更方法は、Yaphi Berhanuによるちょっとしたヒントでより詳しく議論されています。要素のプロパティは、ほかのオブジェクトのプロパティと同じように操作できます。
// 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()というメソッドがあります。これらのメソッドは要素のHTML属性(DOMプロパティとは別)を直接変更するため、ブラウザーの再描画が必要になります。ブラウザーの開発ツールを使って要素を調べると変更を確認できます。これらのメソッドによるブラウザーの再描画は、単にDOMプロパティをセットするよりも大きな負荷がかかるだけでなく、予期せぬ結果を招く可能性もあります。
経験から、対応するDOMプロパティがない属性(colspanなど)にのみ、または変更をどうしてもHTML内に「維持」させたい場合にのみ、これらのメソッドを使うべきです。たとえば、要素の複製、親要素の.innerHTMLを操作したあとも変更されたままにしたい場合です(「DOMの変更」参照)。
CSSスタイルの追加
ほかのプロパティと同じようにCSSルールを適用できますが、JavaScriptではプロパティの表記がキャメルケースになるので注意してください。
myElement.style.marginLeft = '2em'
CSSで指定した値が必要な場合.styleプロパティから値を取得できます。しかし、このプロパティで取得できるのは明示的に適用したスタイルだけです。計算値を取得するには.window.getComputedStyle()を使います。このメソッドは要素を引数として受け取り、要素のスタイル、要素が親から継承したスタイルすべてを含むCSSStyleDeclarationを戻り値として返します。
window.getComputedStyle(myElement).getPropertyValue('margin-left')
DOMの変更
次の方法で要素を移動できます。
// 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)
要素を移動するのではなくコピーを挿入したい場合、次の方法でクローンを作れます。
// Create a clone
const myElementClone = myElement.cloneNode()
myParentElement.appendChild(myElementClone)
.cloneNode()メソッドはオプションとして、ブール型の値を引数として受け取ります。trueにセットした場合、ディープコピーが作られます。要素の子もまたクローンされるということです。
もちろん、新たな要素やテキストノードも作成できます。
const myNewElement = document.createElement('div')
const myNewTextNode = document.createTextNode('some text')
これらは、これまで説明した方法で挿入できます。要素は直接削除できませんが、以下のように親要素から子を削除できます。
myParentElement.removeChild(myElement)
これを利用すればうまく対処できます。つまり、親要素を参照することで間接的に要素を削除できるのです。
myElement.parentNode.removeChild(myElement)
要素のプロパティ
すべての要素は.innerHTML、.textContentプロパティを持っています。ほかにも.innerTextがあり、.textContentに似ていますが大きな違いがあります。この2つのプロパティはそれぞれHTMLとプレーンテキストを格納しており、どちらも書き込み可能です。つまり、要素もそのコンテンツも直接変更できるようになっています。
// 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に新たなマークアップを追加するのは、基本的に良い考えではありません。関連する要素に対して以前に実施したプロパティの変更やイベントリスナーの設定が失われてしまうからです。前の「クラスと属性の変更」で説明したように、こうした変更を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)
しかし、この手法では各要素にHTMLを追加する際に1回ずつ、合計2回のブラウザー再描画が必要になります。一方、.innerHTMLでは1回だけです。このパフォーマンスの問題に対処するにはすべてのノードを一度DocumentFragmentに集めたあとに、次のひとかたまりのフラグメントを追加します。
const fragment = document.createDocumentFragment()
fragment.appendChild(text)
fragment.appendChild(hr)
myElement.appendChild(fragment)
イベントのリッスン
以下の方法はおそらく、イベントリスナーをバインドするためのもっとも良く知られた方法です。
myElement.onclick = function onclick (event) {
console.log(event.type + ' got fired')
}
しかし、この方法は基本的に避けるべきです。.onclickは要素のプロパティなので変更できますが、追加のイベントリスナーを作るためには利用できません。新たな関数を作ることで、古い関数への参照が上書きされてしまうからです。
代わりに、はるかに強力な.addEventListener()メソッドを使えばどのようなタイプのイベントも好きなだけ作れます。引数は3つあり、イベントタイプ(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も同様ですが、もちろん、アロー関数を使わないかぎりです。従って、以下の方法により要素のプロパティへ簡単にアクセスできます。
// 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)
})
})
デフォルト動作の防止
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()
}
})
もう1つの重要なイベントメソッドが.stopPropagation()です。このメソッドはDOMのバブリングを抑止します。つまり、ある要素にいわば伝播防止クリックリスナーのようなものがあり、その要素の親に別のクリックリスナーがある場合、クリックにより子要素でイベントが発生しても、親要素でイベントが発生することはありません。このメソッドがなければ、両方の要素でイベントが発生します。
.addEventListener()はオプションとして、第3引数に設定オブジェクトを受け取ります。この引数は以下のブール型プロパティのどれかになります(デフォルトはすべてfalse)。
- capture:DOMの階層でより上位にある要素からイベント起動するようになる(イベント補足とバブリングはそれだけで記事が書けるので、より詳しく知りたい場合はここを参照)
- 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')を使って繰り返し処理する方法がありますが、フォーム自体にイベントリスナーを追加してevent.targetのコンテンツをチェックするだけでも同じことができます。
myForm.addEventListener('change', function (event) {
const target = event.target
if (target.matches('input')) {
console.log(target.value)
}
})
このパターンのもう1つの利点は、子を動的に挿入した場合に子をそれぞれ新たなイベントリスナーにバインドする必要がなく、挿入した子を自動的にリッスンできることです。
アニメーション
通常は、CSSのクラスにtransitionプロパティを適用するかCSSの@keyframesを使うことがもっとも簡潔にアニメーションを実装する方法です。しかし、より柔軟性の高いアニメーション(ゲーム用など)が必要な場合は、JavaScriptによる実装もできます。
単純な手法として、アニメーションが完了するまでwindow.setTimeout()に自分自身を呼び出させ続ける方法があります。しかし、ドキュメントのリフローを頻繁に発生させてしまうため非効率です。レイアウトスラッシング(強制的にレイアウトの同期を起こす現象)により、特にモバイルデバイスにおいて処理落ちが発生しやすくなります。代わりにwindow.requestAnimationFrame()を使うことで更新を同期させ、すべての変更を次のブラウザーの再描画フレームに合わせて実施できます。この関数は引数としてコールバック関数を受け取ります。コールバック関数は引数として現在の、高精度なタイムスタンプを受け取ります。
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)
}
}
こうして、とてもなめらかなアニメーションを実現できます。より詳しく知りたい場合はMark Brownの『知ってる? Web開発者がJavaScriptでゲームを作るときのはじめの一歩』を参照してください。
独自のヘルパーメソッドを書く
$('.foo').css({color: 'red'})のような簡潔でメソッドチェーンも使えるjQueryの構文に比べれば、各要素に対する処理をいつも繰り返さなければならないのは確かに面倒です。以下のように、独自の簡略化メソッドを書くことをおすすめします。
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に実装しました。ソースコードをぜひ参照してください。
最後に
素のJavaScriptによるDOM操作は決して高度な技術ではないこと、多くのjQueryメソッドとまったく同じ機能が実はネイティブのDOM APIにもあることが伝われば幸いです。この事実は、よくある用途(ナビゲーションメニューやモーダルポップアップなど)の一部においてDOMライブラリーの使用に伴うオーバーヘッドを削減できる可能性を示しています。
ネイティブAPIの一部が冗長で不便(ノードリストに対する処理を毎回繰り返す必要があることなど)だというのは事実ですが、小さなヘルパー関数を自分で書いてこうした反復操作を抽象化することはさほど難しくありません。
※本記事はVildan Softic、Joan Yinが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:The Basics of DOM Manipulation in Vanilla JavaScript (No jQuery))
[翻訳:薮田佳佑/編集:Livit]