본문 바로가기

FrontEnd

컴파운드 컴포넌트 잘만들기 2편 : Smarter, Dumb Breadcrumb

반응형

컴파운드 컴포넌트를 이용하여, 컴포넌트의 렌더링 위치에 관계없이 DOM을 원하는 곳에 삽입하는 방법을 배워봅니다.

원문 번역입니다 : https://jjenzz.com/smarter-dumb-breadcrumb

 

Smarter, Dumb Breadcrumb

With the help of React Context and Portals we can utilise the component tree to generate a breadcrumb trail that doesn't know your hierarchy or your location within it.

jjenzz.com

이동 경로(Breadcrumbs)는 사용자가 웹사이트나 애플리케이션 내에서

자신의 위치를 찾는 데 도움이 되는 유용한 도구입니다.

 

컴파운드 컴포넌트 패턴(compound component pattern)을 사용하여

그리고 React ContextPortals를 이용해

컴포넌트의 계층 구조와 현재 URL location을 모르는 Breadcrumbs를 구현해 보겠습니다.

 

styled-components를 사용해 프리젠테이션(presentational parts of the component) 컴포넌트 먼저 만들겠습니다.

내가 선호하는 스타일링 방법이지만 필수는 아닙니다.

import React from 'react';
import { BrowserRouter, NavLink } from 'react-router-dom';
import styled from 'styled-components';

const Breadcrumb = ({ children }) => (
  <nav aria-label="Breadcrumb">
    <Items>{children}</Items>
  </nav>);

const Items = styled.ol`
  margin: 0;
  padding-left: 0;
  list-style: none;
`;

const BreadcrumbItem = ({ children, to, ...props }) => (
  <Item {...props}>
    <ItemLink to={to}>{children}</ItemLink>
  </Item>);

const Item = styled.li`
  display: inline;

  & + &::before {
    content: '';
    display: inline-block;
    transform: rotate(15deg);
    border-right: 1px solid currentColor;
    height: 1em;
    margin: 0 8px -0.2em;
  }
`;

const ItemLink = styled(NavLink).attrs({ exact: true })`
  color: #36d;
  text-decoration: none;
  border-bottom: 1px solid transparent;

  &:hover {
    border-color: currentColor;
  }

  &.active {
    border: none;
    color: inherit;
  }
`;

const App = () => (
  <Breadcrumb>
    <BreadcrumbItem to="/one">One</BreadcrumbItem>
    <BreadcrumbItem to="/two">Two</BreadcrumbItem>
  </Breadcrumb>);

이것은 Breadcrumb 컴포넌트이며 dumb(비즈니스 로직을 모름)하지만

각 API 컨슈머는 현재 페이지 앞에 있는 이동 경로 항목을 수동으로 하드코딩하거나

계층 구조 및 경로 내 당신의 위치에 관한 레코드를 유지 관리하는 스마트 컨테이너를 생성해야 합니다.

const App = () => (
  <Breadcrumb>
    <BreadcrumbItem to="/one">One</BreadcrumbItem>
    <BreadcrumbItem to="/two">Two</BreadcrumbItem>
  </Breadcrumb>);

더 간단한 방법이 있습니다.

Portals

React Portal은 DOM의 어느 위치에나 컴포넌트를 렌더링하는 기능을 제공합니다.

즉 컴포넌트 렌더링 위치와 DOM의 위치와 Decoupling을 가능하게 합니다.

컴포넌트 트리의 현재 분기 외부에서 컴포넌트를 렌더링할 수 있습니다.

이것은 유용할 뿐만 아니라 유익합니다.

포털을 통해 컴포넌트를 전달하면, 포털에 이미 존재하는 컴포넌트의 children을 대체하지 않습니다.

즉, children 배열에 push할 수 있습니다.

이게 뭐가 좋을까요?

 

이동 경로 추적은 계층적 순서로 현재 페이지의 상위 페이지에 대한 링크 목록으로 구성됩니다.

https://www.w3.org/TR/wai-aria-practices-1.1/#breadcrumb

 

React Router와 같은 계층을 유지하는 라우터 패키지를 사용하면,

페이지 별 컴포넌트는 계층적으로 렌더링됩니다.

 

하지만 Breadcrumb가 애플리케이션 헤더의 Portal 노드가 되면

BreadcrumbItems는 페이지 단위로 헤더 분기 외부에서 소비되고 계층적 순서대로 헤더에 추가됩니다.

 

예제를 통해 확인해 봅시다.

이름이 바뀐 BreadcrumbItems(현재 Breadcrumb)가 렌더링 계층 외부(BreadcrumbPortal 아래가 아닌)에서 렌더링 되지만,

결과적으로는 Header 컴포넌트 아래에 렌더링되는 것을 볼 수 있습니다. 

어떻게 가능한 걸까요?

const Breadcrumb = ({ children, to, ...props }) => {
  const [portalNode, setPortalNode] = useState();

  useEffect(() => {
    setPortalNode(document.getElementById('breadcrumb'));
  }, []);

  return portalNode
    ? ReactDOM.createPortal(
        <Item {...props}>
          <ItemLink to={to}>{children}</ItemLink>
        </Item>,
        portalNode,
      )
    : null;
};

일단 버추얼돔의 메모리 상 가상 표현이 마운트(실제 돔에 삽입)되기 이전에는

브라우저는 아무것도 그리지 않습니다.

useEffect는 컴포넌트가 마운트될 때 포털 노드를 가져와 로컬 상태에 저장합니다. (첫번째 렌더링)

이것은 컴포넌트의 리렌더링을 유발하며, (두번째 렌더링)

breadcrumb ID를 가진 요소를 포함한

BreadcrumbPortal 컴포넌트로 자신을 이동(portal)하여 렌더링 합니다.

 

이 방법은 불행히도 페이지에서 충돌하는 "breadcrumb" ID가 발생할 수 있기에,

현재 페이지에는 이 컴포넌트의 인스턴스가 하나만 있을 수 있습니다.

즉, 소비자가 이미 동일한 ID를 다른 엘리먼트에 제공했을 가능성이 있습니다.

컨텍스트를 이용해 노드를 참조하여 이를 해결할 수 있습니다.

Context

id 속성을 사용할 필요가 없도록 포털 컴포넌트를 일부 변경해 보겠습니다.

const BreadcrumbPortal = () => {
  const [, setPortalNode] = useBreadcrumbContext();
  return (
    <nav aria-label="Breadcrumb">
      <Items ref={setPortalNode} />
    </nav>);
};

Items 컴포넌트에서 id 속성을 제거하고 대신 ref prop을 사용하여 DOM 노드를 가져옵니다.

이 컴포넌트의 자식이 아닌 breadcrumb 컴포넌트에 이 값을 전달할 방법이 필요합니다.

useBreadcrumbContext 훅을 사용할 때입니다.

 

훅을 사용하면 breadcrumb 컴포넌트가 상위/자식 관계 없이

BreadcrumbProvider 컴포넌트에서 제공하는 일부 공유 상태를 사용할 수 있습니다.

import React, { useContext } from 'react';

const Context = React.createContext();

const useBreadcrumbContext = () => {
  const context = useContext(Context);

  if (!context) {
    throw new Error('Missing BreadcrumbProvider.');
  }

  return context;
};

BreadcrumbProvider는 초기 상태 [state,setState]를 제공하고,

애플리케이션 내의 모든 breadcrumb 컴포넌트가 이 상태를 공유할 수 있도록 애플리케이션을 래핑합니다.

const BreadcrumbProvider = ({ children }) => {
  const portalNodeState = useState();

  return (
    <Context.Provider value={portalNodeState}>{children}</Context.Provider>);
};

const rootElement = document.getElementById('root');

ReactDOM.render(
  <BrowserRouter>
    <BreadcrumbProvider>
      <App />
    </BreadcrumbProvider>
  </BrowserRouter>,
  rootElement,
);

이제 Dumb Component인 BreadCrumb는,

가장 가까운 부모 BreadcrumbProvider아래에서 위치상 가장 가까운 BreadcrumbPortal을 부모로 자신을 렌더링하게 됩니다.

(렌더 트리 상에서 포탈이 꼭 부모일 필요가 없습니다.)

BreadcrumbProvider아래 BreadcrumbPortal을 하나만 사용할 수 있도록 조심해야 합니다.

const Breadcrumb = ({ children, to, ...props }) => {
  const [portalNode] = useBreadcrumbContext();

  return portalNode
    ? ReactDOM.createPortal(
        <Item {...props}>
          <ItemLink to={to}>{children}</ItemLink>
        </Item>,
        portalNode,
      )
    : null;
};

React Router와 함께 사용하면 애플리케이션 계층 구조를 모르는

Breadcrumb을 만들 수 있습니다.

주 : React-router 6.4 버전 이후에는 함수로 라우터를 만들어 프로바이더에 제공하기 때문에,
Router와 같이 사용하는 것은 불가능 할 듯 하네요.
반응형