🛠️

集合駅検索サービス KokoneでNext.js App Routerを大活用してリファクタリングしてみた (RSC/Streaming)

2024/05/09に公開

この記事で話すこと

この記事では、集合駅WEB検索サービス Kokoneで、Next.js App RouterのReact Server Component(RSC)を活用できる様にリファクタを進めた中で検討したことや結果について共有できればと思います!

恐らく今回のリファクタでSever Component(SC)とClient Component(CC)を分離する上で、躓くであろう壁にはほとんどぶつかりワークアラウンドができたのではないかなと思っています😅

現状App RouterでSCが活用できてない方CCとの分離で悩まれている方に有益な情報を共有できたらと思っています!(もっといい方法あるよ、という方是非是非コメント頂けると非常に嬉しいです!)
また、Next.js App Routerに興味があるけど使ったことない方Pages Routerから移行できてない方も参考になる記事になればと思っていますので、ぜひご一読ください!

(想定してたより大分長い記事になりましたので、目次から興味のあるところだけでも読んでもらえると嬉しく思います!)

背景

以前こちらの記事でご紹介しましたが、
2023年12月にKokoneという集合駅検索WEBサービスをリリースしました!

https://kokone-app.com?utm_source=zenn&utm_medium=referral&utm_campaign=sc-migration-article-middle

KokoneではNext.js App Routerを利用していますが、
一旦開発速度を優先し、Server ComponentStreamingといった多くのApp Routerの旨みを諦めて、全てClient Componentの状態で初期リリースをしていました。

今回、それらの負の遺産を解消する取り組みとして、
改めてApp Routerらしいコンポーネント設計から見直しを行い、
可能な限りApp Routerの機能の恩恵を得られる様にリファクタリングしてみましたので、その検討事項や躓きどころや結果、所感などをこちらの記事でまとめていきたいと思います!

リファクタリングの結果

リファクタリングの詳細は後述しますが、
Client Component(CC)からServer Component(SC)へ移行し、
App Routerの機能を可能な限り活用することで、Lighthouseのスコアにおいて、下記の様な改善が見られました!

体感としても、実際に操作が非常に軽量になった様に感じられました!
(スコアは未チューニングなのでまだまだ低いですが単純なSC利用による改善のご参考までに🙇‍♂️)

パフォーマンスのAsIs/ToBe
WEBパフォーマンスの改善

ちなみに、、、すっごく余談ですが、こちらのLighthouseのスコアは、
下記の様なyamlファイルを.github/workflows配下においておくだけで、
簡単に無料で継続的に計測してくれるため、導入しておいて損はないかと思います!

Lighthouseワークフローのyamlファイルサンプル
.github/workflows/lighthouse.yml
on:
  push:
    branches:
      - main
jobs:
  lighthouse:
    name: Existing Web Performance
    runs-on: ubuntu-latest
    steps:
      - name: Cancel Previous Runs
        uses: styfle/cancel-workflow-action@0.11.0
        with:
          access_token: ${{ github.token }}
      - uses: actions/checkout@v2
      - name: Audit URLs using Lighthouse
        uses: treosh/lighthouse-ci-action@v9
        with:
          urls: |
            https://kokone-app.com
            https://kokone-app.com/?departures=1130101&departures=1130208&type=drink
          uploadArtifacts: true # save results as an action artifacts
          temporaryPublicStorage: true # upload lighthouse report to the temporary storage

リファクタリングの詳細

App Routerでは、Server ComponentStreamingといったReact v18を利用した新たな機能が提供されました。

今回、Kokoneでは、これらの機能について理解を深めつつ、
新しい機能をなるべく活用してリファクタリングを進めて見ましたので、その内容を共有できればと思います!

Server Component(SC)の理解

Pages Routerを使われていた方で、App Routerを実際のサービスに使用して見ようと試みた方は、
まずServer Component(SC)とClient Component(CC)の分離でつまずくのではないかなーと思いました。(実際ぼくはここでかなり躓きました😂)

早速このSCとCCの分離についてのお話しを始めたいところなのですが、
まずは、SC/CCにおけるServerClientについてイメージを擦り合わせておきたいと思います!

"Server" / "Client"のイメージの擦り合わせ

"Server" Component(SC)は名前の通り、全てサーバー側で実行されるコンポーネントです。
Client(ブラウザ)側へJavascriptで記述されたソースコード(バンドル)は送信されず、サーバー側でRSC(React Server Component)ペイロードと呼ばれる形式にレンダリングされ、ブラウザに配信されます。

一方、
"Client" Component(CC)は名前と異なり、実はサーバーサイドレンダリング(SSR)されるコンポーネントです。
SSRのプロセスは従来のPages Routerと同様の理解で、
HTMLがSSRされ、DOMとバンドル化されたJavascriptとして、ブラウザ(ブラウザ)側に配信されます。
その後、Hydrationと呼ばれる処理によってイベントハンドラーなどがブラウザ上でコンポーネントに紐づけられブラウザ上で有効になります。

参考:

では、SC/CCについてざっくりのイメージが分かった上で、それぞれのComponentの特徴を考えていきたいと思います。

Server Component (SC)の特徴

https://nextjs.org/docs/app/building-your-application/rendering/server-components

App Routerで定義したコンポーネントはデフォルトでServer Component(SC)として扱われます。
SCは、サーバー側のみでレンダリングされるため、下記の様な特徴があります。

  • Clientに情報が漏洩しない (トークンやキーなど、シークレットがClientに送信されない)
  • サーバー側のキャッシュを有効活用できる
  • Streaming(Suspense)が利用できる (準備ができたコンポーネントから順に配信)

App Routerでは、パフォーマンスの向上やSEO、セキュリティの観点より、基本的には可能な限りServer Componentを活用することが推奨になります。

更には、Server Componentでは、Streamingを活用することにより、初期レスポンスの大幅なパフォーマンス改善が期待できます。

Streaming(Suspense)の活用によるパフォーマンス改善

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

Server Component(SC)では、Server Streamingという機能を利用することができます。
個人的にこの機能がApp Routerを利用する一番の旨みな気がしてます。

Server Streamingを利用することで、FCP(First Contentful Paint)を大幅に改善することができます。FCPは最初のコンテンツがレンダリングされるタイミングを示します。これは、実際にユーザーが見ている画面に情報が反映されるタイミングを意味するため、非常に重要なUXのパフォーマンス指標です。

従来のNext.js(Pages Router)のSSRにおけるレンダリングフローでは下図の様なシーケンシャルな処理が必要でした。


従来のNext.js(SSR)におけるレンダリングフロー

一方、App Routerでは、
Server Streamingを上手く活用することにより、下図の様に上記のシーケンシャルな処理をコンポーネント毎に分離することが可能になります。従って、情報取得のないコンポーネントはすぐに描画し、時間のかかるコンポーネントは情報取得完了次第描画させるといったことができる様になります。


Next.js App Router(React Server Component)におけるレンダリングフロー

Server Streamingをするためには、Suspenseというコンポーネントにより非同期で処理したいコンポーネントをラップする必要があります。
Suspenseは内部でPromiseが解決するまで、ラップしたコンポーネントの描画の代わりにfallbackで指定されたコンポーネントを返却するため、ユーザーはサーバー側の情報取得を待つことなくすぐにフィードバックを受けれることになります。

イメージが湧きづらいかと思うので、実際に下記のサンプルを見てもらえればと思います。
サンプルでは、SomethingHeavyComponentが実行されており、
従来のSSRの様なやり方ではサーバー側で3秒かかる処理を待つために、ページ全体の返却に3秒時間を要していました。
しかし、下記の例では、Server Streamingを利用しているため、
ページアクセス後すぐにページが表示され、3秒待っている間はfallbackに定義しているコンポーネントが表示され、SomethingHeavyComponent内のPromiseが解決されるとSomethingHeavyComponentが描画されているのが分かるかと思います。

import { Suspense } from 'react'

const SomethingHeavyComponent = async () => {
  await new Promise((resolve) => setTimeout(resolve, 3 * 1000))
  return <div>3秒経過しました</div>
}
export default async function Page() {
  return (
    <section>
      <div>ここはすぐ描画されます</div>
      <Suspense fallback={<p>3秒待っています...</p>}>
        <SomethingHeavyComponent />
      </Suspense>
    </section>
  )
}


Streamingの簡単なサンプル

Client Component(CC)の特徴

続いて、 Client Component(CC)についてのざっくり説明です。

https://nextjs.org/docs/app/building-your-application/rendering/client-components

ファイルの先頭にuse client;をつけることで、
Next.jsはコンポーネントをClient Component(CC)として扱います。
CCはNext.js Pages Routerを使ったことある人にとっては、基本的には従来のコンポーネントと同様と理解して頂いて問題ないかと思います。

つまり、CCでは下記の様なことをすることができます。

  • React Hooks (useXxx)が利用できる
  • ステートが管理できる
  • onClick, onFocus, onBlur, ...etc などイベントハンドラーを利用可能
  • ブラウザのAPIにアクセスできる

App Routerでは、基本的な思想としては、
CCはインタラクティブなコンポーネント、つまりユーザーのアクションによる処理(onClickやステータスの変更、UIの変更)がある場合に利用するイメージになります。(ステートやブラウザのAPIを利用したい場合にもClient Componentを利用します。)

SCとCCの使い分け

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#when-to-use-server-and-client-components

上記の公式ドキュメントにSC/CCの使い分けについて、分かりやすい表が載っていたので、こちらに載せておきます!


SC/CCの使い分け

それでは、
CC/SCに関してざっくりと理解できたところで、
ようやく本題であるSever Component(SC)とClient Component(CC)の分離について、お話ししていきたいと思います!

Sever Component(SC)とClient Component(CC)の分離

上記でSCとCCの特徴や制約について色々と説明しましたが、シンプルにいうと、

  • SCはサーバー上で動くステートレス(状態を持たない)なコンポーネント
  • CCはブラウザ上で動くステートフル(状態を持つ)なコンポーネント
    という説明が、個人的にはしっくりきています。

しかし、実際にApp Routerを使ってみた方はご存知かと思いますが、
このSCとCCは単純に分離できるものでもありません。。。

Client Component(CC)で定義された子コンポーネントには、Sever Component(SC)を読み込むことができないというやっかいな制約があります。
つまり、AppRouterには明確な境界があり、一度CCを親に定義してしまうと、本来はSCで記述したいコンポーネントであっても、SCで定義することが不可能になってしまいます。更に言うと、SC→CCはPropsを介してステートを受け渡すことができますが、CC→SCへはステートを渡すことはできなくなります。(クエリパラメータやCookieを利用して、SCからサイドレンダリングさせることは可能です。)

これらの制約により、これまでPages Routerでは、再レンダリング再利用を考慮したコンポーネント設計が有効でしたが、App Routerではデータ取得描画遅延範囲などSCとCCの境界を考慮したコンポーネント設計が必要となる様に感じました。

では、実際にKokoneで、どのようにしてSCとCCを分離したのかをご紹介します。

KokoneにおけるSCとCCの分離

Kokoneでは、今回のリファクタにより、下記の様にSCとCCの分離をしています。
(僕たちのチームでは、コンポーネントはファイル名でSC or CCが判別できるように、.server.tsx / .client.tsxといったネーミングルールを用いました。)

KokoneにおけるSC/CCの分離対応図
KokoneにおけるSC/CCの分離対応図

この分離にあたり、下記の様な課題がありましたが、それぞれについてKokoneでの解決策を共有していきたいと思います!

  • 課題①. ステートの伝搬ができないから親コンポーネントをCCにする必要がある。。。
  • 課題②. ステータスを持つコンポーネント(CC)が親コンポーネントにある。。。

また、Streamingを活用する上で下記の様な課題もありました。

  • 課題③. 各コンポーネント毎にローディング表示させたいけど、結局上位階層のコンポーネントでプロミスが解決(await)されてしまう。。。

課題①の解決策: グローバルステートの活用によるSC/CCの分離

ステートの伝搬ができないから結局親をCCにする必要がある。。。

=> この問題はグローバルステートの導入により解決することができます!


Global Stateを用いたStateの伝搬

上の図を見て頂いても分かる通り、
App Routerでは大元の親コンポーネントは必ずSC(上図緑枠)となるため、
ステートを持つことができず、SCを上手く活用すればするほどCCコンポーネント間でステート伝搬が難しくなります。
(実際Kokoneでは初期サービス開発時、ヘッダーと検索ドロワー間で共通で持ちたいステートの管理ができなかったため、SCの利用を諦め全てCCで作っていました。。。😂)

この様なステート伝搬問題はグローバルステートを用いることで、解決することができました。
コンポーネントツリーを超えて、CC同士でグローバルステートをやり取りすることで、ステート伝搬に依存せずSCを分離することができます。

App Routerにおけるグローバルステートライブラリ

2024年時点で調べた限りでは、AppRouterで利用されるグローバルステートライブラリは主に下記の3つありました!


https://npmtrends.com/jotai-vs-nrstate-vs-zustand

  • jotai: Atomベースの状態管理ライブラリ
  • zustand: Stateベースの状態管理ライブラリ
  • nrstate: SCとCC間でグローバルにステートを共有できるライブラリ
    • 一見SC/CC問題を多く解決する様でいいなと思ったのですが、SCはステートレスにするべきというAppRouterの思想に反しており、キャッシュ効率などが悪くするため、利用はやめておきました🙇‍♂️

それぞれ、ざっくり上記の様な特徴がありますが、Kokoneではzustandを採用しています!
Reduxの様にStoreを作るための複雑な定義は必要なく、簡単にグローバルステートを作成でき、オプション一つでユーザーのローカルストレージと同期させることもできるため、非常に使い勝手がいい様に感じました。

(Atomベース vs Stateベースについてはこちらの記事が非常に丁寧で分かりやすかったです🙇‍♂️)

課題②の解決策: Composite Componentパターンの活用によるSC/CCの分離

ステータスを持つコンポーネント(CC)が親コンポーネントにある。。。

=> この問題はComposite Componentパターンを用いることで解決することができます!


Search Drawerのコンポーネント構成

図右側にある検索ドロワーはSearchDrawerというコンポーネント内で定義しています。
SearchDrawerは、ユーザーのアクションによりトグルさせたいため、CCでステータスを保持する必要があります。しかし、検索ドロワーのコンテンツ自体は情報を待たずとも、すぐに描画させたいためコンテンツはできればSCとして定義したいという課題がありました。

App Routerでは、Client Component(CC)はServer Component(SC)をimportすることはできませんが、実はComposite Componentというパターンを用いることができます。これは、CCのProps (children)としてSCを受け渡す実装の仕方です。

このパターンを用いることで、ドロワー内のコンテンツをSCに保ったまま、CCでドロワーがオープン/クローズかのステータスを保持することができる様になります。

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#supported-pattern-passing-server-components-to-client-components-as-props

課題③の解決策: PromiseをPropsに渡すことによる細やかなStreamingの実現

各コンポーネント毎にローディング表示させたいけど、結局上位階層のコンポーネントでプロミスが解決(await)されてしまう。。。

=> この問題はコンポーネントに渡すPropsをPromiseにすることで、細かいStreamingの制御が可能になります!

こちらで説明の通り、App RouterではStreamingを活用することにより大幅なUX/パフォーマンス向上が期待できます。しかし、遅延描画させたいそれぞれのコンポーネントでSuspenseするためには、Suspenseでラップしたコンポーネント下でPromiseを返してあげる必要があります。

実際に理想的なStreamingを目指して、Componentを分離していくと、データソースは1つなのにStreaming表示させたいコンポーネントが分かれているため、どこでデータ取得をさせるかが非常に難しいポイントになります。

Kokoneでの具体例を示すと、KokoneではAPIから取得するデータとして下記の3つがありました。

データ 利用箇所 条件
#1 駅情報データ トップ(検索/結果)ページ全体 -
#2 中間駅情報データ トップ(結果)ページ全体 クエリパラメータに出発駅情報が存在する場合
#3 駅周辺情報データ トップ(結果)ページ > 駅周辺情報 中間駅情報が存在する場合 (#2に依存)

Kokoneは検索UIをドロワーに持たせており、トップページ(/)で検索と結果確認のアクションをさせています。
ページアクセス時に駅情報データを取得(#1)し、出発駅情報がクエリパラメータに存在する場合、中間駅情報をAPIから取得(#2)し、取得した中間駅の周辺情報をまた別のAPIから取得(#3)しています。

従って、下記の様にデータソース毎にStreamingさせるのが理想的でした。


データ取得と理想的なStreamingの対象コンポーネント

ただ、これらを普通にStreamingさせようとすると、#1,2,3の親SCコンポーネントでプロミスを解決する必要が出てきます。

一方で、上記の赤枠だけを細やかに遅延表示させるためには、
上記赤枠で囲ったそれぞれのコンポーネントがデータ取得中にPromiseを返してくれる必要があります。

下記の公式ドキュメントにある様に、Request Memorizationという機能により、
各コンポーネント内で直接fetchを定義してもパフォーマンス上問題ない様にはなっている様なのですが、
Next.jsの暗黙的なキャッシュを完全に理解仕切れていない現段階では、
APIによってはダイナミックなものがあったり、キャッシュに関する深い理解やテストがないと、意図しない動作が起こる懸念がありました。
https://nextjs.org/docs/app/building-your-application/caching#request-memoization

そこで今回Kokoneでは、この課題への解決策として、
親Componentでfetchした結果をawait(プロミスを解決)せずに、
Promise型のインターフェースとして、Propsにそのまま受け渡すことで、各コンポーネント内でプロミスの解決を実現でき、細やかな<Suspense>の制御を実現しています。(下記のイメージ)

sample.tsx
interface ChildProps {
  pData: Promise<Data>
}
const ChildComponent: FC<ChildProps> = async ({ pData }) => {
  const data = await pData // ここでawait
  return (<Something data={data}>)
}

const ParentComponent: FC = () => {
  const pData = fetch(url) // awaitしない
  return (
    <div>
      <h1>すぐ描画したいコンポーネント</h1>
      <Suspense fallback={<div>待ってる間に表示したいコンポーネント</div>}>
        <ChildComponent pData={pData} />
      </Suspense>
    </div>
  )
}

以上がKokoneがApp Routerを活用するためにRefactoringした際に発生した課題とそのワークアラウンドでした!
ご不明な点などあれば是非お気軽にコメント等頂ければと思います!


APPENDIX

残課題

StreamingとgenerateMetadataについて

Kokoneでは、下記の様な検索後のURLを共有時、検索駅名などをタイトルに含める様にしています。


検索結果URLのメタデータ

これにはApp RouterのgenerateMetadataというファンクションの中で、駅情報などを取得してメタデータを更新しています。

しかし、挙動を見る限りだと、こちらのメタデータの取得はページレンダリング前に走っている様でして、せっかくSuspenseを使ってStreamingで表示させられるページにしていても、どうやらgenerateMetadata内のawaitを待ってしまう様な挙動をしていました。。。

これについてはまだ有効なワークアラウンドが見つかっておらず、どなたか何かご存知でしたら情報提供是非お願いします🥺

FAQ(?)

App Router理解前に自分が感じていた疑問について下記でFAQ(?)形式で回答しておきます。

Pages RouterからApp Routerの移行は大変?

ちゃんとApp Routerの旨みを得ようとすると、コンポーネント設計やデータ取得タイミングから見直す必要がありかなり大変です。多くの記事で述べられている様に、まずは全てClient Componentで移行し、徐々にSCを活用する様に段階的に最適解を目指していく方が良いかなと思います。

Pages Routerから全てApp Routerに移行し、全てClient Componentにしたらむしろパフォーマンス悪くなる?

Pages Routerのパフォーマンス vs App Router with 全てClient Componentのパフォーマンスでは大きな差はない様に思います。Client Componentと名前が付いたことで勘違いしやすいですが、Pages Router上で定義されているコンポーネントも全てClient Componentという理解です。(どなたか間違っていたら教えてください。)
ただ、App Routerのキャッシュなどは理解しないと、不要なデータリクエストが増えパフォーマンスが悪化するなどは大いに有り得るかと思います。

App Routerはどんなサービスに有利/不利?

App RouterはブログやシンプルなECサイトなど、ユーザーとのインタラクションやステートが少ない場合、ほとんどのコンポーネントをSCで記述できるため、多くの恩恵を受けられる様に感じます。
一方でインタラクションやステータスの多いサービスでは、CCのステート管理やコンポーネント設計の複雑さ、記述方法のバリエーションの多さなどの観点から、実装や検討事項が複雑になりやすい様に感じます。(僕の今いまの理解度ではですが。。。)
むしろ個人的にはgetStaticPropsやgetServerSidePropsなど明示的に多様なレンダリング方法を指定できるため、まだPages Routerの方が実装しやすい様に感じます。(特にチームで開発する際には実装方法が個人に寄りづらく最適解が分かりやすい様に感じます。)

App Routerのデメリット / 注意点は?

App Routerを使用すると基本的には、SCでデータ取得をすることになりますが、
SCではステートを持たないため、URLにクエリパラメータとして情報を持たせる必要があります。一つのページが複雑なUI(データ取得やインタラクティブなデータアクセスが多いUI)では、URLに持たせるクエリパラメータが増えてしまったり、システムのためのURL設計になり得る点が個人的にはうーーんでした。

また、キャッシュが暗黙的で非常に複雑なため、深い理解がないと想定外のキャッシュやリクエストが発生していたりする点も注意が必要に感じました。キャッシュがファイルベースのため、スケーリングをさせたい場合にはRedisやNFSなどでキャッシュ共有する仕組みをインフラ側で持つ必要があり、インフラ上の制約を持つ点も注意が必要に感じました。(キャッシュ共有をしなくても良いがキャッシュヒット率が下がる&ランダム性が強い。)

あとはこれら理解のための学習コストが非常に高い様に感じました。(チームに一人はApp Routerを理解するつよつよエンジニアがいないと開発がきつそうな気がしました。。。)

App Routerに移行する価値ある?

実際にApp Routerに移行してみて、Streaming(Suspense)まで使ってみた後のページのさくさく感とレスポンスの速さを見るとSCを活用してみる価値はかなりある様に感じました!


最後まで見てくださりありがとうございました!!
今後ともより快適なサービスとなる様に、Kokoneの改善に努めていきますので、是非是非応援やフィードバックなど、よろしくお願いいたします!

Xアカウントでもサービスに関すること(開発/運営/マネタイズ)も発信していたりするので、ぜひフォロー頂ければと思います!

KokoneのXアカウント
https://twitter.com/kokone_official


集合駅検索WEBサービスKokone
https://kokone-app.com?utm_source=zenn&utm_medium=referral&utm_campaign=sc-migration-article-bottom

Discussion