addEventListenerとinnerHTMLの関係
JS基礎固めにヒィヒィいってます、ゆりかです🦉
最近ハマったaddEventListener
が1回しか適用されない問題😔
単純なことだったんですが、備忘録として記録しておきます。
目標実装
- 「ジャンル」の選択に合わせて、「タイトル」の選択肢を変更する=ふたつのセレクトボックスを連動させる
よくみるやつですね。
今回はセレクトボックスにaddEventListener
を仕掛けて、条件合致したらinnerHTML
プロパティでHTMLの置き換えをしていきたいと思います。
ハマりポイント
「ジャンル」選択変更すると1度目は「タイトル」の選択肢が上手く変更されるものの、2度目からはうんともすんとも言わない。
HTMLの変更もなされていない。なぜ。
解決ポイント
結論、innerHTML
…!この人が原因でした…!!!
innerHTML
は要素のHTMLの内容を変更してくれるプロパティですが、登録しているイベントは変更時に削除されちゃうようです。
MDNにもしっかりと記載してありました。
innerHTML
を使用してHTML要素を追加すると(略)、以前設定したイベントリスナーを取り除くことになります。 つまり、この方法でHTML要素を追加すると、以前設定したイベントリスナーで待ち受けすることができなくなります。
じゃあどうしよう?🙄
今回はinnerHTML
で書き換える要素の粒度を小さくして、addEventListener
に影響を与えないようにしてみました。
実装してみる
まずはindex.htmlを用意。
<div id="app"></div>
JSでデータを作成。
(本来はJSONでズラッと作成しているのですが、ブログにおいては本題ではないので簡単に配列で…!)
const genreArry = ['HTML', 'JavaScript'];
const htmlTitleArry = ['入門書', 'アクセシビリティ'];
const jsTitleArry = ['入門書', 'React', 'VanillaJS'];
それぞれデータを使ってoption
タグを作成し、これも配列として保持しておきます。
(ここ、もっときれいにリファクタリングできそう。再考ポイント🙄)
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
を表示させています。
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
を上書きして親要素の中身を書き換えています。
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
で書き換える要素をタイトルのセレクトボックスに限定しました。
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.
コードのまとめはこちら👇
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>
タグは実行するべきではないと定義しているからです。
とあるので大丈夫なんじゃ?と思いますが、script
タグを使わず攻撃される場合もあるので対策は必須。
-
対策1
プレーンテキストの場合は必ずtextContent
プロパティを使用する。 -
対策2
挿入する文字列にエスケープ処理をかける。
下記の参考ページでは、正規表現でエスケープする方法とcreateTextNode
でエスケープする方法が紹介されています。 JavaScriptで特殊文字エスケープする3つの方法【htmlspecialchars的実装】 | PisukeCode - Web開発まとめ
試しに処理時間を簡単に調べてみました。
- 正規表現 : 0.39013671875 ms
- createTextNode : 0.549072265625 ms
誤差レベルかなと思うので、私は可読性のある正規表現でのエスケープを使用していこうかなと思います。
setHTML
プロパティのブラウザ対応はよ😇
おわり
ということで、無事完成にもっていけたのでよかったです~
ちなみにこれは ステップアップJavaScriptフロントエンド開発の初級から中級へ進むために という本の総合演習の改造編での模索でした。(アフィリンクではないのでご安心を!)
それでは、 ☁️ぼんっ