フィーチャー フォンでもウェブアプリの読み込みを高速化する手法

PROXX でコード分割、コードインライン化、サーバーサイド レンダリングを使用した方法。

Google I/O 2019 で、マリコ、ジェイク、私はウェブ向けの最新の Minesweeper クローンである PROXX をリリースしました。PROXX の特徴は、ユーザー補助機能(スクリーン リーダーでプレイ可能)に重点を置いていることと、ハイエンドのパソコン デバイスだけでなく、フィーチャー フォンでも実行できることです。フィーチャー フォンには、次のような制約があります。

  • 低性能の CPU
  • 低性能の GPU または GPU がない
  • タッチ入力のない小画面
  • メモリ量が非常に少ない

ただし、最新のブラウザを搭載しており、非常に手頃な価格です。そのため、新興市場でフィーチャー フォンが再び登場しています。低価格であるため、これまでオンラインにアクセスできなかった新しいオーディエンスが、最新のウェブを利用できるようになります。2019 年にはインドだけで約 4 億台のフィーチャー フォンが販売されると予測されています。そのため、フィーチャー フォンのユーザーは視聴者の重要な部分を占める可能性があります。さらに、新興市場では 2G に近い接続速度が標準となっています。フィーチャー フォンの環境で PROXX を適切に動作させるにはどうすればよいですか?

PROXX のゲームプレイ。

パフォーマンスは重要です。これには、読み込みパフォーマンスとランタイム パフォーマンスの両方が含まれます。優れたパフォーマンスは、ユーザー維持率の向上、コンバージョン数の増加、そして最も重要なインクルーシビティの向上に関連付けられていることが示されています。Jeremy Wagner は、パフォーマンスが重要な理由について、さらに多くのデータと分析情報を提供しています。

この記事は全 2 部シリーズのパート 1 です。パート 1 では読み込みのパフォーマンスに焦点を合わせ、パート 2 ではランタイムのパフォーマンスに焦点を当てます。

現状をキャプチャする

実際のデバイスで読み込みパフォーマンスをテストすることが重要です。実際のデバイスが手元にない場合は、WebPageTest(特に「シンプル」な設定)をおすすめします。WPT は、エミュレートされた 3G 接続を使用して、実際のデバイスで一連の読み込みテストを実行します。

3G は測定に適した速度です。4G、LTE、まもなく 5G に慣れているかもしれませんが、モバイル インターネットの現実はかなり異なります。電車の中、会議中、コンサート中、フライト中など、3G に近い速度になる可能性があり、場合によってはそれよりも遅くなることもあります。

ただし、PROXX はフィーチャー フォンと新興市場を明示的にターゲットとしているため、この記事では 2G に焦点を当てます。WebPageTest がテストを実行すると、(DevTools と同じように)ウォーターフォールと、上部にフィルムストリップが表示されます。フィルム ストリップは、アプリの読み込み中にユーザーに表示される内容を示します。2G では、最適化されていないバージョンの PROXX の読み込みエクスペリエンスはかなり悪くなっています。

フィルムストリップの動画は、実際のローエンド デバイスで、エミュレートされた 2G 接続経由で PROXX が読み込まれるときにユーザーに表示される内容を示しています。

3G で接続すると、白色の何もない状態で 4 秒間表示されます。2G では 8 秒間以上何も表示されません。パフォーマンスが重要な理由をご覧いただければ、ユーザーの忍耐力が足りなかったために、多くの潜在的なユーザーを失ったことがおわかりいただけると思います。画面に何かを表示するには、62 KB の JavaScript をすべてダウンロードする必要があります。このシナリオの唯一の救いは、画面に何かが表示されると、それがインタラクティブになることです。有効か

最適化されていないバージョンの PROXX の [最初の意味のある描画][FMP] は、_技術的には_ [インタラクティブ][TTI] ですが、ユーザーにとって無用です。

gzip で圧縮された約 62 KB の JS がダウンロードされ、DOM が生成されると、アプリが表示されます。このアプリは技術的にインタラクティブです。しかし、画像を見ると、現実は異なることがわかります。ウェブフォントはバックグラウンドで読み込み中であり、準備が整うまでユーザーにはテキストが表示されません。この状態は最初の意味のあるペイント(FMP)の条件を満たしていますが、ユーザーが入力内容を把握できないため、適切にインタラクティブであるとは言えません。アプリが使用可能になるまで、3G ではさらに 1 秒、2G では 3 秒かかります。合計で、3G では 6 秒、2G では 11 秒かかります。

ウォーターフォール分析

ユーザーに表示される内容がわかったので、その理由を探ります。そのために、ウォーターフォールを確認して、リソースの読み込みが遅すぎる理由を分析できます。PROXX の 2G トレースには、次の 2 つの大きな問題が確認できます。

  1. 複数の色の薄い線がある。
  2. JavaScript ファイルはチェーンを形成します。たとえば、2 つ目のリソースは 1 つ目のリソースの読み込みが完了してから読み込みを開始し、3 つ目のリソースは 2 つ目のリソースの読み込みが完了してから読み込みを開始します。
ウォーターフォールでは、どのリソースがいつ読み込まれ、どのくらいの時間がかかっているかを把握できます。

接続数の削減

各細い線(dnsconnectssl)は、新しい HTTP 接続の作成を表します。新しい接続のセットアップには費用がかかります。3G では約 1 秒、2G では約 2.5 秒かかります。ウォーターフォールでは、次の新しい接続を確認できます。

  • リクエスト 1: index.html
  • リクエスト 5: fonts.googleapis.com のフォント スタイル
  • リクエスト 8: Google アナリティクス
  • リクエスト 9: fonts.gstatic.com のフォント ファイル
  • リクエスト 14: ウェブアプリ マニフェスト

index.html の新しい接続は避けられません。ブラウザは、コンテンツを取得するために Google のサーバーへの接続を作成する必要があります。Google アナリティクスへの新しい接続は、Minimal Analytics などでインライン化することで回避できますが、Google アナリティクスはアプリのレンダリングやインタラクティブ化を妨げるものではないため、読み込み速度はそれほど重要ではありません。Google アナリティクスは、他の要素がすでに読み込まれているアイドル時に読み込まれるのが理想的です。これにより、初期読み込み時に帯域幅や処理能力を消費することがなくなります。ウェブアプリ マニフェストの新しい接続は、認証情報のない接続を介して読み込む必要があるため、取得仕様で規定されています。繰り返しになりますが、ウェブアプリ マニフェストはアプリのレンダリングやインタラクティブ化をブロックしないため、それほど気にする必要はありません。

ただし、この 2 つのフォントとそのスタイルは、レンダリングとインタラクティビティをブロックするため、問題となります。fonts.googleapis.com によって配信される CSS を見ると、フォントごとに 1 つずつ、合計 2 つの @font-face ルールしかありません。フォント スタイルは非常に小さいため、HTML にインライン化し、不要な接続を 1 つ削除することにしました。フォント ファイルの接続設定の費用を回避するには、それらを独自のサーバーにコピーします。

負荷の並列化

ウォーターフォールを見ると、最初の JavaScript ファイルの読み込みが完了すると、新しいファイルの読み込みがすぐに開始されていることがわかります。これはモジュール依存関係では一般的です。おそらくメイン モジュールには静的インポートが含まれているため、それらのインポートが読み込まれるまで JavaScript は実行できません。ここで重要なのは、このような依存関係はビルド時に既知であるということです。<link rel="preload"> タグを使用すると、HTML を受信した瞬間にすべての依存関係の読み込みを開始できます。

結果

変更によって達成されたことを見てみましょう。結果に影響する可能性のあるテスト設定の他の変数は変更しないことが重要です。この記事の残りの部分では、WebPageTest のシンプルな設定を使用してフィルムストリップを確認します。

WebPageTest のフィルムストリップを使用して、変更によってどのような効果が得られたかを確認します。

この変更により、TTI が 11 から 8.5 に短縮されました。これは、削除を目指していた接続設定時間の約 2.5 秒です。よくできました。

事前レンダリング

TTI は短縮されましたが、ユーザーが 8.5 秒間我慢しなければならない、永遠に続くような白い画面にはほとんど影響していません。FMP のパフォーマンスを最大限に高めるには、index.html でスタイル設定されたマークアップを送信するのが最適です。これを実現するための一般的な手法は、プリレンダリングとサーバーサイド レンダリングです。これらは密接に関連しており、ウェブでのレンダリングで説明しています。どちらの方法でも、Node でウェブアプリを実行し、結果の DOM を HTML にシリアル化します。サーバーサイド レンダリングでは、リクエストごとにサーバー側でこの処理が行われますが、事前レンダリングでは、ビルド時にこの処理が行われ、出力が新しい index.html として保存されます。PROXX は JAMStack アプリでサーバーサイドがないため、プリレンダリングを実装することにしました。

事前レンダラを実装する方法は数多くあります。PROXX では 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 を実行することです。

フィルムストリップでは、FMP 指標が大幅に改善されています。TTI にはほとんど影響ありません。

First Meaningful Paint は 8.5 秒から 4.9 秒に短縮され、大幅に改善されました。TTI はまだ約 8.5 秒で発生しているため、この変更による影響はほぼありません。ここで行ったのは、知覚の変化です。手品に例える人もいるかもしれません。ゲームの中間ビジュアルをレンダリングすることで、読み込みのパフォーマンスが向上します。

インライン化

DevTools と WebPageTest の両方から得られるもう一つの指標は、Time To First Byte(TTFB)です。リクエストの最初のバイトが送信されてからレスポンスの最初のバイトが受信されるまでの時間です。この時間はラウンドトリップ時間(RTT)とも呼ばれますが、技術的にはこの 2 つの数値には違いがあります。RTT には、サーバー側でのリクエストの処理時間は含まれません。DevTools と WebPageTest は、リクエスト/レスポンス ブロック内で TTFB を明るい色で可視化します。

リクエストの Light セクションは、リクエストがレスポンスの最初のバイトの受信を待機していることを示します。

ウォーターフォールを見ると、すべてのリクエストがレスポンスの最初のバイトが到着するのを待機することに時間の大部分を費やしていることがわかります。

この問題は、HTTP/2 Push が最初に考案されたものです。アプリ デベロッパーは、特定のリソースが必要であることを認識し、ワイヤーを介してプッシュできます。クライアントが追加のリソースを取得する必要があることに気付いたときには、そのリソースはすでにブラウザのキャッシュに保存されています。HTTP/2 Push は実現するのが難しすぎることが判明し、推奨されていません。この問題領域は、HTTP/3 の標準化の際に再検討されます。現時点では、キャッシュの効率性を犠牲にして、すべての重要なリソースをインラインに配置するのが最も簡単なソリューションです

CSS モジュールと Puppeteer ベースのプリレンダラにより、重要な CSS はすでにインライン化されています。JavaScript の場合は、重要なモジュールとその依存関係をインライン化する必要があります。このタスクの難易度は、使用しているバンドルツールによって異なります。

JavaScript をインライン化することで、TTI を 8.5 秒から 7.2 秒に短縮しました。

これにより、TTI が 1 秒短縮されました。これで、index.html に初期レンダリングとインタラクティブ化に必要なものがすべて含まれるようになりました。HTML はダウンロード中にレンダリングできるため、FMP を作成しています。HTML の解析と実行が完了すると、アプリはインタラクティブになります。

積極的なコード分割

はい。index.html には、インタラクティブにするために必要なものがすべて含まれています。しかし、よく見ると、他のものもすべて含まれていることがわかります。index.html は約 43 KB です。まず、ユーザーが最初に操作できるものと考えておきましょう。ゲームを構成するためのフォームには、いくつかのコンポーネント、開始ボタン、ユーザー設定を保持して読み込むためのコードが含まれています。ほぼ以上です。43 KB は多すぎるように思えます。

PROXX のランディング ページ。ここでは重要なコンポーネントのみを使用します。

バンドルのサイズの原因を把握するには、ソースマップ エクスプローラなどのツールを使用して、バンドルの構成を分解します。予想どおり、このバンドルにはゲーム ロジック、レンダリング エンジン、勝利画面、敗北画面、さまざまなユーティリティが含まれています。ランディング ページに必要なのは、これらのモジュールのごく一部です。インタラクティビティに厳密に必要でないすべてのものを遅延読み込みモジュールに移動すると、TTI が大幅に減少します。

PROXX の「index.html」の内容を分析すると、不要なリソースが多数表示されます。重要なリソースがハイライト表示されます。

必要なのはコード分割です。コード分割では、モノリシック バンドルを、オンデマンドで遅延読み込みできる小さな部分に分割します。WebpackRollupParcel などの一般的なバンドラは、動的 import() を使用してコード分割をサポートしています。バンドラはコードを分析し、静的にインポートされたすべてのモジュールをインライン化します。動的にインポートされたものはすべて独自のファイルに保存され、import() 呼び出しが実行された場合にのみネットワークから取得されます。もちろん、ネットワークにアクセスするには費用がかかります。余裕のある時間にのみ行ってください。ここでの要点は、読み込み時に不可欠なモジュールを静的にインポートし、それ以外はすべて動的に読み込むことです。ただし、間違いなく使用されるモジュールを遅延読み込みする最後の瞬間まで待たないでください。Phil WaltonIdle 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();
    }
  };
}

これで、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 で確認できます。

フィルムストリップで確認すると、TTI は 5.4 秒になっています。元の 11 秒から大幅に改善。

インライン化された JavaScript の解析と実行のみであるため、FMP と TTI の差はわずか 100 ミリ秒です。2G ではわずか 5.4 秒でアプリが完全にインタラクティブになります。その他の重要度の低いモジュールはすべてバックグラウンドで読み込まれます。

手品

上記の重要なモジュールのリストを見ると、レンダリング エンジンは重要なモジュールの一部ではないことがわかります。もちろん、ゲームをレンダリングするレンダリング エンジンがなければ、ゲームを開始することはできません。レンダリング エンジンがゲームを開始する準備が整うまで [スタート] ボタンを無効にすることもできますが、私たちの経験では、通常、ユーザーがゲーム設定を構成するのに十分な時間を必要としていますが、これは必須ではありません。ほとんどの場合、レンダリング エンジンとその他のモジュールは、ユーザーが [Start] を押すまでに読み込みを完了します。ユーザーの操作がネットワーク接続よりも速い場合でも、残りのモジュールが完了するまで待機するシンプルな読み込み画面が表示されます。

まとめ

測定は重要です。現実ではない問題に時間を費やさないようにするには、最適化を実施する前に必ず測定することをおすすめします。また、3G 接続の実際のデバイスで測定するか、実際のデバイスが手元にない場合は WebPageTest で測定する必要があります。

フィルムストリップは、ユーザーに対するアプリの読み込みの感触についての分析情報を提供します。ウォーターフォールでは、読み込み時間が長くなる原因となっているリソースを確認できます。読み込みパフォーマンスを改善するためにできることを、以下にチェックリストで示します。

  • 1 つの接続でできるだけ多くのアセットを配信します。
  • 最初のレンダリングとインタラクティビティに必要なリソースをプリロードするか、インライン リソースでもプリロードできます。
  • アプリをプリレンダリングして、読み込みのパフォーマンスを向上させます。
  • 積極的なコード分割を利用して、インタラクティビティに必要なコードの量を削減します。

パート 2 では、制約の厳しいデバイスでランタイム パフォーマンスを最適化する方法について説明します。