本セッションの登壇者
セッション動画
古川と申します。@yosuke_furukawaでTwitterなどをやっております。

SPAは”見えるようになるまでが遅い”
私が今回お話するのは「MPA化するSPA」です。もともとSPAは、画面遷移(トランジション)をアプリケーションに合わせて最適化することを目的として発展した技術だと思っています。変更が発生したところだけレンダリングすることで高速化するテクニックだったのが、それをすべてのページで行うことで全体のUXを上げる - こういうふうに発展してきたのかなというところですね。

もうちょっとざっくり話をすると、ブラウザから何かしらのリクエストが発生してサーバからHTMLが返ってきます。そのときのHTMLにはまだ何も中身が入ってないので、中身のHTMLをもう1回構築するためにJSを取ってきて、JSが読み込まれて、最終的に表示がされて、そのときには操作もできるというのが、シングルページアプリケーションのクラシックな動きですね。

シングルページアプリケーションの問題点としては、見えるようになるまでが若干重たく、遅いです。最初のタイミングでHTMLが返ってきているんだけど、コンテンツがあまり見えなくて、JSが読み込まれて初めて見えるという動きです。そのかわり、この後は速い。これがSPAの一番のポイントですね。つまり、最初は遅いんだけど、その後は速いのが特徴です。

この問題(見えるようになるまでが遅い)を解決するためにNext.jsなどが取っているアプローチがSSR / SSG / ISRです。これらはすべて最初のHTMLを返す時点で内容も含めて返すというアプローチで、SSRはリクエストのときに作るし、SSGは事前に作っておくもの、ISRは事前に作っておいて後から更新するもの(ハイブリッドな動き)ですね。
要は、HTMLを何かしらのタイミングで作っておいて、それを返すということをやれば、どっちもいいとこ取りできるじゃん、という技術です。

MPA技術を使ってSPAを作る - MPA化するSPA
SSRは普通にMPA(マルチページアプリケーション)でやっていたことなんで、MPAの技術を使ってSPAを作っている、これがMPA化するSPAのひとつの事例ですね。
どういうことになってるかというと、何かしらリクエストが走ります。クラシックなSSRを例に挙げると、SSRを実行して、実行が終わったらHTMLが返ります。その時点ではすでに描画されていて、その上でもう1回JSをロードするためにJSを取ってきてロードされます。こんな動き(下図)になります。

この時点でだいぶMPAとSPAのいいとこ取りをしていて、MPAの技術であるHTMLをサーバサイドでレンダリングするのと、ロードされた後はSPAの技術でハイブリッドな戦略を取る感じになっています。SPAだったけど、徐々にMPAに近寄っているなと…いう印象を持っていただければいいかなと思います。

ただし、問題点が2つあるんですよ。ひとつは、SSRを実行したときの時間が伸びていること、もうひとつの問題がHTMLが描画されてからJSがロードされるまでの時間です。この2つに時間とコストがかかってしまいます。
問題1 - SSR実行時の時間とコスト
SSRを実行しているときって、毎回リクエストしてる最中のオンザフライで作るというのはけっこう問題も大きくて、たとえばイベントループを止めちゃうとか、CPUコストが高いとか、結果、サーバからHTMLが返るまでのコストがかかってしまいます。
かといってSSGならどうかというと、SSGは事前に全部作らないといけないので、たとえば30万ページあれば、30万ページ分を一気に作らないといけないので、あまり現実的ではないです。
ではISRはどうかというと、結局これはキャッシュなので、最初にリクエストしたときに作り直す時間がかかってしまいますし、かつ、revalidateと呼ばれるキャッシュを再検証する期間をどうするかというのも問題のひとつなんですが、これを長くすればするだけ反映される時間は遅くなってしまうし、短くすると今度はSSRと同じになってしまう。キャッシュライフサイクルという問題に移し替えればできますが、本質的には変わらないです。ちなみにこれだけだったらCDNとSSRだけでできてしまうので、ISRだからといってソリューションになっているかというと、ワークアラウンドのひとつでしかないです。

(問題1については)いまのところ根本的な解決策はなくて、キャッシュを持つ、もしくはエッジサイドに持っていってなんとかしようとしているフシがある。React 18とNext 12ではStreaming SSRなどの方法もあって、これにより「でき上がったところからちょっとずつ返せばいいんじゃね?」っていう作戦もあるので、その辺もひとつのポイントかなと思います。

Streaming SSRというのは、リクエストが来てから、SSRでHTMLを返すときに全部一気に作って返すんじゃなくて、でき上がったところからちょっとずつ返す感じです。HTMLの描画が開始されてから終わるまでの体感時間を短縮します。

問題2 - JSがロードされるまで「見えるのに押せない!」
JSがロードされるまで操作できない - これは見えるのに押せないという問題があるのですが、これが今のところフレームワーク界隈ではホットトピックだなと思っています。LCP(重要なコンテンツが見えるまで)は速いけど、そこからTTI(操作可能)になる時間が遅いという状況が今生まれています。ここをなんとかしないといけないところです。

そもそもbundle.jsのサイズがでかいから削ろうぜ!という話ももちろんあります。たとえば、Next.jsではある程度ページごとやライブラリのサイズやどこから使われているかなどを判断して、最適化したcode split(JavaScriptのコードを削って部分的にしてくれる)をしてくれるということができます。ただ、現時点での最適化はこの状態どまりです。

React陣営に関しては、もっと積極的なアプローチを取ろうとしていて、Selective Hydrationを発表しています。2019年のGoogle IOでは、Progressive Hydrationと呼ばれていましたけど、要は部分的に使われているところだけ利用可能にしていくアプローチがあるんじゃない?という話があります。

すべてのコンポーネントに対して一気にHydrateするのではなくて、段階を踏んで少しずつやっていきましょうというのが、Selective Hydrationの流れになっています。こうすることによって、Hydrationもレイアウトも同じように少しずつでき上がったところからやっていくのが良いんじゃない? というのがReactのアプローチです。

ここでちょっと待った! です。Reactはこれで良いと思っているとしてもちょっと待った。

そもそもなんでそんな時間がかかるんですか? という話なんですよね。そもそもなんでそんなにHydrationとかをやるのに時間がかかるんですか? そもそものやり方から見直しませんか?
新しいアプローチ - AstroとQwik
ここで新しいアプローチを2つ紹介します。まず、そもそもなんでそんなに時間がかかるのっていうと、まずJSが重いからですよね。そしてJSがロードされた後にイベントハンドラやサーバで作られた状態をコンポーネントに登録するのに時間がかかる(これをHydrationといいます)、この2つが問題になっている。

1つ目の対抗馬がAstroになります。Astroというのは、Next.jsとはまったく別のアプローチで、MPAを基本としています(Next.jsはSPAを基本としています)。基本アイデアはReact、Vue、Preactなど何を使ってもいいんですけど、そのかわりサーバサイドでレンダリングするのを基本としていて、クライアントサイドでのJSは基本的には作りません。なので、普通のMPAです。

それじゃインタラクションがあるような部分的な更新が必要なところはどうするの? という話なんですが、インタラクションがあるコンポーネントだけを狙って、そこにだけ「JSロードが必要です」という特別なフラグを置いて、そこだけHydrationすればいいというアプローチです。ほかにもいろいろありますが、これがAstroの基本的なアプローチです。

あとはエッジに置くこともサポートしているので、SSRで遅いという問題もエッジに置いて、分散されたノードでやればCPUの負荷を分散させることができるんじゃない?という話があります。

Astroの解決策は、SSR遅い問題は「Edgeに置けばいい」し、JS遅くてTTI遅い問題は「そもそもJS配信しなければいい」し、イベント登録できなくてという問題は「狙ったところにだけJS読み込ませればいい」というアプローチを取っています。

もうひとつの対抗馬のQwikも同じです。QwikはそもそもMPAで、Hydrationなんてそもそもいらないだろという立ち位置です。

(Qwikの)言いたいことはこの図に詰まっています。

つまりQwikも、基本的にMPAで、Hydrationしないものです。

この特徴を以下のようにいろいろ書き換えてみたんですけど、何を言いたいかというと、Reactのコンポーネントを書いて、DOM上にイベントハンドラを読み込ませるためのJSを置きます。イベントが起きたらそのタイミングでそのJSを取ってくるみたいなことをします。


なので、押されるまでJSはロードされてないんです。押されて初めて初期起動していく - かなりオピニオンが強いフレームワークです。

というわけでQwikに関してはいろんなことをやっています。
AstroとQwikはどちらかというとSPAを作ってMPAを作っているところですね。このあたりは動きがあるところなので、続きはディスカッションで話させてください。ありがとうございました。