2020.09.23

JavaScript超基礎講座!DOM操作を学ぶ


本記事では、JavaScript 初心者の方向けに、ブラウザにおける JavaScript 開発の基礎について書いていきます。

最近は React や Vue.js などのフレームワークがスタンダードになって、「生の」JavaScript を扱うことは少なくなっているかもしれません。しかし、フレームワークの裏側で動く仕組みは当然同じです。技術トレンドが移り変わっても対応できる「基礎知識」を身につける、一助になれば幸いです。

  • 少なくとも、HTML / CSS と、JavaScript の文法知識を前提としています。
  • フロントエンド、つまりブラウザを実行環境とする JavaScript を扱います。Node.js などのサーバサイドの話題は含みません。

そもそもDOMって?

JavaScript の主な目的は、Web ページの表示を変化させることです。

フレームワークや非同期処理など、他にもいろいろトピックがあると思うかもしれませんが、結局、ゴールはページの見た目を変えることです(だからこそ、基礎をしっかり学ぶことが大切だと思います)。

どのようにして JavaScript がページの見た目を操作するのか理解するために、まずはブラウザが Web ページを読み込む仕組みを簡単に押さえておきましょう。

  1. Web ブラウザは、Web サーバから受け取った HTML 文書を解析し、DOM(Document Object Model)と呼ばれるデータ構造に変換します。
  2. DOM に CSS を解析して得られたスタイルルールを紐づけてレイアウト(画面上の配置)が計算され、最終的に画面に描画(レンダリング)されます。

この流れをざっくり絵にしたのが下の画像です(CSS は省略しました)。

DOM は、HTML が持つ要素の親子関係を、ツリー構造で表現します。たとえば以下の HTML を読み込むと…

<html>
  <head>
    <title>ぼくのサイト</title>
  </head>
  <body>
    <article>
      <h1>すきなどうぶつ</h1>
      <ul>
        <li>ねこ</li>
        <li>いぬ</li>
        <li>とり</li>
      </ul>
    </article>
  </body>
</html>

ブラウザ内部にこのような DOM データが構築されます。

まず画面に表示するまでの流れはこんな感じなのですが、ここからが JavaScript の出番です。

JavaScript を使うと、ブラウザ内部にある DOM にアクセスして、その内容を書き換えることができます。DOM が書き換えられると、自動的に画面の内容が新たな DOM に合わせて再描画される仕組みになっています。

この記事では、このように、JavaScript で DOM を操作する代表的な方法を紹介します。

ちなみに、ブラウザレンダリングの仕組みについては、以下の記事により詳しく書いてありますので、興味があれば読んでみてください。

DOM の取得

ノード

もう一度、DOM の概念図を見てみましょう。

上図の一つ一つの(ピンクや黄色の)箱を、「ノード」と言います。ノードにはいくつか種類がありますが、主に「要素ノード」「テキストノード」の2種類を意識しておけば OK です。

「要素ノード」は、HTML 要素を表します。属性や適用されたスタイルなどの情報(プロパティ)を持っています。「テキストノード」は、文字通り、要素ではない、テキスト部分です。

ここで見ていくのは、DOM から特定のノードの情報を取得する方法です。後述しますが、取得したノード情報を書き換えると、DOM にもその変更が反映されます。

「DOM の取得」というタイトルですが、正確には「DOM を構成するノードの情報の取得」とも言えますね。

Document オブジェクト

本題に入る前に、もう一点、前提知識を書いておきます。

ブラウザ内の JavaScript エンジンには、基本的な文法解析機能のほかに、DOM 操作などブラウザ固有の処理を行うためのオブジェクトがあらかじめ用意されています。

その一つが、document オブジェクトです。これは、HTML 文書(つまり、画面に表示されている Web ページ)を表すオブジェクトで、DOM 操作のためのメソッドが含まれています。

id 属性による検索

getElementById は、最も有名?で、どんな入門書にも最初に出てくるメソッドではないでしょうか。

引数に指定された id 属性を持つ要素ノードを、DOM 全体から検索します。もし見つからなければ、null を返します。

const button = document.getElementById('the-button');

(以降、「要素ノード」を単に「要素」や「HTML 要素」とも呼びます。)

セレクターによる検索

querySelectorquerySelectorAll のほうが、より包括的な方法と言えます。

これらのメソッドは、セレクター文字列を引数に取り、合致する要素を検索します。このセレクター文字列は、CSS で用いるセレクターと同じです。

querySelectorgetElementById と同じように、単一の要素ノードを返却します。セレクターに合致する要素が文書中に複数存在する場合は、HTML 的に一番上に書かれている要素が取得されます。見つからない場合は、null を返します。

const button = document.querySelector('#the-button');

querySelectorAll は、セレクターに合致する複数の要素を返却します。具体的には、NodeList オブジェクトが返されます。見つからない場合は空の NodeList が返却されます。

const buttons = document.querySelectorAll('.button');

NodeList はノードのコレクション(いくつか集まったもの)です。配列のようなイメージですが、厳密には違うので、扱いには注意が必要です。これについては後述します。

document 以外の要素に対して呼び出すと、子要素から、指定のセレクターに合致する要素が検索されます。

<div class="box">
  <button class="button">click me</button>
</div>
const box = document.querySelector('.box');
const button = box.querySelector('.button'); // 👈

親要素を取得する

ある要素の親要素は、parentNode プロパティからアクセスできます。

<div class="box">
  <button class="button">click me</button>
</div>
const button = document.querySelector('.button');
const box = button.parentNode;

子要素を取得する

childNodeschildren プロパティからも、子要素を取得できます。

<ul>
  <li>One</li>
  <li>Two</li>
  <li>Three</li>
</ul>

childNodes プロパティは、子要素の一覧を表す NodeList にアクセスできます。孫やそれ以降のノードは含まれません。

const list = document.querySelector('ul');
const listItems = list.childNodes;

children プロパティも childNodes と同様ですが、こちらは HTML 要素のみの一覧(HTMLCollection)を返します。つまり、テキストノードは含みません。

const listItems = list.children;

兄弟要素を取得する

ある要素の兄弟、つまり、隣り合った要素を取得する方法もあります。

<p id="item-1">One</p>
<p id="item-2">Two</p>
<p id="item-3">Three</p>

nextSibling プロパティは次(下)に隣り合うノードを保持しています。

しかし、以下は期待通り2つ目の <p> 要素を返してはくれません。その代わり、空白のテキストノードが返されます。

const one = document.querySelector('#item-1');
console.log(one.nextSibling);
// -> #text

これはなぜかというと、コード上の改行やホワイトスペースも、テキストノードの形で DOM の一部に組み込まれるためです。

nextElementSibling プロパティを使えば、テキストノードを除いて次の要素を得られます。

const one = document.querySelector('#item-1');
console.log(one.nextElementSibling);
// -> <p id="item-2">Two</p>

ちなみに、previousSibling および previousElementSibling プロパティでは、前(上)に隣り合う要素を取得できます。

ループ処理

querySelectorAll メソッドの返却値や children プロパティの値は、要素(またはノード)のリストですが、通常の配列とは別の種類のオブジェクトです。

NodeListHTMLCollection に対してループ処理を行う場合は、少し工夫が必要な場合があるので、ここで紹介しておきます。

for ループ

まず、普通に for ループで処理はできます。

for (let i = 0; i < listItems.length; i++) {
  console.log(listItems[i].id);
}

forEach ループ

このような HTML があるとします。

<ul>
  <li id="item-1">One</li>
  <li id="item-2">Two</li>
  <li id="item-3">Three</li>
</ul>

結論から言うと、ループ処理に関しては、以下の方法で同様に実現できます。

// NodeList
const listItems = document.querySelectorAll('li');
Array.prototype.forEach.call(listItems, item => {
  console.log(item.id);
});

// HTMLCollection
const list = document.querySelector('ul');
const listItems = list.children;
Array.prototype.forEach.call(listItems, item => {
  console.log(item.id);
});

詳しく説明すると、まず NodeList は、forEach メソッドを実装しています。すなわち、以下のように直接 forEach メソッドを呼び出すことができます(ただし IE は除く)。

listItems.forEach(item => console.log(item.id));

一方、HTMLCollection は、forEach メソッドを実装していません。そのため、上記のように、call メソッド経由で Array 型に実装された forEach を呼び出す必要があります。NodeList でも同様で、この方法なら IE でも動作します。

以下は、「Array 型に実装された forEach メソッドを実行する。ただし、呼び出し元(ループされる対象)は第一引数とする。実行時の引数は第二引数である」という意味です。

Array.prototype.forEach.call(listItems, item => console.log(item.id));

配列に変換する

NodeListHTMLCollection は、以下の方法で配列に変換もできます。

まずは slice メソッドを用いた方法です。

NodeList
const listItems = document.querySelectorAll('li');

const listItemsArray = Array.prototype.slice.call(listItems, 0);
HTMLCollection
const list = document.querySelector('ul');
const listItems = list.children;

const listItemsArray = Array.prototype.slice.call(listItems, 0);

配列のスプレッド構文を用いる方法はもっと簡単です。

const listItemsArray = [...listItems];

一度配列に変換してしまえば、あとは自由に Array 型のメソッドを呼び出せます。

const list = [...document.querySelectorAll('li')];

const activeItem = list.filter(item => {
  return item.classList.contains('is-active');
});

属性の取得と更新

包括的な方法

要素の属性値は getAttribute / setAttribute メソッドで取得 / 更新します。

// 属性値の取得
const content = element.getAttribute('content');

// 属性値の更新
element.setAttribute('title', 'Hello world');

クラス属性

クラス属性は、値を複数持ちます。そのため、classList プロパティを経由してその値を操作する方法が提供されています。

// クラスを追加する
element.classList.add('is-active');

// クラスを削除する
element.classList.remove('is-active');

// クラスの有無を切り替える
element.classList.toggle('is-active');

// クラスを持っているか?
element.classList.contains('is-active'); // -> true / false

これらの操作を setAttribute などで実装しようとすると、空白区切りの考慮などが必要でややこしくなるので、classList を利用するのが適切です。

その他のメソッドについては、classList の返却値型である DOMTokenList を参照してください。

ちなみに、classList は IE では実装されていません。ポリフィルを読み込むことで利用可能になります。

カスタムデータ属性

開発者が HTML 要素にオリジナルの属性を設定する場合は、data- で始まる命名を用いるのが、標準仕様です。

<div id="the-item" data-my-name="john">...</div>

これらの属性はカスタムデータ属性と呼ばれる。JavaScript からは、dataset プロパティから以下のように値にアクセスできます。

const element = document.querySelector('#the-item');
console.log(element.dataset.myName); // -> "john"
element.dataset.myName = 'paul'; // 値の更新

カスタムデータ属性の使いどころは、主に JavaScript との連携でしょう。

少し抽象的に言うと、ある要素に、その要素特有のデータを持たせるために使えます。

これは、idclass など他の属性には無い特徴です。id は、ある要素を文書中で一意に特定するための属性で、class は要素をグルーピングするための属性です。

これに対してカスタムデータ属性は、要素にアプリケーション固有の設定値を与えることができます。

たとえば、ツールチップライブラリ Tippy では、カスタムデータ属性がオプション指定の方法として採用されています。

<button
  data-tippy-content="hello world"
  data-tippy-arrow="true"
>Button 1</button>

<button
  data-tippy-content="good evening"
  data-tippy-arrow="false"
>Button 2</button>

DOM を生成する

動的に要素を生成するには、createElement メソッドを用います。

const image = document.createElement('img');
image.setAttribute('src', '/images/cute-cat.png');
image.setAttribute('alt', 'かわいいネコ');

生成しただけでは特に意味は無く、DOM に挿入することで、画面に反映されます。

DOM を挿入する

insertAdjacentElement

要素の挿入には、insertAdjacentElement メソッドを用います。

とても綴りが難しいメソッド名ですが、Adjacent は「隣接する」という意味らしいです。

このメソッドでは、以下のいずれかの位置に要素を挿入することができます。

  • "beforebegin":対象要素の直前に挿入
  • "afterbegin":対象要素の内部の最初に挿入
  • "beforeend":対象要素の内部の最後に挿入
  • "afterend":対象要素の直後に挿入

HTML で考えると、こういうことです(#target に対して呼び出すとする)。

<!-- beforebegin -->
<div id="target">
  <!-- afterbegin -->
  <div id="inner"></div>
  <!-- beforeend -->
</div>
<!-- afterend -->

一つずつ見ていきましょう。

beforebegin

<!-- Before -->
<div id="elem-1">
  <p>Hello world!</p>
</div>

第一引数で、どの位置に挿入するかを指定します。

const element = document.querySelector('#elem-1');
element.insertAdjacentElement('beforebegin', image);
<!-- After -->
<img src="/images/cute-cat.png" alt="かわいいネコ" />
<div id="elem-1">
  <p>Hello world!</p>
</div>

その他の位置も同様です。以下に例を示します。

afterbegin

<!-- After -->
<div id="elem-1">
  <img src="/images/cute-cat.png" alt="かわいいネコ" />
  <p>Hello world!</p>
</div>

beforeend

<!-- After -->
<div id="elem-1">
  <p>Hello world!</p>
  <img src="/images/cute-cat.png" alt="かわいいネコ" />
</div>

afterend

<!-- After -->
<div id="elem-1">
  <p>Hello world!</p>
</div>
<img src="/images/cute-cat.png" alt="かわいいネコ" />

その他

insertAdjacentElement と同様のメソッドに、insertAdjacentHTMLinsertAdjacentText があります。

insertAdjacentHTML は第二引数に、要素オブジェクトではなく HTML 文字列を取ります。

element.insertAdjacentHTML('afterbegin', '<p>Hello</p>');

insertAdjacentText はテキストを挿入します。

element.insertAdjacentText('afterbegin', 'Goodbye');

また、他にも、要素を挿入する方法として、innerHTML プロパティや appendChild メソッド、insertBefore メソッドがあります。ただ、個人的には、わざわざ使い分ける必要は無いと思います。一番柔軟な insertAdjacentElement を使えるようにしておけばよいでしょう。

DOM を削除する

既存の要素を削除するのは簡単です。remove メソッドを用います。

element.remove(); // 自分を削除する

ただし、IE では動作しないため、ポリフィルを読み込むか、以下の方法で実現しましょう。

// 一度親要素を取得してから removeChild を実行する
const parent = element.parentNode;
parent.removeChild(element);

以上、本記事では、JavaScript の超基礎講座として、DOM の操作方法を見ていきました。

「JavaScript DOM操作」などのキーワードで検索すると、他の方が書いた記事がたくさんヒットするでしょう。初心者の方は、書籍や、最近だと動画も含めて、いろいろな説明を吸収して、総合的に解釈するのが良いと思います。もちろん、手を動かすのも大切です!

次は、イベント処理を扱おうと思います。