Google タグマネージャーでは type="module" な script 要素は実行されない
Google タグマネージャー(以下、GTM)でちょっとハマったので、記事にしておこうかと思います。
type="module" な script 要素は実行されない
GTM の「カスタム HTML」で
<script type="module" src="/foo.js"></script>
のように設定したとします。

この場合、/foo.js のスクリプトが実行されるかと思いきや、実際には実行されません。ちなみに type="module" をつけずに
<script src="/foo.js"></script>
とすれば、期待どおり普通にスクリプトが実行されます。
また、インラインのスクリプトも同様で、type="module" がついていると実行されません。
まとめると以下のようになります。type="module" なスクリプトは実行されません。
<!-- 実行されない -->
<script type="module" src="/foo.js"></script>
<script type="module">console.log('Hi!')</script>
<!-- 実行される -->
<script src="/foo.js"></script>
<script>console.log('Hi!')</script>
GTM 経由でサードパーティースクリプトを使うユーザーにとっても、逆にサードパーティースクリプトを開発・提供する側にとっても、これはちょっと気を付ける必要がありそうですね。
では、どうしてこうなるんでしょう?「GTM が type="module" をサポートしていないから」と言ってしまえばそれまでなんですが、内部的にはどうなっているのか、気になったのでちょっと見てみました。
実行されないけど、script 要素は DOM に追加されている
実は開発者ツールで見てみると、script 要素はちゃんと DOM に追加されているように見えます。例えば、「カスタム HTML」に以下の内容を設定すると、
<!-- 実行されない -->
<script type="module" src="/foo.js"></script>
<script type="module">console.log('Hi!')</script>
ページの DOM は以下のようになります。script 要素が DOM に追加されているのにもかかわらず、スクリプトは実行されていません。不思議ですね。

比較のために、type="module" をつけないバージョンでも見てみましょう。
<!-- 実行される -->
<script src="/foo.js"></script>
<script>console.log('Hi!')</script>
DOM 上では余分な属性がいろいろついていますが、こちらは普通にスクリプトが実行されます。

状況をまとめると、こうなります。
-
type="module"の有無にかかわらず、DOM 上にはscript要素は追加されている - ただし、
type="module"なスクリプトは実行されない
うーん……なぜ type="module" だと実行されないのか、これだけだとよくわかりませんね 🤔
GTM の内部処理を追いかけてみる
よくわからないので、もうちょっと GTM の中まで見てみましょう。ちなみに、GTM の「カスタム HTML」は以下のような仕組みになっています。
- ユーザーが Web ページを開く
- HTML に貼り付けた GTM のスニペットによって、GTM が初期化される
- タグのトリガーが発動すると、GTM によって「カスタム HTML」の内容が HTML に追加される
- (それによって、カスタム HTML に含まれている
script要素の内容が実行される)
デバッガで動きを見てみたところ、script 要素を DOM に追加する処理を見つけました。

ざっくり説明すると、「カスタム HTML」の内容は配列 b に入っていて、これを順次 e(= g)に取り出して処理していきます。g が script 要素だった場合、赤く囲った a.insertBefore(g, null) によって a(body 要素)に追加されます[1]。
ただよく見ると、「g が script 要素だった場合」の if 文に、g.type === "text/gtmscript" という条件もついています。これについてはちょっと説明が必要ですね。GTM の「カスタム HTML」では、type 属性のない script 要素は、GTM 側で type="text/gtmscript" という属性がつけられた上で、上記引用部分の処理に流れてきます。そのため、g.type === "text/gtmscript" も true になります。
一方、type="module" である script 要素の場合は、type="text/gtmscript" という属性はつきません。そのため、この条件 g.type === "text/gtmscript" は false となり、別の枝の方にある a.insertBefore(g, null) によって a(body 要素)に追加されます。

まとめるとこうですが、まだ違いはよく分かりませんね。
- A.
<script src="/foo.js">の場合-
type属性がつけられて<script type="text/gtmscript" src="/foo.js">になる - 上の方の
a.insertBefore(g, null)で DOM に追加 - (スクリプトが実行される)
-
- B.
<script type="module" src="/foo.js">の場合-
type属性は変わらずに<script type="module" src="/foo.js">のまま - 下の方の
a.insertBefore(g, null)で DOM に追加 - (スクリプトは実行されない)
-
A.2. の「上の方の a.insertBefore(g, null)」の直前にも処理があるので、もうちょっと見てみます。どうやら、script 要素をわざわざ作り直してから、body に追加しているようです。

ということで、この作り直しの有無によって、スクリプトが実行されるか否かの違いが出ていそうです。
- A.
<script src="/foo.js">の場合-
type属性がつけられて<script type="text/gtmscript" src="/foo.js">になる script要素を作り直す- 上の方の
a.insertBefore(g, null)で DOM に追加 - (スクリプトが実行される)
-
- B.
<script type="module" src="/foo.js">の場合-
type属性は変わらずに<script type="module" src="/foo.js">のまま - 下の方の
a.insertBefore(g, null)で DOM に追加 - (スクリプトは実行されない)
-
今度は逆に、もう少し上流を追いかけてみます。先ほど
ざっくり説明すると、「カスタム HTML」の内容は配列
bに入っていて、これを順次e(=g)に取り出して処理していきます。gがscript要素だった場合、赤く囲ったa.insertBefore(g, null)によってa(body要素)に追加されます。
と書きましたが、この配列 b の出どころを探ります。すると、以下のような流れで配列 b が作られていることが分かりました。
-
document.createElement('div')で、div要素を作成しておく -
div.innerHTML = `A<div>${カスタム HTML の内容}</div>`;で内容をセット - その
divの子孫から、あらためてカスタム HTML の内容(各要素)を取り出す
回りくどいですね…。これにどんな意図があるのかよく分かりませんが。ただキーポイントとしては、ここでは script 要素を直接作成したわけではないという点です。
const div = document.createElement('div');
div.innerHTML = `A<div><script type="module" src="/foo.js"></div>`;
// 以下略
のようにして、結果的に script 要素ができてはいます。しかし、このように div.innerHTML で作成された script 要素をそのまま DOM に追加しても、そのスクリプトの内容は実行されません。HTML Living Standard でいうと、おそらくこのあたりの話でしょう。
これについては開発者ツールのコンソールで、以下の簡略化したコードを実行してみれば分かります。
const div = document.createElement('div');
div.innerHTML = '<script>console.log("Hi!")</script>';
const script = div.firstChild;
document.body.insertBefore(script, null); // => スクリプトは実行されない
const script = document.createElement('script');
script.innerHTML = 'console.log("Hi!")';
document.body.insertBefore(script, null); // => スクリプトは実行される
どちらも DOM 上には script 要素が追加されますが、それが実行されるか否かは異なります。
またもちろん、document.createElement('script') で script 要素を作り直せば、スクリプトは実行されます。
const div = document.createElement('div');
div.innerHTML = '<script>console.log("Hi!")</script>';
const script = div.firstChild;
const script2 = document.createElement('script');
script2.text = script.text
document.body.insertBefore(script2, null); // => スクリプトは実行される
ということで、長々と見てきて結局のところそこまで大した話ではないですが、type="module" だと GTM ではうまく動かない理由が分かりました。
-
div.innerHTMLへの代入の結果、script要素が作成される -
type="module"をつけていなければ、script要素があらためて作り直される -
script要素が DOM に追加される - (2. で作り直されていれば、スクリプトが実行される)
余談ですが、GTM のこのあたりの処理では Trusted Types API も使われています。オッ! と思ってコードを追いかけてみましたが、今回の問題の本質とは別に関係はなかったようです。
おわりに
この記事では、type="module" な script 要素が GTM で実行されない件について見てみました。GTM を使う側も、スクリプトを提供する側も、注意する必要がありそうですね。
ちなみに、GTM とスクリプトの関係を調べている途中で、以下のブログ記事を見つけました。こちらもおもしろいのでおすすめです。
-
簡単のためにはしょっていますが、厳密には、この
a.insertBefore(g, null)で追加が行われるのはsrc属性がない場合だけです。src属性がある場合、Mcという関数の中で似たような処理が行われ、appendChild()でbody要素に追加されます。 ↩︎
Discussion