PROXX でコード分割、コードのインライン化、サーバーサイド レンダリングをどのように使用したか。
Google I/O 2019 で、Mariko、Jake、そして私は、最新のウェブ用マインスイーパ クローン、PROXX を公開しました。PROXX を際立たせている点は、ユーザー補助を重視している点(スクリーンリーダーを使ってプレイできます!)と、ハイエンドのデスクトップ デバイスと同様にフィーチャー フォンでも実行できることです。フィーチャー フォンには、次のような複数の制約があります。
- 弱い CPU
- GPU が弱い、または存在しない
- タップ入力なしの小画面
- メモリ容量が非常に限られている
最新のブラウザを使用しているので、価格もお手頃です。そのため、新興市場でフィーチャー フォンが再び登場しています。この低価格帯では、以前は購入する余裕がなかったまったく新しいオーディエンスがオンラインにアクセスして、最新のウェブを活用できます。2019 年には、インドだけでも約 4 億台のフィーチャー フォンが販売されると予測されています。そのため、フィーチャー フォンのユーザーがオーディエンスの大部分を占める可能性があります。さらに、新興市場では 2G に近い接続速度が標準となっています。フィーチャー フォンの環境下で PROXX をうまく動作させるためには、どのように対処しましたか?
<ph type="x-smartling-placeholder">パフォーマンスは重要です。これには、読み込みパフォーマンスとランタイム パフォーマンスの両方が含まれます。高いパフォーマンスは、ユーザー維持率の向上、コンバージョンの向上、そして最も重要な点として、包括性の向上と相関関係があることがわかっています。Jeremy Wagner は、パフォーマンスが重要な理由について、さらに多くのデータと分析情報を提供しています。
これは、2 部構成シリーズのパート 1 です。パート 1 では読み込みのパフォーマンスに焦点を合わせ、パート 2 ではランタイムのパフォーマンスに焦点を当てます。
現状の把握
実際のデバイスで読み込みパフォーマンスをテストすることは非常に重要です。実際のデバイスがお手元にない場合は、WebPageTest、特に「simple」設定をご覧ください。WPT は、エミュレートされた 3G 接続を使用して、実際のデバイスで一連の読み込みテストを実行します。
3G は測定に適した速度です。4G、LTE、あるいはまもなく 5G に慣れているかもしれませんが、モバイル インターネットの現実は大きく異なります。電車、会議、コンサート、飛行機などで移動しているときも、実際に体験するのは 3G に近い場合が多く、状況はもっと悪くなります。
とはいえ、PROXX はターゲット オーディエンスの中でフィーチャー フォンと新興市場を明示的にターゲットとしているため、この記事では 2G に重点を置きます。WebPageTest がテストを実行すると、(DevTools と同様に)ウォーターフォールと、上部にフィルムストリップが表示されます。フィルム ストリップは、アプリの読み込み中にユーザーに表示される内容を示します。2G では、最適化されていないバージョンの PROXX の読み込みエクスペリエンスはかなり悪くなっています。
<ph type="x-smartling-placeholder">3G で接続すると、白色の何もない状態で 4 秒間表示されます。2G では 8 秒間以上何も表示されません。パフォーマンスが重要な理由をお読みいただくと、いざというときに焦って潜在的ユーザーの大部分を失っていることがわかります。ユーザーが画面にすべての要素を表示させるには、62 KB の JavaScript をすべてダウンロードする必要があります。もう一つの手がかりとなるのは、画面に表示される 2 つ目の要素もインタラクティブであるということです。有効か
<ph type="x-smartling-placeholder">gzip で圧縮された約 62 KB の JS がダウンロードされ、DOM が生成されると、アプリが表示されます。アプリは技術的にインタラクティブです。しかし、ビジュアルを見ると別の現実が見えます。ウェブフォントは引き続きバックグラウンドで読み込まれており、準備が整うまでテキストは表示されません。この状態は First Meaningful Paint(FMP)とみなされますが、ユーザーは入力が何であるかがわからないため、適切にインタラクティブであるとは言えません。アプリが使用可能になるまで、3G ではさらに 1 秒、2G では 3 秒かかります。全体として、アプリがインタラクティブになるまでに、3G で 6 秒、2G で 11 秒かかります。
ウォーターフォール分析
ユーザーに表示される内容がわかったところで、次に「理由」を突き止める必要があります。そのために、ウォーターフォールを確認して、リソースの読み込みが遅すぎる理由を分析できます。PROXX の 2G トレースには、2 つの大きな危険信号があります。
- 色とりどりの細い線が描かれています。
- JavaScript ファイルはチェーンを形成します。たとえば、2 つ目のリソースは 1 つ目のリソースが終了すると初めて読み込みを開始し、3 つ目のリソースは 2 つ目のリソースが終了したときにのみ読み込みを開始します。
接続数の削減
細い線(dns
、connect
、ssl
)は、新しい HTTP 接続の作成を表します。新しい接続のセットアップには費用がかかります。3G では約 1 秒、2G では約 2.5 秒かかります。ウォーターフォールでは、次の新しい接続が表示されます。
- リクエスト 1:
index.html
- リクエスト 5:
fonts.googleapis.com
のフォント スタイル - リクエスト 8: Google アナリティクス
- リクエスト 9:
fonts.gstatic.com
からのフォント ファイル - リクエスト 14: ウェブアプリ マニフェスト
index.html
への新しい接続は避けられません。コンテンツを取得するには、ブラウザがサーバーへの接続を作成する必要があります。Google アナリティクスへの新しい接続は、Minimal Analytics のようなインライン化によって回避できます。ただし、Google アナリティクスはアプリのレンダリングやインタラクティブ化を妨げるものではないため、読み込み速度はそれほど重要ではありません。Google アナリティクスは、他の要素がすでに読み込まれているアイドル時に読み込まれるのが理想的です。そうすることで、初期読み込み時に帯域幅や処理能力が消費されることがなくなります。ウェブアプリ マニフェストの新しい接続は、フェッチ仕様で規定されています。マニフェストは、認証情報のない接続を介して読み込む必要があるためです。繰り返しになりますが、ウェブアプリ マニフェストはアプリのレンダリングやインタラクティブ性を妨げるものではないので、それほど気にする必要はありません。
ただし、この 2 つのフォントとそのスタイルは、レンダリングとインタラクティビティを妨げるため問題となります。fonts.googleapis.com
によって配信される CSS を見ると、フォントごとに 1 つずつ、合計 2 つの @font-face
ルールだけです。フォントのスタイルは非常に小さいため、HTML にインライン化し、不要な接続を 1 つ削除することにしました。フォント ファイルの接続設定にコストをかけないようにするために、それらを Google のサーバーにコピーします。
読み込みの並列化
ウォーターフォールを見ると、最初の JavaScript ファイルの読み込みが完了すると、すぐに新しいファイルの読み込みが開始されることがわかります。これはモジュールの依存関係の場合は一般的です。おそらくメイン モジュールには静的インポートが含まれているため、それらのインポートが読み込まれるまで JavaScript は実行できません。ここで認識しておくべき重要なことは、この種の依存関係はビルド時にわかっているということです。<link rel="preload">
タグを使用すると、HTML を受け取った直後にすべての依存関係の読み込みが開始されるようにできます。
結果
今回の変更がどのような成果をもたらしたかを見てみましょう。テストのセットアップでは、結果を歪める可能性のある他の変数を変更しないことが重要です。この記事の残りの部分では WebPageTest の簡単なセットアップを使用してフィルムストリップを確認します。
<ph type="x-smartling-placeholder">この変更により、TTI が 11 から 8.5 に短縮されました。これは、削除を目指していた接続設定時間の約 2.5 秒です。お疲れさまでした。
事前レンダリング
TTI は短縮されたばかりですが、ユーザーが 8.5 秒間に耐える必要がある長時間に及ぶ白い画面にはあまり影響していません。FMP の最大の改善は、index.html
でスタイル付きのマークアップを送信することで実現できることでしょう。これを実現するための一般的な手法は、事前レンダリングとサーバーサイド レンダリングです。これらは密接に関連しています。詳しくは、ウェブ上でのレンダリングをご覧ください。どちらの手法でも、Node でウェブアプリを実行し、結果の DOM を HTML にシリアル化します。サーバーサイド レンダリングでは、リクエストごとにサーバー側でこの処理が行われますが、事前レンダリングでは、ビルド時にこの処理が行われ、出力が新しい index.html
として保存されます。PROXX は JAMStack アプリであり、サーバー側を持たないため、事前レンダリングを実装することにしました。
事前レンダラを実装する方法は数多くあります。PROXX では Puppeteer を使用することを選択しました。Puppeteer は、UI なしで Chrome を起動し、Node API を使ってそのインスタンスをリモート制御できるようにします。これを使用してマークアップと JavaScript を挿入し、DOM を HTML の文字列として読み取ります。CSS モジュールを使用しているため、無料で必要なスタイルを CSS がインライン化しています。
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(rawIndexHTML);
await page.evaluate(codeToRun);
const renderedHTML = await page.content();
browser.close();
await writeFile("index.html", renderedHTML);
これにより、FMP の改善が期待できます。以前と同じ量の JavaScript を読み込んで実行する必要があるため、TTI に大きな変化は期待できません。もしそうなら、index.html
が肥大化して、TTI が少し遅れている可能性があります。これを調べる方法は 1 つしかありません。それは、WebPageTest を実行することです。
First Meaningful Paint は 8.5 秒から 4.9 秒に短縮され、大幅に改善されました。TTI はまだ約 8.5 秒で発生しているため、この変更による影響はほぼありません。ここで行ったのは、知覚の変化です。「巧妙な手」と呼ぶ人もいるでしょう。ゲームの中間的なビジュアルをレンダリングすることで、認識される読み込みパフォーマンスを向上させています。
インライン化
DevTools と WebPageTest の両方から得られるもう一つの指標は、Time To First Byte(TTFB)です。これは、リクエストが送信されてからレスポンスの最初のバイトを受信するまでの時間です。この時間はラウンド トリップ時間(RTT)とも呼ばれますが、技術的にはこの 2 つの数値には違いがあります。RTT には、サーバー側でのリクエストの処理時間は含まれません。DevTools と WebPageTest は、リクエスト/レスポンス ブロック内で TTFB を明るい色で可視化します。
<ph type="x-smartling-placeholder">ウォーターフォールを見ると、すべてのリクエストがレスポンスの最初のバイトの到着を待機している時間の大部分が費やされていることがわかります。
この問題は、HTTP/2 Push が当初想定されていたものでした。アプリ デベロッパーは特定のリソースが必要であることを把握し、そのリソースを圧倒できます。クライアントが追加のリソースをフェッチする必要があると認識するころには、すでにブラウザのキャッシュに入っています。HTTP/2 Push は実現するのが難しすぎることが判明し、推奨されていません。この問題空間は、HTTP/3 の標準化の際に再検討されます。現時点の最も簡単なソリューションは、キャッシュの効率と引き換えに、重要なリソースをすべてインライン化することです。
CSS モジュールと Puppeteer ベースの事前レンダリングツールのおかげで、重要な CSS はすでにインライン化されています。JavaScript では、重要なモジュールとその依存関係をインライン化する必要があります。このタスクの難易度は、使用しているバンドラによって異なります。
で確認できます。 <ph type="x-smartling-placeholder">これにより、TTI が 1 秒短縮されました。これで、最初のレンダリングとインタラクティブに必要なすべての要素が index.html
に含まれるようになりました。HTML はダウンロード中にレンダリングできるため、FMP を作成しています。HTML の解析と実行が完了すると、アプリはインタラクティブになります。
積極的なコード分割
はい。index.html
には、インタラクティブにするために必要なものがすべて含まれています。詳しく見ていくと、他のものもすべて含まれていることがわかりました。index.html
は約 43 KB です。まず、ユーザーが最初に操作できるものと考えておきましょう。ゲームを構成するためのフォームには、いくつかのコンポーネント、開始ボタン、ユーザー設定を保持して読み込むためのコードが含まれています。大丈夫です。43 KB は多すぎます。
バンドルサイズの取得元を把握するには、ソースマップ エクスプローラや同様のツールを使用して、バンドルに含まれるものを分類します。このバンドルには予想どおり、ゲームロジック、レンダリング エンジン、勝利画面、負け画面、各種ユーティリティが含まれています。ランディング ページに必要なモジュールは、ほんのわずかです。インタラクティビティに厳密に必要でないすべてのものを遅延読み込みモジュールに移動すると、TTI が大幅に減少します。
<ph type="x-smartling-placeholder">必要なのは、コード分割です。コード分割により、モノリシック バンドルがオンデマンドで遅延読み込みできる小さな部分に分割されます。Webpack、Rollup、Parcel などの一般的なバンドラは、動的な import()
を使用したコード分割をサポートしています。バンドラはコードを分析し、静的にインポートされたすべてのモジュールをインラインでインライン化します。動的にインポートしたデータはすべて別のファイルに格納され、import()
呼び出しが実行されて初めてネットワークから取得されます。もちろん、ネットワークへの侵入にはコストがかかるため、時間に余裕がある場合にのみ行ってください。ここで重要なことは、読み込み時に重要必要なモジュールを静的にインポートし、それ以外は動的に読み込みます。ただし、間違いなく使用されるモジュールを遅延読み込みする最後の瞬間まで待たないでください。Phil Walton の Idle Until Urgent は、遅延読み込みと積極的読み込みの健全な中間点として最適です。
PROXX では、必要でないものすべてを静的にインポートする lazy.js
ファイルを作成しました。メインファイルでは、lazy.js
を動的にインポートできます。ただし、一部の Preact コンポーネントは、最終的には lazy.js
になっていました。Preact は遅延読み込みコンポーネントを最初から処理できないため、少し複雑になりました。そのため、小さな deferred
コンポーネント ラッパーを記述し、実際のコンポーネントが読み込まれるまでプレースホルダをレンダリングできるようにしました。
export default function deferred(componentPromise) {
return class Deferred extends Component {
constructor(props) {
super(props);
this.state = {
LoadedComponent: undefined
};
componentPromise.then(component => {
this.setState({ LoadedComponent: component });
});
}
render({ loaded, loading }, { LoadedComponent }) {
if (LoadedComponent) {
return loaded(LoadedComponent);
}
return loading();
}
};
}
これで、コンポーネントの Promise を render()
関数で使用できます。たとえば、アニメーション化された背景画像をレンダリングする <Nebula>
コンポーネントは、コンポーネントの読み込み中に空の <div>
に置き換えられます。コンポーネントが読み込まれて使用できるようになると、<div>
が実際のコンポーネントに置き換えられます。
const NebulaDeferred = deferred(
import("/components/nebula").then(m => m.default)
);
return (
// ...
<NebulaDeferred
loading={() => <div />}
loaded={Nebula => <Nebula />}
/>
);
これらすべてを考慮して、index.html
を元のサイズの半分以下のわずか 20 KB に縮小しました。これは FMP と TTI にどのような影響を与えますか。WebPageTest がそれがわかります。
インラインの JavaScript を解析して実行するだけなので、FMP と TTI の差はわずか 100 ミリ秒です。2G でわずか 5.4 秒で、アプリは完全にインタラクティブになります。その他のあまり重要でないモジュールは、バックグラウンドで読み込まれます。
優れた手軽さ
上記の重要なモジュールのリストを見ると、レンダリング エンジンは重要なモジュールの一部ではないことがわかります。もちろん、ゲームをレンダリングするレンダリング エンジンが用意されるまでゲームを開始することはできません。「Start」エンドポイントをボタンを渡してゲームを開始する準備が整うまで待つ必要がありますが、Google の経験では、通常、ユーザーがゲーム設定を構成するのに十分な時間を要するため、そうする必要はありません。ほとんどの場合、レンダリング エンジンとその他のモジュールは、ユーザーが [Start] を押すまでに読み込みを完了します。まれなケースですが、ユーザーがネットワーク接続よりも速く処理できる場合は、残りのモジュールが完了するまで待機するシンプルな読み込み画面を表示します。
まとめ
測定は重要です。現実ではない問題に時間を費やさないようにするには、最適化を実施する前に必ず測定することをおすすめします。また、測定は実際のデバイスで 3G 接続するか、WebPageTest(デバイスが手元にない場合は)で行う必要があります。
フィルムストリップは、アプリの読み込みがユーザーにとってどのように感じられるかについての分析情報を提供します。ウォーターフォールでは、読み込み時間が長い原因となっているリソースを確認できます。読み込みパフォーマンスを改善するためのチェックリストは次のとおりです。
- 1 つの接続でできるだけ多くのアセットを提供します。
- 最初のレンダリングとインタラクティビティに必要なリソースをプリロードするか、インライン リソースでもプリロードできます。
- アプリを事前レンダリングして、認識される読み込みパフォーマンスを向上させます。
- 積極的なコード分割を利用して、インタラクティビティに必要なコードの量を減らします。
パート 2 では、非常に制約のあるデバイスでランタイム パフォーマンスを最適化する方法について解説しますので、どうぞお見逃しなく。