PROXX でコード分割、コードのインライン化、サーバーサイド レンダリングをどのように使用したか。
Google I/O 2019 で、ウェブ用の最新のマインスイーパー クローンである PROXX を、Mariko、Jake、私が出荷しました。PROXX が際立っているのは、ユーザー補助機能に重点を置いている(スクリーン リーダーでプレイできる)ことと、ハイエンドのデスクトップ デバイスと同様にフィーチャー フォンでも実行できる機能です。フィーチャー フォンにはさまざまな制約があります。
- 脆弱な CPU
- 脆弱な GPU または存在しない GPU
- 小さな画面(タップ入力なし)
- メモリ量が非常に少ない
最新のブラウザを搭載し、非常に手頃な価格で利用できます。そのため、新興市場でフィーチャー フォンが復活しています。それまでは購入できなかった全く新しいユーザーが、この価格帯を利用することで、最新のウェブを利用できるようになりました。2019 年にはインドだけでも約 4 億台のフィーチャー フォンが販売されると予測されており、フィーチャー フォンがオーディエンスの大部分を占める可能性があります。さらに、新興市場では、2G に近い接続速度が標準となっています。フィーチャー フォンの状況下で PROXX をうまく機能させるには、どうやって実現したのですか?
パフォーマンスは重要です。これには、読み込みパフォーマンスとランタイム パフォーマンスの両方が含まれます。優れたパフォーマンスは、ユーザー維持率の向上、コンバージョンの向上、そして最も重要なこととしてインクルーシブネスの向上と相関することがわかっています。Jeremy Wagner は、パフォーマンスが重要な理由について、さらに多くのデータと分析情報を提供しています。
これは 2 部構成シリーズのパート 1 です。パート 1 では読み込みパフォーマンスに焦点を当て、パート 2 ではランタイム パフォーマンスに焦点を当てます。
現状の把握
読み込みパフォーマンスを実際のデバイスでテストすることが重要です。実機がない場合は、WebPageTest、特に「簡単な」設定をおすすめします。WPT は、3G 接続をエミュレートした実際のデバイスで、一連の読み込みテストを実行します。
3G は測定するのに十分な速度です。4G、LTE、さらには 5G に慣れているかもしれませんが、モバイル インターネットの現実はまったく異なります。電車、会議、コンサート、飛行機の中など、ご利用の環境は 3G に近い場合が多く、場合によってはそれ以上に悪化します。
とはいえ、PROXX はターゲット オーディエンスのフィーチャー フォンと新興市場を明示的にターゲットにしているため、この記事では 2G に焦点を当てます。WebPageTest がテストを実行すると、DevTools に表示されるようなウォーターフォールが表示され、上部にフィルムストリップが表示されます。フィルム ストリップには、アプリの読み込み中にユーザーに表示される内容が表示されます。2G では、最適化されていないバージョンの PROXX の読み込みエクスペリエンスはかなり悪くなります。
3G で読み込むと、4 秒間何も白く表示されません。2G では、8 秒間以上何も見えません。パフォーマンスが重要な理由をご覧いただいた方もいらっしゃると思いますが、焦らずに潜在的なユーザーの大部分を失っています。画面に何かを表示するには、62 KB の JavaScript をすべてダウンロードする必要があります。このシナリオで有利な点は、2 番目に表示されるものはインタラクティブであることです。有効か
gzip で圧縮された JS が約 62 KB ダウンロードされ、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 アナリティクスの新しい接続は、ミニマル アナリティクスのようなものをインライン化することで回避できますが、Google アナリティクスはアプリのレンダリングやインタラクティブ化をブロックしないため、読み込み時間はあまり気にしません。Google アナリティクスは、他のデータがすでに読み込まれているアイドル時間内に読み込まれるのが理想的です。そうすることで、初期読み込み時に帯域幅や処理能力を消費しなくなります。ウェブアプリ マニフェストの新しい接続はフェッチ仕様で規定されています。マニフェストは認証されていない接続を介して読み込む必要があるためです。繰り返しになりますが、ウェブアプリ マニフェストはアプリのレンダリングやインタラクティブ化をブロックしないため、それほど気にする必要はありません。
しかし、この 2 つのフォントとそのスタイルは、レンダリングを妨げるだけでなく、インタラクティビティにも問題があります。fonts.googleapis.com
によって提供される CSS を見ると、フォントごとに 1 つずつ、合計 2 つの @font-face
ルールがあります。実際、フォント スタイルは非常に小さいため、HTML にインライン化し、不要な接続を 1 つ削除することにしました。フォント ファイルの接続設定のコストを回避するには、フォント ファイルを Google のサーバーにコピーします。
読み込みの並列化
ウォーターフォールを見ると、最初の JavaScript ファイルの読み込みが完了すると、すぐに新しいファイルの読み込みが始まっていることがわかります。これはモジュールの依存関係で一般的です。メイン モジュールにはおそらく静的インポートが含まれているため、それらのインポートが読み込まれるまで JavaScript は実行できません。ここで理解しておくべき重要なことは、この種の依存関係はビルド時に判明しているということです。<link rel="preload">
タグを使用すると、HTML を受け取った時点ですべての依存関係の読み込みが開始するようにできます。
結果
この変更によって何が達成されたのかを見ていきましょう。テストのセットアップでは、結果を歪める可能性のある他の変数を変更しないようにすることが重要です。この記事の残りの部分では、WebPageTest の簡単な設定を使用し、フィルムストリップを確認します。
これらの変更により、TTI が 11 から 8.5 に短縮されました。これは、Google が削除しようとしていた接続セットアップ時間の約 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 の両方で得られるもう一つの指標は、最初のバイトまでの時間(TTFB)です。これは、リクエストが送信されてからレスポンスの最初のバイトが受信されるまでにかかる時間です。この時間はラウンドトリップ時間(RTT)と呼ばれることもありますが、技術的にはこの 2 つの数値には違いがあります。RTT には、サーバー側でのリクエストの処理時間は含まれません。DevTools と WebPageTest は、リクエスト/レスポンス ブロック内で TTFB を明るい色で可視化します。
ウォーターフォールを見ると、すべてのリクエストがレスポンスの最初のバイトの到着を待つ時間の大部分を費やしていることがわかります。
この問題は、HTTP/2 プッシュがもともと想定されたものでした。アプリ デベロッパーが特定のリソースが必要であることを認識しており、リソースを配布できる。クライアントが追加のリソースを取得する必要があることに気付く頃には、それらはすでにブラウザのキャッシュに格納されています。HTTP/2 Push は、正しく行うのが難しすぎることが判明したため、推奨されていません。この問題は、HTTP/3 の標準化中に再検討される予定です。今のところ、最も簡単な解決策は、すべての重要なリソースをインライン化することですが、キャッシュの効率は低下します。
重要な CSS は、CSS モジュールと Puppeteer ベースの事前レンダラのおかげで、すでにインライン化されています。JavaScript では、重要なモジュールとその依存関係をインライン化する必要があります。このタスクの難易度は、使用しているバンドラによって異なります。
これにより TTI が 1 秒短縮されました。これで、最初のレンダリングとインタラクティブ化に必要なものがすべて index.html
に含まれるようになりました。HTML はダウンロード中にレンダリングでき、FMP が作成されます。HTML の解析と実行が完了すると、アプリはインタラクティブになります。
積極的なコード分割
はい。index.html
には、インタラクティブにするのに必要なものがすべて含まれています。しかし、詳しく調べると、他のものもすべて含まれていることがわかりました。index.html
は約 43 KB です。これを、ユーザーが開始時に操作できることに照らして考えてみましょう。ゲームを構成するフォームには、いくつかのコンポーネント、スタートボタン、おそらくユーザー設定を保持して読み込むためのコードが含まれています。以上です。43 KB は多いですね。
バンドルサイズがどこから来たのかを把握するには、ソースマップ エクスプローラまたは同様のツールを使用して、バンドルの内容を詳しく分析します。このバンドルには、予測どおり、ゲームロジック、レンダリング エンジン、勝利画面、負け画面、多数のユーティリティが含まれています。ランディング ページに必要なのは、これらのモジュールのごく一部のみです。インタラクティビティに厳密に必要でないものすべてを遅延読み込みモジュールに移動することで、TTI が大幅に短縮されます。
必要なのはコードの分割です。コード分割により、モノリシック バンドルが小さな部分に分割され、オンデマンドで遅延読み込みできるようになります。Webpack、Rollup、Parcel などの一般的なバンドラは、動的な import()
を使用したコード分割をサポートしています。バンドラはコードと、静的にインポートされたすべてのモジュールをインラインで分析します。動的にインポートするものはすべて独自のファイルに配置され、import()
呼び出しが実行された後にのみネットワークから取得されます。もちろん、ネットワークへのアクセスには費用がかかるため、時間に余裕がある場合にのみ行ってください。ここでのマントラは、読み込み時に必要なモジュールを静的にインポートし、それ以外を動的に読み込むことです。ただし、確実に使用されるモジュールの遅延読み込みを、最後の瞬間まで待つ必要はありません。Phil Walton の Idle While Urgent は、遅延読み込みと積極読み込みの中間的な健全な中間地点を示す優れたパターンです。
PROXX では、不要なものをすべて静的にインポートする lazy.js
ファイルを作成しました。メインファイルでは、lazy.js
を動的にインポートできます。しかし、一部の Preact コンポーネントは lazy.js
になってしまいました。これは、遅延読み込みのコンポーネントをすぐに処理できないため、やや複雑になります。そのため、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();
}
};
}
これにより、render()
関数でコンポーネントの Promise を使用できます。たとえば、アニメーション化された背景画像をレンダリングする <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 では、制約の厳しいデバイスでランタイム パフォーマンスを最適化する方法について詳しく説明します。