addEventListenerとinnerHTMLの関係

JS基礎固めにヒィヒィいってます、ゆりかです🦉
最近ハマったaddEventListenerが1回しか適用されない問題😔
単純なことだったんですが、備忘録として記録しておきます。

目標実装

  • 「ジャンル」の選択に合わせて、「タイトル」の選択肢を変更する=ふたつのセレクトボックスを連動させる

よくみるやつですね。
今回はセレクトボックスにaddEventListenerを仕掛けて、条件合致したらinnerHTMLプロパティでHTMLの置き換えをしていきたいと思います。

ハマりポイント

「ジャンル」選択変更すると1度目は「タイトル」の選択肢が上手く変更されるものの、2度目からはうんともすんとも言わない。
HTMLの変更もなされていない。なぜ。

解決ポイント

結論、innerHTML…!この人が原因でした…!!!
innerHTMLは要素のHTMLの内容を変更してくれるプロパティですが、登録しているイベントは変更時に削除されちゃうようです。
MDNにもしっかりと記載してありました。

innerHTMLを使用してHTML要素を追加すると(略)、以前設定したイベントリスナーを取り除くことになります。 つまり、この方法でHTML要素を追加すると、以前設定したイベントリスナーで待ち受けすることができなくなります。

MDN Web Docs - Element.innerHTML

じゃあどうしよう?🙄

今回はinnerHTMLで書き換える要素の粒度を小さくして、addEventListenerに影響を与えないようにしてみました。

実装してみる

まずはindex.htmlを用意。

index.html
<div id="app"></div>

JSでデータを作成。
(本来はJSONでズラッと作成しているのですが、ブログにおいては本題ではないので簡単に配列で…!)

main.js
const genreArry = ['HTML', 'JavaScript'];
const htmlTitleArry = ['入門書', 'アクセシビリティ'];
const jsTitleArry = ['入門書', 'React', 'VanillaJS'];

それぞれデータを使ってoptionタグを作成し、これも配列として保持しておきます。
(ここ、もっときれいにリファクタリングできそう。再考ポイント🙄

main.js
const genreArry = ['HTML', 'JavaScript'];
const genreOptions = [];
for (let i = 0; i < genreArry.length; i++) {
  genreOptions.push(`<option value="${genreArry[i]}" name="genre">${genreArry[i]}</option>`);
}

const htmlTitleArry = ['入門書', 'アクセシビリティ'];
const htmlTitleOptions = [];
for (let i = 0; i < htmlTitleArry.length; i++) {
  htmlTitleOptions.push(
    `<option value="${htmlTitleArry[i]}" name="title">${htmlTitleArry[i]}</option>`
  );
}

const jsTitleArry = ['入門書', 'React', 'VanillaJS'];
const jsTitleOptions = [];
for (let i = 0; i < jsTitleArry.length; i++) {
  jsTitleOptions.push(`<option value="${jsTitleArry[i]}" name="title">${jsTitleArry[i]}</option>`);
}

ジャンルとタイトルのセレクトボックス、おまけの検索ボタンを、新たに作成した<div>タグの中へ入れて#appの子要素として挿入します。
ひとまず画面表示時にはhtmlTitleOptionsを表示させています。

main.js
const rootElm = document.getElementById('app');
const genreSelectorHtml = `<select class="selector genreSelector">${genreOptions.join('')}</select>`;
let titleSelectorHtml = `<select class="selector titleSelector">${htmlTitleOptions.join('')}</select>`;
const searchBtnHtml = `<button class="searchBtn">検索</button>`;
const parentElm = document.createElement('div');
parentElm.innerHTML = genreSelectorHtml + titleSelectorHtml + searchBtnHtml;
rootElm.appendChild(parentElm);

ジャンルが変更された時にイベントを仕掛け、セレクトボックスのvalue属性値に合わせてinnerHTMLを変更していきます。
最初に書いてみたコードは以下のような感じ。

セレクトボックスをループ処理にかけてイベントを付与し、titleSelectorHtmlを上書きして親要素の中身を書き換えています。

main.js
const selectorElm = parentElm.querySelectorAll('.selector');
for (const select of selectorElm) {
  select.addEventListener('change', () => {
    if (select.classList.contains('genreSelector')) {
      if (select.value === 'HTML') {
        titleSelectorHtml = `<select class="selector titleSelector">${htmlTitleOptions.join('')}</select>`;
        parentElm.innerHTML = genreSelectorHtml + titleSelectorHtml + searchBtnHtml;
      } else if (select.value === 'JavaScript') {
        titleSelectorHtml = `<select class="selector titleSelector">${jsTitleOptions.join('')}</select>`;
        parentElm.innerHTML = genreSelectorHtml + titleSelectorHtml + searchBtnHtml;
      }
    }
  });
}

が!タイトルのセレクトボックスが連動してくれません。
イベントを付与しているジャンルのセレクトボックスも書き換えられてしまっているので、イベントが削除されてしまっています😇
しかもちょっとくどいコードなので手直しをば…

セレクトボックスをまとめて取得するのではなく、ジャンルとタイトルそれぞれを変数に格納しループ処理をやめました。
そしてinnerHTMLで書き換える要素をタイトルのセレクトボックスに限定しました。

main.js
const genreElm = parentElm.querySelector('.genreSelector');
const titleElm = parentElm.querySelector('.titleSelector');
genreElm.addEventListener('change', () => {
  if (genreElm.value === 'HTML') {
    titleElm.innerHTML = htmlTitleOptions.join('');
  } else if (genreElm.value === 'JavaScript') {
    titleElm.innerHTML = jsTitleOptions.join('');
  }
});

これでセレクトボックスのイベントを削除することなく、連動されるように実装できました🎉

See the Pen JavaScript - SelectBox by Yurika (@yurika1202) on CodePen.

コードのまとめはこちら👇

main.js
const genreArry = ['HTML', 'JavaScript'];
const genreOptions = [];
for (let i = 0; i < genreArry.length; i++) {
  genreOptions.push(`<option value="${genreArry[i]}" name="genre">${genreArry[i]}</option>`);
}

const htmlTitleArry = ['入門書', 'アクセシビリティ'];
const htmlTitleOptions = [];
for (let i = 0; i < htmlTitleArry.length; i++) {
  htmlTitleOptions.push(
    `<option value="${htmlTitleArry[i]}" name="genre">${htmlTitleArry[i]}</option>`
  );
}

const jsTitleArry = ['入門書', 'React', 'VanillaJS'];
const jsTitleOptions = [];
for (let i = 0; i < jsTitleArry.length; i++) {
  jsTitleOptions.push(`<option value="${jsTitleArry[i]}" name="genre">${jsTitleArry[i]}</option>`);
}

const rootElm = document.getElementById('app');
const genreSelectorHtml = `<select class="selector genreSelector">${genreOptions.join('')}</select>`;
let titleSelectorHtml = `<select class="selector titleSelector">${htmlTitleOptions.join('')}</select>`;
const searchBtnHtml = `<button class="searchBtn">検索</button>`;
const parentElm = document.createElement('div');
parentElm.innerHTML = genreSelectorHtml + titleSelectorHtml + searchBtnHtml;
rootElm.appendChild(parentElm);

const genreElm = parentElm.querySelector('.genreSelector');
const titleElm = parentElm.querySelector('.titleSelector');
genreElm.addEventListener('change', () => {
  if (genreElm.value === 'HTML') {
    titleElm.innerHTML = htmlTitleOptions.join('');
  } else if (genreElm.value === 'JavaScript') {
    titleElm.innerHTML = jsTitleOptions.join('');
  }
});

他の実装法の模索

innerHTMLプロパティ以外にも要素の中身をいじるものはいくつかあるようなので、少し調べてみました。

textContent

要素のテキスト内容を示すプロパティ。
子孫のHTMLタグもそのまま文字列として返すので、書き換え内容がテキストのみの場合に使用する。
Node.textContent - Web API | MDN

innerText

ブラウザで表示されているテキスト内容を示すプロパティ。
子孫のHTMLタグやstyle、非表示になっている要素は取得されず、本当にブラウザで見たまま。
textContentと同様に、書き換え内容がテキストのみの場合に使用する。が、パフォーマンスの面でtextContentを推奨。
HTMLElement.innerText - Web API | MDN

insertAdjacentHTML

破壊的操作をせず、HTMLを指定箇所に挿入できる。
ただ上書きではなく追加なので、上書きするときは一度element.textContent = '';で空っぽにしてから追加かな…?
element.insertAdjacentHTML - Web API | MDN

ちなみに要素を追加するときはinsertAdjacentElement()、テキストを追加するときはinsertAdjacentText()を使用する。

innerHTMLの懸念点

こうしてコンテンツの書き換えを調べてみると、やっぱりinnerHTMLって唯一無二じゃんってなるんですが重大な問題が。
すばり、セキュリティ~~~!!!
動的に要素を生成は、XSS攻撃のスキになっちゃうんですね。

HTML5では innerHTMLで挿入された <script>タグは実行するべきではないと定義しているからです。

Element.innerHTML - Web API | MDN

とあるので大丈夫なんじゃ?と思いますが、scriptタグを使わず攻撃される場合もあるので対策は必須。

試しに処理時間を簡単に調べてみました。

  • 正規表現 : 0.39013671875 ms
  • createTextNode : 0.549072265625 ms

誤差レベルかなと思うので、私は可読性のある正規表現でのエスケープを使用していこうかなと思います。
setHTMLプロパティのブラウザ対応はよ😇

おわり

ということで、無事完成にもっていけたのでよかったです~
ちなみにこれは ステップアップJavaScriptフロントエンド開発の初級から中級へ進むために という本の総合演習の改造編での模索でした。(アフィリンクではないのでご安心を!)

それでは、 ☁️ぼんっ