@TANSTACK/REACT-LOCATION
을 사용하여 SPA구조에서 파일 기반 라우팅 시스템을 개발합니다.
본 라우팅 시스템은 아래와 같은 목표를 달성합니다.
또한 본 라우팅 시스템은 Next.js 와 Remix 라우팅 규칙의 일부를 따릅니다.
그래서 이미 구현된 React-Location 라이브러리에서 왜 파일 기반 라우팅 시스템을 구현하는지 궁금하시다면 간단합니다. React-Router이라던지 Reasct-Location과 같은 툴을 기본적으로 사용하면 라우팅 시스템을 코드로 직접 작성해야 합니다. 그렇게되면 실수가 발생할 가능성이 높아지며(휴먼에러), 이는 곧 생산성의 하락으로 이어집니다.
또한, Vite의 import.meta.glob()
기능을 보고 이를 구현해야겠다고 생각하였습니다. 그러고 찾아보니 Omar Elhawary가 작성한 멋진 글이 있더라구요. 그래서 영감을 받아서 제작하였습니다.
파일 기반 라우팅 시스템은 폴더 구조에서 파일을 추가/제거 및 이름 변경에 대해서 자동으로 경로를 업데이트 해줍니다. 이는 언제든지 같은 파일 기반 라우팅 시스템인 Remix나 Next.js으로의 더 쉬운 마이그레이션을 뜻합니다.
이 모든 기능은 Vite.js 와 @tanstack/react-location 기반으로 구성되었으며, Omar Elhawary의 멋진 글에서 영감을 받아 제작되었습니다! 아래는 제가 참고한 글입니다.
File-based routing with React Location — Data loaders | Omar Elhawary
File-based routing with React Location — Nested layouts | 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.js
와 layout.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에서 다른 차선책을 사용할 수 있습니다.
먼저, 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에서 추진하고 있는 경로 로더를 그대로 사용할 수 있게끔 제작되었습니다. 👏👏
타입스크립트와 함께 경로 로더를 사용하는 방법을 포함하여 로직에 대해서는 이 글에서 따로 서술하지는 않습니다. 자세한 내용은 이 글을 참고해주시길바랍니다! 👀
아래 코드는 위에서 소개한 구현되지 않은 기능에 대해서 구현한 코드입니다.
오직 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>
컴포넌트를 반환합니다.
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
의 레이아웃이 적용되지 않습니다.