본문 바로가기

프로그래밍 - 활용/Front-end

NPM에 라이브러리 배포하기: usePortal편

1. Portal이란

(1) 정의

컴포넌트 일부가 하위의 컨테이너에 위치해도, 해당 컨테이너를 탈출하여 상위 컨테이너에 위치하는 것처럼 요소를 렌더링 할 수 있는 방법이다. Portal 생성을 하면, createPortal의 결과를 렌더링하고 DOM 노드가 있어야 할 위치를 지정한다.

 

구조는 변하지 않으나, 렌더링하는 위치만 바뀌어 보이게 된다.

구조는 하위 컨테이너에 위치해 있기에, 렌더링 하는 위치가 최상위이더라도 하위 컨테이너의 직계 상위 컨테이너와의 기능 연동이 가능하다. 렌더링 되는 위치를 최상위로 지정하게 된다면 z-index를 사용하지 않아도 된다는 이점이 존재한다.

  • 구조: 하위 컨테이너에 위치
  • 렌더링: 상위 컨테이너에 위치

 

(2) 사용하는 이유

리액트의 경우 DOM 트리를 형성할 때, 부모-자식의 구조로 연결하게 된다. 부모 컴포넌트가 렌더링되면, 자식 컴포넌트도 함께 렌더링 되는 구조로 구성이 되는 것이다. 그렇기에 자식 컴포넌트는 부모 컴포넌트의 스타일에 영향을 받아, 자식을 부모보다 높은 레이어에서 보여주고 싶을 경우 z-index와 같은 추가적인 처리가 요구된다.

 

Portal을 사용한다면, 구조적으로는 부모-자식 관계를 유지하고 렌더링 측면으로 독립적인 구조를 유지할 수 있다. 부모 컴포넌트의 DOM 계층 구조의 바깥에 있는 DOM 노드로 자식을 렌더링 할 때, 가장 효율적인 방법이라고 볼 수 있다.

 

(3) 사용 방법

createPortal로 Portal을 생성할 수 있다.

  • 구성요소
    • children: JSX의 일부, Portal로 띄울 컴포넌트이다.
    • domNode: 컴포넌트를 띄울 DOM 노드이며, Portal 생성 전 존재해야 한다.
    • key?: portal의 key로 사용할 고유한 문자열/숫자이며, 선택적이다. (해당 예시에선 생략했다.)
  • 반환값
    • ReactNode
    • React 렌더링 중 해당 Portal을 발견하면, children을 domNode 내부에 배치하게 된다.
const Modal = () => {
  return createPortal(<div>Modal</div>, document.body);
};

body 태그 하위에 div 태그로 Modal 요소가 배치된 것을 확인할 수 있다.

 

2. usePortal 제작

(0) usePortal 제작 배경

Portal은 원하는 DOM 노드에 컴포넌트를 렌더링할 수 있다. 단, 해당 DOM 노드가 Protal 생성 전에 필수적으로 존재해야 한다. 이러한 DOM 노드에 대한 설정 방법은 다양하다.

  • index.html에 DOM 노드 사전에 생성하기
  • 커스텀 훅으로 DOM 노드 동적으로 생성하기

index.html을 수정해 특정 DOM 노드를 직접 생성하면 필요하지 않은 시기에도 DOM 노드가 계속 존재하며, Portal을 사용하는 요소가 많아질수록 DOM 노드의 수가 증가한다는 단점이 존재한다. 그렇기에 커스텀 훅으로 필요한 시기에 DOM 노드를 생성하고 해당 요소를 사용하지 않을 때 DOM 노드를 삭제하는 방법을 선택했다.

 

(1) usePortal의 전체 기능

  • 사용자로부터 id를 전달받아, document.body의 자식으로 컴포넌트를 배치한다.
  • useEffect으로 해당 요소를 사용하지 않는 경우, return을 통해 DOM 노드에서 삭제한다.
  • SSR 환경에서 브라우저가 존재하지 않기에, 동작하지 않는 예외 처리를 진행한다.

 

(2) usePortal 내부 기능: id를 가진 DOM 노드 생성

  • isBrowser가 true인 경우를 가정한다.
    • document에 id를 가진 요소가 있다면, 해당 요소를 element에 할당한다.
    • document에 id를 가진 요소가 없다면, 해당 요소를 생성하여 element에 할당한다.
const element = isBrowser
    ? document.getElementById(selectedId) || createElement(selectedId)
    : null;
  • document에 id를 가진 요소가 없다면, 해당 요소를 생성하는 로직이다.
const createElement = (selectedId: string) => {
  const element = document.createElement("div");
  element.setAttribute("id", selectedId);
  return element;
};

 

(3) usePortal 내부 기능: document.body의 자식으로 생성한 DOM 노드 배치

  • document에 id를 가진 요소가 있다면, document.body의 자식 요소로 할당하지 않는다. (이미 생성된 경우)
  • document에 id를 가진 요소가 없다면, document.body의 자식 요소로 할당한다.
const parentElement = document.body;

if (!document.getElementById(selectedId)) {
	parentElement.appendChild(element);
}

 

(4) usePortal 내부 기능: 생성한 DOM 노드를 사용하지 않는 경우, 삭제

useEffect의 return을 활용해, 사용하지 않는다면 document.body에서 해당 요소를 삭제하게 된다.

useEffect(() => {
    return () => {
      parentElement.removeChild(element);
    };
  }, []);

 

(5) usePortal 내부 기능: SSR 환경에서 예외 처리

  • SSR 환경에선 브라우저가 존재하지 않는다. window, document가 모두 undefined이다.
    • SSR 환경에선 isBrowser가 false이다.
    • SSR 환경이 아니라면 isBrowser가 true이다.
const isBrowser =
  typeof window !== "undefined" && typeof document !== "undefined";
  • isBrowser가 false인 경우를 가정한다.
    • element에 null을 할당한다.
const element = isBrowser
    ? document.getElementById(selectedId) || createElement(selectedId)
    : null;
  • 생성된 DOM 노드가 없기에, useEffect 내부를 실행하지 않고 종료한다.
useEffect(() => {
    if (!element) return;
  }, []);

 

(6) 전체 코드

🏄 https://github.com/minjeongss/surff/blob/main/packages/hooks/usePortal.ts

 

3. Modal에 usePortal 사용

Modal은 대표적으로 최상위 요소에 배치되는 컴포넌트이다. 

 

(1) usePortal로 Modal 배치할 DOM 노드 생성

modal이라는 ID를 가진 div 태그의 DOM 노드가 document.body 자식으로 생성된다.

const element = usePortal("modal");

 

(2) createPortal을 Modal에 적용

const Modal = () => {
  const element = usePortal("modal");
  const [isOpen, setIsOpen] = useState(false);

  if (!element) return null;

  return (
    <>
      <p>Modal {isOpen ? "ON" : "OFF"}</p>
      {createPortal(
        <ModalWrapper onClick={() => setIsOpen((prev) => !prev)}>
          Modal
        </ModalWrapper>,
        element
      )}
    </>
  );
};

 

(3) 전체 코드

🏄 https://github.com/minjeongss/surff/blob/main/packages/components/Modal/Modal.tsx

 

(4) 렌더링 측면 분석

root 하위에 Modal 컴포넌트를 배치했음에도 불구하고, Portal로 Modal 컴포넌트를 생성했기에 body 하위에 modal이라는 id를 가진 컴포넌트가 배치되었음을 확인할 수 있다.

 

(5) 구조적 측면 분석

root 하위에 Modal 컴포넌트가 배치되어 있기에, root 하위 요소인 p와 root의 형제 요소인 Modal이 isOpen이라는 값을 공유한다는 사실을 발견할 수 있다. 즉, Portal은 렌더링적인 측면에서 z-index를 사용하지 않고 최상위 요소에 띄울 수 있으며, 구조적인 측면에서 내부 값을 공유할 수 있다는 이점을 지니고 있다. 👍