こんにちは。4月にヘンリーに入社したSWE / アーキテクト / SETのsumirenです。
弊社ではレセコン一体型クラウド電子カルテの Henry を開発・提供しています。
今回、HenryのフロントエンドをReact + ViteからNext.jsに書き換えました。
この記事では、最初にNext.jsへの切り替えによってもたらされたユーザー体験の向上について説明します。次に、このユーザー体験の向上がどうして生じたのか、その背後にある技術的な要素をエンジニア向けに詳細に解説します。最後に、フロントエンドアーキテクチャに対する我々の長期的なビジョンについて述べます。
対象読者
- エンジニアの方
- ビジネスサイドの方
- Henryを導入されている医療機関様の方
Next.js導入によるユーザー体験向上
私たちは数年前からHenryを開発しており、IT業界のトレンドに沿って技術をアップデートしています。
今回、業界のトレンドとしてNext.jsという技術が進化を遂げており、こちらで画面表示をリプレイスすることにしました。
以前にブログ記事でも紹介したとおり、ヘンリーでは様々なツールを用いてソフトウェアとエンジニアリングのステータスを定量化しています。 dev.henry.jp
医療機関様の環境での画面の性能は、以下のようになっていました。右下のFCP Distributionという箇所を見ると、200ms〜1秒程度のFCPがボリュームゾーンとなっていました。
大部分が200msで済むならそこまで低い数値でないようにも思えるのですが、定性的にパフォーマンスを深掘りしていくと、ページをリロードしたときに半分以上の方が0.5秒〜2秒程度の待ち時間を感じていることが分かりました。
この数値が高い(性能が低い)と、ユーザーがページを読み込み直すときに時間がかかり、ユーザー体験を悪化させます。例えば患者様の会計をするために急いでいるときに、Henryのページが高速で表示されなければ、医療事務の方々はストレスを感じることになります。
Next.js採用は、長期的なビジョンに基づいています(それについては主にエンジニアの方々向けに記事の最後のほうで述べます)。それに加えて、短期的なユーザー体験向上のメリットもあり、特に上記のパフォーマンスについても改善できると見込んで導入を決定しました。
デモ
以下2点のパフォーマンスが向上しています。
- ヘッダーなどのレイアウトが表示されるまでの圧倒的に速くなっている
- 最終的なデータ取得完了までの時間がやや速くなっている
上がNext.js版(新しい)、下がVite版(古い)の比較になっています。同時にページを読み込んでいます。 少し分かりづらいかと思うのですが、上記2点に注目して見ていただけると幸いです。
定量的なパフォーマンス比較
Next.js版のHenryは本番環境にリリースしたばかりで、まだ医療機関様の環境でのパフォーマンスは集計できていません。そのため、検証環境でのパフォーマンスの比較を記載します。
以下が、LighthouseというIT業界で最もスタンダードなツールでの計測結果です。
- 総合パフォーマンス:56点 → 82点
- FCP(初期表示):4.0秒 → 0.5秒
- LCP(データ表示):5.4秒 → 2.9秒
- CLS(カクつき):0.05 → 0.001
FCPが圧倒的に速くなり、LCPもそれにつられてやや速くなっていることが分かります。 CLSというのはデータが表示されるにつれて画面がずれたりするカクつきのことで、これも改善しています。 その結果、総合パフォーマンスが56点から82点まで改善しました。toB バーティカルSaaSとしては先進的な数値ではないかと思います。
計測結果はツールによって異なるので、先述のダッシュボード上の数値と噛み合ってない部分もありますが、本番環境でも同様に高速化すると見込んでいます。
※3画面に対して複数回実施した平均点数を記載しています。
※キャッシュはオフで計測しています。
ユーザー体験がどう向上したか
上記の通り、Next.jsに書き換えたことで、ブラウザのリロード時や初回アクセス時のページ表示が速くなりました。
Henryにアクセスすると、ナビゲーションや画面のレイアウトなど、データ以外の部分が非常に速い速度で表示されます。 これにより、ユーザーが別のタブを見たい場合やデータを新規作成したい場合には、即座に操作を行えます。
また、ページの初期表示が速くなったことで、結果的に最終的なデータ取得までの時間も短くなっています。 加えて、データ以外のレイアウトが先に高速表示されるため、ユーザーは「画面上のこのあたりにデータが出そうだ」というのを事前に認識でき、データ表示後すぐに操作ができます。
FCP高速化の技術的な仕組み
この章では、主にエンジニアの方向けにNext.jsを導入するとFCPが高速化する理由を解説します。
元々のHenryは以下のようなアーキテクチャでした。
- 素のReact + Vite
- 非SSR
今回、上記からNext.jsに置き換えるだけで、自然とFCPが向上しています。 いくつか工夫しているポイントもあるため、併せて説明します。
以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。
また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。
まずはNext.jsとViteの基本的なアーキテクチャの違いを説明した後、高速化を後押しする要因である以下2点について説明します。
- プリレンダリング
- コード分割
Next.jsとViteの基本的なアーキテクチャの違い
Vite版とNext.js版のヘンリーでページをリロードしたときの初回レスポンスを見比べてみます。
左側がVite版です。Viteのような通常のSPA構成では、Reactコンポーネントのレンダリングは全てブラウザで実行されます。
そのため、サーバーからのレスポンスは、Reactが書かれたJavaScriptを読み込むための<script>
タグを含む最小限のhtmlであり、ほとんど何も表示されていません。
通常のSPA構成の経験がある方なら、<div id="root"></div>
は馴染みがあるのではないでしょうか。
一方、右側がNext.js版です。Next.jsは、サーバーサイドでページをHTMLとJSに変換します。
そのため、サーバーからのレスポンスの時点で中身のあるHTMLが返されています。
次はChromeのPerformance Insightsで、ブラウザの処理の流れを見比べてみます。
以下のとおり、Next.js版のHenryでは初回のレスポンスでレイアウトの表示が始まっています。
一方、Vite版のHenryでは、初回のレスポンスでほとんど空のHTMLが返ってきた後、JSの読み込みがあり、その後JSの処理が行われています。
その結果、初回のレスポンスが返ってきてから、いくつものステップを挟まないと画面上には何も表示されていません。
このアーキテクチャの違いがNext.jsのFCPを高速化しています。
Vite版の場合は、ブラウザに初回レスポンスが来た後、JSの読み込み、ブラウザでのJS処理をして、ようやく画面を表示できます。
一方のNext.js版の場合は、ブラウザに初回レスポンスが来た時点で画面にレイアウトを表示できます。
この基本的なアーキテクチャの違いを踏まえたうえで、高速化を後押ししているNext.jsの2つの機能を見ていきます。
上記のPerformance Insightsの比較は処理の流れにフォーカスするためにキャッシュありで比較していましたが、この後はターンアラウンドタイムの最適化やトラフィック量に関わる話になるため、キャッシュを無効化したPerformance Insightsも見ていければと思います。
補足:工夫しているポイント
HenryはtoB SaaSであり、大部分の画面に認証がかかっています。認証には、クライアントサイドでFirebaseを利用しています。
Vite版のHenryでは、認証の状態が得られてから画面上に出すべきコンポーネントが決まりレンダリングしていました。
今回Next.jsに移行するにあたり、認証できている場合のコンポーネントをレンダリングして返しています。これにより、Firebaseの処理待ちやコンポーネントの再レンダリングなしで画面のレイアウトが表示できます。
※データはあくまで認証後に取得するため、セキュリティ上の問題はありません。
const PageRoot: React.FC<{ children: React.ReactNode }> = ({ children }) => { // ... // Firebaseによる認証は副作用として行う useEffect(() => { if (currentUser.loading) { return; } if (requiredAuth(router.pathname) && !currentUser.membership) { setShowLoginPage(true); return; } setShowLoginPage(false); }, [currentUser, router]); // ... return ( <div> {showLoginPage ? ( // ... ) : ( // サーバーサイドレンダリング時点では、認証できている前提でレンダリングしておく <RootLayout showGlobalNav> {children} <WindowsPresenter /> </RootLayout> )} </div> ); };
プリレンダリング
前章で説明したとおり、Next.jsは初回のレスポンスさえ受け取れれば、データを必要としない画面の一部を表示できます。
しかし、単純にリクエストが来てからサーバーサイドでReactをHTMLに変換すると、ブラウザでのJS処理の代わりにサーバーでのJS処理に時間がかかり、初回のレスポンスが遅くなってしまいます。
そこで、Next.jsではビルド時に大部分のHTMLとJSを生成してキャッシュしています。これをプリレンダリングと言います。具体的には、動的なサーバーサイドでのデータ取得を含まないページがプリレンダリングの対象となります。
この機能により、Next.jsは初回リクエストに対するレスポンスのターンアラウンドタイムを最小限に押さえています。
キャッシュなしでのVite版HenryとNext.js版Henryの初回リクエストに対するレスポンス速度を比較してみます。
まず、Vite版のHenryは次のとおりです。
オレンジの部分はJSの読み込みで、かなり気になる長さになっていますが、これは次の章で解説するので一旦見なかったことにしてください。
ここで見たいのは青色の初回リクエストに対するHTMLのレスポンスで、15〜20ms程度に収まっています。これは、Viteでは画面表示にJSが必須となる代わりに、HTMLはほとんど空に近いもので、サーバーサイドでの処理も必要ない静的なものだからです。
次に、Next.js版を見てみます。
一見Vite版より遅くなっていますが、すでにレンダリング済みのHTMLでファイルサイズで多少大きさがあるため、通信完了までの時間が伸びるのは自然です。
特筆すべきはレスポンスが始まるまでのリードタイムです。0msから数えると、わずか5ms程度でレスポンスが始まっているのが分かります。
何もデータフェッチを行わない動的フェッチ(getServerSideProps
)を含むNext.jsのページをローカルで動かすと、このようになりました。Reactのレンダリングで30ms程度ロスしていることが分かります。
これがプリレンダリングの威力です。サーバーサイドでReactレンダリングすることでかかってしまう数十msのリードタイムすらなくし、SPAアーキテクチャとの性能差を圧倒的なものにしています。
補足:工夫しているポイント
Vite版のHenryのデータフェッチは、Apollo Clientを用いたクライアントサイドフェッチでした。
今回、マイグレーションにあたり影響を最小限にするために、データフェッチ手段は変えずにNext.jsに書き換えました。そのため、ほぼすべての画面がプリレンダリングの対象となりました。
個人的には、Streaming SSRでない通常のサーバーサイドフェッチはFCPを落としてしまうため、プリレンダリング可能なクライアントサイドフェッチのほうが望ましいと考えています。
コード分割
加えて、Next.jsには、フロントエンドへのJS転送量も大きく軽減する仕組みもあります。
※正式な機能名がドキュメント上で見つけられなかったため、便宜上コード分割としています。
Viteの場合、基本的には全てのReactコンポーネントが1つの巨大なJSファイルにバンドルされます。Lazy Loadingにより個別のコンポーネントを動的に読み込むことは可能ですが、そうした工夫をしなければ全ページ分のコンポーネントの情報がクライアントサイドで読み込まれます。
先ほど少し目に留まったかと思いますが、改めてVite版のHenryのJS読み込み時のPerformance Insightsを見てみます。
キャッシュヒットなし時には1秒経っても読み込みが終わっていません。このindex.jsは、4.0MBもの大きさになっていました。かつ、前の章で説明したとおり、SPAアーキテクチャではこのJSが読み込みとJSのブラウザ側での処理をしないかぎり、画面には何も表示できません。
記事の最初にあるLighthouseの計測結果でVite版のHenryのFCPが4.0秒もかかる一番の要因は、このキャッシュヒットなし時のJSの読み込みです。
続いてNext.js版のHenryのキャッシュヒットなし時のJS読み込みを見てみます。
細かくて分かりづらいかもしれませんが、下記の図から300ms程度でJSが読み込まれていることが分かります。
このように高速なのには2つの理由があります。
1点目の理由は、Next.jsは細かくJSを分けて並列での読み込みを可能にしていることです。これはこの図からも分かりやすいかと思います。
より重要な2点目の理由は、Next.jsはサーバーサイドでページ単位でReactにレンダリングしているため、そのページに必要なJSだけを返せることです。
実際、上記の図で読み込まれているJSは、数は多く見えますが、1つ1つは10〜300kBほどで、合計すると(この画面では)1MB未満になっています。
以下はNext.jsのビルド実行時のログです。ビルド時点で、ページ単位のFirst Load JSが計算できています。
これによると、多くの画面が500kB〜1MBに収まっており、4.0MB以上を一括で読み込まなければいけないVite版のHenryに対して大きなアドバンテージがあります。
実際にはこれでもNext.js的には大きいということで、First Load JSのログが真っ赤に染まってしまっています。今後、よりFirst Load JSを削減できるように精進していきたいです。
このように、Next.jsでは個別の最適化を行わずとも、JSを小分けにする仕組み・デフォルトでページに必要なJSしか返さない仕組みがあり、高速化とトラフィック削減が実現されます。
補足:工夫しているポイント
Next.jsでも、Vite同様、Lazy Loadingによる個別の最適化が可能です。
今回は、もともとルートに近いコンポーネントでインポートしていた大量のFloatingWindowなどのコンポーネントをLazy Loadingするようにしました。
まずはマイグレーションを完了させることが最優先のため、手早くできるボリュームゾーンのみを対応しましたが、それでもFirst Load JSを300kBほど削ることができました。
補足:JS読み込みとFCP
Next.jsにとって、このJS読み込みは、厳密にはFCPと直接関係がありません。
ここまでに説明してきたとおり、初期表示のためのHTMLはサーバーサイドでレンダリング済みだからからです。
それではこのJSは何に使うかというと、ざっくり言うとユーザーが画面を操作できるようにするために使います(ハイドレーションと言います)。
あくまで初回レスポンスで返ってくるHTMLで、clickなどのイベントは一切登録されていないため、ブラウザ側にReactコンポーネントを正しく紐付ける必要があるということです。
つまり、このJSの読み込みや処理が遅いと、画面は高速で表示されても、ユーザーはデータを作成したり画面遷移するといった操作ができないことになります。
FCPには直接関わらないものの、広い意味では初期表示の高速化に大きく関わるものです。
長期的なビジョン
今回、Next.jsを導入したことで自然とページロードが高速化しました。
しかし、実のところ、Next.jsを導入したのは、単に短期的なページ高速化のメリットを享受するためではありません。
むしろ、Next.jsを導入できた今、フロントエンドエンジニアリングの可能性がさらに広がったのだと思っています。
この章では、フロントエンドアーキテクチャに関して、どのような長期的なビジョンを持ってNext.jsを導入したのか解説します。
この章では、これまでの章に比べ、主観や推測、判断が多くなります。予めご理解いただければと思います。
フルスタックReactというトレンド
まず、最も長期的な観点として、フルスタックの波に乗ることで、トータルでより先進的なユーザー体験や開発者体験が実現できる可能性が高いと考えています。
背景として、フロントエンド界隈全体の流れとして、Web標準とフルスタックに寄せていくことが開発生産性やパフォーマンスを高めることにつながるという動向があるように思います。
Reactチームもそうしたトレンドを推し進めている当事者であり、今後Reactを始めるうえではNext.jsやRemixを採用することを推奨しています。
この記事で解説してきたように、こうしたフルスタックフレームワークはサーバーサイドレンダリングにより初回レスポンスからHTMLを返せます。
また、最近はPOSTについてもServer ActionsやAction Functionのように、個別のinputから値を集めてJSONに詰める今までのやり方に比べ、よりWeb標準に近い形を模索しています。
一方、ViteのようなSPAアーキテクチャはというと、空のHTMLが返ってくることも、巨大なJS読み込みが発生することも、そのJSで画面全体を構築することも、全てがWeb標準やフルスタックのトレンドとは真逆です。
そのため、少なくとも今後盛り上がりや進化を見せる可能性は低く、最悪の場合はエコシステムの豊富なライブラリやホスティングサービスとの統合が難しくなっていく懸念もあると判断しています。
こうしたことを踏まえ、フルスタックフレームワークの波に乗ることで、ますます革新的なユーザー体験や開発者体験が可能になり、未来が明るくなると判断しました。
ユーザー体験
より短期的な観点として、Next.jsを導入したことですぐに始められるアプリケーションレベルのアーキテクチャ施策もたくさんあります。
例えばApp Routerを有効化すれば、React Server Componentsを使ってJSバンドルサイズを削減できます。
Steaming SSRによりFCPを保ちながらGraphQLリクエストをサーバーサイドに集約するなどすれば、ページリロード時だけでなくクライアントサイドナビゲーションによる遷移時のパフォーマンスも向上することができる期待があります。
また、Server Componentsの導入と併せてstyled-componentsからゼロランタイムCSS in JSへの置き換えを行うことで、iPadやスマートフォンでも高いINPを実現できる見込みもあります。
その他、細かい機能も含めると、フォントやイメージの最適化の活用などもあります。
開発生産性
加えて、開発者体験についても、Next.jsを導入したことで今後向上する見込みです。
Next.jsの規約に従うことで、ルーティングやバンドラの設定を自前で管理する必要がなくなり、アプリケーションに集中しやすくなります。
機能開発自体も、レイアウトやメタデータを簡単に共通化できるようになるなど、配管コードの量が削減できる見込みです
より積極的なアプローチとして、ソフトウェアのアーキテクチャを見直す余地も出てきます。
現状はフロントエンド - GraphQL BFF - gRPC Backendという構成ですが、例えばコンポーネントにServer Actionsを書いて直接gRPCを呼んだり、Server Componentsから直接gRPCを呼んでデータを取得するアーキテクチャに変更できます。
このようにすれば、Reactだけでフロントエンドの開発が完結し、より開発を加速できるかもしれません。
まとめ
HenryをViteからNext.jsに書き換えたことで、フロントエンドエンジニアリングの可能性が広がり、直近ではFCPも向上しました。
引き続きHenryフロントエンドアーキテクチャをよくして、ユーザー体験と開発生産性を高めていきたいと思います。
この記事は比較的一般論に近い解説記事となりました。実際、パフォーマンスに寄与しているのはほとんどNext.jsのビルトインの仕組みです。
一方、今回行ったViteからNext.jsへのマイグレーションプロセスには、より実用的な洞察がたくさん含まれていると考えています。
最も面白い点としては、今回Vite版を破壊せずに、ほとんどのコードを共通化した状態でNext.jsを導入し、開発を止めずにマイグレーションしたことが挙げられます。
こうしたマイグレーションを実現したプロセスやコードベース設計について、別途記事として公開したいと思いますので、よければそちらもご覧ください。
最後まで読んでいただき、ありがとうございました。
修正ログ
- 2022/06/02 12:57
- 記事の内容がVite自体の性能について述べるものと誤解を与えうる内容になっていたため、補足を追加しました。
以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。
また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。
- 記事の内容がVite自体の性能について述べるものと誤解を与えうる内容になっていたため、補足を追加しました。