Router v6 Lazy & Build split

homewiz·2024년 4월 4일

React & typescript

목록 보기
13/18
post-thumbnail

1. INTRO

리액트 프로젝트 산출물을 빌드 할때 코드 분할 하는 법에 대하여

브라우져를 통해 사용자가 첫 페이지에 접속을 하면 index.html & main.js파일을 다운로드 한다. 이때 로딩속도는 main.js의 용량과 비례하므로 js를 정해진 용량 이하로 분할 하여 빌드후
각 페이지에 필요한 파일로 다운 받게하여 속도 향상을 할수 있다.

2. Pre Build

@/App.tsx

...
- import { BrowserRouter as Router } from "react-router-dom";
import { HashRouter as Router } from "react-router-dom";
...

정적 서비스를 위해 라우터를 변경 해준다.

  • BrowserRouter를 권장 하지만 본 프로젝트는 정적호스팅을 하기 위해 HashRouter를 사용한다.

yarn build

> output

./build folder

├─ build
	├─ v0.0.1
    	├─ main.js
    	├─ main.js.LICENSE.txt
    └─ index.html

./config/webpack.prod.js

LICENSE.txt파일이 생성되지 않도록 설정 추가

...
const TerserPlugin = require("terser-webpack-plugin");
...

    optimization: {
      minimize: true,
      minimizer: [new TerserPlugin({ extractComments: false })]
    }
...

yarn build

file tree

3. Split code

PageLoader

./src/components/PageLoader.tsx

import React, { SVGProps } from "react";

const PageLoader = () => {
  return (
    <div className="flex flex-col items-center justify-center flex-1 w-full h-[500px] transition-colors duration-500 bg-gradient-to-b from-blue-100 via-white to-white dark:from-gray-900 dark:via-gray-950 dark:to-black">
      {/* 회전하는 로고 아이콘 */}
      <div className="mb-6">
        <LoadingTwotoneLoop className="w-16 h-16 text-gray-600 opacity-90" />
      </div>

      {/* 텍스트 애니메이션 */}
      <div className="flex items-center space-x-1 text-xl font-semibold tracking-wide text-gray-600 dark:text-blue-300">
        <span>페이지를 불러오는 중입니다</span>
        <span className="text-2xl animate-bounce">.</span>
        <span className="animate-bounce [animation-delay:0.2s] text-2xl">.</span>
        <span className="animate-bounce [animation-delay:0.4s] text-2xl">.</span>
      </div>
    </div>
  );
};

export default PageLoader;

export function LoadingTwotoneLoop(props: SVGProps<SVGSVGElement>) {
  return (
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="10em" height="10em" {...props}>
      <g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
        <path strokeDasharray="16" strokeDashoffset="16" d="M12 3c4.97 0 9 4.03 9 9">
          <animate fill="freeze" attributeName="stroke-dashoffset" dur="0.3s" values="16;0"></animate>
          <animateTransform attributeName="transform" dur="1.5s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"></animateTransform>
        </path>
        <path strokeDasharray="64" strokeDashoffset="64" strokeOpacity=".3" d="M12 3c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9c0 -4.97 4.03 -9 9 -9Z">
          <animate fill="freeze" attributeName="stroke-dashoffset" dur="1.2s" values="64;0"></animate>
        </path>
      </g>
    </svg>
  );
}

App.tsx

...
import React, { Suspense } from "react";

import GNB from "@/components/Navigations/GNB";
import Routes from "@/config/Routes";
import { BrowserRouter as Router } from "react-router-dom";
import PageLoader from "./components/PageLoader";

const App = () => {
  return (
    <div className="flex flex-row justify-center min-h-screen">
      <div className="flex flex-col flex-1 ">
        <Router>
          <GNB />
          <div className="flex flex-1 w-full text-black transition-colors duration-300 bg-white dark:bg-gray-900 dark:text-gray-100">
            <Suspense fallback={<PageLoader />}>
              <Routes />
            </Suspense>
          </div>
        </Router>
      </div>
    </div>
  );
};

export default App;

파일이 로드된 후 랜더링이 될수 있도록 Suspense로 감싸준다.

@/config/Routes.tsx

import Home from "@/pages/Home";
import NotFound from "@/pages/NotFound";
import React from "react";
import { useRoutes } from "react-router-dom";

const Category1 = React.lazy(() => import("@/pages/Category1"));
const Page1 = React.lazy(() => import("@/pages/Category1/Page1"));
const Page2 = React.lazy(() => import("@/pages/Category1/Page2"));
const Page3 = React.lazy(() => import("@/pages/Category1/Page3"));

const Routes = () => {
  const element = useRoutes([
    { path: "/", element: <Home /> },
    {
      path: "/category1",
      element: <Category1 />,
      children: [
        { path: "/category1/page1", element: <Page1 /> },
        { path: "/category1/page2", element: <Page2 /> },
        { path: "/category1/:slug", element: <Page3 /> }
      ]
    },
    { path: "*", element: <NotFound /> }
  ]);
  return element;
};

export default Routes;

./config/webpack.prod.js

optimization: {
      minimize: true,
      minimizer: [new TerserPlugin({ extractComments: false })],
      splitChunks: {
        chunks: "all"
      }
    }
  • splicChucks 옵션을 추가 해준다.

  • import file대신 lazy 함수를 이용해 import 할경우 webpack에서 코드분할을 자동으로 해준다.

Build

yarn build

> output

├─ build
	├─ v0.0.1
    	├─ 887.main.js
    	└─ main.js
    └─ index.html

file open

위 빌드 결과물처럼 자동으로 분할되어 파일이 생긴다.


고급 전략

Routes.tsx에서 페이지들을 선언한뒤에 GNB또는 하위 메뉴바에서 링크를 등록을 해주는 과정이 다소 번거로울 수 있으므려 Routes.tsx에서 정의 된 json데이터를 사용 할수 있도록 변경하자.

@/config/RouteData.tsx

import React from "react";

// 동적 로딩이 아닌 정적 컴포넌트 (즉시 import)
import Dashboard from "@/pages/Dashboard";
import NotFound from "@/pages/NotFound";

// 동적 import를 통한 코드 스플리팅 (React.lazy + Suspense 필요)
const Category2 = React.lazy(() => import("@/pages/Category2"));
const Category3 = React.lazy(() => import("@/pages/Category3"));
const Category4 = React.lazy(() => import("@/pages/Category4"));
const Category4Page1 = React.lazy(() => import("@/pages/Category4/Category4Page1"));
const Category4Page2 = React.lazy(() => import("@/pages/Category4/Category4Page2"));
const Category4Page3 = React.lazy(() => import("@/pages/Category4/Category4Page3"));

/**
 * 실제 라우트 데이터 정의
 * - 라우터 구성 및 메뉴, 권한 처리 등 다목적으로 활용 가능
 */
export const RouteData = {
  Dashboard: { name: "Dashboard", icon: "☀️", disable: false, path: "/", element: <Dashboard /> },
  Category2: { name: "Category2", icon: "☀️", disable: false, path: "/category2", element: <Category2 /> },
  Category3: { name: "Category3", icon: "☀️", disable: false, permision: ["admin"], path: "/category3", element: <Category3 /> },
  Category4: {
    name: "Category4",
    icon: "☀️",
    disable: false,
    path: "/category4",
    element: <Category4 />,
    children: {
      Category4Page1: { name: "Category4Page1", icon: "", disable: false, path: "/category4/page1", element: <Category4Page1 /> },
      Category4Page2: { name: "Category4Page2", icon: "", disable: false, path: "/category4/page2", element: <Category4Page2 /> },
      Category4Page3: { name: "Category4Page3", icon: "", disable: false, path: "/category4/:slug", element: <Category4Page3 /> }
    }
  },
  NotFound: { name: "NotFound", icon: "", disable: true, path: "*", element: <NotFound /> }
};

Routes.tsx

import { RouteObject, useRoutes } from "react-router-dom";

/**
 * 라우트 기본 타입 (RouteObject에서 children 제외 후 확장)
 * - name: 라벨용 이름
 * - disable: 비활성화 여부 (메뉴 숨김 등)
 * - permision: 권한 리스트 (ex: ["admin"])
 */
export interface ExRouteObjectBase extends Omit<RouteObject, "children"> {
  name: string;
  icon: string;
  disable?: boolean;
  permision?: string[];
}

/**
 * 라우트 객체 타입
 * - children은 key-value 구조로 선언하여 참조 편의성 확보
 */
export interface ExRouteObject extends ExRouteObjectBase {
  children?: Record<string, ExRouteObject>; // 중첩 라우팅도 key-value로 관리
}

/**
 * 최상위 라우트 모음: 각 항목은 키를 기반으로 직접 참조 가능
 */
export type RouteDataAtts = Record<string, ExRouteObject>;

/**
 * cleanRoutes:
 * - key-value 구조의 RouteDatas를 React Router가 요구하는 배열 형태로 변환
 * - children도 재귀적으로 변환
 */
export function cleanRoutes(routes: RouteDataAtts): RouteObject[] {
  return Object.values(routes).map(({ path, element, children }) => ({
    path,
    element,
    children: children ? cleanRoutes(children) : undefined
  }));
}

/**
 * availableRouteDatas:
 * - disable이 아닌 라우트만 반환
 * - 메뉴 표시, 퍼미션 검사용
 */
export function availableRouteDatas(routes: RouteDataAtts): ExRouteObject[] {
  return Object.values(routes).filter(r => !r.disable);
}

/**
 * Routes 컴포넌트:
 * - useRoutes()를 통해 라우트 요소 생성
 * - cleanRoutes()를 통해 라우터용 포맷으로 변환
 */
const Routes = ({ data }: { data: RouteDataAtts }) => {
  const element = useRoutes(cleanRoutes(data));
  return element;
};

export default Routes;

{ name: "", visible: true, permision: ["admin"], path: "/", element: }

  • name: 메뉴에 표기될 라벨
  • icon: 아이콘 링크 또는 이모지
  • disable: 메뉴에 표기 하지 않을 것인가
  • permision: 관리자 페이지 전용

여기서는 단순하기 메뉴명만 표기 하도록 하겠다.

GNB.tsx

import React, { useEffect, useState } from "react";
import { NavLink } from "react-router-dom";

import { availableRouteDatas, RouteDataAtts } from "@/config/Routes";

export default function GNB({ data }: { data: RouteDataAtts }) {
  const menus = availableRouteDatas(data);
  const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark"));

  const toggleDark = () => {
    const newVal = !isDark;
    document.documentElement.classList.toggle("dark", newVal);
    localStorage.setItem("theme", newVal ? "dark" : "light");
    setIsDark(newVal);
  };

  useEffect(() => {
    const saved = localStorage.getItem("theme");
    if (saved === "dark") {
      document.documentElement.classList.add("dark");
      setIsDark(true);
    }
  }, []);

  return (
    <header className="relative z-50 flex items-center justify-between w-full h-16 px-8 text-gray-900 bg-white border-b border-gray-200 shadow dark:bg-gray-900 dark:border-gray-700 dark:text-white">
      <nav className="flex items-center space-x-6">
        <span className="text-lg font-bold tracking-tight">REACT</span>
        {menus.map((item, idx) =>
          item.children ? (
            <div key={idx} className="relative group">
              <span className="px-3 py-2 rounded cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800">{item.icon + " " + item.name}</span>
              <div className="absolute top-full left-0 mt-1 bg-white dark:bg-gray-800 shadow-lg rounded border dark:border-gray-700 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity z-10 min-w-[160px]">
                {availableRouteDatas(item.children).map((child, i) => (
                  <NavLink key={i} to={child.path} className={({ isActive }) => `block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${isActive ? "font-semibold" : ""}`}>
                    {child.name}
                  </NavLink>
                ))}
              </div>
            </div>
          ) : (
            <NavLink key={idx} to={item.path} className={({ isActive }) => `px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 ${isActive ? "font-semibold" : ""}`}>
              {item.icon + " " + item.name}
            </NavLink>
          )
        )}
      </nav>

      <div className="flex items-center space-x-4 text-sm">
        <button onClick={toggleDark} className="px-3 py-1 text-black bg-gray-200 rounded dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 dark:text-white">
          {isDark ? "🌙 다크" : "☀️ 라이트"}
        </button>
        <span className="text-gray-500 dark:text-gray-300">👤 운영자</span>
        <button className="px-3 py-1 text-white bg-red-500 rounded hover:bg-red-600">로그아웃</button>
      </div>
    </header>
  );
}

SideNavBar.tsx

import React from "react";
import { NavLink } from "react-router-dom";

import { availableRouteDatas, RouteDataAtts } from "@/config/Routes";

export interface SideNavBarProps {
  data: RouteDataAtts;
}

export default function SideNavBar({ data }: SideNavBarProps) {
  const menus = availableRouteDatas(data);
  return (
    <aside className="w-48 border-r border-gray-200 dark:border-gray-700">
      <nav className="">
        {menus.map((item, idx) => (
          <NavLink key={idx} to={item.path} className={({ isActive }) => `block px-3 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 ${isActive ? "font-bold bg-gray-100 dark:bg-gray-800 dark:text-white" : ""}`}>
            {item.icon + " " + item.name}
          </NavLink>
        ))}
      </nav>
    </aside>
  );
}

Category4/index.tsx

import React from "react";

import SideNavBar from "@/components/Navigations/SideNavBar";
import PageLayout from "@/components/PageLayout";

import { Outlet } from "react-router-dom";
import { RouteData } from "@/config/RouteData";

const Category4 = () => {
  return (
    <PageLayout className="flex-row p-0">
      <SideNavBar data={RouteData.Category4.children} />
      <Outlet />
    </PageLayout>
  );
};

export default Category4;

App.tsx

import React, { Suspense } from "react";
import { BrowserRouter as Router } from "react-router-dom";

import GNB from "@/components/Navigations/GNB";
import Routes from "@/config/Routes";
import PageLoader from "@/components/PageLoader";
import { RouteData } from "@/config/RouteData";

const App = () => {
  return (
    <div className="flex flex-row justify-center min-h-screen">
      <div className="flex flex-col flex-1 ">
        <Router>
          <GNB data={RouteData} />
          <div className="flex flex-1 w-full text-black transition-colors duration-300 bg-white dark:bg-gray-900 dark:text-gray-100">
            <Suspense fallback={<PageLoader />}>
              <Routes data={RouteData} />
            </Suspense>
          </div>
        </Router>
      </div>
    </div>
  );
};

export default App;

0개의 댓글