ネイティブJavaScriptによるDOM操作の概要と各種基本操作

2018年05月24日

カテゴリー:

私にとってここ数年、JavaScriptでDOM操作が必要なサイトを制作するにあたってはjQueryかReactの二択しかありませんでした。例えば普通のコーポレートサイトのようなそれほど複雑なDOM操作は要求されないような場合はjQuery、それ以外のSPAのようなものの場合はReactを使うという感じです。まだ当面はこれで何とかなりそうな気もしますが、最近の「脱jQuery」の流れにも対応しておきたいな、と思っていたので「特定のフレームワーク・ライブラリに依存しないDOM操作」をテーマに、抑えておきたい基本的なことと、DOMの基本操作についてまとめました。

本記事では各種DOM操作用APIの使い方にも触れていますが、もちろん全てを網羅しているわけではありません。個人的にではありますが、現場で使用する可能性のありそうなもののみに絞っています。もし詳細を知りたい場合はMDN等を参考にしてください。

【「Vanilla JS」とは?】
本記事ではjQueryとの対比としてネイティブなJavaScriptのことを「Vanilla(ヴァニラ)JS」と呼んでいます。「Vanilla JS」はフレームワークありきで語られることの多いJavaScript界の風潮を皮肉ったジョークフレームワークで、要はフレームワークやライブラリに依存していない、素のJavaScriptのことを指します。

「素のJavaScript」「ネイティブのJavaScirpt」「Pure JavaScript」etc.. という言葉よりもVanilla JSの方が説明しやすかったので、「ネイティブJavaScript = Vanilla JS」として統一して解説しています。

要素の参照

jQueryの場合、DOM参照は次の例の様に「$」関数にセレクタを渡すだけで済みます。また、DOM参照・操作関数が返すオブジェクトは基本的にjQueryオブジェクトに統一されているため、メソッドチェーンで宣言的にDOM操作を行うことが可能です。

const $elms = $('selector1');

$elms.find('selector2')
  .css({color: 'red'})
  .text('hoge')
  .show();

これに対し、Vanilla JSではDOM参照のための関数がいくつか用意されています。

// 要素のIDから参照
const elmById = document.getElementById('#selector');

// クラス名から参照
const elmsByClass = document.getElementsByClassName('className');

// 要素名(タグ名)から参照
const elmsByTag = document.getElementsByTagName('tagName');

// CSSセレクタで参照(単一)
const elmByQuery = document.querySelector('selector');

// CSSセレクタで参照(集合)
const elmsByQuery = document.querySelectorAll('selector');

DOMの取得方法一覧

上記のDOM参照用関数ではElement, HTMLCollection, NodeListのいずれかを取得することができます。
Elementは単一の要素を表し、HTMLCollection, NodeListElementの集合を表します。

次の表はこれらの関数が返すオブジェクトのタイプを表したものです。

document.getElementById Element
document.getElementsByClassName
Element.getElementsByClassName
HTMLCollection
document.getElementsByTagName
Element.getElementsByTagName
HTMLCollection
document.querySelector
Element.querySelector
Element
document.querySelectorAll
Element.querySelectorAll
NodeList

上記以外にも要素を取得するためのインターフェースはいくつかありますが、少なくとも現場レベルでは上に挙げたものを押さえておけば事足りるのではないかと思います。

また、上の表の内、getElementById以外はdocumentからでも、Elementからでも呼び出すことが可能です。documentから呼び出した場合、要素の検索対象が文章全体になるのに対し、Elementから呼び出した場合は、そのElementを子孫要素(サブツリー)が検索対象となります。

単一のElementを返すメソッドで、該当Elementが複数ある場合は、ドキュメント内で最初に出現するElementを返します。

HTMLCollectionとNodeList

HTMLCollectionNodeListはどちらもElementの集合を表しますが別物です。 どちらも配列(Array)に似ていますが、厳密には配列ではなくそれぞれ性質の異なる固有のオブジェクトです。そのため、配列操作で比較的よく使用されるメソッド(map や filter など)も持っていません。

HTMLCollectionNodeListとでは使用できるメソッド等の違いのほか、動的か静的かという性質の違いもあります。

HTMLCollectionは取得後もDOMの変更を反映するため、「動的/生きている」という性質をもちますが、NodeListは取得後のDOMの変更を反映しないので「静的」な性質を持ちます。

この性質の違いは次のコードで確認することができます。

<ul id="items">
  <li>foo</li>
  <li>bar</li>
</ul>
const items = document.getElementById('items');
const htmlCollection = document.getElementsByTagName('li');
const nodeList = document.querySelectorAll('li');
const newItem = document.createElement('li');

console.log(htmlCollection); // HTMLCollection(2) [li, li]
console.log(nodeList); //NodeList(2) [li, li]

newItem.textContent = 'baz';
items.appandChild(newItem);

console.log(htmlCollection); // HTMLCollection(3) [li, li, li]
console.log(nodeList); //NodeList(2) [li, li]

// HTMLCollectionは要素の数が増えているが、NodeListは取得時のままとなる。

HTMLCollection, NodeList内の個々の要素にアクセスするには?

HTMLCollectionNodeListもどちらもElementの集合を表すオブジェクトですが、格納されている個々の要素を取得するには次の様にします。いずれの場合も単一要素であるElementを取得することができます。

<ul>
  <li name="foo">foo</li>
  <li>bar</li>
  <li>baz</li>
</ul>
const elms = document.getElementsByClassName('li');

console.log(elms[0]); // <li name="foo">foo</li>
console.log(elms.item(0)); // <li name="foo">foo</li>
console.log(elms.namedItem('hoge')); // <li name="foo">foo</li>
const elms = document.querySelectorAll('li');

console.log(elms[0]); // <li name="foo">foo</li>
console.log(elms.item(0)); // <li name="foo">foo</li>

HTMLCollection, NodeListのループ処理

HTMLCollectionNodeListはいずれもElementの集合ですが厳密には配列ではありません。では格納されているElementに対してループ処理を行うにはどのようにすればよいのでしょうか?

NodeListには配列と同じようにforEachメソッドがありますのでこれを使うことができます。

<ul>
  <li>foo</li>
  <li>bar</li>
  <li>baz</li>
</ul>
const elms = document.querySelectorAll('li');

elms.forEach(elm => {
  // Elementオブジェクト
  console.log(elm);
});

NodeList.forEachはIEでは使用出来ません。また、HTMLCollectionにもforEachメソッドがなく、代わりのメソッドもないため、代わりの手段を用いてループ処理を実装する必要があります。

次の例は代替手段の一例ですが、ArrayオブジェクトのforEachメソッドを拝借する形で実装しています

const htmlCollection = document.getElementsByTagName('li');
const nodeList = document.querySelectorAll('li');

Array.prototype.forEach.call(nodeList, elm => {
  // Elementオブジェクト
  console.log(elm);
});

Array.prototype.forEach.call(htmlCollection, elm => {
  // Elementオブジェクト
  console.log(elm);
  
});

親子兄弟要素の取得

ある要素の親子兄弟要素へアクセスするには、次のようなElementオブジェクトのプロパティからアクセスすることが可能です。

const elment = document.querySelector('selector');

// すべての子要素を参照
const children = element.children;

// 最初の子要素を参照
const firstChild = element.firstElementChild;

// 最後の子要素を参照
const lastChild = element.lastElementChild;

// 親要素を参照
const parent = element.parentElement;

// 一つ前の兄弟要素を参照
const prev = element.previousElementSibling;

// 次の兄弟要素を参照
const next = element.nextElementSibling;

上記の各プロパティの型は次の通りです。

Element.children HTMLCollection
Element.firstElementChild Element
Element.lastElementChild Element
Element.parentElement Element
Element.nextElementSibling Element
Element.previousElementSibling Element

NodeとElement

NodeElementよりも低レベルのDOMインターフェースで、ElementNodeを継承しています。DOM操作用APIにはElementだけでなく、Nodeを扱う関数も数多く用意されています。

例えば、Element.parentElementは親要素を返しますが、Element.parentNodeは親ノードを返す、といった具合にエレメント操作用関数と対になっているものも数多くあります。

ただ、実際のコーディングにおいては、NodeよりもElementを操作することが殆どではないかと思いますので、本記事ではNode操作の詳細については割愛しています。

基本はquerySelector, querySelectorAll

HTMLCollectionNodeListとでは性質や扱い方に若干の違いがあることは先に述べた通りです。DOM操作の出発点として、まず既存のDOMを取得するケースが多いかと思いますが、その際document.getElementsByClassNamedocument.querySelectorAll等が混在していると、取得したオブジェクトもHTMLCollectionNodeListが混在してしまいます。

個人的な見解としては、DOM取得の際は返されるオブジェクトがHTMLCollectionなのか、NodeListなのかを意識つつ、極力どちらかに寄せていった方がコードに一貫性があるので良いと考えています。

さらに言うと、document.querySelector/document.querySelectorAllの方がCSSセレクタでDOMを抽出できるので、こちらの方が便利かなと思います。ただし、document.querySelectorAllはあらゆる情報を即座に静的リストに集めるためパフォーマンスの低下を引き起こすとのことですので、大量のDOMを一度に取得するような場合は限定的にHTMLCollectionを扱うようにした方が良いかもしれません。

要素の作成

要素(タグ)の新規作成にはdocument.createElement関数を使用します。引数にはタグ名を指定します。

const elm = document.createElement('p');

上のコードはpタグを新規作成した例です。ここでは要素を作成しただけで、まだDOMツリーに追加していませんので、このままでは画面に表示されることはありません。作成した要素をDOMツリーに追加するには、次のDOM追加用関数を使用する必要があります。また、上の例では属性も中身もないただの空のpタグを作成しただけなので、このタグに属性や中身(テキストやその他の要素など)を追加するにはこの後説明する各種関数を利用して操作していく必要があります。

要素の追加

ある要素に別の要素を追加する場合は、Element.appendChildElement.insertBeforeメソッドが使えます。

appendChildは指定した要素の子要素の一番最後に新たな要素を加えます。insertBeforeは指定した要素の子要素のうち、特定の要素の直前に新たな要素を加えます。

<ul>
  <li>その1</li>
  <li>その2</li>
</ul>
const ul = document.querySelector('ul');
const li3 = document.createElement('li');
const li4 = document.createElement('li');
const li5 = document.createElement('li');

li3.textContent = 'その3';
li4.textContent = 'その4';
li5.textContent = 'その5';

// ulの子要素の末尾に追加
ul.appendChild(li3);

// 「その1」と「その2」の間に追加
ul.insertBefore(li4, ul.children[1]);

// insertBeforeの第二引数をnullにすると子要素の末尾に追加される
ul.insertBefore(li5, null);

appendChild/insertBeforeという名前から連想すると、prependChildやinsertAfterのといったメソッドもありそうな気がしますが、Vanilla JSではそのようなメソッドは存在しません(jQueryではprependinsertAfterといったメソッドがあります)。

「子要素の先頭に挿入」「子要素の中の特定の要素の後に挿入」といったことをするにはどちらもinsertBeforeを使って実装することができます。

const ul = document.querySelector('ul');
const li6 = document.createElement('li');
const li7 = document.createElement('li');

li6.textContent = 'その6';
li7.textContent = 'その7';

// ulの子要素の先頭に追加
ul.insertBefore(li6, ul.firstElementChild);

// ulの子要素の中の特定の要素の「後」に挿入
// (nextElementSibling = 対象要素の「次」の兄弟要素)
ul.insertBefore(li7, ul.children[0].nextElementSibling);

要素の変更

コンテンツ(要素の中身)の操作

要素のコンテンツを修正するには、element.textContentelement.innerHTMLプロパティを設定します。

textContentプロパティは要素内のテキストを、innerHTMLプロパティは要素内のHTMLをそれぞれ取得・設定することができます。

textContentプロパティはあくまでもテキストなので、例えばHTMLタグを含む文字列を設定したとしても、そのタグはテキストとして表示されるだけなので、新たなDOMツリーは作成されません。

textContentinnerHTMLのどちらも既存のコンテンツを置き換えてしまうので、注意が必要です。

また、textContentと似たinnerTextというプロパティがありますが、これはIEの独自インターフェースであり標準仕様ではありませんので使用を避けた方が良さそうです。(IE9以降、textContentプロパティが使用可能になったので。)

<div id="container">
  <p class="text">foo</p>
</div>
const container = document.getElementById('container');
container.textContent = 'bar';
<div id="container">
  bar
</div>
const container = document.getElementById('container');
container.innerHTML = '<span class="text">bar</span>';
<div id="container">
  <span class="text">bar</span>
</div>

属性の操作

ある要素に対し属性をセットする場合はsetAttributeメソッドを、設定されている属性を取得する場合はgetAttributeメソッドを使用します。

setAttributeメソッドでは既に同じ属性が設定されている場合は上書きされます。また、getAttributeは存在しない属性名を指定した場合はnullを返します。

属性の削除にはremoveAttributeメソッドを使用します。removeAttributeメソッドの引数には属性名を渡しますが、未設定の属性名を渡したとしても例外は発生しません。

属性の有無を判定するにはhasAttributeメソッドを使用します。

すべての属性の取得にはattributesプロパティが利用できます。attributesHTMLCollectionNodeList同様、配列に似たオブジェクトですが、厳密には配列ではなくNamedNodeMapという属性の集合を表すオブジェクトです。

// 属性の取得
element.getAttribute('id');

// 属性のセット
element.setAttribute('id', 'test');

// 属性の
element.removeAttribute('id');

// 属性を持っているかどうか
element.hasAttribute('id');

// すべての属性のコレクションを取得
const atts = element.attributes;

クラスの操作

classの取得/設定を行う最も単純な方法はElement.classNameプロパティを利用する方法です。classNameプロパティは対象要素に指定されたclass属性値(文字列)をダイレクトに取得/設定することができます。

<div class="foo bar"></div>
const elm = document.querySelector('.foo');

console.log(elm.className); // foo bar

elm.className = 'baz';

console.log(elm.className); // baz

classNameプロパティは単純にclass属性値の文字列を取得/設定するだけなので、classの追加や削除、または特定のクラスのオン/オフを切り替えたいようなケースでは少し扱いづらいです。

そのような場合は、classListプロパティの各種関数を利用するのが良いでしょう。

const elm = document.querySelector('.foo');
console.log(elm.className); // foo

// classの追加
elm.classList.add('bar');
console.log(elm.className); // foo bar

// (カンマ区切りで複数一括追加も可能)
elm.classList.add('hoge', 'fuga');
console.log(elm.className); // foo bar hoge fuga

// classの削除
elm.classList.remove('bar');
console.log(elm.className); // foo hoge fuga

// (カンマ区切りで複数一括削除も可能)
elm.classList.remove('hoge', 'fuga');
console.log(elm.className); // foo

// classのON/Off
elm.classList.toggle('bar');
console.log(elm.className); // foo bar

elm.classList.toggle('bar');
console.log(elm.className); // foo

// クラスの有無判定
console.log(elm.classList.contains('bar')); // false

要素の削除

特定の要素を削除するにはElement.removeChildメソッドが利用できます。removeChildの引数には削除対象となるelementオブジェクトを渡します。removeChild関数の戻り値は削除されたelementオブジェクトです。(つまり、引数で渡したelementオブジェクトが返されます)。

削除されたelementオブジェクトはDOMツリーからは削除されますが、スクリプト内での参照が生きている限りアクセスすることは可能です。

<ul id="items">
  <li id="foo">foo</li>
  <li>bar</li>
</ul>
<p id="text">baz</p>
const items = document.getElementById('items');
const foo = document.getElementById('foo');
const text = document.getElementById('text');

console.log(foo);

//特定の子要素削除
const removed = items.removeChild(foo);
console.log(removed === foo); // true

//自分を削除する場合は
items.parentElement.removeChild(items);


//子要素を全て削除(高速)
while (items.firstChild) {
  items.removeChild(items.firstChild);
}

//子要素を全て削除(低速)
items.textContent = null;

//子要素ではない要素を指定するとエラーになる
items.removeChild(text); //Uncaught DOMException: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this 

要素の移動

要素を移動するには、element.appendChildelement.insertBefore関数が利用できます。

どちらの関数も、「要素の追加」の項で出てきた関数ですが、既にDOMツリーに組み込まれている既存の要素を引数として渡すことで、その要素をツリーの別の場所に移動させることができます。

<ul id="list1">
  <li>foo</li>
  <li>bar</li>
  <li>baz</li>
</ul>

<ul id="list2">
  <li>hoge</li>
  <li>fuga</li>
  <li>piyo</li>
</ul>
const list1 = document.getElementById('list1');
const list2 = document.getElementById('list2');

const hoge = list2.children[0];
const fuga = list2.children[1];

list1.appendChild(hoge);
list1.insertBefore(fuga, items1.firstElementChild);
<ul id="list1">
  <li>fuga</li>
  <li>foo</li>
  <li>bar</li>
  <li>baz</li>
  <li>hoge</li>
</ul>

<ul id="list2">
  <li>piyo</li>
</ul>

対応ブラウザについて

jQueryを使わない場合、ブラウザの対応状況を気にする必要があります。すべてを網羅してちゃんと調べたわけではありませんが、本記事で紹介した各種DOM操作用のメソッドやプロパティについては現時点での最新ブラウザでは問題無く使えるかと思います。

本記事では触れていないトピックのブラウザ対応状況についてはCan I use… Support tables for HTML5, CSS3, etc等を使ってご確認ください。

まとめ

今回はjQueryを使わないDOM操作の全体感を掴むためのリサーチだったので、ここで挙げたDOM操作用のインターフェースはあくまで一部に過ぎませんが、現場での使用が想定されるものは概ねこんなところではないかと思います。さらに詳細なAPIの仕様についてはMDNとかを参照してください。

以前は一口にDOM操作といっても、ブラウザごとに実装にばらつきがあったり、API自身の実装も今ほど充実していませんでした。そういった未熟な部分をjQueryが補完してくれていました。

現在はブラウザ毎の実装の違いもほとんどなく、標準のDOM操作インターフェースも充実しているので、jQueryを使用しなくても納品物としてそのまま使用できるレベルにまで達してきたのではないかと思います。(IE11もサポートする場合はちゃんとチェックしてから使用した方が無難な気もしますが)

jQueryが最も重宝されていた理由として、クロスブラウザ対応や簡単なDOM操作といった点が挙げられるかと思いますが、ネイティブでのDOM操作が現場での使用に耐えられるようになってきた昨今、jQueryを利用するメリットは薄くなってきたのかもしれません。

しかし個人的な意見ですが、ことDOM操作という観点で言えばやはりjQueryを使った方がシンプルで直感的にコーディングできるな、とも思いました。jQueryを使用する場合、ライブラリファイルのロードにかかるオーバーヘッドや、DOM操作におけるパフォーマンスが気になるかもしれませんが、調べた限りでは両者に体感できるほどの違いは無さそうです。どちらを使うべきかというのは無く、プログラマーの好みであったり、その他もろもろの制約であったりと、ケースバイケースでしょう。

jQuery自体は今後も生き残っていくと思いますが、ブラウザ側の実装が充実していくにつれ、jQueryの恩恵は確かに薄くなっていき、jQuery離れも今以上に進んでくるはずです。そういう意味ではネイティブでの書き方を覚えることでjQueryへの依存を減らしていくことは良いことだと考えます。