🌵

async属性を使うと "DOMContentLoaded" イベントが発火しない理由

2024/03/26に公開1

asyncdefertype="module" でJavaScriptを読むと DOMContentLoaded イベントは発火しないことがあります。これらの属性を使いつつもイベントを発火させる方法を紹介します。

async 属性とは

通常、ブラウザは読み込みの途中で <script> タグがあるとそのスクリプトを実行し終わるまで一時的にDOM解析を中断します。スクリプトがDOMの要素に依存している場合や、特定の実行順序を必要とする場合があり、JavaScriptの実行タイミングを保証するためです。

<script src="javascript.js"></script>

一方、src属性を持つ <script> タグに async 属性を付与することにより、DOM解析が中断されなくなります。JavaScriptの読込・実行とページのDOM解析が並行して行われるようになり、実行タイミングが保証されなくなります。

<script src="javascript.js" async></script>

非同期になることにより、DOM解析が中断されずにページ表示の高速化が期待できるため、Web高速化の手法として推奨されている方法です。Lighthouseのスコアも上がります。

https://web.dev/render-blocking-resources/

タイミングによってはイベントが発火しない

一見、 async 属性を追加するだけで高速化ができ、Lighthouseのスコアが上がるのであれば手当たり次第に async 属性を追加したくなりますが、そううまくはいきません。

asnyc 属性を使用しない場合(<script>)、JavaScriptのイベントリスナーを登録した後に DOMContentLoaded イベントが発火することを保証できます。

しかし、async 属性を使用すると、JavaScriptの読み込みと実行が非同期になります。つまり、そのJavaScriptがいつ実行されるかまったく保証されません。DOM解析の途中で実行される可能性もあり、DOM解析が終わったあとに実行される可能性もあります。

もし、DOM解析が終わったあとに実行された場合、DOMContentLoaded イベントが発火しなくなります。

また、type="module"defer 属性を使用した場合もDOM解析が終わったあとに実行されます。

対処方法

対処方法はいくつかあります。

1. すでにDOM解析されている場合は即時に実行する

DOMContentLoaded イベントだけに頼らず document.readyState の値も確認して、DOM解析が完了している場合は即時に実行する方法です。

DOM解析完了後は、 document.readyStatecomplete または interactive となっているため、次のようなif文で、 loading 以外であれば即時実行するようにプログラムを変更します。

https://developer.mozilla.org/ja/docs/Web/API/Document/readyState

function eventHandler() {
  document.removeEventListener("DOMContentLoaded", eventHandler);
  doSomething();
}

if (document.readyState !== "loading") {
  // DOM解析が完了している場合は即実行
  doSomething();
} else {
  document.addEventListener("DOMContentLoaded", eventHandler);
}

2. load イベントに変更する

DOMContentLoaded イベントを使用せずに load イベントを使用する方法です。 load イベントはすべての準備(JavaScriptや画像の読み込みなど)が終わってから実行されます。しかし、これらの準備を待つために場合によっては実行タイミングはとても遅いので注意が必要です。

https://developer.mozilla.org/ja/docs/Web/API/Window/load_event

function eventHandler() {
  document.removeEventListener("load", eventHandler);
  doSomething();
}

window.addEventListener("load", eventHandler);

(おまけ) jQuery における実装

jQueryを活用したとき、 jQuery(function($) {...}); や、 $(function() {...}); をおまじないのように使ったことがあるという方も多いのではないでしょうか。

実は、DOM解析中であれば DOMContentLoaded のタイミングでイベントハンドラーを実行、DOM解析が終わっていれば即時実行するような仕組みが内部に組み込まれています。

内部的には1、互換性がない場合のフォールバックとして2の方法で実装されています。

https://api.jquery.com/jQuery/

https://github.com/jquery/jquery/blob/063831b6378d518f9870ec5c4f1e7d5d16e04f36/src/core/ready.js#L64-L78

まとめ

async 属性を使用すると、JavaScriptの読み込みと実行が非同期になり、DOMContentLoaded イベントは発火しなくなることがあります。

DOMContentLoaded イベントが発火しない場合は、 document.readyState の値を確認して即時実行するか、 load イベントを使用することで対処できます。

参考文献

詳しい仕様はHTML Living Standardの "4.12.1 The script element" をご参照ください。図解もされていてわかりやすいです。

https://html.spec.whatwg.org/multipage/scripting.html#the-script-element

Discussion

junerjuner

type="module" の場合 top level await が使えるのだから これで良さそう感ある。

if (document.readyState === "loading") {
  await new Promise(resolve => document.addEventListener("DOMContentLoaded", resolve));
}
// doing ...