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されてたりしたので、原因が分からず、最小構成で再現させるのに苦労した

豆知識として以下を得た

ちなみに、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の実行順序は難しい

おしまい