🐰

Next.js のデバッグ実行が遅かったのを3倍速くした方法

に公開

1. はじめに

Next.js でデバッグ実行が遅いと感じたことはありませんか?

私は、パッケージを追加してデバッグ実行を行うと、初回の画面描画時に 10 ~ 20 秒もかかってしまうことがありました。2 回目以降はキャッシュが効いているのか、すぐに表示されます。

使用したパッケージは、定番のものを最小限にとどめていたつもりでした。

Next.js アプリでは MUI(Material UI)を使っているのですが、MUI を使わない画面は表示が速く、MUI 周りが原因ではないかと考えました。

実際、MUI、MUI Styled、MUI DatePicker、MUI Icons の使用がパフォーマンスに影響していることが分かりました。なお、同時に使用していた AG Grid の影響はほとんどなかったようです。

私は MUI を使って手軽に UI コンポーネントを使いたいだけですが、しかし、いま人気がある〇 ailwindcss はコードがごちゃごちゃになりやすそうで苦手...

とにかく、プログラムをデバッグ実行しているだけなのに、よくわからないけど重くなるのは困りますね。

2. dev 実行を高速化するために行ったこと

結論から言うと、dev 実行を高速化するために、次の 2 つの方法を実施しました。

・MUI のプリロード処理のコンポーネントを作成
初回画面表示時に自動で実行されるよう、layout.tsx にプリロード用のコンポーネント(AppInitialize.tsx)を埋め込みました。

・ModalContainer や SnackbarContainer などの共通コンポーネントの再構築
これらの子コンポーネント の styled を使っていて、これが間違いで重くなっていたため削除し、再構築しました。

その結果、MUI 関連のプリロードを有効にした場合、初回の画面表示時間が、約 14 秒 →5 秒に短縮することができました。(約 3 倍です w)
API アクセスがある場合は、+ 約 2 秒ほどかかるようです。

(計測結果)

計測の条件として、dev 実行後に時間が経過するとなぜか表示時間が短縮されることがあるため、画面を表示してから即座に画面移動して計測を行いました。

※測定時間にはばらつきがあるため、極端に早かったり遅かったりする時間は除外し、再測定を行っています。

パターン 1 回目(ms) 2 回目(ms) 3 回目(ms) 4 回目(ms) 5 回目(ms) 平均(ms)
プリロード無 13623.2 13201.1 13505.5 13782.5 13557.3 13533.9
MUI 関係のプリロード有 5008.5 4932.7 4369.0 5214.4 5229.0 4950.7
MUI 関係のプリロード有
API アクセス有り
※テーブルは AG Grid を利用
7259.3 7493.7 7639.4 7682.6 7171.2 7449.2

3. プリロードをするために作成したコンポーネント

プリロードをするために作成したコンポーネントの説明をします。

コンポーネント 説明
AppInitializer.tsx このコンポーネントではプリロードするために必要なパッケージのコンポーネントをダミーで準備し実行しています。
layout.tsx AppInitializer.tsx で作成したコンポーネントを layout.tsx に埋め込んで利用します。
これにより、画面の初期描画時にプリロードが自動で行われるようにしています。

また、計測用のカスタムコンポーネントも作成し、デバッグ時に画面表示にかかる時間を測定できるようにしました。(API コールにかかる時間は除く)

(AppInitializer を利用する時の全体の構成)

AppInitializerを利用する時の全体の構成

3-1. AppInitializer.tsx の作成

AppInitializer.tsx の作成について説明します。ここでは、ダミーのコンポーネントをプリロードする処理と、AG Grid を使用するために必要な ModuleRegistry の設定を行っています。

※AG Grid は不要であれば削除してください。個人的には Tanstack Table と比べて扱いやすく、高機能、英語のドキュメントは自動翻訳で読めて、サンプルも掲載されているのでわかりやすくておすすめです。

(/common/AppInitializer.tsx)

/common/AppInitializer.tsx
"use client";

/**************************************************
 * AppInitializer: 一部importのプリロードで高速化 + AG Grid ModuleRegistry
 *
 *
 **************************************************/

// Next.js、React
import { JSX } from "react";

// MUI
import { Autocomplete, Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel, Modal, TextField, Typography, Snackbar, SnackbarContent, Backdrop, CircularProgress } from "@mui/material";

// MUI for DatePicker
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";

// MUI icon materials ※アイコンは重いため個別import
import ClearIcon from "@mui/icons-material/Clear";
import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft";
import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight";

// AG Grid
import { AgGridReact } from "ag-grid-react";
// Community版
import { ModuleRegistry, ClientSideRowModelModule, RowDragModule } from "ag-grid-community";
/* ValidationModule, MenuModule, ColumnsToolPanelModule, FiltersToolPanelModule, SetFilterModule, NumberFilterModule, TextFilterModule, StatusBarModule, SideBarModule, ClipboardModule, CsvExportModule */
// Enterprise版
// import { ExcelExportModule, MasterDetailModule, ServerSideRowModelModule, InfiniteRowModelModule, ViewportRowModelModule, RowGroupingModule, PivotModule, ChartsModule, SparklinesModule } from 'ag-grid-enterprise';

ModuleRegistry.registerModules([
  ClientSideRowModelModule,
  RowDragModule
  // Community版: ValidationModule, MenuModule, ColumnsToolPanelModule, FiltersToolPanelModule, SetFilterModule, NumberFilterModule, TextFilterModule, StatusBarModule, SideBarModule, ClipboardModule, CsvExportModule,
  // Enterprise版: ExcelExportModule, MasterDetailModule, ServerSideRowModelModule, InfiniteRowModelModule, ViewportRowModelModule, RowGroupingModule, PivotModule, ChartsModule, SparklinesModule
]);

// Chart.js
// ここにChart.jsのプリロードしたいものを記載

export const AppInitializer = (): JSX.Element => {
  /**************************************************
   * return JSX.Element: dummy 描画
   *
   **************************************************/
  return (
    <div style={{ display: "none" }}>
      <Autocomplete options={[]} renderInput={(params) => <TextField {...params} />} value={null} onChange={() => {}} />
      <Box></Box>
      <Button></Button>
      <Checkbox></Checkbox>
      <FormControl></FormControl>
      <FormControlLabel control={<Checkbox checked={false} onChange={() => {}} />} label="Dummy" />
      <InputLabel></InputLabel>

      <Modal open={false} onClose={() => {}}>
        <></>
      </Modal>

      <Typography></Typography>

      <Snackbar open={false} />
      <SnackbarContent />

      <Backdrop open={false}>
        <CircularProgress />
      </Backdrop>

      <LocalizationProvider dateAdapter={AdapterLuxon}>
        <DatePicker value={null} onChange={() => {}} />
      </LocalizationProvider>

      <ClearIcon />
      <KeyboardDoubleArrowLeftIcon />
      <KeyboardDoubleArrowRightIcon />

      <AgGridReact rowData={[]} columnDefs={[]} />
    </div>
  );
};

(/app/layout.tsx)

layout.tsx 内に AppInitializer.tsx を埋め込み、MUI のコンポーネントをプリロードします。

/app/layout.tsx
import { JSX } from "react";
import type { Metadata } from "next";
import { AppInitializer } from "@/common/AppInitializer";
import Header from "@/common/organisms/Header";
import Sidebar from "@/common/organisms/Sidebar";
import "./globals.css";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app"
};

export default function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode;
}>): JSX.Element {
  return (
    <html lang="ja">
      <body>
        {/* Header */}
        <header>
          <Header />
        </header>

        {/* Main */}
        <main>
          {/* Sidebar */}
          <Sidebar />

          {/* Content */}
          <div className="content">
            {children}
            {/* 一部コンポーネントをプリロード + AG Grid Module登録 */}
            <AppInitializer />
          </div>
        </main>

        {/* Footer */}
        <footer></footer>
      </body>
    </html>
  );
}

3-2. 共通コンポーネントについて

ModalContainer、SnackbarContainer で styled を使用していることが原因で、画面描画にかなり影響がありました。
styled を使うことで大量のスタイルが読み込まれて遅延していると思われます。
そのため styled を削除し、BOX などで sx を使用してスタイルを当てる必要があります。

ModalContainer、SnackbarContainer のソースはこちらの記事に記載しています。

https://zenn.dev/mofuweb/articles/nextjs-typescript-guide-1-2

3-3. 描画時間の測定に用意したカスタムフック

描画時間を測定するために、カスタムフックを作成しました。
このフックでは、リンクをクリックした時間を sessionStorage に記録し、template.tsx が描画された際の時間とその差を計測して、コンソールに出力します。

(/hooks/common/useRenderTimer.tsx)

描画時間の測定をするカスタムフック

/hooks/common/useRenderTimer.tsx
"use client";

/**************************************************
 * useRenderTimer: 描画時間の計測
 *
 *
 **************************************************/

import { useEffect } from "react";
const STORAGE_KEY = "__RENDER_START__";

// 1. 記録を開始したいコンポーネントに移動するリンクのイベントで、StartTimeをsessionStorageに記録
export const setRenderTimerStart = (): void => {
  sessionStorage.setItem(STORAGE_KEY, performance.now().toString());
};

// 2. マウント完了を確認したいコンポーネント内で呼び出す (Custom Hook)
// 描画時間 = 現在日時 - startTime
export const useRenderTimer = (label?: string): void => {
  useEffect(() => {
    const start = sessionStorage.getItem(STORAGE_KEY);
    if (start) {
      const duration = performance.now() - parseFloat(start);
      console.log(`${label} 描画時間: ${duration.toFixed(2)}ms`);
      sessionStorage.removeItem(STORAGE_KEY);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
};

(/common/organisms/Sidebar.tsx)

測定を開始したいリンクのイベントを用意して、setRenderTimerStart を実行します。

/common/organisms/Sidebar.tsx
"use client";

/**************************************************
 * Sidebar component (共通)
 * ※Sidebar の状態管理付き
 *
 **************************************************/
import { JSX } from "react";
import Link from "next/link";
// import { useRouter } from "next/navigation";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft";
import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight";

import { useSettingSidebar } from "@/hooks/common/useSettingSidebar";
// import { setRenderTimerStart } from "@/hooks/common/useRenderTimer";

export default function Sidebar(): JSX.Element {
  /**************************************************
   * 状態 (State)、カスタムフック、共通関数
   *
   **************************************************/

  const router = useRouter();
  const { isOpen, handleToggleOpen } = useSettingSidebar();

  /**************************************************
   * 関数・イベント ※Propsで渡す関数名はhandleから始める
   *
   **************************************************/

  // 描画時間 計測用
  const handleClick = (): void => {
    setRenderTimerStart();
    router.push("/users");
  };

  /**************************************************
   * return JSX.Element
   *
   **************************************************/
  return (
    <Box
      sx={{
        position: "relative",
        width: isOpen ? 240 : 80,
        paddingTop: isOpen ? "40px" : "20px",
        paddingLeft: isOpen ? "20px" : 0,
        backgroundColor: "ghostwhite",
        borderRight: "1px solid lightgray",
        overflowY: "scroll"
      }}>
      <IconButton
        sx={{
          position: "absolute",
          top: 8,
          right: 8,
          // ← 最前面
          zIndex: 10,
          backgroundColor: "white",
          boxShadow: 1,
          "&:hover": {
            backgroundColor: "lightgray"
          }
        }}
        onClick={handleToggleOpen}>
        {isOpen ? <KeyboardDoubleArrowLeftIcon /> : <KeyboardDoubleArrowRightIcon />}
      </IconButton>

      {isOpen && (
        <>
          <button onClick={handleClick}>User page(描画時間 確認用)</button>
        </>
      )}
    </Box>
  );
}

(/app/users/template.tsx)

画面を表示するコンポーネント内でカスタムフックを実行します。

/app/users/template.tsx
"use client";

// ... (中略)

// Template (Layout & stateを持つ場所)
export default function Template(): JSX.Element {
  // ... (中略)

  // 描画時間計測用
  useRenderTimer("users");

  // ... (中略)
}

(Next.js アプリの画面の描画時間を改善 実行結果)

画面の描画時間が改善されたのを確認できました! (約 7 秒で画面表示)

Next.jsアプリの画面の描画時間を改善

Next.js アプリで dev 実行した際、MUI を利用していると描画が遅くなることがありましたが、プリロードやスタイル管理方法を見直すことで、パフォーマンスを大幅に向上させることができました

これでデバッグ実行の際にスムーズに作業ができますね!

Discussion