2022年夏、スペースマーケットはNext.jsに移行中
こんにちは。開発用のPCをM1 Macbook Pro 14inchに変えてもらってウキウキなサービス基盤グループの佐伯です。
PCのスペックが上がったことにより、ローカルでのJestの実行時間が大幅に短縮できて最高です。Gather繋ぎつつ、Live shareでモブプロしても重たくならないなんて素敵すぎます。
一旦冷静になって本題に入ります。
スペースマーケットでは ゲスト向けのサービスサイトである https://www.spacemarket.com をNext.jsへ徐々に移行しております。
少し前までは技術的負債が溜まったソースコードと向き合う時間が多かったのですが、Next.jsでの置き換えが進むにつれて開発体験が良くなり、サービスにも良い影響が出始めました。
本記事ではNext.js置き換えてみて実際どうだったか、また開発で得られた知見などを紹介いたします。
2年前の世界
今から2年前、スペースマーケットでは 新規サービスでNext.jsを 採用し本番稼働を始めた頃です。当時のブログはこちら。
Next.js を使い始めはしましたがメインとなるサービスページ (https://www.spacemarket.com)ではExpressを使った自前SSRを行っていました。
ExpressとrenderToStringを組み合わせた自前SSR
スペースマーケットは 2016年からReactを採用しています。
当時のブログ
最初にReactが導入された時はreact-railsを使用してSSRを行なっておりましたが、その後はフロントエンドとバックエンドを完全に分けるためにExpressとReactのrenderToStringのメソッドを組み合わせてSSR を行っておりました。
このSSRの仕組みが社内で4年ほど使われていたのですがコードベースの拡大するにつれ開発体験が悪くなってきました。
コードを編集するたびに20秒待つ。。。辛いですね。
当時の開発メンバーもいないためメンテナンスできる人材も社内の限られたメンバーのみとなってしまいました。
Next.jsへの移行
開発体験の悪化も問題なのですが、スペースマーケットへの主なアクセスはgoogleなどからのオーガニック流入のため パフォーマンス改善も必要になります。
開発体験の向上とサービスのパフォーマンス改善を目指して メインのサービスのNext.js移行を1年前から開始しました。
Next.jsへ移行したページ
スペースマーケットのサービスページは数多くのページがありますが、
2022年6月現在 以下のようなページがNext.jsへ移行しています。
移行できていないアクセスの多い主要なページとして以下のようなページが残っており順次移行をしています。
サービスページの移行は去年の4月頃から行なっていますが、社内に実装のノウハウが溜まってきており去年と比べるとかなり早くなってきたのではないでしょうか。
移行してみて
開発体験が劇的に改善されました。修正が瞬時に反映されるようになったため暇つぶしに小躍りする必要もなくなり開発に集中できます。
SSRの処理をNext.jsが行ってくれるため、新規に参画したメンバーも公式のドキュメントを見れば開発に取り掛かれるのでオンボーディングのコストも減りました。
さらに コードベースがTypeScriptに完全移行したのと、ユニットテストのカバレッジが約90%あるため改修にかなり強い状態となりました。
次はパフォーマンス面です。
下の画像はスペース詳細ページのmobile 画面をlighthouse でパフォーマンスの前後比較したものです。
同一ページを20回ずつ計測した平均を比較したところリリース前→後で 28.6pt → 46.7pt と変化、18.1pt 向上しました。
しかしながら46.7ptもそれほど高い数字ではないです。
ページの情報量が多いためフロントエンドを改善してもバックエンドからのレスポンス待ちでどうしても伸び悩みます。
パーソナライズしたコンテンツはCSRで行いキャッシュ可能に
Next.jsへ移行する際にページの作り方にも気をつけました。
既存のSSRではバックエンドにリクエストを投げる際に、ユーザーのログイン状態も渡してリクエストしておりました。
そのためSSRで出来上がるHTMLは一人一人のユーザーに合わせたものです。ユーザーにパーソナライズしたHTMLではキャッシュすることが出来ません。
この問題を解決するために アクセスの多いスペース詳細ページでは、SSR時にユーザーのログイン情報を使わないようにしています。
その結果ユーザーがログインしている、していないに関わらず一律同じHTMLを返すことができるようになりました。
この対応のためにパーソナライズしたコンテンツの仕様を変更する必要があったのですが、社内のPMの理解があったため順調に進めることが出来ました。
キャッシュはFastlyで行い、スペースのホストが情報を更新した際はパージされるようになっています。
こちらキャッシュが効いた場合のパフォーマンスです。
キャッシュの力はすごいです。
キャッシュのhit率は50%程度なので常に早いというわけではないですが、かなり改善されたのではないでしょうか。
しかしながらキャッシュは取り扱いが難しいのでアクセスの多いページだけに絞って使用する予定です。
Next.js のプロジェクトのご紹介
採用している主なpackage
Next.jsを使用したプロジェクトでは主に以下のようなpackageを使用しています。Next.jsで開発したことある人であれば これを見るだけである程度プロジェクトのイメージがつきそうですね。
個人的な注目点はChakra UIを採用したところです。
Next.jsへの移行 を始めた当初はStyleにstyled-components採用していたのですが現在はChakra UI に全面的に移行しました。
styled-componentsの当時に比べてHTMLのすぐそばにStyleを定義できるようになったので、読みやすく早く書けるのはかなりメリットがありました。
styled-componentsで作られた箇所はChakra UIが依存しているEmotionに比較的簡単に移行できたのでありがたかったです。
コンポーネントディレクトリについて
プロジェクトのコンポーネントディレクトリの一部を紹介します。
基本方針として関連あるものは分けずに1箇所にまとめています。
以前スペースマーケットではAtomic Designに沿ったディレクトリ構成にしておりました。
ページ数が増えるにつれてmolecules, organismsのコンポーネントが増えに増えコンポーネントの影響範囲の調査に時間がかかるようになってしまったことを反省して今の形になっています。
components
├─pages // ①
│ └─rooms // 機能的に近しいもので分けています
│ ├─RoomPage // ③
│ │ ├─index.ts
│ │ ├─RoomPage.tsx
│ │ ├─RoomPagePresenter.tsx
│ │ ├─useRoomPageData
│ │ │ ├─index.ts
│ │ │ ├─useRoomPageData.ts
│ │ │ ├─useRoomPageDataContext.ts
│ │ │ └─UseRoomPageDataProvider.tsx
│ │ ├─RoomPage.graphql
│ │ ├─RoomPage.generated.ts
│ │ └─components ④
│ │ ├─AboutCompensation
│ │ ├─AboutFood
│ │ ├─AboutTerms
│ │ ├─AboutTrash
│ │ ├─ActionPanel
│ │ ├─AmenityList
│ │ ├─CancelPolicy
│ │ ├─EquipmentDescription
│ │ ├─Heading
│ │ └─etc...
│ ├─RoomInquiryPage
│ └─RoomLayout // rooms配下で共通のレイアウト
└─shared // ②
├─gui
│ └─Button
│ ├─index.ts
│ └─ButtonPresenter.tsx
└─service // サービス独自の共通コンポーネント
├─GlobalHeader
├─ index.ts
├─ GlobalHeader.tsx
└─ GlobalHeaderPresenter.tsx
1. pages
スペースマーケットではコンポーネントを主にページごとに分けていますので、大半のコンポーネントはこのディレクトリに置かれることになります。
pagesの下はページの内容的に近しいもの(layoutが共通など)で分けられており、その下に各ページのindexとなる コンポーネントが配置されています。
サービスサイトはウェブアプリケーションというよりも、情報を伝えるメディア的な特性が強いのでページ単位で切り分ける構成がうまく合いました。
2. shared
pagesに対してこちらにはサービスで使用する汎用なコンポーネント配置しています。
gui
gui ディレクトリにはButtonやLinkといった 一般的なUIライブラリなどで提供される UI要素を置くようにしています。
このディレクトリのコンポーネントはサービスを跨いで使う想定なので、ゆくゆくは別なリポジトリに切り出し、スペースを管理するホスト向けのサービスなどでも使用する予定です。
service
serviceディレクトリはGlobalHeaderやGlobalFooterなどのサービス共通で使うコンポーネントを置いています。
サービス全体を見渡すと共通なコンポーネントに見える要素はたくさんありますが、無理に共通化はしておりません。
見た目が似ているからという理由で安易に共通化し始めると改修時に考慮事項が増えることがあるため、少し見た目が似ているという場合は割りきって別なコンポーネントを作るようにしてます。
3. コンポーネントの基本構成
コンポーネントの基本構成は以下のとおりです。
GlobalHeader
├─index.ts // export用
├─GlobalHeader.tsx // Container
├─GlobalHeaderPresenter.tsx // Presenter
├─useGlobalHeader.ts // hooks は必要に応じて分割
├─GlobalHeader.graphql // graphqlのschemaはco-locationで定義
├─GlobalHeader.generated.ts // graphql-codegenで生成されたコード
├─__dev__ // runtimeで動作しない開発用のコードはこちら
│ ├─index.test.tsx // exportしたコンポーネントに対しての結合テスト
│ ├─useGlobalHeader.test.ts // index.test.tsx で足りない分を補う
│ └─GlobalHeaderPresenter.stories.tsx // storiesはPresenterに対して作成
└─components // コンポーネント内で使うコンポーネントは一階層下げて管理
├─Foo
└─Bar
├─index.ts
└─BarPresenter.ts
Container / Presenter / hooks
コンポーネントはContainer / Presenterに分けて実装しています。
Containerは主にPresenterに渡す値の計算やhooksの呼び出しを行います。
PresenterはContainerから渡されたpropsに応じて表示の切り替えや、DOMのイベントを整形してContainerから渡された 関数の呼び出しを行います。
コンポーネントがPresenterのみで完結できる場合はContainerを作らずPresenterをindex.tsからexportするようにしています。
hooksに関しては複数のstateの管理やstateを変更する関数などを定義したい場合にhooksに切り出しています。
必ず作る必要はないですし、責務が違う場合は1コンポーネントでもhooksを複数作ることもあります。
ロジックをhooksに完全に移してContainerコンポーネントを無くすのも可能なのですが、Containerを用意することでPresenter 単体でStoryが作れるのでContainer / Presenter の構成となっています。
.graphql
スペースマーケットではColocating fragmentsパターンを採用しています。graphqlから直接値を引く、または親要素からpropsとして値をもらうようなコンポーネントではgraphqlのスキーマをそのコンポーネントと同じ場所においています。
.graphqlのファイルはgraphql-code-generatorを実行することで同じ階層に.generated.tsを作り、そこからfragmentの定義や、hooksなどをimportするようにしています。
__dev__
本番で稼働させない開発用のコードはこのディレクトリに一箇所にまとめています。
以前は __tests__ や __stories__ というように細かく分けていたのですが、testやstoriesで共通のpropsなど使いたくなるケースがあったので開発用のコードはここに投げ込もうということで今の __dev__ という形に落ち着きました。
testに関しては 外部にexport するindex.tsに対してコンポーネントの結合テストを書き、index.tsのみでは対応しづらいテストに関しては追加でhooksなどに対してもユニットテストを書いています。
storiesに関してはPresenter に対してのstoriesにすることで、簡単に見た目の確認を行えるようにしています。pages以下のstoriesはあくまで実装サポートのためという位置付けにしているので 細かく作り込むということはしておりません。
components
基本的には1コンポーネントにまとめたいですがコンポーネントが肥大化した際、責務が分けられるものはcomponentsディレクトリに切り出します。
そして親コンポーネントと同様にContainerやPresenter などを定義します。
ポイントとしては分けすぎるとコードジャンプが増えるため、ちょっとしたPresentationalな要素であればファイルを分けない方が可読性が下がらなくて良い感じです。
4. ページコンポーネントの構成
Next.jsへの移行を進めていく中でページ単位の作り方がある程度パターン化されてきたので紹介したいと思います。
先ほどの例のRoomPageについて注目してみます。
components配下のコンポーネントがとてもフラットな構造になっており、その下のネストはほぼありません。
components配下のコンポーネントはcolocating fragmentsのパターンに則ってコンポーネントで必要な分の値を framgent として定義しており、そのfragmentを親のRoomPage.graphqlに集約、そしてRoomPageで引いてきたgraphqlの値をuseRoomPageDataというcontextを使ったhooksを通して各コンポーネントに受け渡しています。
RoomPage
├─index.ts
├─RoomPage.tsx
├─RoomPagePresenter.tsx
├─useRoomPageData
│ ├─index.ts
│ ├─useRoomPageData.ts
│ ├─useRoomPageDataContext.ts
│ └─UseRoomPageDataProvider.tsx
├─RoomPage.graphql
├─RoomPage.generated.ts
└─components
├─AboutCompensation
├─AboutFood
├─AboutTerms
├─AboutTrash
├─ActionPanel
├─AmenityList
├─CancelPolicy
├─EquipmentDescription
├─FrequentlyAskedQuestion
├─Heading
├─Movies
├─NearByAreas
├─NonActiveHost
├─OwnerInformation
├─Heading
└─etc...この倍ぐらいあります
RoomPage.graphqlに定義されたfragmentを見るとたくさんのfragmentがまとめられていています。
fragment RoomPageRoom on Room {
...ShowRoomDetailEvent
...RoomPageHead
...RoomOptions
...Heading
...RoomMeta
...TwoWeekCalendar
...RoomDescription
...EquipmentDescription
...RoomAddress
...MainVisual
...PopularAmenities
...FrequentlyAskedQuestion
...AmenityList
...RoomDescription
...RoomReviews
...AboutTrash
...AboutFood
...AboutTerms
...WifiInformation
...RoomMainUsage
...ActionPanel
...NearbyAreas
...GenerateRoomBreadCrumbs
...PossibleUses
...ActionPanel
...OwnerInformation
...CancelPolicy
...RoomPlans
...RoomStory
...RoomMovies
...RoomRentTypeSwitcher
...NonActiveHost
...RoomEditLink
...PopularRooms
...RoomPayments
}
Reactのcontext APIは値に変化があると受け取ったコンポーネントで再レンダリングが発生してしまうため、普段は機能を絞って値の受け渡しを行いますが、useRoomPageDataから渡す値はgetServerSidePropsで取得してくる値でページ表示後に変化しないので再レンダリングを気にせずに1箇所にまとめることができます。
その結果、煩雑になりやすいページのルート要素から各コンポーネントへのpropsのバケツリレーがなくなり、RoomPagePresenterは、components配下のコンポーネントをどのように並べるかということだけに注力できるようになります。
新しい機能を追加する際はRoomPage.graphqlに追加された fragment を付け足して、RoomPagePresenterにコンポーネントを配置するだけなので改修にとても強くなっています。
スペースマーケットでは 軽量さ重視でurqlを使用しているため、contextに入れて値を受け渡す作りになっていますが、relayなどを採用していれば useFragmentを使ったりしてより簡単にできそうですね。
今後
エンジニアの開発体験より改善していきたいので既存の自前SSRで動いているページはもちろん置き換えていきますが、並行してReact@18の対応や Chakra UIのアップデートも行なって今のモダンな環境を維持していく予定です。
ちょっと気を抜くと遅れを取り戻すのが大変で、このNext.jsのプロジェクトも一時はNext@12に対してNext@10ということがありました。
メンテできない状態を作らず健全にプロジェクトを育てていきます。
最後に
スペースマーケットでは現在エンジニア募集中です。
今回のようなリファクタリングだけではなくスペースマーケットを使ってくださるユーザー向けの機能開発など、弊社ではやれることがたくさんあります。
最近では各メンバー技術向上のためにの週1回モブプロをみっちり行なったり、モブレビュー会を開いたりしているので良い職場なのではないでしょうか。きっと誰かがその話をブログで書いてくれると思うのでお楽しみに。
っと、このブログをコツコツと作っているうちにモブレビュー会のことを書いてくれていたので、こちらもよろしければご覧ください。
最後まで読んでくださりありがとうございました。
もし弊社に興味を持っていただけましたら以下のwantedlyをぜひ覗いてみてください。