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

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] ですが、ユーザーにとって無意味です。

約 62 KB の gzip 圧縮 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 秒に短縮され、大幅に改善されました。YouTube の TTI は引き続き 8.5 秒程度で発生するため、この変更による影響はほとんどありません。ここで行った変更は知覚的な変更です。手品に例える人もいるかもしれません。ゲームの中間ビジュアルをレンダリングすることで、読み込みのパフォーマンスが向上します。

インライン化

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

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

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

この問題は、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 秒でアプリが完全にインタラクティブになります。その他の重要度の低いモジュールはすべてバックグラウンドで読み込まれます。

手品

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

まとめ

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

フィルムストリップでは、アプリの読み込みがユーザーにどのように感じられるかを把握できます。ウォーターフォールでは、読み込み時間が長くなる原因となっているリソースを確認できます。読み込みパフォーマンスを改善するためにできることを、以下にチェックリストで示します。

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

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