🏎️

パフォーマンス観点でみる Next.js の getLayout

2022/05/20に公開約5,700字4件のコメント

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をマウントしています。

_app.tsx
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 使用
  • /articlesarticles/[id]は、静的生成ページ、Recoil 不使用

この「First Load JS shared by all」には、Recoil の package が含まれてしまっています。なぜなら、_appRecoilRootをマウントしているからです。

あるアプローチを使うことで、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 観点メリットのみが取り上げられています。

  • ページごとにレイアウトを定義するもの
  • コンポーネントツリーをページ遷移間でも維持し、状態の永続化を可能にするもの

https://nextjs.org/docs/basic-features/layouts#per-page-layouts

なぜこれが、チャンク分割最適化に効くのか見ていきましょう。冒頭で示した_appは、ここでは「Single Shared Layout」にあたります。

_app.tsx
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」になったのは、こういった理由です。

_app.tsx
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 相当の実装も含まれるでしょう。

layouts/articles.tsx
import { GetLayout } from "@/types";
export const ArticlesLayout: GetLayout = (page) => page;
layouts/my.tsx
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】

https://github.com/takefumi-yoshii/nextjs-getlayout/tree/per-page-layouts

【Single Shared Layout】

https://github.com/takefumi-yoshii/nextjs-getlayout/tree/single-shared-layout

Discussion

こんにちは。
例としてRecoilを出すのであれば、RecoilRootをLayoutに出す場合の注意点を書いておいた方がいいと思ったので、コメントさせていただきます(ライブラリの使い方がこの記事の本質ではないと思いますが)。
RecoilRootを持たないレイアウトに遷移した場合に、Recoilに保存しておいた値が消えてしまいます。そのためこの記事の前提の「ログインユーザー情報格納のために利用している」のであれば、この点をケアしておく必要があると思います。

フォークして実装したリポジトリはこちらになります。こちらではレイアウトが変わっても値が消えないように実装してありますが、本番環境で利用できるほどの品質ではありません。参考程度に見ていただければと思います。

https://github.com/mya-ake/nextjs-getlayout
追加したコード
  • authenticatedというbool値をRecoilに追加
  • /my/login でログインできるようにし、authenticatedtrue
  • /my/profileauthenticatedの値を表示
  • ヘッダーを作成し遷移できるように
  • Atom Effectsを利用して、値の永続化を実装(この実装を消すと値を保持できなくなります)
    • Suspenseを使えばきれいに書けるのでは?とこのコメントを書きながら思いつきましたが、試せてはないですmm
    • レイアウト間の値保持だけが目的であればシングルトンに保持させておくとかでも十分そうです

@mya-ake コメント・サンプルご提案ありがとうございます。そうですね、React.Context Provider をマウントする場合と同様ですが、Layout をアンマウントすると状態破棄されてしまいますね。なので、前提例としてもう少し別のものを挙げるべきだったなと思います。

懸案事項のような、レイアウトアンマウントでも維持したい状態値であれば、React.Context に持たせて_app にマウント、そうでないものは Recoil で管理するのも一つ手かもしれません(この構成試して比較してはないですが、RecoilRoot を _app にマウントするよりも軽量になるかも知れません)ここは管理の複雑さと、記事本題のファイル分割観点のトレードオフになるんじゃないかと思います。

お返事ありがとうございます。後述されているようにReact.Contextで持たせることで十分に軽量なものができると思います。サンプルのLocalStorageに持たせるコードも1kBぐらいの増加なので、Recoilに比べれば十分に軽量なものは作れそうです。そのためそのトレードオフも十分にやる価値はあるものになると僕は思います。
本題とは関係ない箇所でしたが、追加でコメントいただきありがとうございます。

いえ、とんでもないです!ご指摘ありがとうございます。getLayout はとくに、今回の本題と関連して Recoil と Conext の違いがでる点だと思うので、後日まとめたいと思います。

ログインするとコメントできます