【JavaScript】Element.innerHTMLで埋め込んだscriptタグは動作しない

# Element.innerHTMLで埋め込んだscriptタグは動作しない
タイトルの通りです。セキュリティの関係でこうなっているのだと思いますが、いざ埋め込もうと思うとかなり大変だったのでメモ。

## まずは普通に書いてみる
ホームページ内のパーツを共通化するため、JavaScriptで`include`関数を定義する場合を考えてみましょう。よくある例ですね。

### index.html
メインのページです。コンテンツの後にフッター（footer.html）を埋め込んでいます。

```HTML:index.html
<!DOCTYPE html>
<html>
<head>
    <title>サンプル</title>
    <script src="script.js"></script>
</head>
<body>
    <h1>サンプルホームページ</h1>
    <p>
        にゃうーん
    </p>
    <div class="footer">
        <script>includeByClasses("footer.html", "footer", 0);</script>
    </div>
</body>
</html>
```

### footer.html
フッター部分です。著作権表示の年号を最新にするためにJavaScriptを使用しています。
<b><font color="red">このscriptタグが、今回の記事の主役です。</font></b>

```HTML:footer.html
<hr />
<small>
    <!--↓ここから 問題のscriptタグ↓-->
    <script>
        const year = new Date().getFullYear();
        document.write("&copy; " + (year == 2021 ? "2021" : "2021 - " + year) + " Automatic9045");
    </script>
    <!--↑ここまで 問題のscriptタグ↑-->
</small>
```

### script.js
他ファイルのHTMLを埋め込む機能を実装しています。HTTP GETリクエストで埋め込み対象のファイルの内容を取得し、指定したクラスの要素に埋め込んでいます。

```JavaScript:script.js
// 他ファイルのHTMLを埋め込む関数
function include(path, element) {
    const xhr = new XMLHttpRequest();
    
    // 非同期でGETリクエストする準備
    xhr.open("GET", path, true);
    
    // GETリクエストが完了したら実行
    xhr.onreadystatechange = (() => {
        // 正常にデータが取得できた場合、ファイルの内容を書き込む
        if (xhr.readyState === 4 && xhr.status === 200) {
            element.innerHTML = xhr.responseText;
        }
    });
    
    // GETリクエストを送信
    xhr.send();
}

// クラスから埋め込み先を指定する
function includeByClasses(path, classes, index) {
    include(path, document.getElementsByClassName(classes)[index]);
}
```

## 実行、しかし……
さあ見た目は完璧。満を持して実行してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/536d56fe-0e5d-15a1-a3c7-7ed128d76b6d.png)
……あれ？　折角JavaScriptで自動化した著作権表示が消えてしまっています。

## Element.innerHTMLで埋め込んだscriptタグは動作しない
タイトル回収。消えてしまった原因はこれです。
しかもタチの悪いことに、埋め込まれたscriptタグはエラーすら吐かずに沈黙します。仕様を知っていないと原因に辿り着くことは困難です。

## ググってみる
StackOverFlowの[こちら](https://ja.stackoverflow.com/questions/2756/innerhtml%E3%81%AB%E5%85%A5%E3%82%8C%E3%81%9F%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AE%E4%B8%AD%E3%81%ABscript%E3%82%BF%E3%82%B0%E3%81%8C%E3%81%82%E3%81%A3%E3%81%A6%E3%82%82%E5%AE%9F%E8%A1%8C%E3%81%95%E3%82%8C%E3%81%AA%E3%81%84%E3%81%AE%E3%81%AF%E3%81%AA%E3%81%9C)の質問がドンピシャでした。
ここで目をつけたのが、こちらの回答。

> より高度な制御方法の例としては jQuery の .html() の実装とか参考になります。
このメソッドは単なる element.innerHTML とは異なり、.html('&lt;div&gt;&lt;script&gt;alert();&lt;/script&gt;&lt;/div&gt;') のようにして渡された &lt;script&gt; タグ内のスクリプトを実行できます。
その大まかな仕組みとしては、一先ず引数文字列をから innerHTML を用いて DOM 要素を作ってから改めてこの DOM 要素中を走査し、script 要素があった場合はそのテキストノードを eval する、ということをしています。

これを再現してみることにします。

## φ(..)ｺｰﾄﾞｶｷｶｷ
```JavaScript:script.jsの頭に追加
// evalを使うと警告が煩いので代替の関数
function eval2(obj) {
    if (obj == "") return;
    return Function("\"use strict\"; return (() => {" + obj + "})()")();
}

// 他ファイルのjsを実行する関数
function evalFromFile(path) {
    const xhr = new XMLHttpRequest();

    xhr.open("GET", path, true);
    xhr.onreadystatechange = (() => {
    if (xhr.readyState === 4 && xhr.status === 200) {
            eval2(xhr.responseText);
        }
    });
    xhr.send();
}

// HTMLCollectionからscript要素を見つけ出して実行する関数
function evalFromHTMLCollection(htmlCollection) {
    for (let i = 0; i < htmlCollection.length; i++) {
        const element = htmlCollection.item(i);

        if (element.nodeName == "SCRIPT") { // script要素なら関連のjsを実行
            // srcが指定されている場合は、そのファイルのjsも実行
            if (element.src != null && element.src != "") evalFromFile(element.src);

            // テキストノードのjsを実行
            eval2(element.textContent);
        } else { // script要素以外なら子要素にscript要素が無いか検索
            evalFromHTMLCollection(element.children);
        }
    }
}
```

`evalFromCollection`はhtmlCollectionからscript要素を見つけ出して実行する関数です。
script以外の要素については、その`children`に対してもまた`evalFromCollection`関数を呼び出すことで、再帰的に全てのscript要素を見つけることが出来ます。
`include`関数の`xhr.onreadystatechange`を以下のように書き換えることで、インクルード時、自動的に`evalFromCollection`関数の走査が始まるようにしておきます。

```JavaScript:script.jsを変更
xhr.onreadystatechange = (() => {
    // 正常にデータが取得できた場合、ファイルの内容を書き込む
    if (xhr.readyState === 4 && xhr.status === 200) {
        element.innerHTML = xhr.responseText;
        evalFromHTMLCollection(element.children);
    }
});
```

## 今度こそ
成功を願って、実行！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/e6dcb8f6-7836-983e-7ba8-e64cd56b4ad7.png)
……違う、そうじゃなーい！

## 非同期で呼んでいるので、scriptの埋め込み位置が正しく解釈されない
原則`document.write`は実行した場所にコンテンツを埋め込んでくれますが、非同期で呼ぶと埋め込み位置が判断できないようです。考えてみれば当然ですね。
その根拠として、`document.write`の手前にブレークポイントを置いて`document.currentScript`を見てみると`null`になっています。

……ちなみに、そもそも`document.write`は既に非推奨になっています。使っちゃだめです。

今回の場合はフッターなので、smallタグを検索するか最後のsprictタグを取得するかすれば位置を確定出来ます。
その他の場合は`class`や`id`、`data`を使って特定して下さい。このあたりについては[こちら](https://qiita.com/suzunone/items/ce8894b8e338ee66a9d5)が参考になりました。

```HTML:footer.htmlを変更
<hr />
<small>
    <!--↓ここから 問題のscriptタグ↓-->
    <script>
        const year = new Date().getFullYear();
        const element = document.createElement("span");
        element.innerHTML = "&copy; " + (year == 2021 ? "2021" : "2021 - " + year) + " Automatic9045";
        const scripts = document.getElementsByTagName('script');
        scripts[scripts.length - 1].parentNode.appendChild(element); // 一番最後のscriptタグを検索する
    </script>
    <!--↑ここまで 問題のscriptタグ↑-->
</small>
```

## ようやく……
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/677302/8ff99d80-25bf-6bb8-ae38-2fafb943e124.png)
やっと上手くいきました。ここまで2日かかりました……

## 最終的にこうなりました
長いので折りたたんであります。

<details>
<div>
### footer.html
```HTML:footer.html
<hr />
<small>
    <!--↓ここから 問題のscriptタグ↓-->
    <script>
        const year = new Date().getFullYear();
        const element = document.createElement("span");
        element.innerHTML = "&copy; " + (year == 2021 ? "2021" : "2021 - " + year) + " Automatic9045";
        const scripts = document.getElementsByTagName('script');
        scripts[scripts.length - 1].parentNode.appendChild(element); // 一番最後のscriptタグを検索する
    </script>
    <!--↑ここまで 問題のscriptタグ↑-->
</small>
```

### script.js
```JavaScript:script.js
// evalは危険らしいので代替の関数
function eval2(obj) {
    if (obj == "") return;
    return Function("\"use strict\"; return (() => {" + obj + "})()")();
}

// 他ファイルのjsを実行する関数
function evalFromFile(path) {
    const xhr = new XMLHttpRequest();

    xhr.open("GET", path, true);
    xhr.onreadystatechange = (() => {
        if (xhr.readyState === 4 && xhr.status === 200) {
            eval2(xhr.responseText);
        }
    });
    xhr.send();
}

// HTMLCollectionからscript要素を見つけ出して実行する関数
function evalFromHTMLCollection(htmlCollection) {
    for (let i = 0; i < htmlCollection.length; i++) {
        const element = htmlCollection.item(i);

        if (element.nodeName == "SCRIPT") { // script要素なら関連のjsを実行
            // srcが指定されている場合は、そのファイルのjsも実行
            if (element.src != null && element.src != "") evalFromFile(element.src);

            // テキストノードのjsを実行
            eval2(element.textContent);
        } else { // script要素以外なら子要素にscript要素が無いか検索
            evalFromHTMLCollection(element.children);
        }
    }
}

// 他ファイルのHTMLを埋め込む関数
function include(path, element) {
    const xhr = new XMLHttpRequest();
    
    // 非同期でGETリクエストする準備
    xhr.open("GET", path, true);
    
    // GETリクエストが完了したら実行
    xhr.onreadystatechange = (() => {
        // 正常にデータが取得できた場合、ファイルの内容を書き込む
        if (xhr.readyState === 4 && xhr.status === 200) {
            element.innerHTML = xhr.responseText;
            evalFromHTMLCollection(element.children);
        }
    });
    
    // GETリクエストを送信
    xhr.send();
}

// クラスから埋め込み先を指定する
function includeByClasses(path, classes, index) {
    include(path, document.getElementsByClassName(classes)[index]);
}
```
</div>
</details>

## 最後に
そもそも&lt;script&gt;を動的に追加する需要など少ないような気もしますが、GitHub Pagesや安いレンタルサーバーなど、PHPが使えない静的なサービスは多いかと思います。
そういった静的なサービスでどうしてもインクルードしたいんだ！という著者のような方には、参考になったのではないでしょうか。
また、以前趣味で作った[私のGitHub Pages](https://automatic9045.github.io)では今回説明した方法を使ってインクルードしています。良ければ参考にしてみて下さい。

他にもこんな方法あるよ！という情報があれば是非コメント欄で教えて頂けると嬉しいです:smile:

## 参考
[innerHTMLに入れたコードの中にscriptタグがあっても実行されないのはなぜ？ - StackOverFlow](https://ja.stackoverflow.com/questions/2756/innerhtml%E3%81%AB%E5%85%A5%E3%82%8C%E3%81%9F%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AE%E4%B8%AD%E3%81%ABscript%E3%82%BF%E3%82%B0%E3%81%8C%E3%81%82%E3%81%A3%E3%81%A6%E3%82%82%E5%AE%9F%E8%A1%8C%E3%81%95%E3%82%8C%E3%81%AA%E3%81%84%E3%81%AE%E3%81%AF%E3%81%AA%E3%81%9C)
[外部JavaScript本人の&lt;script&gt;要素を取得方法を考える - Qiita](https://qiita.com/suzunone/items/ce8894b8e338ee66a9d5)
[document.write()を使わない方法 - Qiita](https://qiita.com/michiko89/items/2db6faf4b8d121bf34a2)
[document.writeでscriptを読み込んではいけない - Qiita](https://qiita.com/aya_taka/items/1255909b3db622272cee)