Help us understand the problem. What is going on with this article?

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

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

タイトルの通りです。セキュリティの関係でこうなっているのだと思いますが、いざ埋め込もうと思うとかなり大変だったのでメモ。

まずは普通に書いてみる

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

index.html

メインのページです。コンテンツの後にフッター(footer.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を使用しています。
このscriptタグが、今回の記事の主役です。

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リクエストで埋め込み対象のファイルの内容を取得し、指定したクラスの要素に埋め込んでいます。

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
……あれ? 折角JavaScriptで自動化した著作権表示が消えてしまっています。

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

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

ググってみる

StackOverFlowのこちらの質問がドンピシャでした。
ここで目をつけたのが、こちらの回答。

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

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

φ(..)コードカキカキ

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関数の走査が始まるようにしておきます。

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

今度こそ

成功を願って、実行!
image.png
……違う、そうじゃなーい!

非同期で呼んでいるので、scriptの埋め込み位置が正しく解釈されない

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

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

今回の場合はフッターなので、smallタグを検索するか最後のsprictタグを取得するかすれば位置を確定出来ます。
その他の場合はclassiddataを使って特定して下さい。このあたりについてはこちらが参考になりました。

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
やっと上手くいきました。ここまで2日かかりました……

最終的にこうなりました

長いので折りたたんであります。


footer.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

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]);
}


最後に

そもそも<script>を動的に追加する需要など少ないような気もしますが、GitHub Pagesや安いレンタルサーバーなど、PHPが使えない静的なサービスは多いかと思います。
そういった静的なサービスでどうしてもインクルードしたいんだ!という著者のような方には、参考になったのではないでしょうか。
また、以前趣味で作った私のGitHub Pagesでは今回説明した方法を使ってインクルードしています。良ければ参考にしてみて下さい。

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

参考

innerHTMLに入れたコードの中にscriptタグがあっても実行されないのはなぜ? - StackOverFlow
外部JavaScript本人の<script>要素を取得方法を考える - Qiita
document.write()を使わない方法 - Qiita
document.writeでscriptを読み込んではいけない - Qiita

Automatic9045
メインはC#、他色々。 POS系の機器が大好物です。
https://automatic9045.github.io
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away