호진방 블로그
경험

Next.js 블로그 만들기 Ver.3

# Next.js + MDX 블로그 개발기 3탄

2024년 06월 29일

이전 글

사이드바 구현

글 상세 페이지에서 목차를 표시하고, 목차를 클릭하면 해당 헤딩으로 이동하는 사이드바를 구현하는 아이디어는 다음과 같다.

기존의 PostHeader에서는 모든 글 데이터 중 현재 주소에 매칭되는 글 상세 데이터를 가져온다. 이 데이터에는 content 속성이 포함되어 있으며, 이 속성에는 HTML로 파싱되기 이전의 순수한 마크다운 코드가 들어 있다. 이 마크다운 코드를 parseContent 함수를 이용하여, ##이 포함된 헤딩 컴포넌트를 찾아내어, 텍스트를 추출하고, #을 추가하여 링크를 생성한다.


PostHeader.tsx
export function PostHeader({ posts }: PostHeaderType) {
  const segments = useSelectedLayoutSegments(); // think/why-i-use-next 으로 왔을 때 ["think" , "why-i-use-next"] 형식
  const post = posts.find((post) => post.id === segments[segments.length - 1]); //해당 글 가져오기 받은 post는 배열이라서 find씀
  console.log(post.content);
  if (post == null) return <></>;
 
  const parsedContent = parseContent(post.content);
 
  return (
    <>
      <div className="pt-3 pb-8">
        <div
          className="max-w-max flex items-center py-1 px-2 bg-gray-200 font-naverBold rounded-md mb-2 dark:text-white dark:bg-gray-800"
          suppressHydrationWarning={true}
        >
          {getTag(post.tag)}
        </div>
        <h1 className=" text-2xl font-naverBold dark:text-gray-100">{post.title}</h1>
      </div>
      <Sidebar parsedContent={parsedContent} />
    </>
  );
}
export const parseContent = (content: string): ParsedPost[] => {
  const regex = /^(##) (.*$)/gim;
  const headingList = content.match(regex);
  return (
    headingList?.map((heading: string) => ({
      text: heading.replace('##', ''),
      link:
        '#' +
        heading
          .replace('# ', '')
          .replace('#', '')
          .replace(/[\[\]:!@#$/%^&*()+=,.]/g, '')
          .replace(/ /g, '-')
          .toLowerCase()
          .replace('?', ''),
    })) || []
  );
};

해당 함수로 작성할 수 있는데, URL에는 특수 문자를 포함할 수 없기 때문에, link에서 특수 문자를 제거하거나 공백을 -로 대체하여 URL 형식으로 변환한다.


변환된 ## 헤딩 코드
[
  {
    text: ' 남은 기능 구현해보기',
    link: '#남은-기능-구현해보기',
  },
  {
    text: ' 메인페이지 - 글 리스트',
    link: '#메인페이지---글-리스트',
  },
  {
    text: ' PostHeader',
    link: '#postheader',
  },
];

반환된 link 데이터를 Linkhref 속성에 넣어주면 된다. 그러나 HTML에는 해당 링크와 매칭되는 ID를 지정하지 않았기 때문에, 링크가 제대로 생성되더라도 HTML에 ID를 넣지 않으면 해당 헤딩 위치로 이동하지 않는다. 따라서 HTML로 렌더링될 때, 위 link 데이터 형식과 동일한 ID를 h2 태그에 추가해야 한다.


H2.tsx
export function H2({ children }) {
  //##에 대해 id추가 하기, sidebar에서 바로 갈 수 있어야함
  const parsedId = children
    .replace('# ', '')
    .replace('#', '')
    .replace(/[\[\]:!@#$/%^&*()+=,.]/g, '')
    .replace(/ /g, '-')
    .toLowerCase()
    .replace('?', '');
  return (
    <h2
      id={parsedId}
      className="font-naverBold pl-2 flex items-center group my-8 relative text-[24px] bg-my-strong-gray py-2 dark:text-white dark:bg-gray-800"
    >
      {children}
    </h2>
  );
}

parsedContent 함수와 동일한 방식으로 H2 컴포넌트에 id를 추가해준다.


이제 렌더링된 h2태그에는 id가 들어온다.


Sidebar.tsx
const Sidebar = ({ parsedContent }: SidebarType) => {
 
  const activeHeading = useHeadingsObserver('h2');
 
  return (
    <>
      <div className="text-xs fixed ml-[700px] -mt-6">
        <div className="border-l border-gray-300 flex flex-col gap-1 py-4 px-4">
          <div className="font-naverBold text-lg text-gray-700 dark:text-gray-300">
            On This Page
          </div>
          <ul>
            {parsedContent.map((item, i) => {
              return (
                <li
                  key={i}
                  className={`
                  ${
                    activeHeading[0] == item.link
                      ? 'text-pink-600 font-naverBold'
                      : 'text-gray-500 dark:text-gray-300 font-naverSemi'
                  }
                  mt-2 text-xs  `}
                >
                  <Link href={item.link}>{item.text}</Link>
                </li>
              );
            })}
          </ul>
        </div>
    </>
  );
};

Sidebar에서는 textlink로 변환된 데이터를 이용하여 화면을 렌더링한다. useHeadingsObserver 훅을 사용하여 IntersectionObserverh2 태그를 감지하며, 화면에 현재 활성화된 헤딩과 링크가 같다면, 텍스트를 빨간색으로 표시하여 유저에게 현재 읽고 있는 단락을 시각적으로 구분할 수 있도록 편의성을 제공한다.

IntersectionObserver ?


결과


ToTop Button 구현

ToTop 버튼?

구현 아이디어

PostHeader.tsx
export function PostHeader({ posts }: PostHeaderType) {
  const [showToTop, setShowToTop] = useState(false);
  const segments = useSelectedLayoutSegments(); // think/why-i-use-next 으로 왔을 때 ["think" , "why-i-use-next"] 형식
  const post = posts.find((post) => post.id === segments[segments.length - 1]); //해당 글 가져오기 받은 post는 배열이라서 find씀
 
  if (post == null) return <></>;
 
  const parsedContent = parseContent(post.content);
 
  useEffect(() => {
    const handleScroll = () => {
      if (window.pageYOffset > 1500) {
        setShowToTop(true);
      } else {
        setShowToTop(false);
      }
    };
 
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
 
  return (
    <>
      <div className="pt-3 pb-8">
        <div
          className="max-w-max flex items-center py-1 px-2 bg-gray-200 font-naverBold rounded-md mb-2 dark:text-white dark:bg-gray-800"
          suppressHydrationWarning={true}
        >
          {getTag(post.tag)}
        </div>
        <h1 className=" text-2xl font-naverBold dark:text-gray-100">{post.title}</h1>
      </div>
      {showToTop && <ToTop />}
      <Sidebar parsedContent={parsedContent} />
    </>
  );
}

useEffectPostHeader 마운트 될 시 스크롤 이벤트를 지정하여 pageYOffset Y좌표가 1500이 넘을시 setShowToTop을 true로 변경하고, 1500 이하 일 때는 false로 변경한다. 해당 setShowToTop 상태에 따라 ToTop 컴포넌트의 렌더링을 결정한다.

다만 위와 같은 방식은 스크롤 이벤트 핸들러가 너무 자주 호출되어 성능에 영향을 미칠 수 있다. 실제로 콘솔로 해당 이벤트가 발생 될 때를 콘솔로 출력해보면 스크롤을 아주 조금만 이동해도 2-30번 실행되기 때문에 최적화 과정이 필요하다.

이를 해결하기 위해 debounce 함수를 적용할 수 있다. 디바운스 함수는 연이어 호출되는 함수들을 일정 시간 간격으로 호출하도록 만들어준다.


const debounce = (func, wait) => {
  let timeout;
  return function (...args) {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
      func.apply(this, args);
    }, wait);
  };
};

useEffect(() => {
  const handleScroll = debounce(() => {
    if (window.pageYOffset > 1500) {
      setShowToTop(true);
    } else {
      setShowToTop(false);
    }
  }, 200); // 200ms 간격으로 스크롤 이벤트를 실행
 
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

디바운스 함수를 적용한 코드


ToTop.tsx
const ToTop = () => {
  const handleClick = () => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  };
  return (
    <div
      className="sm:hidden ml-[310px] text-sm font-bold cursor-pointer fixed bottom-[30px] z-50 flex items-center justify-center w-10 h-10 rounded-full bg-white text-black shadow-2xl"
      onClick={handleClick}
      style={{ boxShadow: '0 0 1px 1px #b9b9b9' }}
    >
      <BiSolidToTop size={22} color="#4d4d4d" />
    </div>
  );
};

setShowToTop 상태에 따라 렌더링된 ToTop 컴포넌트 클릭 시 window.scrollTo 으로 화면 최상단으로 이동한다.


프로그레스바 구현

프로그레스바?

구현 아이디어

Progressbar.tsx
const Progressbar = () => {
  const [progress, setProgress] = useState(0);
  const rafRef = useRef<number | null>(null);
 
  useEffect(() => {
    const scroll = () => {
      const scrollTop = document.documentElement.scrollTop;
      const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
 
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
      }
 
      rafRef.current = requestAnimationFrame(() => {
        setProgress(scrollTop / height);
      });
    };
 
    window.addEventListener('scroll', scroll);
 
    return () => {
      if (rafRef.current) {
        cancelAnimationFrame(rafRef.current);
      }
 
      window.removeEventListener('scroll', scroll);
    };
  }, []);
  return (
    <div
      className="fixed top-0 left-0 right-0 h-[3px] rounded-lg bg-gray-600 dark:bg-gray-300"
      style={{ width: `${progress * 100}%` }}
    ></div>
  );
};

useState, useRef

const [progress, setProgress] = useState(0);: progress 상태 변수는 현재 스크롤 위치에 따른 진행 상태를 저장한다. 초기값은 0 const rafRef = useRef<number | null>(null): requestAnimationFrame의 핸들을 저장하는 useRef

scroll 함수:

const scrollTop = document.documentElement.scrollTop;: 현재 스크롤 위치를 가져온다. const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;: 페이지의 총 높이에서 브라우저 창의 높이를 뺀 값을 가져와서 페이지의 전체 스크롤 가능 높이를 계산한다. cancelAnimationFrame(rafRef.current);: 현재 진행 중인 requestAnimationFrame 콜백을 취소한다 rafRef.current = requestAnimationFrame(() => { ... });: requestAnimationFrame을 사용하여 스크롤 이벤트가 끝난 후에 UI를 업데이트하는데, requestAnimationFrame은 자체적으로 성능 최적화를 제공하여 디바운스 함수를 적용하지 않아도 된다.

이벤트 리스너 등록 및 해제:

window.addEventListener('scroll', scroll);: 스크롤 이벤트가 발생할 때마다 scroll 함수를 호출하여 진행 상태를 업데이트 한다. return () => { ... }: 진행 중인 requestAnimationFrame을 취소

me
@banhogu
안녕하세요 배움을 나누며 함께 전진하는 1년차 주니어 개발자 방호진입니다.