はじめに
ちょっとAIで調べていたら汎用モジュールを作成してくれたので、その内容をブログ化してみました。
それではGeminiさん、どうぞ!!
jQueryのいる世界で、最新ESMライブラリをCDNから動的に読み込むハック
(副題: import / export でエラー? もう怖くない! レガシーJS環境とモダンライブラリを共存させる汎用ローダー)
はじめに
「長年運用しているWebサイト、全体をリニューアルするのは難しい...でも、あの便利な最新ライブラリだけサクッとCDNで使いたい!」
wasm-packでビルドされた高速なライブラリや、イマドキのイケてるUIライブラリ。使おうと思って<script>タグで読み込んだら、コンソールにこんなエラーが表示されて頭を抱えた経験はありませんか?
Uncaught SyntaxError: Unexpected token 'export'
これは、モダンなJavaScript(ESM)で書かれたライブラリを、古い仕組みのまま読み込もうとしたときに発生する典型的なエラーです。
この記事では、jQueryなどが現役で動いているような「クラシックなJavaScript環境」を維持したまま、ESモジュール(import/export)形式で提供されるモダンなライブラリを、動的に読み込んで利用するための汎用的なテクニックを紹介します。
最終的に、こんな関数を作って問題を解決します。
直面した課題:なぜエラーが起きるのか?
状況を再現してみましょう。以下は、jQueryが読み込まれている、ごく普通のHTMLファイルです。
<!DOCTYPE html>
<html>
<head>
<title>Legacy Page</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
</head>
<body>
<h1>My Awesome Site</h1>
<script src="main.js"></script>
</body>
</html>
ここに、WebAssemblyで書かれた高速なSQLフォーマッターライブラリuroborosql-fmtをCDNから読み込んで使ってみます](https://www.google.com/search?q=https://github.com/future-architect/uroborosql-fmt)%E3%82%92CDN%E3%81%8B%E3%82%89%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%82%93%E3%81%A7%E4%BD%BF%E3%81%A3%E3%81%A6%E3%81%BF%E3%81%BE%E3%81%99)。
<script src="https://future-architect.github.io/uroborosql-fmt/uroborosql_fmt_wasm.js"></script>
この瞬間、コンソールにあの忌まわしきエラーが表示されます。
Uncaught SyntaxError: Unexpected token 'export'
エラーの原因は、スクリプトの「実行モード」のミスマッチです。
-
クラシックスクリプト:
<script>タグで普通に読み込まれたJS。windowがグローバルスコープとなり、importやexportという構文を知りません。 -
ESモジュール:
<script type="module">で読み込まれるJS。ファイルごとに独立したスコープを持ち、import/exportで連携します。
つまり、export文を含むESモジュールファイルを、クラシックスクリプトとして読み込んでしまったため、ブラウザが「知らない単語だ!」とパニックになっていたのです。
結論:動的モジュールローダー関数
この問題を解決するために、JavaScriptを使って動的に<script type="module">タグを生成し、DOMに追加する汎用的なローダー関数を作成しました。
まずはこちらが完成した関数です。
動的モジュールローダー関数 `loadModuleFromString.js`
/**
* ESモジュール形式のスクリプトを文字列から動的に読み込む汎用関数
* @param {string} moduleId - スクリプトを識別するための一意のID
* @param {string} scriptContent - 実行するモジュールのコードを含む文字列
* @returns {Promise<void>} スクリプトの読み込みと実行が完了したら解決されるPromise
*/
function loadModuleFromString(moduleId, scriptContent) {
return new Promise((resolve, reject) => {
// 1. 重複チェック:同じIDのスクリプトがすでに追加されているか確認
if (document.querySelector(`script[data-module-id="${moduleId}"]`)) {
console.log(`✅ Module "${moduleId}" is already loaded.`);
resolve(); // すでに存在する場合は、即座に成功として扱う
return;
}
// 2. script要素を生成
const script = document.createElement('script');
script.type = 'module';
script.textContent = scriptContent;
// 3. 識別用のIDをdata属性として付与
script.dataset.moduleId = moduleId;
// 4. 読み込み完了・失敗時の処理を定義
script.onload = () => {
console.log(`🚀 Module "${moduleId}" loaded successfully.`);
resolve();
};
script.onerror = () => {
console.error(`❌ Failed to load module "${moduleId}".`);
reject(new Error(`Failed to load script with ID: ${moduleId}`));
};
// 5. script要素をbodyの末尾に追加して、読み込みと実行を開始
document.body.appendChild(script);
});
}
関数のポイント解説
この関数がどうやって魔法を実現しているのか、3つのポイントに分けて解説します。
① type="module" を持つ <script> を動的に生成
このハックの心臓部です。document.createElement('script')で<script>要素を作り、そのtypeプロパティに'module'を設定します。こうして生成した要素をDOMに追加すると、ブラウザはそれをESモジュールとして正しく解釈してくれます。
② data-module-id で重複読み込みを防止
ユーザーがボタンを連打するなどして、同じモジュールを何度も読み込もうとするケースを考慮しています。data-module-idというカスタムデータ属性をキーにして、DOMに同じIDのスクリプトが既に存在するかをチェックすることで、無駄な処理とエラーを防ぎます。
③ Promise で非同期処理をスマートに制御
スクリプトの読み込みは非同期で行われます。いつ完了するか分かりません。そこで、onloadとonerrorイベントを使い、処理の成功・失敗をPromiseでラップします。これにより、呼び出し側はasync/await構文を使って、モジュールの読み込み完了をスマートに待つことができます。
実践的な使い方
それでは、実際にこの関数を使ってuroborosql-fmtライブラリを読み込んでみましょう。
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Dynamic Generic Module Loader</title>
</head>
<body>
<h1>汎用モジュールローダーのテスト</h1>
<button id="loadSqlFormatter">SQLフォーマッターを読み込む</button>
<pre id="output">ボタンを押してライブラリを読み込み...</pre>
<script src="loadModuleFromString.js"></script>
<script src="main.js"></script>
</body>
</html>
main.js (jQueryと共存するクラシックスクリプト)
const loadButton = document.getElementById('loadSqlFormatter');
const output = document.getElementById('output');
// 読み込むモジュールを定義
const UROBOROSQL_MODULE_ID = 'uroborosql-formatter';
const UROBOROSQL_SCRIPT = `
// この文字列の中で外部モジュールをインポートする
import init, {
format_sql_for_wasm as format_sql,
} from "https://future-architect.github.io/uroborosql-fmt/uroborosql_fmt_wasm.js";
// 非同期処理を実行
await init();
const sql = "select * from books where price > /*price*/1000 order by published_date desc";
const formatted = format_sql(sql, "{}");
// 結果をグローバルスコープの関数経由で表示する
window.displayResult(formatted);
`;
// ボタンクリック時のイベントリスナー
loadButton.addEventListener('click', async () => {
try {
output.textContent = 'ライブラリを読み込み中...';
// 関数を呼び出してモジュールを読み込むのを待つ
await loadModuleFromString(UROBOROSQL_MODULE_ID, UROBOROSQL_SCRIPT);
loadButton.disabled = true;
loadButton.textContent = '読み込み完了';
} catch (error) {
console.error(error);
output.textContent = 'モジュールの読み込みに失敗しました。';
}
});
// モジュール内から呼び出されるグローバル関数
window.displayResult = (result) => {
output.textContent = result;
};
これで、ボタンをクリックしたタイミングで初めてESモジュールが読み込まれ、SQLがフォーマットされ、その結果が画面に表示されるようになりました!
このテクニックの「位置づけ」と注意点 ⚠️
このテクニックは非常に強力ですが、使うべき場面を理解することが重要です。
これは「銀の弾丸」ではなく、特定の課題を解決するための「ブリッジ(橋渡し)技術」です。
モダン開発の王道
これから新しくWebアプリケーションを開発する場合、ViteやWebpackのようなバンドラーを導入するのが第一選択肢です。これらのツールは、開発時にはimportを使って快適なモジュール開発を行い、本番リリース時には依存関係を解決して最適化された単一のJSファイルに変換してくれます。
セキュリティへの言及
今回の例では安全な文字列を使っていますが、ユーザーが入力した文字列など、信頼できないソースからスクリプトを生成・実行するとXSSの脆弱性につながる可能性があります。この関数を使う際は、スクリプトの内容が信頼できることを必ず確認してください。
さいごに
古いシステムだからと諦める必要はありません。type="module"の動的生成とPromiseを組み合わせることで、レガシーな環境とモダンなライブラリを共存させる道が開けます。
この記事が、同じような課題を持つ方の助けになれば幸いです。
もっと良い方法や改善点があれば、ぜひコメントで教えてください! Happy Hacking! 🚀