スクリプト読み込みの不明瞭な状況を掘り下げる

はじめに

この記事では、JavaScript をブラウザに読み込んで実行する方法について説明します。

いや、待って、戻ってきてください。単純で平凡な手法に思えるかもしれませんが、これはブラウザ上で行われており、理論的には単純なものが、レガシー主導のちょっとした厄介な作業になってしまいます。これらの特性を理解しておくと、スクリプトを読み込む最も速く、中断が少ない方法を選択できます。時間がない場合は、クイック リファレンスまでスキップしてください。

まず、仕様でスクリプトをダウンロードして実行するさまざまな方法がどのように定義されているかを確認しましょう。

スクリプトの読み込みに関する WHATWG
スクリプトの読み込みに関する WHATWG

WHATWG の全仕様と同じく、最初はスクラブル工場のクラスター爆弾が起きた直後のように見えますが、5 回目まで読み終えて目から血を拭き取ってみると、実はかなり興味深い情報です。

最初のスクリプト インクルード

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

シンプルで心地よい。この場合、ブラウザは両方のスクリプトを並列でダウンロードし、順序を維持しながらできるだけ早く実行します。「2.js」は「1.js」が実行されるまで(または実行に失敗するまで)実行されません。「1.js」は、前のスクリプトまたはスタイルシートが実行されるまで実行されません。

残念ながら、この処理が行われている間は、ブラウザがページのレンダリングをブロックします。これは、パーサーが処理しているコンテンツ(document.write など)に文字列を追加できる「ウェブの最初の時代」の DOM API が原因です。新しいブラウザでは、ドキュメントのスキャンまたは解析はバックグラウンドで継続され、必要に応じて外部コンテンツ(js、画像、css など)のダウンロードがトリガーされますが、レンダリングはブロックされたままです。

そのため、パフォーマンスの専門家は、スクリプト要素をドキュメントの最後に配置することをおすすめしています。これにより、コンテンツがブロックされる範囲を最小限に抑えることができます。つまり、HTML がすべてダウンロードされるまで、ブラウザはスクリプトを見ることができません。その時点では、CSS、画像、iframe などの他のコンテンツのダウンロードが開始されています。最新のブラウザは画像よりも JavaScript を優先するほど高性能になっていますが、それについては改善の余地があります。

IE 様、(皮肉な態度ではありません)

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Microsoft はこのパフォーマンスの問題を認識し、Internet Explorer 4 での使用を「延期」しました。これは基本的に、「document.write などのものを使用してパーサーにデータを挿入しないことを約束します。もし私がこの約束を破った場合、あなたはどのような方法で私を罰してもいい」と言っています。」この属性は HTML4 に変換され、他のブラウザにも表示されました。

上記の例では、ブラウザは両方のスクリプトを並行してダウンロードし、DOMContentLoaded がトリガーされる直前に実行して順序を維持します。

羊の工場でクラスター爆弾が爆発したように、「defer」は羊毛の混乱に陥りました。「src」属性と「defer」属性、スクリプト タグと動的に追加されたスクリプトの間に、スクリプトを追加する 6 つのパターンがあります。もちろん、ブラウザは実行順序について一致しませんでした。2009 年当時の状況について、Mozilla が問題に関する優れた記事を作成しています。

WHATWG では、動的に追加されたスクリプトや「src」がないスクリプトには「defer」が適用されないことを明示的に宣言し、この動作を明確にしました。それ以外の場合、遅延スクリプトはドキュメントの解析後に、追加された順序で実行されます。

IE 様、(皮肉な態度をとっています)

与えたり奪ったりします。残念ながら、IE4~9 には、スクリプトが予期しない順序で実行されるという厄介なバグがあります。処理の流れは次のとおりです。

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');

2.js

console.log('3');

ページに段落があると想定した場合、ログの順序は [1, 2, 3] になりますが、IE9 以前では [1, 3, 2] になります。特定の DOM オペレーションを行うと、IE は現在のスクリプトの実行を一時停止し、保留中の他のスクリプトを実行してから続行します。

ただし、IE10 や他のブラウザなどのバグのない実装でも、ドキュメント全体のダウンロードと解析が完了するまでスクリプトの実行は遅延します。いずれにせよ DOMContentLoaded を待つ場合は便利ですが、パフォーマンスを重視する場合は、リスナーの追加とブートストラップを早めに開始できます。

HTML5 で課題を解決

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

HTML5 には「async」という新しい属性があります。この属性は、document.write を使用しないことを前提としますが、ドキュメントの解析が完了するまで待機しません。ブラウザは両方のスクリプトを並行してダウンロードし、できるだけ早く実行します。

残念ながら、できるだけ早く実行されるため、「2.js」が「1.js」の前に実行される可能性があります。独立している場合は問題ありません。たとえば、「1.js」が「2.js」と関係のないトラッキング スクリプトである場合です。しかし、「1.js」が「2.js」が依存する jQuery の CDN コピーである場合、ページはエラーで覆い尽くされます。これは、クラスター爆弾が… いや、何に例えたらよいかわかりません。

必要なのは JavaScript ライブラリです。

理想は、レンダリングをブロックすることなく、一連のスクリプトをすぐにダウンロードし、追加された順序でできるだけ早く実行することです。残念ながら、HTML ではそうした操作はできません。

この問題は、JavaScript によっていくつかの方法で対処されていました。一部のライブラリでは、JavaScript に変更を加えて、ライブラリが正しい順序で呼び出すコールバックでラップする必要がありました(RequireJS など)。他にも、XHR を使用して並列ダウンロードし、その後 eval() を正しい順序でダウンロードする方法がありましたが、CORS ヘッダーが設定されていてブラウザがサポートしている場合を除き、別のドメインのスクリプトでは機能しませんでした。LabJS などの超マジック ハックを使用した人もいます。

このハッキングでは、ブラウザを欺いてリソースをダウンロードさせ、完了時にイベントを発生させるものの、その実行を回避します。LabJS では、スクリプトが間違った MIME タイプ(<script type="script/cache" src="..."> など)で追加されます。すべてのスクリプトがダウンロードされると、正しいタイプで再度追加され、ブラウザがキャッシュから直接取得して、順番にすぐに実行できるようになります。これは便利ではあるが未指定の動作に依存しており、HTML5 でブラウザが認識できないタイプのスクリプトをダウンロードしないように宣言されたときに機能しなくなりました。LabJS はこれらの変更に対応しており、この記事で説明するメソッドを組み合わせて使用しています。

ただし、スクリプト ローダには独自のパフォーマンスの問題があります。ライブラリの JavaScript がダウンロードされて解析されるまで待ってから、管理するスクリプトのダウンロードを開始する必要があります。また、スクリプト ローダを読み込む方法は?読み込む内容をスクリプトローダに指示するスクリプトをどのように読み込むのでしょうか。誰が見張りを見張るのか?なぜ裸になっているのですか?どれも難易度の高い問題です。

基本的に、他のスクリプトをダウンロードする前に追加のスクリプト ファイルをダウンロードする必要がある場合、パフォーマンスは低下します。

DOM による解決

答えは実は HTML5 の仕様に記載されていますが、スクリプト読み込みセクションの一番下に隠れています。

これを「地球」に換算してみましょう。

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

動的に作成されドキュメントに追加されるスクリプトは、デフォルトでは非同期になります。つまり、レンダリングはブロックされず、ダウンロード後すぐに実行されます。つまり、間違った順序で出力される可能性があります。ただし、非同期ではないことを明示的にマークすることもできます。

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

これにより、プレーン HTML では実現できない動作をスクリプトで実現できます。非同期ではなく明示的にスクリプトを実行キューに追加します。これは、最初のプレーン HTML サンプルで追加されたものと同じキューです。ただし、動的に作成されるため、ドキュメントの解析の外部で実行されるため、ダウンロード中にレンダリングがブロックされることはありません(非非同期スクリプトの読み込みと同期 XHR を混同しないでください。これは決して良いことではありません)。

上記のスクリプトは、ページのヘッダーにインラインで含め、プログレッシブ レンダリングを中断することなく、できるだけ早くスクリプトのダウンロードをキューに追加し、指定した順序でできるだけ早く実行する必要があります。「2.js」は「1.js」の前にダウンロードできますが、「1.js」が正常にダウンロードされて実行されるか、どちらかが失敗するまで実行されません。非同期ダウンロード、順序付きの実行です。

この方法でスクリプトを読み込むことは、Safari 5.0 を除き、async 属性をサポートするすべてのブラウザでサポートされています(5.1 は問題ありません)。また、Firefox と Opera のすべてのバージョンがサポートされています。これらのバージョンでは、async 属性をサポートしていないため、動的に追加されたスクリプトはドキュメントに追加された順序で実行されます。

スクリプトを読み込む最も速い方法ですよね。そうですよね

読み込むスクリプトを動的に決定する場合は、そうです。それ以外の場合は、そうではないかもしれません。上記の例では、ブラウザはスクリプトを解析して実行し、ダウンロードするスクリプトを見つける必要があります。これにより、プリロード スキャナからスクリプトが非表示になります。ブラウザはこれらのスキャナを使用して、次にアクセスする可能性が高いページのリソースを検出します。また、パーサーが別のリソースによってブロックされている間にページ リソースを検出します。

ドキュメントのヘッダーに以下を追加すると、見つけやすさを復元できます。

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

これにより、ページに 1.js と 2.js が必要であることがブラウザに伝えられます。link[rel=subresource]link[rel=prefetch] に似ていますが、セマンティクスが異なります。残念ながら、現在のところこの機能は Chrome でのみサポートされており、読み込むスクリプトを 2 回宣言する必要があります(1 回はリンク要素で、もう 1 回はスクリプトで)。

訂正: 当初、これらの問題はプリロード スキャナによって検出されると記載しましたが、これは誤りでした。これらの問題は通常のパーサーによって検出されます。ただし、プリロード スキャナはこれらのスクリプトを検出できるはずですが、まだ検出できません。一方、実行可能コードに含まれるスクリプトはプリロードできません。コメントで訂正していただいた Yoav Weiss さんに感謝します。

この記事が不快だ

状況は憂鬱で、憂鬱に感じるのは当然です。実行順序を制御しながらスクリプトを迅速かつ非同期でダウンロードする、反復的でない宣言型の方法はありません。HTTP2/SPDY を使用すると、リクエストのオーバーヘッドを削減して、個別にキャッシュに保存できる複数の小さなファイルでスクリプトを配信するのが最速の方法になるようにできます。たとえば、

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

各拡張機能スクリプトは特定のページ コンポーネントを処理しますが、dependencies.js のユーティリティ関数が必要です。理想的には、すべてを非同期でダウンロードし、dependencies.js の後に、任意の順序でできるだけ早く拡張スクリプトを実行します。プログレッシブ プログレッシブ エンハンスメントです。残念ながら、dependencies.js の読み込み状態をトラッキングするようにスクリプト自体を変更しない限り、宣言型の方法でこれを実現することはできません。async=false にしても、enhancement-10.js の実行が 1~9 でブロックされるため、この問題は解決しません。実際に、ハッキングなしでこれを可能にできるブラウザは 1 つだけです。

IE にはアイデアがある

IE は他のブラウザとは異なる方法でスクリプトを読み込みます。

var script = document.createElement('script');
script.src = 'whatever.js';

IE は「whatever.js」のダウンロードを開始しますが、他のブラウザはスクリプトがドキュメントに追加されるまでダウンロードを開始しません。IE には、読み込みの進捗状況を示すイベント「readystatechange」とプロパティ「readystate」もあります。スクリプトの読み込みと実行を個別に制御できるため、実際には非常に便利です。

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

ドキュメントにスクリプトを追加するタイミングを選択することで、複雑な依存関係モデルを構築できます。IE では、バージョン 6 以降でこのモデルがサポートされています。非常に興味深い方法ですが、async=false と同じプリローダ検出の問題が引き続き発生します。

十分です。スクリプトの読み込み方法

わかりました。レンダリングをブロックせず、繰り返しが発生せず、優れたブラウザ サポートを備えた方法でスクリプトを読み込むには、次のようにします。

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

名前はbody 要素の末尾に追加します。ウェブ デベロッパーになるのは、シスフス王になるようなものです。ギリシャ神話の参照で 100 ヒップスター ポイント)。HTML とブラウザの制限により、これ以上の改善は困難です。

JavaScript モジュールによって、スクリプトを読み込み、実行順序を制御する宣言型非ブロッキング方法を提供することで、負担が軽減されることを願っています。ただし、そのためには、スクリプトをモジュールとして記述する必要があります。

うーん、もっと良い方法があるはずです。

ボーナス ポイントとして十分ですが、パフォーマンスを極端に高め、多少の複雑さや繰り返しは気にせずに、上記のトリックのいくつかを組み合わせることができます。

まず、プリローダ用の子リソース宣言を追加します。

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

次に、ドキュメントのヘッド内で、async=false を使用して JavaScript でスクリプトを読み込み、IE の準備状態ベースのスクリプト読み込みにフォールバックし、遅延にフォールバックします。

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

いくつかのトリックと圧縮を加えると、362 バイト + スクリプト URL になります。

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

単純なスクリプト インクルードと比較して、追加のバイト数に見合う価値があるか?BBC のように、すでに JavaScript を使用してスクリプトを条件付きで読み込んでいる場合、ダウンロードを早めにトリガーすることもできます。それ以外の場合は、シンプルな end-of-body メソッドを使用します。

これで、WHATWG のスクリプト読み込みセクションが非常に長い理由がわかりました。飲み物を飲みたい。

クイック リファレンス

シンプルなスクリプトの要素

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

仕様: 一緒にダウンロードし、保留中の CSS の後に順番に実行し、完了するまでレンダリングをブロックします。ブラウザの回答: はい、

延期

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

仕様: 同時にダウンロードし、DOMContentLoaded の直前に順番に実行します。「src」のないスクリプトの「defer」を無視。IE 10 より前の場合: 1.js の実行の途中で 2.js が実行される可能性があります。楽しそうでしょう? 赤色のブラウザの説明: 「遅延」が何なのか、まったくわかりません。スクリプトが存在しないかのように読み込みます。 他のブラウザ: わかりました。ただし、「src」のないスクリプトの「defer」は無視しない場合があります。

非同期

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

仕様: まとめてダウンロードし、任意のダウンロード順序で実行する。 赤色のブラウザ: 「非同期」って何ですか?非同期がなくてもスクリプトを読み込みます。他のブラウザによると: そうですね。

非同期 false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

仕様: 一緒にダウンロードし、すべてダウンロードされたらすぐに順番に実行します。Firefox < 3.6 に関する Opera の発言: この「非同期」が何なのかはわかりませんが、JS で追加されたスクリプトを、追加された順番に実行してしまうのです。 Safari 5.0 の場合: 「async」は理解できますが、JS で「false」に設定する意味がわかりません。スクリプトが到着したら、その順番ですぐにスクリプトを実行します。 IE 10 より前: 「非同期」についてはよくわかりませんが、「onreadystatechange」を使用した回避策があります。他のブラウザ(赤色): 「非同期」についてはよくわかりません。スクリプトが届き次第、順序に関係なく実行します。それ以外はすべて: 私はあなたの友人です。手順に沿って対応します。