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

はじめに

この記事では、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 では、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 を除き、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 の 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 を使用してスクリプトを条件付きで読み込んでいる場合、ダウンロードを早めにトリガーすることもできます。そうでない場合は、本文の末尾に記述する方法に留めてください。

これで、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」を使用した回避策があります。他のブラウザ(赤色): 「非同期」についてはよくわかりません。スクリプトが届き次第、順序に関係なく実行します。それ以外のすべては次のように言っています。私はあなたの友人です。この件は手順に沿って対応します。