본문 바로가기

Project Log/personal website

Table of Content 컴포넌트 만들기 - useContext

원페이지 개념이라 스크롤이 좀 길어지다 보니 스크롤을 맨 위로 올리는 top 버튼이나 목차 컴포넌트가 필요했다.

처음에는 간단하게 top버튼을 만들까 했는데 그것보다는 목차 컴포넌트를 만드는 게 뭔가 더 포멀 한 느낌을 줄 것 같아 목차 컴포넌트를 만들기로 결정했다.

 

각 목차는 <a>태그로 감싸고, 이동할 컴포넌트에는 id를 달아줘서 <a>태그가 해당 id를 가진 섹션으로 이동할 수 있게 만들었다.

이동하는 것 까지는 완성했는데 문제는 스크롤이 머무는 해당 섹션의 스타일을 다르게 표현하고 싶었는데 도저히 어떻게 해야 할지 감이 안 왔다. 자주 보는 블로그 사이트 velog에도 ToC컴포넌트가 있어서 뜯어보려고 개발자 도구를 열어서 봤는데 그것만으로는 알 수가 없었다. 다행히도(?) 개발자이신 velopert님이 오픈소스로 velog코드를 깃에 올려주셔서 뜯어볼 수 있었다. 거기에서 아이디어를 얻어서 각 섹션이 렌더링 될 때 섹션의 위치를 저장한 다음에, ToC컴포넌트에서는 스크롤이 이동할 때마다 현재 스크롤의 위치와 각 섹션의 위치를 비교해서 현재 스크롤이 어느 섹션에 머물러있는지 active state에 넣어주는 식으로 구현했다.

 

섹션의 위치 값을 저장해서 ToC컴포넌트에 해서 해야했는데, 고작 이 기능 하나 쓰자고 redux를 쓰는 것도 애매해서 useContext를 써보기로 했다. 

 

context는 src/context/toc.js파일을 만들어주고, 아래와 같이 작성하고 각 섹션의 시작 위치를 가질 pos라는 이름의 state를 만들어준다.

import React from 'react';
import { createContext, useState } from 'react';

const ToCContext = createContext([{}, () => {}]);

const ToCProvider = ({children}) => {
    const [pos, setPos] = useState({
        profile: 0,
        project: 0,
        skills: 0,
        education: 0
    });

    return (
        <ToCContext.Provider value = {[pos, setPos]}>{children}</ToCContext.Provider>
    );
};

const { Consumer: ToCConsumer } = ToCContext; 
export { ToCProvider, ToCConsumer };
export default ToCContext;

그리고 context를 사용하려는 컴포넌트 바깥에서 Provider로 감싸준다.

(...)
import { ToCProvider } from 'context/toc';

const cx = classNames.bind(styles);

const Template = () => {

    return(
        <div className = {cx('Template')}>
            <ToCProvider>
                <Header/>
                    <main>
                        <Info/>
                        <div className = {cx('sections')}>
                            <Project/>
                            <Skills/>
                            <Education/>
                        </div>
                    </main>
                <Footer/>
            </ToCProvider>
        </div>
    )
}

export default Template;

위치 값이 필요한 컴포넌트는  Header > ToCComponent, Project, Skills, Education 컴포넌트이다.

 

Project, Skills, Education 컴포넌트에서는 렌더링 될 때 시작 위치를 context의 state로 넣어준다.

아래는 Project 컴포넌트의 예시이다. 요소에 useRef를 사용하여 reference를 달아주고, getBoundingClientRect() 메서드로 top값을 얻어온 다음에 window.pageYOffset 값과 더해준다. 그러면 위치의 절댓값을 얻을 수 있다.

element.getBoundingClientRect(). top만 사용할 경우 Viewport의 시작지점을 기준으로 한 상대 좌표이기 때문에 값이 정확하지 않다. 나는 컴포넌트가 마운트 될 때 pos state에 저장되도록 했으므로, 어느 스크롤 위치 지점에서 새로고침 하느냐에 따라 좌표가 계속 달라진다. 따라서 아래 코드를 이용해서 절댓값을 구해주었다. window.pageYOffset 은 전체 페이지에서 얼만큼 scroll 됐는지 픽셀 단위를 알려주는 값이다.

 

const absoluteTope = window.pageYOffset + element.getBoundingClientRect(). top;
import React, { useRef, useEffect, useContext } from "react";
import styles from "./Project.scss";
import classNames from "classnames/bind";
import ProjectImg from "./ProjectImg";
import ToCContext from "context/toc";

const cx = classNames.bind(styles);

const Project = ({ projectList }) => {
  const element = useRef(null);
  const [pos, setPos] = useContext(ToCContext);

  useEffect(() => {
    const rect = element.current.getBoundingClientRect();
    setPos((pos) => ({
      ...pos,
      [element.current.id]: rect.top + window.pageYOffset,
    }));
  }, [setPos]);

  const list = projectList.map((el, idx) => {
    return (
      <li
        className={cx(
          "project " + (idx === projectList.length - 1 ? "no-border" : "")
        )}
        key={idx}
      >
        <ProjectImg src={el.src} github={el.github} website={el.website} />
        <div className={cx("detail")}>
          <div className={cx("inner")}>
            <h2>{el.title}</h2>
            <div className={cx("content")}>{el.content}</div>
            <ul className={cx("li-wrapper")}>
              <li>기술스택: {el.techStack}</li>
            </ul>
          </div>
        </div>
      </li>
    );
  });

  return (
    <section id="project" ref={element} className={cx("Project")}>
      <h1>개인 프로젝트</h1>
      <ul className={cx("project-list")}>{list}</ul>
    </section>
  );
};

export default Project;

그리고 ToC컴포넌트에서는 스크롤할 때마다  window.pageYOffset으로 scrollTop위치가 섹션의 시작 포지션에 진입했는지 확인한 다음에, 진입했다면 active state에 index를 넣어줘서 바꿔준다.

import React, { useState, useEffect, useContext, useRef } from "react";
import styles from "./ToC.scss";
import classNames from "classnames/bind";
import ToCContext from "context/toc";

const cx = classNames.bind(styles);

const ToC = ({
  list = [
    {
      name: "🙋‍♀️ Profile",
      href: "profile",
    },
    {
      name: "🐶 Pet Project",
      href: "project",
    },
    {
      name: "🔥 Skills",
      href: "skills",
    },
    {
      name: "🎓 Education",
      href: "education",
    },
  ],
}) => {
  const [active, setActive] = useState(0);
  const [pos] = useContext(ToCContext);
  const element = useRef(null);

  useEffect(() => {
    const onScroll = () => {
      const scrollTop = window.pageYOffset;

      if (scrollTop < pos.project) return setActive(0);
      if (pos.project <= scrollTop && scrollTop < pos.skills)
        return setActive(1);
      if (pos.skills <= scrollTop && scrollTop < pos.education)
        return setActive(2);
      if (pos.education <= scrollTop) return setActive(3);
    };

    if (pos.project !== 0 && pos.skills !== 0 && pos.education !== 0) {
      window.addEventListener("scroll", onScroll);
    }

    return () => {
      window.removeEventListener("scroll", onScroll);
    };
  }, [pos]);

  return (
    <div className={cx("ToC")} ref={element}>
      <div className={cx("wrapper")}>
        <ul>
          {list.map((el, idx) => (
            <li className={cx(idx === active ? "active" : "")} key={idx}>
              <a href={`#${el.href}`}>{el.name}</a>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default ToC;

education컴포넌트 같은 경우에는 페이지의 맨 아랫부분에 위치해 있어서 scrollTop이 education 컴포넌트의 pos에 다다르지 못하기 때문에 전체 페이지 길이에서 뷰포트만큼 뺀 위치가 scrollTop과 일치한다면 스크롤이 마지막에 다다랐다고 보고 active를 education으로 바꿔주도록 했다.

    useEffect (() => {
        const rect = element.current.getBoundingClientRect();
        setPos(pos => ({
           ...pos,
            [element.current.id] : document.documentElement.offsetHeight - window.innerHeight
        }));
    }, [setPos]);

 

구현 모습

 

 

아쉬운 건 컴포넌트로서 list를 넘겨주는 것과 active state를 설정하는 데 있어서 현명하지 못하게 구현한 것 같다.

ToC 컴포넌트에 값을 설정하려면 ToC에 가서 list값을 넣어주고, toc 콘텍스트 파일에 가서 pos 값을 설정해줘야 한다. active state도 직접 index값으로 설정해놓은 것도 뭔가 마음에 안 들고... 콘텍스트의 pos값을 아예 object 말고 배열로 만들어서 순서대로 만드는 게 나았을 것 같기도 하다. 그런데 컴포넌트에서 context의 state를 업데이트하려면 자신이 배열에서 몇 번째 인덱스 인지도 알아야 하고... 그래서 아예 key값으로 설정해버리게끔 object로 만들었는데 아예 그냥 context에 list값을 넣어버릴 걸 그랬나? 그리고 컴포넌트에서는 context state에서 pos값 만 바꿔주는 건 그게 더 낫나ㅋㅋㅋㅋㅋprop 왔다 갔다 하는 것도 찬찬히 생각하면서 컴포넌트를 짜야하는데 구현하는데 급급하다 보니 생각의 흐름대로 짜게 되는 것 같다. 미리 습관을 들여놔야 하는데.. 일단 구현은 완료했으니 나중에 리팩터링ㅇ해봐야지