見出し画像

ページの読み込みが約2倍早くなりました

ここ数日間の改善により、ハックフォープレイのページ(トップページ)の読み込み速度が、およそ2倍に早くなりました。環境によっては2倍以上になっているかも知れません。以前からチューニング次第でもっと早く出来ると思っていたのですが、ここまでの成果が出たことに自分も驚いています。

本記事ではハックフォープレイがどのように読み込み速度を向上させたのか技術的な部分を解説します。僕の理解が怪しい部分もあるので、マサカリも歓迎です。なるべく具体的な数値を出しながら解説しますが、古いページはもう再現できないので再現試験も不可能です。参考程度に留めてください。


「最近ハックフォープレイが重い」

以前から「ハックフォープレイが重い」とは良く言われていました。子どもは正直なので、すぐに教えてくれます。これには非常に助けられています。

ただ、子どもに限った話ではありませんが、ウェブに詳しくない方はページの読み込みが重い原因として検討外れな予想をしてしまうようです。具体的にいうと以下の予想は全て的外れで、間違っています。

・利用者が増えたことにサーバーが耐えきれなくなり重くなった
・有料ユーザー優先なので、無料ユーザーは重くなる
・ハックフォープレイでステージを作り過ぎると段々重くなってくる

これらは誤解です。こういった誤解をされるとハックフォープレイに対してマイナスのイメージがついてしまうので、誤っていると明言しておきます。

本当の原因は、様々なチューニング不足です。ここ最近は特に新機能の追加に注力してきたため、細かなチューニングには気を配れていませんでした。しかし、オンラインワークショップなどをする中で、モバイル(おそらくは3G)回線でハックフォープレイをする様子を見て、しばらく新機能の追加を止めて、高速化のためのチューニングに全力を注ぐことを決意しました。


とにかく計測する。話はそれからだ。

WebPageTest.org でトップページの読み込み速度を計測してみました。
まずは、改善前の結果がこちらです。

画像2

一番左の Load Time が非同期処理も含めたすべてのかかった時間を表しているようですが、約 32 秒もかかっています。Document Complete / Bytes in は、総ダウンロードサイズでしょうか。約 17 MB もあります。大きい……。

そして、改善後の結果がこちらです。

画像2

Load Time が約 16 秒と、半分になりました。Document Complete / Bytes in の数値も約 8MB と半分になっています。最初の1バイトが到着するまでにかかった時間を表すであろう First Byte も 0.362s です。まだまだ改善の余地はありますが、全体的な UX はそこそこマシになったと思います。

今回行ったチューニングは次の3つです。ひとつずつ解説していきます。
1.画像を圧縮して、転送量を減らす →Contenful
2.Code Splitting でビルドサイズを減らす →Webpack
3.CDN でキャッシュする →Cloud Functions & Cloudflare
(おまけ)Lazy Loading で Waterfall を解消する  →React


画像を圧縮して、転送量を減らす

WebPageTest によれば、ファーストビューに掛かった時間のうち最も支配的だったのがコンテンツのダウンロードにかかる時間でした。ほとんどの URI で TTFB (最初の1バイト目が到達する時間)よりもダウンロードにかかる時間の方が大きいので、これは純粋にデータサイズが大きいのが問題です。Contentful の URI から 1MB〜5MB の画像ファイルが送られており、これが極端に時間を伸ばす原因になっていました。

Contentful の画像は全て Media タブにあり、データサイズでクエリできるのが非常に便利でした。今回は 200kB という閾値を決めて、全てのアセットが 200kB 未満になるまで圧縮することにしました。ご覧の通り今は空です。

スクリーンショット 2020-04-21 14.04.18

恥を忍んでカミングアウトすると、僕はなぜか Contentful が適宜画像を圧縮してくれると思い込んでいたので、Contentful のダウンロード時間が支配的だったと知って驚きました。冷静に考えたら勝手に圧縮する Headless CMS とか嫌ですよね……。デザイナーだったらブチギレするよな……。


Code Splitting でビルドサイズを減らす

WebPageTest によれば、次に支配的なのは JavaScript の実行時間でした。改善前の JavaScript は主に3つに分割されており、ビルドサイズの比はこのようになっていました。WebpackBundleAnalyzer で可視化しています。

スクリーンショット 2020-04-21 13.47.11

図だけではなんのこっちゃと思うかも知れません。右上に注目して下さい。
Parsed size: 3.51 MB と書かれているのがお分かりいただけると思います。これは簡単に言うと JavaScript の実行サイズです。 alert('Hello World!'); というコードなら 22 bytes です。3.51 MB というのは、昨今のフロントエンド環境をご存知ない方には(いや、ご存知な方でも)巨大過ぎてドン引きするようなサイズで、実行するだけで2秒以上もタブがフリーズするほどです。

どのモジュールが支配的なのかはパッと見で分かりますが (脚注 *1)、だからと言って「Firebase ライブラリを消せばいいじゃん!」とはいきませんね。いくつかのライブラリは機能を削ることで消せるかも知れませんが、支配的でない部分をいくら削減しても焼け石に水です。そこで登場するのが、 Code Splitting というテクニックです。

Code Splitting について説明すると長くなるので、結果からお見せします。こちらは改善後に同じ方法で可視化した結果です。

スクリーンショット 2020-04-21 13.49.15

3色だったのが5色に増えているのは、 JavaScript のファイルが5つに分割されたことを表しています。おそらくですが、総量は増えているはずです。

右上に Parsed size: 2.64 MB と書かれているので、約 25 % OFF という結果になったようです。数値としてはまあまあですが、JavaScript を実行している間はフリーズしたように画面が固まってしまうため、この時間を短縮することは確実にユーザー体験を良くすることになります。

React 使いが Code Splitting を語る際に外せない Lazy Loading の話はおまけとして最後に紹介します。


CDN でキャッシュする

ハックフォープレイから提供される HTML や画像などほとんどのリソースは Cloudflare (と、一部は Google の CDN)のエッジサーバを経由して配信されています。これは極東の国ジャパンでインターネットをする私たちには非常に重要な問題です。ほとんどのリソースは us-central リージョン、即ちアメリカのデータセンターに置いてあるからです。テレビで喩えるなら、
「現地のアメリカと中継が繋がっています。〇〇さ〜ん!」
「…………あっ、はーい!こちらアメリカの〇〇です!」
みたいなやり取りを行っているのです。この喩えで言うなら、エッジサーバというのは、事前にアメリカで収録された動画を日本のスタジオで再生するようなもので、いくらか制約があるものの、大きく遅延が軽減されます。

名称未設定のノート-2

通常、 HTML は Firebase Hosting にデプロイするだけで Google CDN の恩恵に預かれますが(脚注 *2)、ハックフォープレイでは HTML を配信する際に OGP タグを付与するなどの目的で SSR をしているため、 Cloud Functions でリクエストを処理する必要があります。この場合は firebase.json ではなく関数内部でヘッダを付与する方が良いです。firebase.json では 2xx 以外のレスポンスを返す場合でも同じヘッダを付与してしまうからです。一時的なエラーで 500 を返したレスポンスがエッジサーバにキャッシュされる以上の悲劇はありません。(経験者は語る)

firebase.json は罠が多いので Firebase Hosting の OSS である SuperStatic の実装はなるべく読みましょう。JavaScript なのでフロントエンドエンジニアにも読めるはずです。PaaS を使えばバックエンドの知識がなくてもサービス運用できるとかほざいてる人はモグリかポジショントークのどちらかです。

HTML を CDN から配信する時は、セッションを共有しないよう注意が必要です。ハックフォープレイでは認証系を全てステートレスにしているので、エッジサーバにキャッシュしても大丈夫だと思うのですが、実は不安です。

スクリーンショット 2020-04-21 15.44.19

index.html の TTFB が 900ms から 33ms になったのは嬉しいけど、こんなうまい話があっていいのだろうかと逆に気を揉んでいたりもします。何か、重大なことを見落としているのではないだろうか……


(おまけ)  Lazy Loading で Waterfall を解消する

Code Splitting の章で JavaScript を分割したと言いましたが、具体的に何をどう分割したのか、まで書かなければ実用的なアドバイスにはなりません。そこで、おまけとして今回行った Code Splitting の詳細と、 Lazy Loading について僕なりの理解を述べます。

ハックフォープレイにはオリジナルの Web IDE のようなものが搭載されています。依存モジュールも含めたビルドサイズは 1MB 近くなりますが、これは「プログラミングをしない画面」では一切使いません。開発当初はサービスと密結合だったのですが、コンポーネントとデータストアを分割するなどの工夫で疎結合になるようリファクタリングすることが出来ました。そこで、この Web IDE の部分を Code Splitting の最初のターゲットに決めました。

Web IDE は React Component なので、ロードが完了するまで描画出来ません。さて、コンポーネントはいつロードを始めるべきでしょうか。ユーザーがプログラミングする画面に移行した時、即ちナビゲーションのタイミングでしょうか?それとも、トップページを開いた直後でしょうか?

分からない時は、もう一度解決すべき問題に立ち返るべきです。何故 Code Splitting するのでしょうか? それは JavaScript の実行中にレンダリングがブロックされるのを防ぐためです。転送量を減らしたいのではないというのがミソです。ナビゲーションのタイミングでロードを開始する前者の方法はユーザーを待たせるタイミングを先送りしているだけで、プログラミングをしたいユーザーの待ち時間は今よりも長くなってしまいます。(図・上)

名称未設定のノート-2 2

この問題は、Waterfall と呼ばれているそうです。ある React Component がマウントされるまで非同期リクエストを始められず、次の React Component もマウント出来ないので、その次の非同期リクエストも始まらず、結果的に並行処理した場合と比べて非常に時間がかかってしまうのです。

開発者は、ただ JavaScript を分割するだけで良いのです。あとはブラウザがユーザーの環境に適した賢いやり方(マルチスレッド処理など)でロードと実行のタイミングを制御してくれます。という訳で、正解は、後者のトップページを開いた直後にロードを開始する方が良いということになります。

ところが新たな疑問が生まれます。トップページを開いた直後に開始された非同期処理が完了したことをどうやって React Component に伝えれば良いのでしょうか? 言い換えれば、React Component の外から State をセットするという”御法度”(脚注 *3)を、どのように許容すべきでしょうか?

React コミュニティは React 16.6.0 (October 23, 2018) のリリースで、 React.lazy と React.Suspense という解決策を示しました。まだコンセプト的な意味合いが強く、React コミュニティで実際に使いながらより良い方法を模索している段階だそうですが、既に十分実用に耐え得る素晴らしい実装だと僕は思います。詳しくはこのツイートに書いてあるので、割愛します。

話が逸れたので元に戻します。React がコンポーネントを非同期に取得するためにとった方法は、「非同期コンポーネントの親となるコンポーネント」を作ることでした。親コンポーネントは子要素のレンダリングを制御できるので、ロードが完了していない場合は代わりの fallback を描画するといったことが可能です。要するに ErrorBoundary の進化系で、簡単な実装なら多分十数行程度で書けると思います。React が export している関数があるので、特に理由がなければそれを使いましょう。非同期コンポーネントを作る関数が React.Lazy で、親となるコンポーネントが React.Suspense です。

import  * as React from 'react';

// 非同期コンポーネントの先読み
const IDE = React.lazy(() => 
  import(
    /* webpackChunkName: "ide" */
    /* webpackPrefetch: true */
    '../ide/Root.tsx'
  )
);

// プログラミングする画面
export function ProgrammingView() {
  return (
    <React.Suspense fallback={<div>Loading...</div>}>
      <IDE />
    </React.Suspense>
  );
}

Waterfall の問題は、 Lazy Loading 以外にも潜んでいる可能性があります。特にデータストアを分離していないプロジェクトではルート Component のコード量が肥大化しないために非同期リクエストの結果を子要素の State に持たせることが多いと思います。そのような実装では、重たい非同期処理がネストしていき、いずれは描画パフォーマンスに大きな影響を及ぼします。


さて、ウェブページの読み込みを高速化するために僕が行った3つの方法を紹介しました。昨今では家庭に光回線を引いていない方も多くいらっしゃるということを、ウェブ開発者はもっと憂慮すべきです。あなたのオフィスは(あるいは WFH しているご自宅は)高速インターネット回線を引いているでしょうが、ユーザーの環境も同じだとは限らないのです。

Chrome DevTools を使うとモバイル並の遅い回線を再現できるので、ウェブ開発者の方は、ぜひ一度試してみることをお勧めします。

スクリーンショット 2020-04-21 17.41.38



脚注

*1 どのモジュールが支配的なのかはパッと見で分かりますが
パッと見で分かると思わせて、実は錯覚です。もし仮に、それぞれの面積がデータサイズを正確に表していたならば、こんなに綺麗に整列していませんし、文字だってほとんど潰れて読めなくなっているはずです。ただし大きさの順番は分かるので、非常に参考になることは変わりありません。

*2 デプロイするだけで Google CDN の恩恵に預かれますが
エッジサーバにキャッシュを許可するには firebase.json の hosting.headers に Cache-Control: public を追加する必要があります。デフォルトの設定ではエッジサーバにキャッシュされませんが、デフォルトでもそこそこ早いので何かしらの Google Magic が働いているのかも知れません(?)

*3 React Component の外から State をセットするという”御法度”
誤解のないように言っておくと、これは React の”エンドユーザー”に対して言った表現です。一部の Redux middleare などのライブラリは外から State をセットします。これらはごく稀に問題になることがあるという程度です。

この記事が気に入ったらサポートをしてみませんか?