Element.innerHTMLで埋め込んだscriptタグは動作しない
タイトルの通りです。セキュリティの関係でこうなっているのだと思いますが、いざ埋め込もうと思うとかなり大変だったのでメモ。
まずは普通に書いてみる
ホームページ内のパーツを共通化するため、JavaScriptでinclude
関数を定義する場合を考えてみましょう。よくある例ですね。
index.html
メインのページです。コンテンツの後にフッター(footer.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タグが、今回の記事の主役です。
<hr />
<small>
<!--↓ここから 問題のscriptタグ↓-->
<script>
const year = new Date().getFullYear();
document.write("© " + (year == 2021 ? "2021" : "2021 - " + year) + " Automatic9045");
</script>
<!--↑ここまで 問題のscriptタグ↑-->
</small>
script.js
他ファイルのHTMLを埋め込む機能を実装しています。HTTP GETリクエストで埋め込み対象のファイルの内容を取得し、指定したクラスの要素に埋め込んでいます。
// 他ファイルの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]);
}
実行、しかし……
さあ見た目は完璧。満を持して実行してみましょう。
……あれ? 折角JavaScriptで自動化した著作権表示が消えてしまっています。
Element.innerHTMLで埋め込んだscriptタグは動作しない
タイトル回収。消えてしまった原因はこれです。
しかもタチの悪いことに、埋め込まれたscriptタグはエラーすら吐かずに沈黙します。仕様を知っていないと原因に辿り着くことは困難です。
ググってみる
StackOverFlowのこちらの質問がドンピシャでした。
ここで目をつけたのが、こちらの回答。
より高度な制御方法の例としては jQuery の .html() の実装とか参考になります。
このメソッドは単なる element.innerHTML とは異なり、.html('<div><script>alert();</script></div>') のようにして渡された <script> タグ内のスクリプトを実行できます。
その大まかな仕組みとしては、一先ず引数文字列をから innerHTML を用いて DOM 要素を作ってから改めてこの DOM 要素中を走査し、script 要素があった場合はそのテキストノードを eval する、ということをしています。
これを再現してみることにします。
φ(..)コードカキカキ
// 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
関数の走査が始まるようにしておきます。
xhr.onreadystatechange = (() => {
// 正常にデータが取得できた場合、ファイルの内容を書き込む
if (xhr.readyState === 4 && xhr.status === 200) {
element.innerHTML = xhr.responseText;
evalFromHTMLCollection(element.children);
}
});
今度こそ
非同期で呼んでいるので、scriptの埋め込み位置が正しく解釈されない
原則document.write
は実行した場所にコンテンツを埋め込んでくれますが、非同期で呼ぶと埋め込み位置が判断できないようです。考えてみれば当然ですね。
その根拠として、document.write
の手前にブレークポイントを置いてdocument.currentScript
を見てみるとnull
になっています。
……ちなみに、そもそもdocument.write
は既に非推奨になっています。使っちゃだめです。
今回の場合はフッターなので、smallタグを検索するか最後のsprictタグを取得するかすれば位置を確定出来ます。
その他の場合はclass
やid
、data
を使って特定して下さい。このあたりについてはこちらが参考になりました。
<hr />
<small>
<!--↓ここから 問題のscriptタグ↓-->
<script>
const year = new Date().getFullYear();
const element = document.createElement("span");
element.innerHTML = "© " + (year == 2021 ? "2021" : "2021 - " + year) + " Automatic9045";
const scripts = document.getElementsByTagName('script');
scripts[scripts.length - 1].parentNode.appendChild(element); // 一番最後のscriptタグを検索する
</script>
<!--↑ここまで 問題のscriptタグ↑-->
</small>
ようやく……
最終的にこうなりました
長いので折りたたんであります。
```
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では今回説明した方法を使ってインクルードしています。良ければ参考にしてみて下さい。
他にもこんな方法あるよ!という情報があれば是非コメント欄で教えて頂けると嬉しいです
参考
innerHTMLに入れたコードの中にscriptタグがあっても実行されないのはなぜ? - StackOverFlow
外部JavaScript本人の<script>要素を取得方法を考える - Qiita
document.write()を使わない方法 - Qiita
document.writeでscriptを読み込んではいけない - Qiita