File-Based Routing System

NB·2022년 8월 25일
7
post-thumbnail
post-custom-banner

@TANSTACK/REACT-LOCATION을 사용하여 SPA구조에서 파일 기반 라우팅 시스템을 개발합니다.

Goal

본 라우팅 시스템은 아래와 같은 목표를 달성합니다.
또한 본 라우팅 시스템은 Next.jsRemix 라우팅 규칙의 일부를 따릅니다.

  • SPA(Single-Page-Application)
  • Declarative File-based
  • Nested Layout
  • Nested Path Without Pathless Layout
  • Dynamic Routes
  • Data Loader
  • Code Split

Why

그래서 이미 구현된 React-Location 라이브러리에서 왜 파일 기반 라우팅 시스템을 구현하는지 궁금하시다면 간단합니다. React-Router이라던지 Reasct-Location과 같은 툴을 기본적으로 사용하면 라우팅 시스템을 코드로 직접 작성해야 합니다. 그렇게되면 실수가 발생할 가능성이 높아지며(휴먼에러), 이는 곧 생산성의 하락으로 이어집니다.

또한, Vite의 import.meta.glob() 기능을 보고 이를 구현해야겠다고 생각하였습니다. 그러고 찾아보니 Omar Elhawary가 작성한 멋진 글이 있더라구요. 그래서 영감을 받아서 제작하였습니다.

파일 기반 라우팅 시스템은 폴더 구조에서 파일을 추가/제거 및 이름 변경에 대해서 자동으로 경로를 업데이트 해줍니다. 이는 언제든지 같은 파일 기반 라우팅 시스템인 Remix나 Next.js으로의 더 쉬운 마이그레이션을 뜻합니다.


React-Location을 사용한 파일기반 중첩 레이아웃 라우팅

이 모든 기능은 Vite.js@tanstack/react-location 기반으로 구성되었으며, Omar Elhawary의 멋진 글에서 영감을 받아 제작되었습니다! 아래는 제가 참고한 글입니다.

하지만, 위 두 글에서 볼 수 있는 구조는 아직 구현되지 않은 몇 가지 기능들이 있습니다.

지정하지 않은 하위 라우트가 로드

예로들어 프로젝트에서 지정한 경로가 /home/name 와 같다고 가정할 때, 위에서 적용한 라우팅 구조는 아래 경로로 접근해도 똑같이 /home/name 으로 라우팅되게 됩니다.

  • /home/name/dummy 👉 /home/name

  • /home/name/1 👉 /home/name

  • /home/name/1/dummy/... 👉 /home/name

이 것은 결국 의도하지 않은 경로로 사용자가 접근해도 404 에러 페이지를 보여줄 수 없으며 의도치않은 동작을 야기할 수 있습니다.

경로로 인식되지 않는 중첩 레이아웃 파일

이 기능은 Next.js의 Layouts RFC 와 영감을 받았습니다. pages 폴더에 파일이 있지만, 경로에 포함되지 않는 레이아웃을 위한 파일이 존재합니다.

Next.js에서는 고급 레이아웃 기능을 적용하기 위해서 layout.js 와 page.js 가 존재합니다.각 폴더는 실제 경로로 적용됩니다.

auth 라는 동일한 레이아웃을 가져가는 sign-in, sign-up 등의 페이지가 존재합니다. 이 때, 저는 Next.js처럼 page.jslayout.js를 만드는 대신에 다른 방법을 택하였습니다. 각 폴더가 실제 라우팅 경로가 되는 것은 일치합니다. 대신에 다음과 같이 변형되어 적용되었습니다.

  • layout.js 👉 <폴더명>.js

  • page.js 👉 index.js

  • 전체 👉 *.js

이는 VSCode 또는 많은 idle에서 전역으로 파일을 검색할 때, page.js가 수십 개 표시되는 것을 방지하기 위한 일환입니다. (index.js 로 하는 것은 다른 방법이 없었습니다! 좋은 방법이 있다면 알려주세요. 🤔)

setting.tsx
setting/
    user/
        index.tsx
    article/
        index.tsx

라우팅 시스템 제작

서론이 길었습니다. 우리는 Vite를 사용하여 해당 시스템을 제작할 것이며, Vite 환경에서 사용할 수 있는 import.meta.glob() 기능을 사용할 것입니다. 만약 Vite를 사용하지 않는다면 NPM에서 다른 차선책을 사용할 수 있습니다.

Origin

먼저, RouterRrovider.tsx 파일에 내부 모듈을 로드하여 React-Location에서 요구하는 객체로 만들어줍니다.
아래는 Omar Elhawary가 작성한 결과물 코드입니다.

// src/providers/RouteProvider.tsx

type Element = () => JSX.Element;
type Module = { default: Element; Loader: LoaderFn; Pending: Element; Failure: Element };

/** 기존 예약 파일 (Like Next.js) */
const PRESERVED = import.meta.glob<Module>('/src/pages/(_app|_error).tsx', { eager: true });
const preservedRoutes: Partial<Record<string, () => JSX.Element>> = Object.keys(PRESERVED).reduce(
  (routes, key) => {
    const path = key.replace(/\/src\/pages\/|\.tsx$/g, '');
    return { ...routes, [path]: PRESERVED[key]?.default };
  },
  {},
);

/** 모든 페이지 파일 */
const ROUTES = import.meta.glob('/src/pages/**/[a-z[]*.tsx');
const regularRoutes = Object.keys(ROUTES).reduce<Route[]>((routes, key) => {
  const module = ROUTES[key];
  const route: Route = {
    element: () => module().then(mod => (mod?.default ? <mod.default /> : <></>)),
    loader: async (...args) => module().then(mod => mod?.Loader?.(...args)),
    pendingElement: async () => module().then(mod => (mod?.Pending ? <mod.Pending /> : null)),
    errorElement: async () => module().then(mod => (mod?.Failure ? <mod.Failure /> : null)),
  };
  
  // 정규표현식을 통해서 세그먼트를 뽑아냅니다.
  const segments = key
    .replace(/\/src\/pages|\.tsx$/g, '')
    .replace(/\[\.{3}.+\]/, '*')
    .replace(/\[([^\]]+)\]/g, ':$1')
    .split('/')
    .filter(Boolean);
  
  // 뽑아낸 세그먼트 리스트를 분석합니다.
  segments.reduce((parent, segment, index) => {
    const path = segment.replace(/index|\./g, '/');
    const root = index === 0;
    const leaf = index === segments.length - 1 && segments.length > 1;
    const node = !root && !leaf;
    const insert = /^\w|\//.test(path) ? 'unshift' : 'push';
    
    // 루트에 존재하는 경로 파일
    if (root) {
      const dynamic = path.startsWith(':') || path === '*';
      if (dynamic) return parent;

      const last = segments.length === 1;
      if (last) {
        routes.push({ path, ...route });
        return parent;
      }
    }

    // 루트 또는 중간에 존재하는 경로 파일
    if (root || node) {
      const current = root ? routes : parent.children;
      const found = current?.find(route => route.path === path);
      if (found) found.children ??= [];
      else current?.[insert]({ path, children: [] });
      return found || (current?.[insert === 'unshift' ? 0 : current.length - 1] as Route);
    }

    // 말단에 존재하는 경로 파일
    if (leaf) {
      parent?.children?.[insert]({ path, ...route });
    }
    
    return parent;
  }, {} as Route);

  return routes;
}, []);

const App = preservedRoutes?.['_app'] || Fragment;
const NotFound = preservedRoutes?.['_error'] || Fragment;

const location = new ReactLocation();
const routes = [...regularRoutes, { path: '*', element: <NotFound /> }];

export const Routes = () => {
  return (
    <Router location={location} routes={routes}>
      <App>
        <Outlet />
      </App>
    </Router>
  )
}

위 코드를 작성한 Omar Elhawary는 대단합니다. element에서 라우트 컴포넌트를 비동기적으로 로드하여, element 속성이 React.lazy 또는 <Suspense> 를 사용하지 않아도 가능하도록 만들었습니다. 또한, React-Location에서 추진하고 있는 경로 로더를 그대로 사용할 수 있게끔 제작되었습니다. 👏👏

타입스크립트와 함께 경로 로더를 사용하는 방법을 포함하여 로직에 대해서는 이 글에서 따로 서술하지는 않습니다. 자세한 내용은 이 글을 참고해주시길바랍니다! 👀

Develop

아래 코드는 위에서 소개한 구현되지 않은 기능에 대해서 구현한 코드입니다.

의도치 않은 경로 라우팅

오직 index.js만이 실질적인 페이지로 인식시킬 필요성이 있습니다. 또한, 지정한 경로를 초과한 경로로 접속해도 에러 페이지를 띄울 필요성이 있습니다. 다만, *.js 를 생성한 경우에는 의도한 경로이므로 아래와 같이 코드를 작성합니다.

const ROUTES = import.meta.glob<Module>('/src/pages/**/(*|[a-z[])*.tsx');
const regularRoutes = Object.keys(ROUTES).reduce<Route[]>((routes, key) => {

  ...

  const segments = key
    .replace(/\/src\/pages|\.tsx$/g, '')
    .replace(/\[([^\]]+)\]/g, ':$1')
    .split('/')
    .filter(Boolean);
    
  segments.reduce((parent, segment, index) => {
    const path = segment.replace(/index|\./g, '/');
    
    ...
    
    return parent;  
  }, {} as Route);

  return routes;
}, []);
const Pages = () => {
  const location = useLocation();
  const pathname = location.current.pathname;
  const currentRoutes = Object.keys(ROUTES)
    .filter(path => /(index|\*)\.(js|jsx|tsx)$/.test(path))
    .map(
      key =>
        new RegExp(
          '^' +
            key
              .replace(/\/src\/pages|(\/|\.)index|\.tsx$/g, '')
              .replace(/\./g, '/')
              .replace(/\*/g, '.+')
              .replace(/\[([^\]]+)\]/g, '[0-9a-zA-Z]+') +
            '(/)?$',
        ),
    );

  const matched = currentRoutes.map(regex => regex.test(pathname)).some(Boolean);

  return <App>{matched ? <Outlet /> : <NotFound />}</App>;
};

현재 존재하는 페이지 경로를 가진 Regex를 생성하여, 해당 필터에 부합하지 않은 경로면 <NotFound> 컴포넌트를 반환합니다.

Root Router

Omar Elhawary가 작성한 글에 따르면 <Router> 컴포넌트 내부는 다음과 작성됩니다.

export const Routes = () => {
  return (
    <Router location={location} routes={routes}>
      <App>
        <Outlet />
      </App>
    </Router>
  )
}

관리 포인트를 최소화 하기 위하여 위에서 작성한 <Pages> 컴포넌트를 넣어줍니다.

const Routes = (props: Omit<RouterProps, 'children' | 'location' | 'routes'> = {}) => {
  return (
    <Router {...props} location={location} routes={routes}>
      <Pages />
    </Router>
  );
};

이제 Vite의 앱 진입 파일인 main.tsx 파일을 다음과 같이 작성해면 됩니다. (React v18 이상)

// src/main.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import RouteProvider from 'components/providers/RouteProvider';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <RouteProvider />
  </React.StrictMode>,
);

라우팅 폴더 구조 및 규약

위에서 소개한 과정을 거치면 다음과 같은 폴더 구조를 가질 수 있습니다. 👏👏

src/
  pages/
    _app.tsx
    _error.tsx
    index.tsx
    setting.tsx
    setting/
      user/
        index.tsx
      article/
        index.tsx
    setting.no-layout.index.tsx
    articles/
      index.tsx
      [id]/
        index.tsx

레이아웃 규약

React-Location을 사용한 레이아웃을 작성하기 위한 파일 작성법은 아래와 같습니다.

  • 태그를 통해서 하위 페이지를 넣을 공간을 지정합니다.
// src/pages/setting.tsx

import { Outlet } from '@tanstack/react-location';
// components
import MainLayout from 'components/layouts/Main';

const SettingLayout = () => {
  return (
    <MainLayout>
      <h1>Setting Layout Title</h1>
      <Outlet />
    </MainLayout>
  );
};

export default SettingLayout;

라우팅 규약

위 라우팅 시스템을 적용시킨 프로젝트 폴더 구조에서 가질 수 있는 라우팅 규약은 다음과 같습니다.

색인 경로

  • src/pages/index.tsx 👉 /

  • src/pages/setting/index.tsx 👉 /setting

중첩 경로

  • src/pages/setting/user/index.tsx 👉 /setting/user

동적 경로

  • src/pages/articles/[id]/index.tsx 👉 /articles/:id

    • { params: { id: 'Value' }}
  • src/pages/articles/*.tsx 👉 /articles/*

    • { params: { *: 'Value' }}

중첩 레이아웃 없는 경로

  • src/pages/setting.admin.tsx 👉 /setting/admin

  • setting.tsx 의 레이아웃이 적용되지 않습니다.


References

profile
𝙄 𝙖𝙢 𝙖 𝙛𝙧𝙤𝙣𝙩𝙚𝙣𝙙 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙚𝙧 𝙬𝙝𝙤 𝙚𝙣𝙟𝙤𝙮𝙨 𝙙𝙚𝙫𝙚𝙡𝙤𝙥𝙢𝙚𝙣𝙩. 👋 💻
post-custom-banner

0개의 댓글