JavaScriptはWebブラウザの <script>
から始まりました。まずはこの最も基本的な使い方を見ていきます。
JavaScriptの実行モデル
モジュールの同期・非同期読み込みの話と関係するため、あらかじめJavaScriptの実行モデルを整理しておきます。
JavaScriptはシングルスレッドです。つまり以下のようなコードで、別のJavaScriptの処理の割り込みを受けません。
window.counter = 0;
console.log(`counter = ${window.counter}`);
window.counter += 1;
// ←この位置で別のJavaScriptプログラムが実行されることはない
console.log(`counter = ${window.counter}`); // ←常に1になる
すでに多くのJavaScriptプログラムがこの仮定のもとに作られているので、これが覆されることはないでしょう。マルチスレッドによって実現したいことがある場合、かわりにマルチプロセス (Web Worker) と共有メモリ (SharedArrayBuffer) を組み合わせて使うのが現在の流れのようです。
JavaScriptの実行中は画面上の操作も全て止まってしまうため、JavaScriptプログラムは連続して長い間実行されるべきではないことになります。これに該当するのは以下のような場合です。
-
alert
/confirm
などの古典的なダイアログ関数を呼び出した場合。 - 無限ループや、重い計算を実行した場合。
-
非推奨の同期的な
XMLHttpRequest
を使った場合。
JavaScriptには (同期的な) sleep関数に相当するものが存在しませんが、これも同じような理由だと言えます。もしそういった関数があったとすると、1秒間ユーザーは何もできない、ということになってしまいます。
// もし、同期的なsleep関数があった場合……
console.log("foo");
sleep(1000); // ←この実行中、他のJavaScriptコードは実行できない。ユーザーも画面を操作できない
console.log("bar");
この問題を回避するために、時間のかかる処理には同期的なAPIは使わず、非同期的なAPIを使うのが一般的になっています。たとえば上記のような(想像上の)同期的な sleep
関数ではなく、その非同期版にあたる setTimeout
を使うと以下のようになります。
console.log("foo");
// setTimeout自体は一瞬で終わり、1秒後に "baz" が表示される
setTimeout(() => {
console.log("baz");
}, 1000);
// setTimeout自体は一瞬で終わるので、 "bar" はすぐに表示される
console.log("bar");
JavaScriptの視点からは、 setTimeout
自身はイベントハンドラを登録する処理にすぎないため一瞬で完了します。イベントハンドラが登録されてからイベントハンドラが実行されるまでの間に別のJavaScriptが実行されるのはおかしな話ではなく、それで期待しない挙動になってもそれはJavaScriptプログラマの責任です。そのため、ブラウザはこの時間に別の処理を遠慮なく実行できることになります。
このように「コールバックを登録する」という方法はタイマー処理に限った話ではありません。重い処理を分割するために0ミリ秒の setTimeout
を行う手法も存在します。また、通信にも (上で挙げた同期版 XMLHttpRequest
ではなく) 非同期的なAPIを使うのが一般的です。
// 古典的な非同期XMLHttpRequest
var xhr = new XMLHttpRequest();
xhr.onload = function() {
// ...
};
xhr.open("GET", "/example.json");
xhr.send();
// fetch API
fetch(new Request("/example.json"))
.then(function(response) {
// ...
});
なお、 XMLHttpRequest
や setTimeout
のような比較的古いAPIはコールバックを指定するAPIを提供していましたが、このような非同期処理はプログラムが複雑化するにつれコールバック地獄に陥り、実際のロジックに対してコードの見通しが悪くなるため、ライブラリサポートであるPromiseや構文サポートであるasync関数が導入されました。比較的新しい通信APIであるfetch APIもPromiseに基づいています。
原初のJavaScript
原初、JavaScriptはブラウザで実行するもので、モジュールシステムはなく、特に指定がなければグローバル空間に値が定義される仕組みでした。
<!-- script.jsを読み込んで実行 -->
<script type="text/javascript" src="./script.js"></script>
<!-- HTML内に埋め込まれたスクリプトを実行 -->
<script type="text/javascript"><!--
alert("bar");
//--></script>
<noscript>JavaScriptを有効にしてください。</noscript>
alert("foo");
ブラウザ上のJavaScriptには window
という特殊な変数があり、グローバルな文脈で定義された変数や関数は window
のメンバとして扱われます。 1 2
var x = 2;
function f() { return 3; }
console.log(window.x); // => 2
console.log(window.f()); // => 3
scriptタグの同期的読み込み
原初のJavaScriptには document.write
がありました。
<script>
document.write("<p");
</script>
>hoge</p>
この恐ろしい機能のため、ブラウザは <script>
タグのJavaScriptの読み込みと実行が終わるまでHTMLのパースをブロックする必要がありました3。パースが進まないため、続くJavaScriptの読み込みや実行、画像の読み込みなど多くの操作も結果としてブロックされることになります。
実際のところ、JavaScriptで行う多くの作業は、文書が全て読み込まれてから行いたいはずです。 (たとえば、 document.getElementById("root")
は <div id="root"></div>
が読み込まれた後に行いたいはずです。) こういった処理は次のように書かれることがありました。 4
window.onload = function() {
var root = document.getElementById("root");
// rootを使った処理……
};
それならば、そもそも <script>
タグを文書の最初のほうに置いてもスクリプトの実行開始タイミングには寄与しないことになります。むしろスクリプト以外の文書の構成要素 (HTML本体や画像など) のロードをブロックするデメリットのほうが大きいため、一時期は以下のように文書の最後に <script>
を置くのが望ましいとされていました。
<!-- ... -->
<script src="jquery.js"></script>
<script src="script.js"></script>
</body>
scriptタグの非同期的読み込み
上記の古い挙動はHTML5で改善され、 defer
, async
, type="module"
のいずれか5が指定された場合にはパーサーをブロックしないようになりました。これらの関係は以下の通りです。
-
type="module"
はdefer
を暗黙的に含む -
async
はdefer
より強い-
defer
のほうが登場が古いため、async
を指定するときはdefer
も同時に指定してフォールバックを狙うことがある
-
asyncとdeferの違いについては省略します。
当然、これらの属性を指定した場合は document.write
は使えなくなります。
scriptタグの非同期的読み込みに対応したブラウザでは、何を読み込むべきであるか早めに指定するほうが効率的であると考えられます。scriptタグは再び、文書の最初のほうに戻ってきました。
<!-- ... -->
<script src="jquery.js" defer></script>
<script src="script.js" defer></script>
</head>
<body>
<!-- ... -->
スコープを切る (IIFE)
原初のJavaScriptでは、せっかく var
で変数宣言しても、それがグローバルスコープだったらグローバル定義になってしまうという問題がありました。
これを回避するために、無名関数を作り、その中で作業をするというパターン (IIFE; 即時実行関数式) が確立されました。
var my_library = (function() {
// my_libraryを作る作業
// ここでvarを使っても、それ自体は外には漏れない
return my_library;
})();
これにより、「1~2個程度のごくわずかな識別子をグローバルに定義する」という形のライブラリが登場するようになり、それまでのJavaScriptのあり方に比べてクリーンになりました。2010年~2015年頃に栄華を誇ったjQueryというライブラリ/フレームワークはその典型例です。 jquery.js
を読み込むとグローバルに $
と jQuery
という2つの値が定義されます。 (この2つは同じものです)
このように1ファイルにまとめられたライブラリは、アプリケーションと同じサーバーにコピーして使われることもあれば、jsDelivrなどの公開サーバーにあるものを参照して使うこともありました。
この方法にはいくつかの欠点がありました。
- モジュールシステムとしては全く機能していませんでした (複数ファイル間の依存関係を記述できない)
- パッケージシステムとしては、各パッケージを1ファイルにまとめないといけないこと、間接依存を管理できないこと、またエクスポートする名前が衝突する可能性があること6などが欠点としてありました。
JSmin
さらに遡ること2001年、Douglas CrockfordによりJSminが作られました。これはC言語で書かれた簡易的なプログラムで、入力JavaScriptコードから空白やコメントを除去することで、より文字数の少ない等価なJavaScriptコードを出力することができました。現在のYUI Compressor, UglifyJS, Closure Compilerに代表されるMinifier、そして「JavaScriptをJavaScriptに変換する」という考え方の源流であると言えます。
まとめ
ここまでは全て、ブラウザ上の古典的な <script>
タグだけでできることを述べてきました。この状況だけではこんにち見るようなJSエコシステムの飛躍的な発展は望めなかったかもしれません。少なくともこの段階では、モジュールやパッケージに相当する仕組みの萌芽は見られますが、間接依存やグローバル名の汚染などの問題は解決されていませんでした。
このフロントエンドの状況を覆す革新的な動きは、フロントエンドの外から始まりました。次回はその中心地ともいえるNode.jsについて説明します。
-
ブラウザ以外に目を向けると、環境ごとに
window
,self
,frames
,global
など別の名前があったため、現在では統一的な名前としてglobalThis
が導入されています。 ↩ -
また、strictモードでない場合は
var
/const
/let
を使わない変数代入が可能で、この場合もグローバルになります。 ↩ -
実際のところ、パーサーの挙動に影響のあるような出力が起こることは稀なので、投機的なパースを行うpreload scannerという仕組みが存在するようです。 ↩
-
DOMContentLoadedのほうが適切な場合もありますが、ここではより伝統的なonloadでの例を示しました。 ↩
-
defer
はHTML 4.0の時点で存在しましたが、実行順序に関する詳細な規定は存在しませんでした。Can I useの記述を見る限り、これが実際に実装されたのもHTML5 (draft) の登場以降と考えてよさそうです。 ↩ -
たとえばprototype.jsというライブラリとjQueryはどちらも
$
という名前をエクスポートしていて、両方を使おうとすれば必ず衝突する構造になっていました。 ↩