개발새발 로그

react-router-dom으로 GNB(Global Navigation Bar) 만들기 본문

React

react-router-dom으로 GNB(Global Navigation Bar) 만들기

이즈흐 2024. 5. 27. 21:36

📖들어가며..

 

리액트에서 빈번하게 만드는 UI 컴포넌트들을 만들어서 컴포넌트 모음집을 만들어보려고 한다.

참고 자료를 보면서 공부하고 있는데

이때 UI 컴포넌트들을 하나씩 볼 수 있도록 GNB를 만드는 과정이 있었다.

 

이 GNB도 나중에 활용하기 좋을 것 같아 react-router-dom을 이용해서 만들어 보려고 한다.

보통 네비게이션을 만들 때와 비슷하지만 좀 더 선언적이고, 아코디언 기능 까지 넣었다.

이때 상태는 관리하지 않았다.

 

그럼 먼저 완성본을 보면서 이해해보도록 하자.

메인페이지에는 나중에 마크다운 라이브러리를 이용해서 README와 똑같이 넣어보려고 한다.

 

현재 gif를 보면 메뉴가 하위요소를 포함하는 메뉴 하위요소가 없는 메뉴가 있다.

또한 하위 메뉴에서 또 하위메뉴가 있을 가능성이 있다.

또 아코디언이 열려있는 상태에서 다른 요소를 클릭하면 기존 아코디언은 닫힌다.

하나의 아코디언만 열리도록 한다.

그리고 계속해서 각종 메뉴들이 추가될 예정이다.

 

조건들을 생각하면서 개발해보자

 

🛠️설계

먼저 설계를 그림으로 그려보았다.

 

👨‍💻reat-router-dom 설계

먼저 react-router-dom으로 경로를 설정했다.

나는 createBrowserRouter을 사용했다.

 

먼저 루트 경로에 GNB 컴포넌트를 두고 Outlet으로 하위 페이지들을 보여주게 했다.

그러면 사이드에 GNB가 나오고 해당되는 페이지는 오른쪽에 나오게 될 것이다.

코드로 보면 아래와 같다.

export const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      {
        path: '/',
        element: <MainPage />
      },
      {
        path: '/accordion/1_r',
        element: <Accordion1 />
      },
      {
        path: '/accordion/2_r',
        element: <Accordion2 />
      },
      {
        path: '/accordion/3_r',
        element: <Accordion3 />
      },
      {
        path: '/tab-menu',
        element: <TabMenuPage />
      }
    ]
  }
])

 

 

👨‍💻GNB routes 설계

그럼 이제 실제 GNB의 요소들은 어떻게 구성해야할까?

나는 아래와 같이 객체로 구성해보았다.

먼저 경로에 해당하는 string을 key값으로 두고,

key, link, name, children이라는 데이터들을 갖게 했다.

  • link는 해당 아코디언 요소를 클릭했을 때 이동하는 경로다.
  • children배열 또는 컴포넌트 또는 null을 가지는데 
    • 배열이라면 하위요소가 있다는 것을 뜻하고,
    • 컴포넌트라면 하위 요소가 없고, 해당 컴포넌트를 보여주는 것이다.
    • null인 경우는 아무것도 없는 경우다.

모든 경로들은 위처럼 객체를 가져야된다.

추후 추가하는 요소가 있다면 위처럼 구성해야한다.

/accordion 요소에 다른 점이 하나있다.

먼저 하위요소를 가진 아코디언을 누르면 /accordion이라는 경로로 가는 것이 아니라 

/accordion/1_r이라는 경로로 link를 가도록 했다.

 

 /accordion이라는 페이지를 따로 만들지 않고, 클릭하면 바로 맨 첫번째 하위요소로 가도록 설계했다.

 

그럼 코드를 보자

export const routePaths = [
  '/',
  '/accordion',
  '/accordion/1_r',
  '/accordion/2_r',
  '/accordion/3_r',
  '/tab-menu'
] as const
export type ROUTE_PATH = (typeof routePaths)[number]

type BaseRoute = {
  key: ROUTE_PATH
  link: ROUTE_PATH
  name: string
}
export type ParentRoute = BaseRoute & {
  children: ROUTE_PATH[]
}
export type ChildRoute = BaseRoute & {
  children: ((props: unknown) => JSX.Element) | null
}

export type ROUTE = ParentRoute | ChildRoute

export const routes: Record<ROUTE_PATH, ROUTE> = {
  '/': {
    key: '/',
    link: '/',
    name: 'root',
    children: ['/accordion', '/tab-menu']
  },
  '/accordion': {
    key: '/accordion',
    link: '/accordion/1_r',
    name: '01. 아코디언',
    children: ['/accordion/1_r', '/accordion/2_r', '/accordion/3_r']
  },
  '/accordion/1_r': {
    key: '/accordion/1_r',
    link: '/accordion/1_r',
    name: '리액트 아코디언 첫 번째 방법',
    children: Accordion1
  },
  '/accordion/2_r': {
    key: '/accordion/2_r',
    link: '/accordion/2_r',
    name: '리액트 아코디언 두 번째 방법',
    children: Accordion2
  },
  '/accordion/3_r': {
    key: '/accordion/3_r',
    link: '/accordion/3_r',
    name: '리액트 아코디언 세 번째 방법',
    children: Accordion3
  },
  '/tab-menu': {
    key: '/tab-menu',
    link: '/tab-menu',
    name: '탭 메뉴',
    children: TabMenuPage
  }
}

경로들을 상수화해서 타입 에러를 잡을 수 있도록 했다.

여기서 타입 부분이 이해가 안갈 수 있어서 간단하게 설명해보려고 한다.

 

🤔이 타입은 무엇을 의미하나요?

export type ROUTE_PATH = (typeof routePaths)[number]

typeof Array[number] 패턴이라고 불리는 것 같았다.

 

먼저 모든 경로의 문자열을 담은 routePaths 객체를 Const assertions으로 선언한 변수들을 마치 const처럼 만들어 준다/

export const routePaths = [
  '/',
  '/accordion',
  '/accordion/1_r',
  '/accordion/2_r',
  '/accordion/3_r',
  '/tab-menu'
] as const

그러면 배열과 같은 객체는 string[ ]이라는 타입에서 const assertion을 사용해 범위를 좁혀준다.

 

export type ROUTE_PATH = (typeof routePaths)[number]
// "/" | "/accordion" | "/accordion/1_r" | "/accordion/2_r" | "/accordion/3_r" | "/tab-menu"

그리고   typeof names[number]를 사용하여 배열 안에 있는 숫자로 이루어진 모든 index 값을 가져와 유니온 타입으로 만들어준다.

(index signature 문법)  이로 인하여 똑같은 값을 한번 더 사용하지 않고도 유니온 타입을 만들었다.

 

 

🤔Record에 대해서 아시나요?

Record 타입은 두 개의 제네릭 타입을 받을 수 있다.
첫번째 제네릭 타입 K는 key 값의 타입으로, 두번째 제네릭 타입 T은 값의 타입으로 갖는 타입을 리턴한다.

type Record<K, T> = {
    [P in K]: T;
};

프로퍼티 키 값을 K 타입으로, 값을 T 타입으로 하는 타입을 만들 수 있다.

 

Record는 검색하면 잘 나오기 때문에 이정도만 설명하겠다.

 

 

 


 

🛠️실제 경로들을 설계대로 만들어보자

이제 라우트 경로와 GNB요소들을 만들었으니 이를 실제로 컴포넌트로 만들어 사용해보자

👨‍💻레이아웃과 페이지 설계

먼저 루트 레이아웃을 만들어서 GNB는 항상 사이드에 보이도록했다.

const RootLayout = () => {
  return (
    <>
      <Gnb />
      <Outlet />
    </>
  )
}

 

그리고 페이지들은 아래와 같이 구성했다.

아까 위에서 react-router-dom 코드를 보여줬는데 그걸 다시 보면서 참고하면 이해가 쉽다.

👨‍💻GNB 설계

GNB 들이 모두 어떻게 출력되는지 설계했다.

GNB는 위에서 만든 GNB routes 객체를 나열해서 GnbItem 컴포넌트에 담아 호출한다.

export const gnbRootList = (routes['/'] as ParentRoute).children.map(
  r => routes[r]
)

이 때 아까 만든 GNB routes 객체를 실제로 나열할 데이터만 갖고오기 위해 위 코드를 이용해서 "/"안의 객체들을 풀어준다.

 

아래 코드를 보자

나는 emotion을 이용해서 styled-component가 존재한다.

아래에서 명시하지않은 컴포넌트들은 모두 styled-component이다.

✨ Gnb 컴포넌트

const Gnb = () => {
  const { pathname } = useLocation()
  return (
    <SidebarContainer>
      <h1>
        <Link to="/">
          리액트 UI 만들기 <sub>개발새발</sub>
        </Link>
      </h1>
      <ul>
        {gnbRootList.map(r => (
          <GnbItem
            route={r}
            currentPath={pathname as ROUTE_PATH}
            key={r.key}
          />
        ))}
      </ul>
    </SidebarContainer>
  )
}

 

여기서 중요한 코드는 아래 코드다.

  const { pathname } = useLocation()
  // "/accordion/1_r"

useLocation은 현재 경로를 문자열로 가져올 수 있도록 도와주는 훅이다.

문자열을 그대로 ParentGnbItem과 ChilGnbItem 컴포넌트에 넘겨준다.

 

그럼 이제 ParentGnbItem과 ChilGnbItem 컴포넌트의 분기 처리를 하는 GnbItem 코드를 보자

✨ GnbItem 컴포넌트

const GnbItem = ({
  route,
  currentPath
}: {
  route: ROUTE
  currentPath: ROUTE_PATH
}) => {
  if (isParentRoute(route))
    return (
      <ParentGnbItem
        route={route}
        currentPath={currentPath}
      />
    )
  return (
    <ChildGnbItem
      route={route}
      currentPath={currentPath}
    />
  )
}

여기서 아까 말한 하위요소가 있는지 없는지를 판단해서 어떤 컴포넌트를 호출할지 결정한다.

isParentRoute()는 아래와 같다.

export const isParentRoute = (route: ROUTE): route is ParentRoute =>
  Array.isArray(route.children)

간단하게 배열인지 아닌지를 판단했다.

 

그럼 ParentGnbItem을 보자

✨ ParentGnbItem컴포넌트

const ParentGnbItem = ({
  route: { name, link, children },
  currentPath
}: {
  route: ParentRoute
  currentPath: ROUTE_PATH
}) => {
  const open = children.includes(currentPath)

  return (
    <ParentGnbItemLi open={open}>
      <Link to={link}>{name}</Link>
      <ParentGnbItemUlSubRoutes
        open={open}
        length={children.length}>
        {children.map(r => {
          const route = routes[r]
          return (
            <GnbItem
              route={route}
              currentPath={currentPath}
              key={route.key}
            />
          )
        })}
      </ParentGnbItemUlSubRoutes>
    </ParentGnbItemLi>
  )
}

아까 GNB routes 객체에 있는 name,link, children을 가져와서 출력해준다.

이때 하위요소가 있으므로 하위요소들은 다시 GnbItem 컴포넌트를 호출하는 모습이다.

 

그 다음 현재 아코디언이 열려있는지 판단하는 코드다.

  const open = children.includes(currentPath)
  // children: ['/accordion/1_r', '/accordion/2_r', '/accordion/3_r']
  // currentPath : "/accordion/1_r"

우리는 상위 아코디언 객체에 children이라는 배열에 하위 요소의 문자열 경로를 넣어줬었다.

그러면 현재 경로가 만약 children 배열 데이터 중에 하나에 포함된다면 해당 상위 아코디언을 열어줘야한다.

open은 열려있는지 닫혀있는지를 판단할 것이다.

또한 다른 상위 아코디언 요소를 누르면 currentPath가 바뀌므로 다시 닫히게 될 것이다.

 

 

여기서 ParentGnbItemUlSubRoutes 컴포넌트는 styled 컴포넌트인데 아래처럼 overflow와 height를 조건부로 둬서 상위 아코디언 요소를 클릭해야 펼쳐지도록 했다.

import styled from '@emotion/styled'
import { ParentGnbItemUlSubRoutesProps, commonTransition } from '../style'

export const ParentGnbItemUlSubRoutes = styled.ul<ParentGnbItemUlSubRoutesProps>`
  ${commonTransition}
  display:block;
  background-color: ${props => (props.open ? ' #2fb3ff;' : 0)};
  height: ${props => (props.open ? `${props.length * 71}px` : 0)};
  overflow: hidden;
  li {
    padding-left: 10px;
    a {
      padding-left: 33px;
      &::before {
        content: '';
        display: inline-block;
        border: 2px solid #fff;
        border-radius: 2px;
        margin-right: 8px;
        vertical-align: middle;
      }
    }
  }
`

현재 height를 조정해서 transition 효과를 나타냈는데 transform을 이용해서도 가능할 것 같아서 이 부분은 추후 리팩토링이 필요할 것 같았다.

height를 조정하는 것은 좋지 않기 때문이다.

 

이때 ParentGnbItemLi 컴포넌트도 styled 컴포넌트인데 여기에는 height:auto를 줘야 펼쳐졌을 때 아래에 있던 형제 아코디언 요소가 내려가면서 이쁘게 보여질 수 있다.

 

그럼 ChildGnbItem을 보자

✨ChildGnbItem 컴포넌트

const ChildGnbItem = ({
  route: { name, link, children },
  currentPath
}: {
  route: ChildRoute
  currentPath: ROUTE_PATH
}) => {
  return (
    <ChildGnbItemLi active={link === currentPath}>
      {children ? <Link to={link}>{name}</Link> : name}
    </ChildGnbItemLi>
  )
}

 

코드도 간단하고, 위에서 설명했어서 설명은 생략하겠다.

 

 


그러면 이제 모두 완성했다.

styled-component를 사용했기 때문에 스타일 부분들도 있는데 중요한 부분들은 위에서 설명했고, 나머지는 간단해 생략했다.

그러면 이제 사이드에 아코디언 GNB가 완성된 것을 볼 수 있을 것이다.

아 참고로 router를 App.tsx에 적용해야한다. 

이건 다들 알겠지만 혹시 몰라서 올려두었다.

function App() {
  return (
    <div>
      <Global styles={GlobalStyle()} />
      <RouterProvider router={router} />
    </div>
  )
}

 

 

📘마무리하며...

이렇게 GNB를 만들어 보았다.

GNB를 만들면서 뭔가 부족했던 것 같은 부분이 있었는데

height를 사용해서 아코디언을 열고 닫는 것

GNB routes 객체와 react-router-dom에서 사용한 createBrowserRouter 객체가 조금 중복되는 느낌을 받았다.

 

가지고 있는는 데이터가 비슷한데 이를 어떻게 하면 중복을 줄일 수 있을까 생각하게 됐다.

이 부분은 추후 리팩토링해보려고 한다.

 

728x90
반응형
LIST