本文へスキップ
ウェブエンジニア問題集
第16章

DOM操作の基本 — 要素取得・イベント・preventDefault

約29分
この章の目次開く

ブラウザはHTMLを読み込むと、その内容をDOM(Document Object Model)というオブジェクトの木に変換します。JavaScriptから画面を書き換えられるのは、このDOMを操作できるからです。逆に言えば、DOMを経由しない限り、JavaScriptは画面に何も表示できません。

この章では、要素を取得する → 中身を変える → 要素を作って追加する → イベントを受け取る → ブラウザの既定動作を止める → イベントを親でまとめて処理する、という順に扱います。DOMはJavaScriptの言語仕様(ECMAScript)ではなく、WHATWGのDOM Standardで定義されたブラウザ側のAPIです。そのため、Web APIと同じくNode.jsには標準で存在しません。

学習者学習者

ボタンを取ってクリックを拾うところまでは書けたんですが、フォームの送信ボタンを押すとページが丸ごと再読み込みされて、書いた処理が途中で消えてしまいます。あれって止められるんですか?

止められます。その役目を持つのが、この章の後半で扱う preventDefault() です。まずは要素の取得から順に見ていきます。


DOMとは — HTMLがオブジェクトの木になる

DOMは、HTMLのタグ1つひとつをノードというオブジェクトに置き換え、親子関係のある木構造として並べたものです。document が木の根で、そこから html、body、その中の各要素へとぶら下がります。

<body>
  <h1>記事一覧</h1>
  <ul id="list">
    <li>1件目</li>
  </ul>
</body>
html

このHTMLは、次のような木としてメモリ上に展開されます。

ここで押さえておきたいのは、JavaScriptが操作しているのはHTMLの文字列ではなく、ブラウザがメモリ上に組み立てたこの木であるということです。textContent で文字を書き換えても、サーバー上の .html ファイルは変わりません。変わるのは今開いているページのDOMだけです。

タグに対応するノードを要素ノード(Element)、タグの中の文字に対応するノードをテキストノードと呼びます。この章で扱うメソッドの多くは Element に生えています。


要素を取得する — querySelectorとquerySelectorAll

目的の要素を探しているイメージ

DOM操作は「まず対象の要素を手に入れる」ところから始まります。現在の標準的な取得方法は querySelector と querySelectorAll の2つです。CSSセレクターをそのまま書けるため、CSSを知っていれば新しく覚えることがほとんどありません。

構文: document.querySelector(selectors)

引数渡せるもの説明
selectors(第1引数)CSSセレクターの文字列'.card'、'#list'、'ul > li:first-child' など。CSSで書ける形ならそのまま使える

戻り値: 最初に一致した要素。一致するものが無ければ null

構文: document.querySelectorAll(selectors)

戻り値: 一致した全要素を含む NodeList。0件でも null ではなく空の NodeList が返る

const list = document.querySelector('#list'); // 最初の1件
const items = document.querySelectorAll('#list li'); // 全件
 
console.log(list); // <ul id="list">...</ul>
console.log(items.length); // 1
js

取得方法は他にもあり、性質が少しずつ違います。

メソッド引数戻り値
document.getElementById(id)id文字列(# は付けない)要素 / null
document.querySelector(selectors)CSSセレクター最初に一致した要素 / null
document.querySelectorAll(selectors)CSSセレクター静的な NodeList(0件なら空)
document.getElementsByClassName(name)クラス名ライブな HTMLCollection

迷ったら querySelector と querySelectorAll の2つで足ります。getElementById はid検索専用で最も高速ですが、実務で体感差が出る場面は稀です。

見つからなければ null が返る

querySelector は例外を投げません。一致しなければ静かに null を返します。その null に対してプロパティを触った瞬間にエラーになるため、エラーメッセージが出る場所と原因の場所がずれます。

const title = document.querySelector('.ttile'); // タイポでヒットしない
title.textContent = 'こんにちは';
// TypeError: Cannot set properties of null — title が null なのが原因
js

要素の存在が保証できない場面では、早期リターンで守ります。

const title = document.querySelector('.title');
if (!title) return; // 以降、title は要素であることが確定する
title.textContent = 'こんにちは';
js

NodeListは配列ではない

querySelectorAll が返す NodeList は配列に似ているだけの別物です。forEach は持っていますが、map や filter は持っていません。

const items = document.querySelectorAll('li');
 
items.forEach((item) => console.log(item.textContent)); // OK
items.map((item) => item.textContent); // エラー — NodeListにmapは無い
 
const texts = Array.from(items).map((item) => item.textContent); // OK
js

配列メソッドを使いたいときは Array.from(items) か [...items] で本物の配列に変換します。

さらに、querySelectorAll の結果は取得した瞬間の静的なコピーです。一方 getElementsByClassName が返す HTMLCollection はライブで、後からDOMに要素が増えると自動で増えます。

const staticList = document.querySelectorAll('.item'); // 静的
const liveList = document.getElementsByClassName('item'); // ライブ
 
const added = document.createElement('div');
added.classList.add('item');
document.body.append(added); // .item を1つ増やす
 
console.log(staticList.length); // 取得した時点の件数のまま
console.log(liveList.length); // 追加後の件数に増えている
js

ライブなコレクションを for ループで回しながら要素を削除すると、件数が途中で変わって飛ばし漏れが起きます。ループ中にDOMを変更するなら、静的な querySelectorAll を使うほうが安全です。


要素の中身を変える — textContent・classList・属性

要素を手に入れたら、次は中身の書き換えです。テキストを入れる方法は主に3つあり、どれを選ぶかで安全性が変わります。

プロパティ何を読み書きするかHTMLタグの扱い
textContent要素内のテキスト全体タグとして解釈しない。そのまま文字として表示される
innerHTML要素内のHTMLタグとして解釈され、DOMが作られる
innerText画面に見えているテキストCSSの表示状態を反映する。レイアウト計算が走る
const box = document.querySelector('#box');
 
box.textContent = '<b>太字</b>';
// 画面には <b>太字</b> という文字列がそのまま出る
 
box.innerHTML = '<b>太字</b>';
// 画面には 太字 が太字で出る(b要素が作られる)
js
学習者学習者

タグごと差し込みたいときは innerHTML 一択ですか?危ないと聞いた覚えもあって、どっちなのか分かりません。

危ないのは「入れる文字列の出どころ」です。自分で書いた固定の文字列なら問題ありません。危険なのは、ユーザーの入力・URLのクエリ・APIのレスポンスなど、外部から来た文字列をそのまま innerHTML に入れる場合です。その中に <img src=x onerror=...> のようなタグが混ざっていれば、スクリプトとして実行されます。

クラスの付け外し — classList

見た目の切り替えは、スタイルを直接書くよりクラスの付け外しで行うのが基本です。CSS側に見た目を寄せられるため、JavaScriptが見た目の詳細を知らずに済みます。

メソッド引数戻り値
classList.add(...names)クラス名(カンマ区切りで複数可)undefined
classList.remove(...names)クラス名(カンマ区切りで複数可)undefined
classList.toggle(name, force?)クラス名、付けるかどうかの真偽値実行後に付いていれば true
classList.contains(name)クラス名boolean
const panel = document.querySelector('#panel');
 
panel.classList.add('is-open');
panel.classList.remove('is-open');
panel.classList.toggle('is-open'); // 付いていれば外す、無ければ付ける
panel.classList.toggle('is-open', isLoggedIn); // 第2引数で「付ける/外す」を明示できる
js

toggle の第2引数は見落とされがちですが便利です。if で分岐して add / remove を呼び分ける代わりに、真偽値を1つ渡せば済みます。

属性とdata属性

メソッド / プロパティ引数戻り値
setAttribute(name, value)属性名、値undefined
getAttribute(name)属性名属性値の文字列 / null
removeAttribute(name)属性名undefined
dataset—data-* 属性を読み書きするオブジェクト

data-* 属性は、要素にアプリ固有の値を持たせたいときの置き場所です。dataset から、ハイフンをキャメルケースに変えた名前で読み書きできます。

<button data-user-id="12" data-role="admin">編集</button>
html
const button = document.querySelector('button');
 
console.log(button.dataset.userId); // '12' — data-user-id が userId になる
console.log(button.dataset.role); // 'admin'
 
button.dataset.userId = '34'; // 属性値も書き換わる
js

dataset から取り出した値は常に文字列です。数値として使うなら Number(button.dataset.userId) のように変換します。この落とし穴は、イベント委譲でIDを取り回すときに特によく踏みます。


要素を作って追加する — createElementとappend

一覧の行を増やす、通知を出すといった処理では、要素そのものを作ってDOMに差し込みます。

構文: document.createElement(tagName)

引数渡せるもの説明
tagName(第1引数)タグ名の文字列'div'、'li'、'button' など。山括弧は付けない

戻り値: 新しく作られた要素(この時点ではまだ画面に出ていない)

作った要素は、親要素に差し込んで初めて画面に現れます。差し込みには append と appendChild があります。

メソッド渡せるもの戻り値
parent.append(...nodes)要素・文字列を複数まとめてundefined
parent.appendChild(node)要素を1つだけ(文字列は不可)追加したノード
element.remove()—undefined
const list = document.querySelector('#list');
const item = document.createElement('li');
 
item.textContent = '2件目';
item.classList.add('item');
 
list.append(item); // ここで初めて画面に出る
js

複数の要素をループで1つずつ append すると、そのたびにDOMの再計算が走る可能性があります。件数が多いときは DocumentFragment にまとめてから、1回で差し込みます。

const fragment = document.createDocumentFragment();
 
for (const name of ['Taro', 'Hanako', 'Jiro']) {
  const li = document.createElement('li');
  li.textContent = name;
  fragment.append(li); // この時点ではまだ画面のDOMに触っていない
}
 
list.append(fragment); // DOMへの差し込みは1回だけ
js

イベントを受け取る — addEventListener

クリック、入力、送信といったユーザーの操作はイベントとしてDOMに流れてきます。それを受け取るのが addEventListener です。

構文: target.addEventListener(type, listener, options?)

引数渡せるもの説明
type(第1引数)イベント名の文字列'click'、'submit'、'input' など。on は付けない
listener(第2引数)関数イベント発生時に呼ばれる。第1引数にイベントオブジェクトが渡される
options(第3引数・省略可)オブジェクト / 真偽値capture / once / passive / signal を指定する

戻り値: undefined

const button = document.querySelector('#save');
 
button.addEventListener('click', (event) => {
  console.log('保存ボタンが押されました');
  console.log(event.type); // 'click'
});
js

第3引数のオプションは、知っていると書き方が変わるものが揃っています。

オプション型効果
capturebooleanバブリング段階ではなくキャプチャ段階で受け取る
onceboolean1回実行したら自動的に解除される
passivebooleanpreventDefault() を呼ばないと宣言する。スクロール性能のために使う
signalAbortSignalabort() でリスナーを解除できる
// モーダルを閉じる処理は1回だけで十分
closeButton.addEventListener('click', closeModal, { once: true });
js

removeEventListenerは「同じ関数」でないと外れない

リスナーを外すときの典型的な失敗がこれです。

button.addEventListener('click', () => console.log('hi'));
button.removeEventListener('click', () => console.log('hi'));
// 外れない — 見た目は同じでも別の関数オブジェクト
js

removeEventListener には、登録したときと同一の関数を渡す必要があります。そのため、後から外す予定のリスナーは名前付きの関数として変数に入れておきます。

const handleClick = () => console.log('hi');
 
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick); // 外れる
js

イベントオブジェクト — targetとcurrentTarget

リスナーの第1引数には、そのイベントの情報を持つオブジェクトが渡されます。慣習的に event または e と名付けます。

プロパティ何が入っているか
typeイベント名('click' など)
target実際にイベントが起きた要素(クリックされた当の要素)
currentTargetいまリスナーが登録されている要素
eventPhase伝播のどの段階か。1 キャプチャ / 2 ターゲット / 3 バブリング
cancelablepreventDefault() で既定動作を止められるイベントかどうか
defaultPreventedすでに preventDefault() が呼ばれたかどうか
isTrustedユーザー操作由来なら true、dispatchEvent による発火なら false

target と currentTarget の違いは、後で扱うイベント委譲の要になります。ボタンの中にアイコンの span が入っている場合、クリックされたのは span なので target は span になり、リスナーを付けた button は currentTarget に入ります。

<button id="save"><span class="icon">💾</span>保存</button>
html
document.querySelector('#save').addEventListener('click', (event) => {
  console.log(event.currentTarget); // <button id="save"> — 常にリスナーを付けた要素
  console.log(event.target); // アイコンを押すと <span class="icon">
});
js

preventDefault() — ブラウザの既定動作を止める

ブラウザには、要素と操作の組み合わせごとに「何もしなくても起きること」が決められています。これを既定動作(デフォルトアクション)と呼びます。

操作イベントブラウザの既定動作
送信ボタンを押すsubmit(form)フォームを送信し、ページを再読み込み・遷移する
リンクをクリックするclick(a[href])リンク先へ移動する
チェックボックスをクリックするclick(input)チェック状態が切り替わる
右クリックするcontextmenuブラウザのコンテキストメニューが開く
要素の上にファイルをドラッグするdragover受け取りを拒否する(既定のままだと drop が起きない)
入力中にTabキーを押すkeydown次の要素へフォーカスが移る

この既定動作を止めるのが preventDefault() です。冒頭の「送信するとページが再読み込みされてしまう」は、submit の既定動作がそのまま走っている状態です。

構文: event.preventDefault()

引数説明
なし引数は受け取らない

戻り値: undefined(戻り値で結果を判定するメソッドではない)

フォームを非同期で送信する

もっとも出番が多いのがこのパターンです。preventDefault() でページ遷移を止め、代わりに fetch で送ります。

const form = document.querySelector('#signup');
 
form.addEventListener('submit', async (event) => {
  event.preventDefault(); // ← これが無いとページが再読み込みされ、以降の処理は中断される
 
  const formData = new FormData(form);
 
  const response = await fetch('/api/signup', {
    method: 'POST',
    body: formData,
  });
 
  if (!response.ok) {
    form.querySelector('.error').textContent = '送信に失敗しました';
    return;
  }
 
  form.reset();
});
js

フォームを止めるリスナーは、送信ボタンの click ではなく form の submit に登録します。入力欄でEnterキーを押した送信はボタンの click を経由しないため、click で止める書き方では素通りします。

止めたかどうかは defaultPrevented で分かる

preventDefault() が呼ばれたかどうかは、同じイベントオブジェクトの defaultPrevented で確認できます。伝播の先にある別のリスナーが、既に処理済みかを判断するのに使えます。

document.addEventListener('click', (event) => {
  if (event.defaultPrevented) return; // 誰かが既に既定動作を止めている
  console.log('未処理のクリック');
});
js

preventDefault()は伝播を止めない

混同されやすい点なので、はっきり分けておきます。

preventDefault() が止めるのは「ブラウザの既定動作」だけで、イベントが親要素へ伝わっていくこと自体は止めません。伝播を止めるのは次節の stopPropagation() です。

link.addEventListener('click', (event) => {
  event.preventDefault(); // ページ遷移はしない
  // ただし、このクリックは親要素のリスナーにも届き続ける
});
js
学習者学習者

遷移もさせたくないし、親のリスナーも動かしたくないときは、どうすればいいんですか?

先生先生

そのときは両方呼びます。event.preventDefault() と event.stopPropagation() は役割が別なので、片方だけではもう片方は止まりません。ただ、親のリスナーまで止めるのは影響範囲が広いので、本当に必要かは一度立ち止まって考えるのがおすすめです。

return false は addEventListener では効かない

jQueryや古い解説記事の影響で「false を返せば止まる」と覚えている場合は、そこも整理しておきます。

// on〜プロパティやHTML属性のハンドラーでは、falseを返すと既定動作が止まる
link.onclick = () => false;
 
// addEventListenerで登録したリスナーでは、falseを返しても何も起きない
link.addEventListener('click', () => false); // 効かない — preventDefault()を呼ぶ
js

addEventListener で登録したリスナーの戻り値は、ブラウザから見れば使い道がありません。既定動作を止めるなら preventDefault() を明示的に呼びます。


イベントの伝播 — キャプチャ・ターゲット・バブリング

イベントが要素をたどって伝わっていくイメージ

li をクリックしたとき、イベントは li だけで完結しません。まず document から目的の要素へ降りていき(キャプチャ段階)、目的の要素に到達し(ターゲット段階)、そこから親をたどって戻っていきます(バブリング段階)。

addEventListener は既定でバブリング段階に反応します。キャプチャ段階で受け取りたい場合だけ { capture: true } を指定します。実務で書くリスナーのほとんどはバブリングで足ります。

伝播を止めるメソッドは2つあり、止める範囲が違います。

メソッド止まる範囲
event.stopPropagation()親要素へ伝わるのを止める。同じ要素に付いた他のリスナーは実行される
event.stopImmediatePropagation()上に加えて、同じ要素に付いた残りのリスナーも実行しない
inner.addEventListener('click', (event) => {
  event.stopPropagation(); // outerのリスナーは呼ばれなくなる
  console.log('内側だけ処理する');
});
 
outer.addEventListener('click', () => {
  console.log('ここには届かない');
});
js

stopPropagation() は手軽ですが、他の開発者が後から付けたリスナーや、ライブラリが document に付けているリスナーまで黙って止めてしまいます。「モーダルの外側クリックで閉じる処理が効かない」といったトラブルの典型的な原因です。使う前に、defaultPrevented の確認や条件分岐で済ませられないかを検討します。


イベント委譲 — 親要素でまとめて受け取る

バブリングを前提にすると、子要素1つずつにリスナーを付けず、共通の親に1つだけ付けるという書き方ができます。これをイベント委譲(イベントデリゲーション)と呼びます。

// 悪くはないが、行が増えるたびにリスナーも増える
document.querySelectorAll('#list li').forEach((li) => {
  li.addEventListener('click', onClick);
});
 
// 委譲 — 親に1つ付けるだけ
document.querySelector('#list').addEventListener('click', (event) => {
  const li = event.target.closest('li');
  if (!li) return; // li の外側(余白など)がクリックされた場合
  console.log(li.dataset.id);
});
js

委譲には2つの利点があります。1つはリスナーの数が減ること。もう1つは、あとからJavaScriptで追加した要素にも、追加作業なしでイベントが効くことです。親に付いたリスナーは、子が増えても減っても関係なく動き続けます。

closest() で目的の要素まで遡る

委譲で必ず使うのが closest() です。

構文: element.closest(selectors)

引数渡せるもの説明
selectors(第1引数)CSSセレクターの文字列自分自身から親方向へ遡り、最初に一致した要素を探す

戻り値: 一致した要素。自分自身も対象に含まれる。見つからなければ null

学習者学習者

委譲で書いたら、ボタンの中のアイコンを押したときだけ反応しなくなりました。同じボタンなのに…。

原因は event.target です。target には「実際にクリックされた一番深い要素」が入るため、アイコンを押せば span、文字を押せば button と、クリック位置によって変わります。そこで target をそのまま判定に使わず、closest() で目的の要素まで遡ります。

list.addEventListener('click', (event) => {
  const button = event.target.closest('button.delete');
  if (!button) return; // 削除ボタン以外のクリックは無視する
 
  button.closest('li').remove();
});
js

よくあるハマりどころ

1. querySelectorがnullになる(スクリプトが先に走っている)

head 内で普通の <script> を読み込むと、HTMLの解析より先にJavaScriptが実行され、まだ存在しない要素を探しにいきます。<script> に defer を付けるか、type="module" にすると、HTMLの解析後に実行されます。詳しくはHTMLとJavaScriptの連携とモジュールで扱っています。

2. リスナーが外れない

無名関数で登録して無名関数で外そうとしているケースです。同じ関数の参照を渡すか、{ once: true } や AbortController を使います。

3. innerHTMLで書き換えたらイベントが効かなくなった

innerHTML に代入すると、中の要素はすべて作り直されます。古い要素に付けたリスナーは、その要素ごと捨てられます。イベント委譲で親に付けておけば、この作り直しの影響を受けません。

4. preventDefault()したのに止まらない

3つの可能性を順に疑います。(a) event.cancelable が false のイベントである、(b) passive なリスナーである、(c) そもそも止めたかったのは既定動作ではなく伝播で、必要なのは stopPropagation() だった、の3つです。

5. submitではなくclickで止めている

送信ボタンの click を止める書き方では、入力欄でのEnterキー送信を拾えません。form の submit に登録します。

6. 委譲でtargetを直接判定している

event.target はクリック位置によって子要素になります。closest() で目的の要素まで遡ってから判定します。


ちゃんと使うためのポイント

  • 要素の取得は querySelector / querySelectorAll を基本にする。見つからなければ null が返るので、存在チェックを挟む
  • querySelectorAll の戻り値は NodeList。配列メソッドを使うなら Array.from() で変換する
  • 外部由来の文字列は textContent で入れる。innerHTML に渡さない
  • addEventListener の第3引数(once / signal / passive)を知っておくと、解除まわりの書き方が変わる
  • preventDefault() は既定動作を止め、stopPropagation() は伝播を止める。名前は似ているが役割は別
  • フォーム送信を止めるのは form の submit。ボタンの click ではない
  • 子要素が増減する一覧では、親に1つだけリスナーを付けるイベント委譲を使い、closest() で対象を特定する

次の章では、Map・Set・WeakRef を扱います。「この要素にはこのデータが紐づいている」という対応関係を持ちたくなる場面はDOM操作で頻繁に出てきますが、そこで効くのが任意の値をキーにできる Map と、要素が消えたら一緒に消える WeakMap です。

参考リンク

JavaScriptクイズに挑戦するこの章で学んだJavaScriptの知識を、4択クイズでアウトプットして定着させよう