addEventListenerが虚空に消える話
TL;DR
- ブラウザでのJSの実行順序は難しい
- ありがとう、JS toolchains
- HTMLは偉大
はじめに
とあるアプリでscript tagに書いたJSが動かなくなったので修正しようとなった
構成はLaravel v6 + Vue v2でblade templateとVueで出来ているMPA
コードを見たら、とあるinputのfocusイベントにaddEventListenerしたcallbackが発火してない
よくあるDOMContentLoadedの前にgetElementByIdがnull返してるかと思いきや、consoleを見てもエラーは出ていない 🤔
だが、Chrome devtools のEvent Listenersにも該当のlistenerが登録されていない 🤔
何が起こっているんだ...
とりあえず、該当箇所を雑にdocument.addEventListener('DOMContentLoaded', () => { /* 処理 */ })
にしたら動くようになった
windowのload eventでもOKだった
我々調査隊は真相を調べるためにアマゾンの奥地へと足を向けた...
再現コード
アマゾンを探索した末に現象再現したコードがこちら
<head> </head> <body> <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script> <div id="app"> <form> <input type="text" id="hoge"> </form> </div> <script> const hoge = document.getElementById("hoge") window.debugHoge = hoge hoge.addEventListener("focus", () =>{ console.log("focused!") }) </script> <script> var app = new Vue({ el: '#app' }) </script> <script> const changedHoge = document.getElementById("hoge") console.log("isSame?: ", window.debugHoge === changedHoge) </script> </body>
手元にコピペで index.html
して、python -m http.server 8036
して、 http://localhost:8036 するとただのinputがあるだけのウェブページが表示される
ここのinputにfocusしたときにconsoleに focused!
と表示されてほしいが、それがされないというのが今回の事象
再現してしまえば、発火するはずのEventListenerが虚空に消えた理由は簡単で、Vueがcomponentをregister(用語が合ってるか分からない)した際に、index.htmlのinput ElementとVueの配下になったDOM Elementが別物になってしまったというオチだった
isSame?: false
なので、scriptタグ直書きでキャプチャされているhoge elementとVueの配下になったhoge Elementが違うことが分かる
仮想DOMライブラリの外で起こった出来事は関知できないので、それはそう
この状態なら気づけるが、実際は、Vueインスタンスをnewする部分が別ファイルでdeferされてたりしたので、原因が分からず、最小構成で再現させるのに苦労した
豆知識として以下を得た
- debugging - How to set breakpoints in inline Javascript in Google Chrome? - Stack Overflow
- Node.isEqualNode() - Web API | MDN
- <script>: スクリプト要素 - HTML: HyperText Markup Language | MDN
- defer、inlineだと意味ないの初知り
ちなみに、new Vueの部分をeventListenerより上に持ってくると動くようになる
こんなに雑なHTMLでもきちんとinputが描画されるHTMLとJSの複雑な実行順序が実装されているブラウザの偉大さに改めて感服してしまった
ちなみに、Reactだとこうなる
こうなります
<head> </head> <body> <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script> <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script> <div id="app"> <input type="text" id="hoge"> </div> <script> const hoge = document.getElementById("hoge") hoge.addEventListener("focus", () =>{ console.log("focused!") }) </script> <script> const domContainer = document.querySelector('#app'); const root = ReactDOM.createRoot(domContainer); root.render( React.createElement('input', {id: 'hoge'}) ) </script> </body>
これは明らかに後からcallされるrenderの中身にappの中身が書き換わるのが分かりやすい
まとめ
JSの実行順序は難しい
おしまい