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

はじめに

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

待って、戻ってきて!ありふれた単純なことに思えるかもしれませんが、これはブラウザにおけることであり、理論的にはシンプルなものが従来の慣習的な慣習的な穴になりつつあることを覚えておいてください。こうした特徴を理解することで、スクリプトを最も速く、最も中断の少ない方法で読み込むことができます。ご都合が合わない場合は、クイック リファレンスにお進みください。

まず、スクリプトのダウンロードと実行のさまざまな方法を定義する仕様を次に示します。

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

WHATWG のすべての仕様と同様に、最初はスクラブブル工場でのクラスター爆弾の余波のように見えますが、5 回目を読んで血液を拭き取ると、実はかなり興味深いものになります。

最初のスクリプトの内容

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

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

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

これが、優れたパフォーマンスの世界では、ドキュメントの最後にスクリプト要素を配置することをおすすめしています。スクリプト要素はブロックされるコンテンツを最小限に抑えるためです。残念ながら、すべての HTML がダウンロードされるまで、スクリプトはブラウザに表示されません。すべての 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」属性、script タグと動的に追加されるスクリプトの間に、スクリプトを追加するパターンが 6 つあります。もちろん、ブラウザは実行すべき順序を一致させませんでした。Mozilla はこの問題について優れた記事を執筆し、2009 年にこの問題を解決しました。

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 では、document.write を使用しないことを前提とし、ドキュメントの解析が完了するまで待機しない新しい属性「async」が追加されました。ブラウザは両方のスクリプトを並行してダウンロードし、できるだけ早く実行します。

残念ながら、「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(5.1 で十分)を除き、async 属性をサポートするすべてのものでサポートされています。また、async 属性をサポートしていないバージョンでも、動的に追加されたスクリプトをドキュメントに追加された順序で簡単に実行できるため、Firefox と Opera のすべてのバージョンがサポートされています。

これが、スクリプトを読み込むための最速の方法です。そうですよね

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

これをドキュメントの先頭に配置すると、見つけやすさをさらに高めることができます。

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

次に、ドキュメントの先頭でインラインで JavaScript を使用してスクリプトを読み込み、async=false を使用して IE の ReadyState ベースのスクリプト読み込みにフォールバックし、defer にフォールバックします。

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>

仕様の説明: 一緒にダウンロードし、ダウンロードした順序で実行します。赤色のブラウザの表示: 「非同期」とは何ですか?スクリプトが存在しないかのように読み込みます。 他のブラウザでの表示: はい、わかりました。

Async 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 未満の説明: 「async」についてはまったく考えられませんが、「onreadystatechange」を使用する回避策があります。他の赤色のブラウザの記述: 私はこの「非同期」のことは理解できません。スクリプトが到着したらすぐに、任意の順序で実行します。 その他に言及しているもの: 私があなたの友人です。これは本ごとに行います。