2019年のJavaScriptコスト
注意: 記事を読むのではなく、プレゼンテーションを見る方が好きなら、以下の動画をご覧ください!そうでない場合は、動画をスキップして読み進めてください。
JavaScriptのコストにおける過去数年間での大きな変化の1つは、ブラウザがスクリプトを解析しコンパイルする速度の改善です。2019年には、スクリプト処理の主なコストはダウンロードとCPU実行時間の支配的な影響を受けるようになっています。
ブラウザのメインスレッドがJavaScriptを実行するので忙しい場合、ユーザーの操作が遅延する可能性があり、スクリプトの実行時間とネットワークのボトルネックを最適化することが重要です。
実用的な高レベルのガイダンス
ウェブ開発者にとってこれは何を意味するのでしょうか?解析とコンパイルのコストは、かつて考えられていたほど遅くなくなりました。JavaScriptバンドルに焦点を当てるべき3つのポイントは以下の通りです:
- ダウンロード時間の改善
- JavaScriptバンドルをコンパクトに保つこと、特にモバイルデバイス向けには重要です。小さいバンドルはダウンロード速度を向上させ、メモリ使用量を減少させ、CPUコストを削減します。
- 単一の大きなバンドルを避ける; バンドルが約50~100 kBを超える場合は、別々の小さなバンドルに分割します。(HTTP/2の多重通信により、複数のリクエストとレスポンスメッセージが同時にフライト可能で、追加リクエストのオーバーヘッドを減らすことができます。)
- モバイルでは特にネットワーク速度の低さを考慮して少量しか送信しないことが適切です。また、平易なメモリ使用量を低く保つためにも有益です。
- 実行時間の改善
- メインスレッドを占有してページがインタラクティブになるまでのタイミングを遅らせる可能性があるロングタスクを避ける。ダウンロード後のスクリプト実行時間が現在主要なコストです。
- 大きなインラインスクリプトを避ける(これはメインスレッドで解析とコンパイルが行われます)。良い目安として、スクリプトが1 kBを超える場合はインライン化を避ける(また、コードキャッシュが外部スクリプトに対して起動するのが約1 kBからであるため)。
ダウンロードと実行時間が重要な理由
ダウンロードと実行時間を最適化する理由は何でしょうか?ダウンロード時間は低速なネットワークで重要です。世界中で4G(さらには5G)の普及が進んでいるにもかかわらず、私たちの有効な接続タイプは一貫しておらず、移動中には3G(またはそれ以下)に感じる速度に直面することが多いです。
JavaScriptの実行時間は、遅いCPUを持つ電話機にとって重要です。CPU、GPU、熱のスロットリングの違いにより、高性能な電話と低価格の電話の間で性能に大きな差があります。これはJavaScriptの性能に影響を与えます。なぜなら、実行はCPUに依存するからです。
実際、Chromeのようなブラウザでページが読み込まれる合計時間のうち、最大で30%はJavaScriptの実行に費やされることがあります。以下は、高性能デスクトップマシン(Reddit.com)での典型的なワークロードを持つサイトのページロード例です:
モバイルでは、高性能なデバイス(Pixel 3)に比べ、平均的な電話(Moto G4)でRedditのJavaScriptを実行するのにかかる時間は約3〜4倍長く、低性能デバイス(<$100 Alcatel 1X)では6倍以上かかります:
注意: Redditはデスクトップウェブとモバイルウェブで異なる体験を提供しているため、MacBook Proの結果を他の結果と比較することはできません。
JavaScriptの実行時間を最適化しようとするときは、長時間タスクに注意してください。これらはUIスレッドを長時間専有し、ページが見た目に準備が整っているように見えても、重要なタスクの実行を妨げる可能性があります。これらを小さなタスクに分割してください。コードを分割し、ロードの順序を優先させることで、ページをより早くインタラクティブにし、入力遅延を低く抑えることができます。
V8はパース/コンパイルをどのように改善したのか?
V8のJavaScript生パース速度はChrome 60以降2倍に向上しました。同時に、Chromeがパースを並列化するなどの他の最適化作業のおかげで、生パース(およびコンパイル)のコストの可視性/重要性は低下しています。
V8はワーカースレッドでパースとコンパイルを実行することにより、メインスレッドでのパースとコンパイル作業を平均40%削減しました(たとえば、Facebookで46%、Pinterestで62%、最高ではYouTubeで81%)。これに既存のオフメインスレッドストリーミングパース/コンパイルが加わります。
これらの変更のCPU時間への影響を、Chromeの異なるリリースにおけるV8バージョン全体で視覚化することもできます。Chrome 61がFacebookのJSをパースするのにかかった時間で、Chrome 75ではFacebookのJSとTwitterのJSを6回分パースすることが可能になりました。
これらの変更がどのように実現されたかを掘り下げてみましょう。簡単に言えば、スクリプトリソースはワーカースレッド上でストリーミングパースおよびコンパイルが可能です。これはつまり:
- V8はメインスレッドをブロックすることなくJavaScriptをパース+コンパイルできます。
- ストリーミングは、完全なHTMLパーサが
<script>
タグに遭遇した瞬間に開始されます。パーサブロッキングスクリプトの場合、HTMLパーサは一時停止し、非同期スクリプトの場合は続行します。 - 現実のほとんどの接続速度において、V8はダウンロードよりも高速にパースします。そのため、最後のスクリプトバイトがダウンロードされた数ミリ秒後には、V8はパース+コンパイルを完了しています。
もう少し詳細を説明すると… 過去のChromeの非常に古いバージョンでは、スクリプト全体をダウンロードしてからパースを開始していました。これは単純なアプローチですが、CPUを完全には活用していません。バージョン41から68の間に、Chromeは非同期および遅延スクリプトをダウンロード開始時に別スレッドでパースするようになりました。
Chrome 71では、スケジューラが複数の非同期/遅延スクリプトを同時にパースできるタスクベースのセットアップに移行しました。この変更の影響で、メインスレッドのパース時間が約20%削減され、現実のウェブサイトで測定されたTTI/FIDが約2%改善されました。
Chrome 72では、パースの主な方法としてストリーミングを採用しました。これにより通常の同期スクリプトもこの方法でパースされるようになりました(インラインスクリプトは除く)。また、メインスレッドが必要とする場合にタスクベースのパースをキャンセルすることをやめました。これにより、既にある作業を不必要に二重化することを防ぎます。
以前のChromeバージョンでは、ネットワークから来るスクリプトソースデータがChromeのメインスレッドに一度届いてからストリーマーに転送されるストリーミングパースおよびコンパイルをサポートしていました。
これにより、しばしばストリーミングパーサがネットワークから既に到着していたデータを待つことになりましたが、そのデータはメインスレッド上の他の作業(HTMLパース、レイアウト、JavaScriptの実行など)によってブロックされていたため、ストリーミングタスクにまだ転送されていませんでした。
現在では、プリロード時にパースを開始する実験を行っており、以前はこれがメインスレッドのバウンスによって妨げられていました。
Leszek SwirskiのBlinkOnプレゼンテーションでは詳細が説明されています:
これらの変更はDevToolsにどのように反映されていますか?
上記に加え、DevToolsの問題があり、全パーサタスクをCPUを使用している(完全にブロックしている)ように描画していました。ただし、パーサはデータ不足(これがメインスレッドを経由する必要があります)の場合にブロックされます。私たちが単一のストリーマスレッドからストリーミングタスクに移行したため、これが非常に顕著になりました。以下はChrome 69でよく見られたものです:
“スクリプト解析”タスクが1.08秒かかると表示されています。ただし、JavaScriptの解析は実際にはそれほど遅くありません。その時間のほとんどは、メインスレッド上でデータを待つだけに費やされています。
Chrome 76では別の状況が示されています:
一般に、DevToolsのパフォーマンスペインは、ページ上で何が起こっているかを高レベルで把握するのに優れています。JavaScriptの解析やコンパイル時間など、V8固有の詳細なメトリクスについては、Runtime Call Stats (RCS) を使用したChrome Tracingをお勧めします。RCSの結果では、Parse-Background
と Compile-Background
はメインスレッド外でJavaScriptを解析およびコンパイルするのに費やされた時間を示し、Parse
と Compile
はメインスレッドのメトリクスを記録します。
これらの変更の現実世界の影響は何ですか?
実際のWebサイトの例を見て、スクリプトストリーミングの適用方法を確認してみましょう。
Reddit.comにはいくつかの100 kB以上のバンドルがあり、これらが外部関数でラップされているため、メインスレッドでの大量の遅延コンパイルを引き起こします。上記のグラフでは、メインスレッドの時間が最も重要であり、メインスレッドのビジー状態はインタラクティビティを遅延させる可能性があります。Redditはメインスレッドで最も多くの時間を費やし、Worker/Backgroundスレッドの使用は最小限です。
Redditは、より大きなバンドルの一部を小さなバンドル(例:それぞれ50 kB)に分割し、並列化を最大限にすることで恩恵を受けるでしょう。これにより、各バンドルがストリーミング解析および個別にコンパイルされ、スタートアップ時のメインスレッドの解析/コンパイルが削減されます。
また、Facebook.com のようなサイトについても見てみます。Facebookは、~292件のリクエストで約6MBの圧縮JSをロードします。一部は非同期で、一部はプリロードされ、一部は低優先度でフェッチされます。彼らのスクリプトの多くは非常に小さく細分化されていますが、これにより、これらの小さなスクリプトを同時にストリーミング解析/コンパイルできるため、Background/Workerスレッドでの全体的な並列化に役立ちます。
注意:あなたがFacebookでない限り、このような大量のスクリプトを正当化できるデスクトップ向けの長期利用のアプリ(例えばFacebookやGmail)を持っている可能性は低いです。しかし一般的には、バンドルを粗く保ち、必要なものだけをロードするようにしてください。
ほとんどのJavaScript解析およびコンパイル作業は、背景スレッド上でストリーミング方式で実行できますが、一部の作業は依然としてメインスレッド上で実行する必要があります。メインスレッドがビジー状態のときは、ページがユーザー入力に応答しません。コードのダウンロードと実行がUXに与える影響を監視するようにしてください。
注意: 現在、すべてのJavaScriptエンジンおよびブラウザーがスクリプトストリーミングを読み込みの最適化として実装しているわけではありません。それでもここでの全般的なガイドラインは、全体的に良いユーザー体験につながると信じています。
JSON解析のコスト
JSONの文法はJavaScriptの文法よりもはるかに簡素なので、JSONはJavaScriptより効率的に解析できます。この知識は、大量のJSONライクな設定オブジェクトリテラル(例:インラインReduxストア)を提供するWebアプリの起動性能を向上させるために適用できます。以下のようにJavaScriptオブジェクトリテラルとしてデータをインライン化する代わりに:
const data = { foo: 42, bar: 1337 }; // 🐌
…そのデータをJSON文字列化された形で表現し、ランタイムでJSON解析することができます:
const data = JSON.parse('{"foo":42,"bar":1337}'); // 🚀
JSON文字列が1回だけ評価される限り、JavaScriptオブジェクトリテラルと比較して、JSON.parse
のアプローチははるかに高速です。特にコールドロードの場合に効果的です。経験則として、サイズが10 kB以上のオブジェクトにはこのテクニックを適用することをお勧めしますが、性能アドバイスと同様に、変更を加える前に実際の影響を測定してください。
以下のビデオでは、性能差の原因について詳細を説明しています(2:10マークから始まります)。
JSON.parse
を使用して高速化するアプリ」プレゼンテーション[JSON ⊂ ECMAScript]機能解説の例をご覧ください。これにより、任意のオブジェクトを受け取り、それをJSON.parse
する有効なJavaScriptプログラムを生成します。
大量のデータに対してプレーンなオブジェクトリテラルを使用する場合、追加のリスクがあります。それは、_2回解析されてしまう_可能性があることです!
- 最初のパスはリテラルが事前解析されるときに発生します。
- 2回目のパスはリテラルが遅延解析されるときに発生します。
最初のパスは避けられません。ただし、幸いなことに2回目のパスは、オブジェクトリテラルをトップレベルに置くか、PIFE内に配置することで回避できます。
再訪時の解析/コンパイルはどうなりますか?
V8の(byte)コードキャッシング最適化が役立ちます。スクリプトが初めてリクエストされた場合、ChromeはそれをダウンロードしてV8に渡してコンパイルを行います。また、ファイルをブラウザーのオンディスクキャッシュに保存します。JSファイルが2回目にリクエストされると、Chromeはブラウザーキャッシュからファイルを取得し、再びV8に渡してコンパイルします。この際、今回のコンパイル後のコードはシリアライズされ、キャッシュされたスクリプトファイルにメタデータとして添付されます。
3回目には、Chromeがキャッシュからファイルとそのメタデータの両方を取得し、V8に渡します。V8はメタデータをデシリアライズすることでコンパイルをスキップできます。コードキャッシングは、最初の2回の訪問が72時間以内に行われた場合に発生します。Chromeはまた、サービスワーカーを使用してスクリプトをキャッシュする場合、先行コードキャッシングを備えています。コードキャッシングについての詳細はWeb開発者向けコードキャッシングをご参照ください。
結論
2019年におけるスクリプトの読み込みの主なボトルネックは、ダウンロードと実行時間です。同期的(インライン)スクリプトの小さなバンドルを使用して直上のコンテンツを提供し、その後に1つ以上の遅延スクリプトをページの残り部分に使用することを目指してください。大きなバンドルを分割し、ユーザーが必要なときに必要なコードだけを配送することに集中してください。これにより、V8内での並列化が最大化されます。
モバイルの場合、ネットワーク、メモリ消費、実行時間(遅いCPUの場合)を考慮して、より少ないスクリプトを配送する必要があります。レイテンシーとキャッシュ可能性のバランスを取り、メインスレッド外で発生できる解析およびコンパイルの作業量を最大化してください。