パフォーマンス観点でみる Next.js の getLayout
Next.js は、ページ単位でデータ取得・レンダリング手法を選べる事が利点です。そして、ページ単位でチャンクファイルが生成されるため、パフォーマンスに貢献します。
これはあるページに来訪した際、必要最低限のファイルロードで済むということです。ファイルロードの時間は、ユーザーが操作開始できるまでの時間(TTI)に繋がります。Next.js でコーディングしていれば意識せずとも、ファイル分割の最適化は適用されます。
これだけでも SPA 構築に Next.js を選ぶ理由になりますが、ファイル分割は実装次第で、良くも悪くもなることを紹介していきます。
First Load JS shared by all
_app
は、どのページにアクセス・ナビゲーションしても、必ず通過します。そのため、_app
に関連するファイルは 「First Load JS shared by all」 として、全てのページで必要な共有ファイルとして、ビルドされます。
next build
でビルド完了後、チャンクファイルサイズの一覧が出力されます。以下のchunks/pages/_app-389b549c2fab3b3b.js(23.3 kB)
がその出力結果です。
+ First Load JS shared by all 97.7 kB
├ chunks/framework-1f10003e17636e37.js 45 kB
├ chunks/main-fc7d2f0e2098927e.js 28.7 kB
├ chunks/pages/_app-389b549c2fab3b3b.js 23.3 kB
├ chunks/webpack-69bfa6990bb9e155.js 769 B
└ css/27d177a30947857b.css 194 B
_app
の実装は、以下の様になっていました。いたって普通の、公式通りのものですね。状態管理ライブラリRecoilのプロバイダーであるRecoilRoot
をマウントしています。
import type { AppProps } from "next/app";
import { RecoilRoot } from "recoil";
import "../styles/globals.css";
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
);
}
この Recoil は「ログインユーザー情報格納のために利用している」という前提で話を進めます。
プロジェクト構成を確認する
プロジェクト構成確認のため、ビルド後のチャンクファイル一覧を詳しく見てみましょう。動的生成ページ・静的生成ページが含まれていますね。Next.js ならではの、ハイブリッドな構成です。
Page Size First Load JS
┌ ○ / 6.26 kB 104 kB
├ /_app 0 B 97.7 kB
├ ○ /404 193 B 97.9 kB
├ ● /articles 291 B 98 kB
├ ● /articles/[id] 296 B 98 kB
├ λ /my/login 295 B 98 kB
└ λ /my/profile 295 B 98 kB
+ First Load JS shared by all 97.7 kB
├ chunks/framework-1f10003e17636e37.js 45 kB
├ chunks/main-fc7d2f0e2098927e.js 28.7 kB
├ chunks/pages/_app-389b549c2fab3b3b.js 23.3 kB
├ chunks/webpack-69bfa6990bb9e155.js 769 B
└ css/27d177a30947857b.css 194 B
実装内訳は次のとおりです。
-
/my/login
・/my/profile
は、動的生成ページ、Recoil 使用 -
/articles
・articles/[id]
は、静的生成ページ、Recoil 不使用
この「First Load JS shared by all」には、Recoil の package が含まれてしまっています。なぜなら、_app
でRecoilRoot
をマウントしているからです。
あるアプローチを使うことで、Recoil の package は分離でき 「23.3 kB」だった _app は「538 B」になります。 アプローチ採用後のファイル一覧は次のとおりで、静的生成ページが軒並み軽量化されているのが分かります。不要な Recoil のロードが減った証拠ですね。
Page Size First Load JS
┌ ○ / 6.28 kB 81.3 kB <- here
├ /_app 0 B 75 kB <- here
├ ○ /404 193 B 75.2 kB <- here
├ ● /articles 334 B 75.3 kB <- here
├ ● /articles/[id] 338 B 75.3 kB <- here
├ λ /my/login 352 B 98.3 kB
└ λ /my/profile 353 B 98.3 kB
+ First Load JS shared by all 75 kB
├ chunks/framework-1f10003e17636e37.js 45 kB
├ chunks/main-fc7d2f0e2098927e.js 28.7 kB
├ chunks/pages/_app-306b5430e697db65.js 538 B <- here
├ chunks/webpack-69bfa6990bb9e155.js 769 B
└ css/27d177a30947857b.css 194 B
getLayout が _app 肥大化を防ぐ
チャンク分割最適化に採用したアプローチが、掲題の 「getLayout」 です。公式ドキュメントで getLayout は以下とおり、UI 観点メリットのみが取り上げられています。
- ページごとにレイアウトを定義するもの
- コンポーネントツリーをページ遷移間でも維持し、状態の永続化を可能にするもの
なぜこれが、チャンク分割最適化に効くのか見ていきましょう。冒頭で示した_app
は、ここでは「Single Shared Layout」にあたります。
import type { AppProps } from "next/app";
import { RecoilRoot } from "recoil";
import "../styles/globals.css";
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<RecoilRoot>
<Component {...pageProps} />
</RecoilRoot>
);
}
この_app
を「Per-Page Layouts」に変更してみます。<RecoilRoot />
が取り除かれているのが分かりますね。_app
が「23.3 kB」から「538 B」になったのは、こういった理由です。
import type { AppPropsWithLayout } from "@/types";
import "../styles/globals.css";
export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
return getLayout(<Component {...pageProps} />);
}
各ページで指定する getLayout 関数です。Recoil がlayouts/my.tsx
に移り、必要なページでのみ読み込まれるようになりました。実際には本来目的どおり、ここに Layout 相当の実装も含まれるでしょう。
import { GetLayout } from "@/types";
export const ArticlesLayout: GetLayout = (page) => page;
import { GetLayout } from "@/types";
import { RecoilRoot } from "recoil";
export const MyLayout: GetLayout = (page) => <RecoilRoot>{page}</RecoilRoot>;
この例では Recoil を取り上げましたが、プロジェクト全体とはいかずとも「PageA・PageB では ContextX」を「PageC・PageD では ContextY」を共有したい、といったこともあるでしょう。この時にも、getLayout が役に立ち、各々のページで必要最低限のファイルが「First Load JS」として含まれることになります。
※ 追記
コメントいただいている通り、 RecoilRoot を持たない Layout に切り替えた場合、状態は破棄されてしまいます。破棄されては困る場合、追加で検討が必要です。
まとめ
レイアウトが選べるのはもちろんですが「必要最小限のアセットを配信する」という観点からみても、はじめから getLayout は必須で採用すべきでしょう。
「グローバルな設定をしたいから」という理由で_app
に実装を継ぎ足していくと「First Load JS shared by all」が肥大化し続けます。_app
になにか実装を足す際には、慎重に検討しましょう。
本稿で紹介したサンプルコードは、以下リポジトリにブランチを分けてアップしています。
【Per-Page Layouts】
【Single Shared Layout】
Discussion
こんにちは。
例としてRecoilを出すのであれば、RecoilRootをLayoutに出す場合の注意点を書いておいた方がいいと思ったので、コメントさせていただきます(ライブラリの使い方がこの記事の本質ではないと思いますが)。
RecoilRootを持たないレイアウトに遷移した場合に、Recoilに保存しておいた値が消えてしまいます。そのためこの記事の前提の「ログインユーザー情報格納のために利用している」のであれば、この点をケアしておく必要があると思います。
フォークして実装したリポジトリはこちらになります。こちらではレイアウトが変わっても値が消えないように実装してありますが、本番環境で利用できるほどの品質ではありません。参考程度に見ていただければと思います。
追加したコードauthenticated
というbool値をRecoilに追加/my/login
でログインできるようにし、authenticated
をtrue
に/my/profile
でauthenticated
の値を表示@mya-ake コメント・サンプルご提案ありがとうございます。そうですね、React.Context Provider をマウントする場合と同様ですが、Layout をアンマウントすると状態破棄されてしまいますね。なので、前提例としてもう少し別のものを挙げるべきだったなと思います。
懸案事項のような、レイアウトアンマウントでも維持したい状態値であれば、React.Context に持たせて_app にマウント、そうでないものは Recoil で管理するのも一つ手かもしれません(この構成試して比較してはないですが、RecoilRoot を _app にマウントするよりも軽量になるかも知れません)ここは管理の複雑さと、記事本題のファイル分割観点のトレードオフになるんじゃないかと思います。
お返事ありがとうございます。後述されているようにReact.Contextで持たせることで十分に軽量なものができると思います。サンプルのLocalStorageに持たせるコードも1kBぐらいの増加なので、Recoilに比べれば十分に軽量なものは作れそうです。そのためそのトレードオフも十分にやる価値はあるものになると僕は思います。
本題とは関係ない箇所でしたが、追加でコメントいただきありがとうございます。
いえ、とんでもないです!ご指摘ありがとうございます。getLayout はとくに、今回の本題と関連して Recoil と Conext の違いがでる点だと思うので、後日まとめたいと思います。