2024年11月06日
こんにちは! 今日もコーディングを楽しんでいますか?
JavaScript は、プログラムの外からの信号を契機に特別な処理を行う仕組み、すなわち「イベント」という機能があります。addEventListener('click', function(e) { ... })
でおなじみですね。
一般的に、イベントに応じて処理を行うプログラミングを「イベント駆動式(イベントドリブン)プログラミング」と呼んだりするのですが、JavaScript でのイベントは、内部的には決してコストの低い処理ではありません。なのでイベント定義のさいは、なるべく「必要なときだけ」「最低限に」使い、不要になったときに「必ず」「確実に」破棄する習慣が欠かせません。
しかし、イベント定義を「最小限に抑える」ことはよく意識されても、一方で「イベントの委譲」の「まとめ」でも触れた「イベントの確実な破棄」については、「破棄しようとして、実は正しく破棄できていない」ばかりにメモリを食いつぶし続けて(=メモリリークして)いる実装を、ときおり目にします。こんかいは、そうした「イベントの確実な破棄」について、周辺情報とともにご紹介していこうと思います。
イベントアイデンティティ
JavaScriptにおいて、定義されたイベントは、その一つ一つが内部的に区別されています。これを「イベントアイデンティティ」と呼びます。イベントアイデンティティが等しいイベントは、すなわち、同じイベントです。すなわち、特定のイベントを破棄するということは、そのイベントアイデンティティに対する破棄を行わねばならないという意味になります。
例として、target.addEventListener('type', handlerFunc, {capture: trueOrFalse})
の形で、target
に対して行ったイベント定義を考えてみます。このイベントにおいて「イベントアイデンティティ」とは、以下の3つを指します。この3つが等しいことで、初めて特定のイベントが識別される事を、まず、しっかりと覚えてください。
イベントタイプ
'type'
で指定された、文字列のことです。「見張るイベントの種類」を意味します。大文字小文字は区別されます。
イベントハンドラ
handlerFunc
という関数、この、関数に対する参照です。「イベントが発生したときに行うべき処理」を意味します。
イベントフェーズ
{capture: trueOrFalse}
で指定されている、trueOrFalse
の値(true
またはfalse
)です。「イベントディスパッチャの探索方法として、イベントバブリングではなく、イベントキャプチャリングを用いるのかどうか」を意味します。
バブリング vs キャプチャリング
以前の「リストのタッチ/クリックイベントは「委譲」しよう」の記事の中で「イベントバブリング」について説明したことを覚えていらっしゃるでしょうか? じつは、イベントの捕捉には、バブリングの他にもうひとつ「イベントキャプチャリング」という方法があるのです。
これはかつての「ブラウザ戦争」の名残で、過去に広く使用されていたブラウザ Internet Explorer が採用していたイベント捕捉方式でした。詳細は省きますが、W3Cがイベント捕捉の動作を標準化するさい、バブリングとキャプチャリングの両方を採用することが決まりました。そしてこれも、イベントアイデンティティを構成する一つとされたのです。ただしMDN のドキュメントによると、「イベントの捕捉はほとんどの場合、バブリングで事足りる」とされています。
よくある落とし穴
上で、「イベントを破棄しようとして、実は破棄できていないという事例をときおり目にする」と書きましたが、代表的な落とし穴をご紹介します。
イベントハンドラの「参照」を指定していない
イベントアイデンティティの3要素のうちのイベントハンドラ。イベント破棄の処理で、こんなコードを見かけたりしたことはありませんか?
/** @type {HTMLButtonElement} ボタン */
const myDom = document.querySelector('#button');
// 上記は JQuery の以下のコードと同じです。
// const myDom = $('#button')[0];
// イベント定義
myDom.addEventListener('click', function() {
alert('ボタンを押しました!');
});
// イベント破棄
myDom.removeEventListener('click', function() {
alert('ボタンを押しました!');
});
これでは、イベント破棄はできません。下にその答えを書きますが、まずはみなさんご自身で、破棄ができない理由を考えてみましょう。
答え
上の例ではイベント定義時や破棄時に、イベントハンドラとして関数の直値を与えてしまっているからです。
// イベント定義
myDom.addEventListener('click', function() {
alert('ボタンを押しました!');
});
イベントハンドラの説明で、
関数に対する参照です。
と、「参照」を強調しました。参照ではなく直値だと、イベントの破棄はできないのです。
イベントの破棄を考慮するのであれば、定義時に、直値ではなく参照にしなければなりません。そうすれば、破棄時に同じ参照を渡すことで、確実に破棄ができます。
// イベントハンドラの「参照」を、 `myOnClick` という名前で定義しよう!
function myOnClick() {
alert('ボタンを押しました!');
}
// イベント定義時に、ハンドラにその参照を渡せば……
myDom.addEventListener('click', myOnClick);
// イベント破棄時に、同じ参照を渡すことで破棄できる!
myDom.removeEventListener('click', myOnClick);
「なぜ直値じゃだめなの?」を説明するには、残念ながらこの記事の範囲を超えてしまいますので、いずれ別の記事で説明したいと思います。
その時までは「イベントハンドラは別関数に切り出す、という習慣を身につけよう!」と覚えておきましょう!
破棄時のイベントフェーズが食い違っている
イベントフェーズには、「バブリング vs キャプチャリング」の対立があることを説明しました。これが食い違うと、イベントアイデンティティは異なるものとされます。しかし困ったことに、(歴史的経緯によって)addEventListener
の第3引数は、直感的ではないややこしさを抱えています。
どんなふうにややこしいのか、3問のクイズ形式で説明してみましょう。
問1
以下のイベントのイベントフェーズは、バブリングでしょうか? それともキャプチャリングでしょうか?
/** イベントハンドラの参照 */
const myOnClick = () => alert('ボタンを押しました!');
// このときの、イベントフェーズは?
myDom.addEventListener('click', myOnClick);
答え
バブリングです。addEventListener
の第3引数が省略された場合は、デフォルト値として{capture: false}
を指定したものとみなされます。
問2
以下のイベントのイベントフェーズは、バブリングでしょうか? それともキャプチャリングでしょうか?
/** イベントハンドラの参照 */
const myOnClick = () => alert('ボタンを押しました!');
// このときの、イベントフェーズは?
myDom.addEventListener('click', myOnClick, true);
答え
キャプチャリングです。addEventListener
の第3引数がtrue
の場合は、{capture: true}
を指定したものとみなされます。
問3
以下のイベントのイベントフェーズは、バブリングでしょうか? それともキャプチャリングでしょうか?
/** イベントハンドラの参照 */
const myOnClick = () => alert('ボタンを押しました!');
// このときの、イベントフェーズは?
myDom.addEventListener('click', myOnClick, {passive: true});
答え
バブリングです。addEventListener
の第3引数のオブジェクトに{capture: boolean}
のキー値がなければ、デフォルトとして、{capture: false}
の指定がなされたとみなされます。
……このわかりにくい挙動、本当に困ったものですね。おかげで、このようなミスを誘発しがちです。
// イベントハンドラの「参照」を、 `myOnClick` という名前で定義しよう!
function myOnClick() {
alert('ボタンを押しました!');
}
// イベント定義時に、なにか `true` を渡してるな
myDom.addEventListener('click', myOnClick, {passive: true});
// じゃあイベント破棄時には、 `true` 渡しておけばいいか
myDom.removeEventListener('click', myOnClick, true);
結果はもうおわかりですね。イベントフェーズが食い違っているので、異なるアイデンティティとされます。よって、イベントは破棄できません。
余談:なんでこんなに面倒な仕様なの?
同じ要素に対して、異なるイベントを「重ねがけ」できるようにするためです。その意味は、以下の2つのコードを見比べてみれば一目瞭然です。
コード1:
// -- 昔ながらの、「on~メソッドに、直接関数を渡す」方法
// クリックしたら、アラートを出させよう
function displayAlert() {
alert('ボタンを押しました!');
}
// クリックしたら、文字の色を赤に変えよう
function changeTextColorRed() {
this.style.color = '#a00';
}
// クリックしたら、アラートの関数を動かそう
myDom.onClick = displayAlert;
// ついでに、文字の色も変えさせよう
myDom.onClick = changeTextColorRed;
// -- さて、 myDom をクリックして、2つの関数は動くでしょうか?
コード2:
// -- 「addEventListenerで、関数の参照を渡す」方法
// クリックしたら、アラートを出させよう
function displayAlert() {
alert('ボタンを押しました!');
}
// クリックしたら、文字の色を赤に変えよう
function changeTextColorRed() {
this.style.color = '#a00';
}
// クリックしたら、アラートの関数を動かそう
myDom.addEventListener('click', displayAlert);
// ついでに、文字の色も変えせよう
myDom.addEventListener('click', changeTextColorRed);
// -- さて、 myDom をクリックして、2つの関数は動くでしょうか?
コード1は、changeTextColorRed
しか動作しません。myDom.onClick
に、関数を上書きで代入しているのですから、最初に代入したdisplayAlert
関数の定義は無かったことにされます。一方のコード2は、displayAlert
に引き続き、changeTextColorRed
が実行されます。これが、イベントの重ねがけです。
イベントターゲットへの参照の切り忘れ
実はこれが、最も気づきにくいメモリリークの原因の代表です。以前の、リストのタッチ/クリックイベントは「委譲」しようの記事の末尾で、
さらにイベントの確実な解放のためには欠かせないテクニックです。
と書きました。では、もしこの「イベントの委譲」をせず、対象の要素一つ一つをイベントターゲットに設定した場合、イベントの破棄はどう実装すればいいでしょうか?
/** クリック対象のすべてのカードのコンテナ */
const sectionNode = document.querySelector("section.card-container");
// ---- 中略 ----
// articles 内を順番に、イベント定義する
for (const article of articles) {
/** 各カードの要素 */
const articleNode = document.createElement("article");
// ---- 中略 ----
// カード要素に対するクリックイベント
articleNode.addEventListener("click", onClickCard);
// ---- 以下略 ----
}
// ---- 破棄したいとき ----
// articles 内を順番に、イベント破棄する
for (const article of sectionNode.children) {
// カード要素に対するクリックイベント
articleNode.removeEventListener("click", onClickCard);
// ---- 以下略 ----
}
// ……で、いいよね?
これでいいかどうかは、場合によります。例示のコードならこれでもいいでしょう。(個人的には避けるべきだと思いますが……)。
しかし、以下のようにarticle.card
要素を個別に削除することができ、なおかつその要素は保持されるとしたら?
/** 閉じたカードのキャッシュ */
const closedCards = [];
/** クリック対象のすべてのカードのコンテナ */
const sectionNode = document.querySelector("section.card-container");
// ---- 中略 ----
// articles 内を順番に、イベント定義する
for (const article of articles) {
/** 各カードの要素 */
const articleNode = document.createElement("article");
// ---- 中略 ----
// 閉じるボタン
const closeNode = articleNode.querySelector('.close');
closeNode.addEventListener('click', onClickClose);
// カード要素に対するクリックイベント
articleNode.addEventListener("click", onClickCard);
// ---- 以下略 ----
}
/**
* カードを閉じる
*/
function onClickClose(e) {
e.stopPropagation();
const articleNode = e.target.closest('article.card');
// コンテナの直接の子なのは自明なので、これで閉じることができる
sectionNode.removeChild(articleNode);
// 閉じたカードへの参照を追加 <- 重要!
closedCards.push(articleNode);
}
// ---- 破棄したいとき ----
function onClickRemove() {
// articles 内を順番に、イベント破棄する
for (const articleNode of sectionNode.childNodes) {
const closeNode = articleNode.querySelector('.close');
closeNode.removeEventListener('click', onClickClose);
articleNode.removeEventListener('click', onClickCard);
// ---- 以下略 ----
}
}
// ……で、いいよね?
実際に動くデモはこちら。”Preview” 右上の「矢印がバツマークになっているマーク」をクリックし、開発ツールを開いてソースコードのとおりにブレークポイントを張り、いくつかの項目を「X」で閉じてから、getEventListeners(closedCards[0])
を実行してみてください。
残念、すべてのイベントは消えてくれません! 具体的には、closedCards
配列に存在している要素へのイベントは、もはや動作しないにもかかわらず残り続けます! この積み重ねが、メモリリークを引き起こしてゆくのです。
この例ではあえてわかりやすく「閉じたカードへの参照を保持する配列」を用意しましたが、アプリケーションが複雑になるほど、参照を受け渡していくうちに「どこに参照が残っているのかわからない」という状況になりがちです。
もし参照を完全に管理することが現実的ではない場合……そう、「管理しないですむ方法」を使えばいいのです。
委譲すれば管理不要!
先ほどのコードは、カード自体をイベントターゲットにしていたため、参照を管理しなければなりませんでした。なので、委譲に変えましょう。
コーディング中は、様々なことを考えなければなりません。しかし、人間が一度に考える事ができる個数には限度があります。少なければ少ないほど、よいです。そのため、いちいちイベントターゲットの増減などを気にせずにすむ委譲を使って、イベントを管理してしまう事を、強くおすすめします!
まとめ
こんかいは「イベントの破棄」について、改めて考えてみました。
- イベントには、イベントアイデンティティがあること。
- イベントアイデンティティが等しくないと、
removeEventListener()
による破棄はできないこと。 - イベントターゲットへの参照がどこかに存在する限り、イベントは残り続けること。
……いかがでしょうか、今まで意識したことのない方もいらっしゃるかもしれません。今後、イベントの破棄をするときは、改めて「そのイベント、本当に破棄できてる?」と自問するようにしてみましょう。
こんかいのお話は、以上です。それでは、よきコーディングライフを!