スタディサプリ Product Team Blog

株式会社リクルートが開発するスタディサプリのプロダクトチームのブログです

Cloudflare Workers を使って prerendering した App Shell を返してみる

こんにちは。最近は主に frontend を書いている @banyan です。

現在私達のチームではスタディサプリの Web の生徒アプリケーションをリニューアルしています。 この夏に合格特訓コースというプランに絞ってベータ版という形でリリースして、これから段階的に全面リニューアルに向けて開発を進めていきます。

技術スタックとしては React + Redux + TypeScript + Workbox (Service Worker) です。 SSR に関しては、SNS での流入がないことや、ページが基本的に生徒のデータを扱いキャッシュしにくいサイトのため、採用しませんでした。

またこの記事も参考にさせて頂き、

Does your app require a sign-in to view most content (e.g. GMail)? If so, you don’t need SSR. Build a static app shell that pops up the login page with as fast of Time-To-Interactive as you can, and preload all of your app’s assets and scripts while the user types in their credentials. Use a service worker to cache your app shell so subsequent loads remain super-speedy.

基本的にはログイン画面でその後の画面で必要になる JavaScriptCSS などをすべて precache しています。 Webpack4 の maxsize option も使って HTTP/2 のメリットを享受しています。*1

ただし、First Meaningful Paint (FMP) に関してはこの方法では最適化ができません。 JavaScript を download して、parse して execute するまでの時間が client side でかかってしまうからです。

この問題を解決するために SSR を行うのがよくとられる手法だと思いますが、今日は試しに Cloudflare Workers を使って prerendering ができないかということを試してみました。同じことは Fastly でも実現できると思いますが Cloudflare Workers を触ってみたかったということがあります :) Cloudflare Workers は CDN の Edge 上で Service Worker のような JavaScript を実行できる環境です。

prerendering とは何か?

prerendering とはいろいろな定義があると思いますが、今回指すのは webpack などのビルドツールで、ビルド時に静的な HTML を生成する方法です。 この方法自体は新しいわけでもなく昔から使われてきました。

PWA の文脈で App Shell モデル という考え方があります。

アプリの「シェル」とは、ユーザー インターフェースが機能するために必要な最小限の HTML、CSSJavaScript です。これらをオフラインで使用できるようにキャッシュしておくことで、ユーザーが同じページに再アクセスした際に、瞬時に高いパフォーマンス が発揮されます。つまり App Shell は、ユーザーがアクセスするたびにネットワークからすべて読み込まれるわけではなく、必要なコンテンツだけが読み込まれます。

App Shell モデル

ただし、App Shell は以下のような制約もあると考えます。

App Shell アーキテクチャは、ナビゲーションには比較的変更がなく、コンテンツが変更されるアプリやサイトに最適です。

私達のサイトには認証があり、また問題を解く画面とその他の画面ではナビゲーションも変わってきます。*2

ダッシュボード 問題を解く画面
image image

そのため現実的には Service Worker を使って App Shell の cache を持つのが難しい側面があります。

その App Shell をあらかじめ prerendering しておいて CDN の Edge で判断して直接返すということをしてみます。 今回は認証前と認証後のデザインが異なった簡単なアプリケーションを用意しました。ログインボタンを押すと cookie に値をセットしてログインとみなすだけのアプリケーションです。

今回は react-snap を使って、prerendering 用の HTML を用意します。*3 また inline CSS はすべて html 側に最初から書いてあります。本来であれば critical css や、webpack などを使って動的に inject する必要があります。 認証前の login-appshell.html と認証後の main-appshell.html になります。

Cloudflare Workers 側には以下のような handler を用意します。

async function handleRequest(request) {
  const parsedUrl = new URL(request.url)
  let path = parsedUrl.pathname

  const cookies = request.headers.get('Cookie') || ""
  const lastSegment = path.substring(path.lastIndexOf('/'))
  const isRoot = lastSegment === '/'

  if (isRoot && cookies.includes("test-logged-in=1")) {
    path += 'main-appshell.html'
  } else if (isRoot) {
    path += 'login-appshell.html'
  }

  return fetch("http://appshell-with-edge.s3-website-ap-northeast-1.amazonaws.com/" + path)
}

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

そもそもアプリケーションが小さいためそのままでも速いため、以下のように少し delay をいれます。また今回の検証には必要ないため Service Worker も無効にしています。

window.setTimeout(() => {
  render(<App />, rootElement)
}, 1000)

検証結果

Before

lighthouse の Simulated Fast 3G, 4x CPU Slowdown で試しています。

image

After

image

まとめ

この例だとアプリケーションが小さいため差は僅かですが、FMP が改善できました。 また今回は実装していませんが Cloudflare Workers から API を直接叩けるため、ユーザ毎に違った App Shell も用意することができそうです。 Production で使うかどうかは別として、もし将来 Cloudflare Workers や他の CDN がもっと使いやすくなったら、Edge がもっとカジュアルに BFF になるような未来がある気がして楽しいなと思いました。

Cloudflare Workers についてのメモ

  • root domain を設定してネームサーバーを設定する必要があります。サブドメイン単位で設定できると使いやすいので残念です。😢
  • Cloudflare Workers には sandbox はありますが無料で使えるドメインがありません。これも少し敷居が高くなりそうです。
  • 1000万リクエストまで5ドルと金額は明瞭ですが、staging 環境と production 環境がない
  • Cloudflare Workers ではまだ DOM を parse することはできないようです。Hack 的に cheerio を使った記事がありました。

*1:ただ HTTP/2 が使えないブラウザに関しては、やはり遅くなってしまうので利用率を鑑みながら、Browser based build/bundles が必要になってくるかもしれません。

*2:またユーザのタイプ毎にナビゲーションが違うのと、更にグローバルのインドネシアやフィリピンなどでのサイトではデザインも変わってきます。

*3:react-snap に似たようなライブラリは https://github.com/stereobooster/react-snap/blob/master/doc/alternatives.md にあるようです。まだ安定はしてませんが、 preact の作者が作った prerender-loader もあります。