BeAct Co., Ltd.

BLOG
社員ブログ

リストのタッチ/クリックイベントは「委譲」しよう

注)委譲は「いじょう」と訓みます。英語のDelegationの訳語です。

こんにちは! 今日もコーディングを楽しんでいますか? こんかいは、HTMLとJavaScriptとを組み合わせたコーディングのお話です。

このふたつの組み合わせ。よく使われる用途のひとつに、「あるHTML要素へのタッチ/クリックを契機として、なにかのスクリプトを動かす」……すなわち「DOMイベントのハンドリング」があります。しかしこの仕掛け、世間でよく使われる割に、あまり良い実装がされていない例を多く見かけます。今回は、こうした例のうち「イベントの委譲」のテクニックについて解説します。委譲を知っているか知らないかで、サイトやWebアプリのパフォーマンスに大きな差が出る場合がありますので、ぜひ覚えていってください!


よくある例

カード状の目録が表示され、それをクリックすると詳細情報が表示される……いろんなサイトでありがちですよね。たとえば、こういうものです。

See the Pen イベントの委譲1 by isobeact (@rmzpacpm-the-builder) on CodePen.

目録といっても1件しかありませんが、その理由は以下のように、データを1件しか用意していないからです。

/** fetch() などで読み込む目録データ */
const articles = [
  {
    title: "カードのタイトル",
    image: "picsum.photos/360/190/",
    text: "今日は何もしない、いい一日だった。",
    detail: "2010年の秋に撮影した写真です。なかなかいい感じに撮れているんじゃないかなあ。",
  }
];

それよりも注目していただきたいのは、カードに対するaddEventListenerの与え方です。

// articles を順番に処理
for (const article of articles) {
  /** 各カードの要素 */
  const articleNode = document.createElement("article");

  // ---- 中略 ----

  // カード要素に対するクリックイベント
  articleNode.addEventListener("click", onClickCard);

  // ---- 以下略 ----
}

お気づきのように、ループの中で、一つ一つのカード要素に対し、addEventListenerを行なっています。なるほど、カードごとにクリック時の振る舞いが変わるわけですから、カードごとにイベントを定義するのは理にかなっているように思えます。実際、こういう実装をしているサイトは珍しくありません。

でも、本当にこれでいいのでしょうか?


目録の件数が増えると…

サンプルの文章は『ポラーノの広場』(宮沢賢治)より抜粋しています。

See the Pen イベントの委譲2 by isobeact (@rmzpacpm-the-builder) on CodePen.

……多いですね。ご想像のとおり、これはデータの件数を増やしたためです。

/** fetch() などで読み込む目録データ */
const articles = [
  {
    title: "カードのタイトル",
    image: "picsum.photos/360/190/",
    text: "今日は何もしない、いい一日だった。",
    detail: "2010年の秋に撮影した写真です。なかなかいい感じに撮れているんじゃないかなあ。"
  },
  {
    title: "前十七等官 レオーノ・キュースト",
    image: "picsum.photos/360/190/",
    text: "そのころわたくしは、モリーオ市の博物局に勤めて居りました。",
    detail: "十八等官でしたから役所のなかでも、ずうっと下の方でしたし俸給(ほうきゅう)もほんのわずかでしたが、受持ちが標本の採集や整理で生れ付き好きなことでしたから"
  },
  {
    title: "わたくしは毎日",
    image: "picsum.photos/360/190/",
    text: "ずいぶん愉快にはたらきました。",
    detail: "殊にそのころ、モリーオ市では競馬場を植物園に拵こしらえ直すというので、その景色のいいまわりにアカシヤを植え込んだ広い地面が、切符売場や信号所の建物のついたまま、わたくしどもの役所の方へまわって来たものですから"
  },
  // ---- 以下略 ----
];

この大量の件数を踏まえ、先ほどの「カードごとにイベントを定義する」コードを思い出してください。

// articles を順番に処理
for (const article of articles) {
  /** 各カードの要素 */
  const articleNode = document.createElement("article");

  // ---- 中略 ----

  // カード要素に対するクリックイベント
  articleNode.addEventListener("click", onClickCard);

  // ---- 以下略 ----
}

DOMイベントの定義は、実はブラウザにとってコストの高い処理です。これを何十、ひょっとしたら100件にも届く回数のループでいちいち定義していくと、ブラウザへの負荷はどんどん上がってしまいます。このやり方では早晩、立ち行かなくなるのは想像に難くありません。

「じゃあどうすればいいの?」とお困りの皆さん、ご安心ください! これを解決するのが「イベントの委譲」なのです!


これが「イベントの委譲」!

さきほどと同様、サンプルの文章は『ポラーノの広場』(宮沢賢治)より抜粋しています。

See the Pen イベントの委譲3 by isobeact (@rmzpacpm-the-builder) on CodePen.

では、肝心の部分を見てみましょう。

次の2点にまず注目してください。

  • ループ内のarticleNode.addEventListener("click", onClickCard)を廃止
  • その代わり、ループの外でsectionNode.addEventListener("click", onClickCard)を追加
// articles を順番に処理
for (const article of articles) {
  /** 各カードの要素 */
  const articleNode = document.createElement("article");

  // ---- 中略 ----

  // ループ内でイベント定義しちゃダメ!
  // articleNode.addEventListener("click", onClickCard);

  // ---- 以下略 ----
}

// イベント定義は、コンテナに対し、1回だけ行えば十分!
sectionNode.addEventListener("click", onClickCard);

イベント定義が、たった1回で済んでしまいました。素晴らしいですね。でもこれでは、どうやって一つ一つのカードを区別しようというのでしょうか?

はい、まさにそれを行うための「イベントの委譲」です! そのコードはこちら:

/**
 * カードごとのクリックイベントハンドラ。
 *
 * この中で「イベントの委譲」を行います。
 *
 * @param {PointerEvent} e イベントオブジェクト
 */
function onClickCard(e) {
  /** イベントを受け取った要素 */
  const target = e.target;

  // ここから、イベントの委譲開始
  /** 委譲先の要素の探索結果 */
  const delegatee = target.closest(".card");

  // 委譲先の要素の存在判定
  if (delegatee == undefined) {
    // 存在していないなら、探索に失敗しているので、これ以上何もしない
    return;
  }

  // 外側の要素がイベントを受けてしまっていないか判定
  if (!sectionNode.contains(delegatee)) {
    // 委譲先がコンテナの中ではない(コンテナそのものだった、など)なら、委譲させてはならない
    return;
  }

  dialogNode.querySelector("p").innerText = delegatee.dataset.detail;
  dialogNode.showModal();
}

注)delegateeとは、「委譲先」という意味の英語です。

さて、このコードのポイントをいくつか挙げて解説をしてみたいのですが、その前に皆さんは「イベントモデル」という概念をご存知でしょうか? 本文ではそこまで踏み込むと長くなってしまうので、ご存知という前提で話を進めさせていただきます。もしご存知でない、という場合は、いつかまた別の機会で説明させていただきますね。

イベントターゲット:「イベントを受け取ったのは、だれ?」

このイベントハンドラ関数onClickCardには、仮引数としてeが指定されていますね。このeに入ってくる値をイベントオブジェクトと呼び、発生したイベントの詳細な情報が収められるようになっています。

イベントの委譲を行うためには、この中にあるtargetプロパティが重要となります。このプロパティには、指定したイベントを実際に受け取ったHTML要素が入っており、これをイベントターゲットと呼びます。

……また変な言葉が出てきましたね。「実際に受け取った」ですって? イベントの指定なら、先ほど sectionNode.addEventListener("click", onClickCard) という形で、 sectionNode に対して行なったはずではありませんか?

ではここで、sectionNode.addEventListener("click", onClickCard) というコードによって何が起こっているのかを、絵で説明してみましょう。

外側の要素に与えたDOMイベントは、内側の要素もイベントをリッスンするようになる。

上の絵ではHTMLを、要素が何重にも入れ子になった「箱」として表現しています。基本的に、特定の箱(要素)に与えたDOMイベントは、暗黙のうちにその子孫要素、つまり箱の中身すべてが次々とリッスンするようになります<p>くんの嘆きは、身につまされるものがありますね。「箱の中身すべて」……これをよく覚えておいてください。

さて、それではその<p>くんをクリックすると、どうなるでしょうか?

イベントは、内側の箱(要素)から外側に「浮き上がって(バブルアップ)」くる!

このように、イベントは「受け取った箱(要素)」から、外側の箱に対して、泡(バブル)が水面に上がるように、次々と外側の箱へと伝わってゆきます。この働きは「イベントバブリング」と呼ばれます。

イベントは最終的に、addEventListenerを行なった対象、すなわちsectionNodeまで浮き上がったところで捕まえられ、イベントハンドラ関数に「イベントオブジェクト」として渡されます。そして…

  • e: イベントオブジェクト
    • target: 実際にイベントを受け取った要素(例だと、<p>要素)

そう、これがイベントターゲットです。ハンドラ関数がイベントターゲットを識別できるようになることは、委譲へ最初の大きなステップです。先のコードでは、この部分に当たります。

function onClickCard(e) {
  /** イベントを受け取った要素 */
  const target = e.target;

では、イベントを与えた要素とイベントターゲットは、必ずしも等しくないということを、実際の例で見てみましょう。

カードのあちこちをクリック(タップ)してみてください。イベントを与えたのは<section>タグのコンテナなのに、クリックイベントは別の要素が受け取っていることを示すため、その要素名を下の方に表示させるようにしています。

See the Pen イベントの委譲1 by isobeact (@rmzpacpm-the-builder) on CodePen.

「あなたのおやごさんは、どなたですか?」

いま、このコードが着目したいのは、sectionNodeの子要素である<article class="card">要素であって、その箱の中身ではありません。しかし、クリックされた要素=イベントターゲットは、対象の<article class="card">という「箱」の中身であることは自明です。ならば、イベントターゲットに「あなたのおやごさんは、どなたですか?」と、所属している<article class="card">を特定すればいいのです。そしてイベントの委譲とは、まさにこの作業のことです。

現代のJavaScriptには、この「おやごさん」を判定する特別な関数があります。それが 要素.closest(セレクタ) というメソッドです。先のコードでは、ここで使用しています。

  // ここから、イベントの委譲開始
  /** 委譲先の要素の探索結果 */
  const delegatee = target.closest(".card");

  // 委譲先の要素の存在判定
  if (delegatee == undefined) {
    // 存在していないなら、探索に失敗しているので、これ以上何もしない
    return;
  }

要素.closest(セレクタ) は、セレクタで指定した要素を、自分自身から親要素に向かって探索し、最初に発見した要素を返すメソッドです(自分自身がセレクタに該当しているなら、自分自身を返します)。ルートノードまでさかのぼっても発見できなかった場合は、探索失敗とみなされ、 null を返します。すなわち「あなたのおやごさんに class="card" が指定されている要素があれば、それを教えて下さい」というわけですね。

上のコードでは、対象の要素を発見できなかった場合のフォールバックとして、 null 判定を入れています。

「あなたは、ほんとうに委譲先の要素ですか?」

イベント定義では、対象の要素ではなく、コンテナに対して行いました。すなわち、上の判定だけでは、もし「イベントターゲットがコンテナそのもので」「外側の要素に偶然<any class="card">が存在していた」場合、それを対象の要素と誤判定してしまいます。これはいけません。誤った委譲です。

いま探索したい .card セレクタを持つ要素は、必ず sectionNode の子孫であるはずです。その外側は対象になりえません。なので、その判定も入れなければなりません。上のコードでは、この部分に当たります。

  // 外側の要素がイベントを受けてしまっていないか判定
  if (!sectionNode.contains(delegatee)) {
    // 委譲先がコンテナの中ではない(コンテナそのものだった、など)なら、委譲させてはならない
    return;
  }

要素.contains(別の要素) は、 要素別の要素 を子孫に持っているかどうかを判定し、 true/false で結果を返すメソッドです。つまりこれによって、「探索した要素は、本当に sectionNode の子孫であるか=あなたは、ほんとうに委譲先の要素ですか?」を確かめることができるのです。

以上で委譲のできあがり!(だじゃれではない)

先の2つの判定にクリアすれば、それがもともとのイベントターゲットとなる要素で間違いありません。あとはこの delegatee を使って、これまで通りにイベントハンドラ関数を実行してゆきましょう。

で、どのくらい差が出るの?

ここまで説明して「あまり意味がありませんでした」というのも、情けない話ですよね。というわけで、イベントを委譲しない例と委譲した例の、パフォーマンスを測ってみました。

イベントの委譲をしない例

リスナーは17個に増えている。それに伴い、ヒープメモリの消費量が突然上がっている。

イベントの委譲をした例

リスナーは6個しか増えていない。また、ヒープメモリの消費量の上昇も、ゆるやかになっている。

実験的な例とはいえ、ここまで顕著に差が出るとは意外でした。やはりループ内でイベントを与えるのではなく、コンテナにイベントを与え、ハンドラでイベントの委譲を実施する戦略は有効だと言えそうですね!

まとめ

イベントの委譲は、ループで生成される要素に対してDOMイベントを扱う場合、メモリ節約や端末のバッテリー節約、さらにイベントの確実な解放のためには欠かせないテクニックです。その割に、文章で説明することが難しいせいか、これが意識されていない実装をよく見かけます。こんかいの記事を読んだみなさんは、今後はぜひ、必要に応じて賢くイベントを委譲していきましょう!

なお、現在メジャーとなっているフロントエンドフレームワーク……React, Vue, Svelte など……は、それぞれ独自にイベントの最適化処理を行なうため、実装者が明示的にイベントの委譲をおこなう必要はありません。

こんかいの記事は、以上です。それでは、よきコーディングライフを!