Codeit Weekly Mission [Week 6] 스크롤에 반응하는 내비게이션 바 만들기

0

Weekly Mission

목록 보기
7/10
post-thumbnail

저번주에 바닐라 js로 구현했던 gnb에서 스크롤에 반응하는 헤더를 만들어 보자.

컴포넌트 안에서 이벤트 리스너 등록하기

먼저, Gnb컴포넌트에서 윈도우에 이벤트 리스너를 등록하는 것이 맞을까? 컴포넌트에서 이벤트 리스너를 다음과 같이 등록하는 것은 몇가지 문제가 있다.

import React from 'react';

const App = (props) => {
  window.addEventListener('keydown', (event) => {
    // ...
  });
  return (
    <div className='container'>
      <h1>Welcome to the Keydown Listening Component</h1>
    </div>
  );
};
  1. 이벤트 리스너는 컴포넌트가 완전히 렌더링 된 후에 등록하는 것이 좋다
  2. 컴포넌트가 언마운트 될 때 이벤트 리스너를 remove해줘야 한다.

이 원칙을 지키면서 등록하려면 useEffect훅을 써서 다음과 같이 등록해주는 것이 좋다.

const App = (props) => {
  const handleKeyDown = (event) => {
    console.log('A key was pressed', event.keyCode);
  };

  React.useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);

    // cleanup this component
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, []);

  return (
    <div className='container'>
      <h1>Welcome to the Keydown Listening Component</h1>
    </div>
  );
};

useLayoutEffect?

아래는 리액트 훅의 실행 순서를 나타낸 유명한 그림이다.

그림을 보면 useLayoutEffect는 브라우저가 DOM을 페인트하기 전에 실행한다. 브라우저가 DOM을 페인팅 하고 나서
useEffect가 실행된다. 그래서 useEffect에서 뭔가를 세팅해서 컴포넌트를 다시 화면에 그리는 작업을 한다면 화면이 깜빡이는 문제가 발생할 수 있다. 하지만 useLayoutEffect는 동기적으로 실행되기 때문에 끝나고 화면이 깜빡일 일은 없지만 무거운 작업이 들어간다면 사용자가 첫 화면을 보기까지 오래 걸릴 수 있다. 여러 문서들을 읽어봐도 useEffect를 사용하는 것을 권장한다고 한다. 그냥 특수한 케이스를 위해서 리액트 코어 팀이 만들어 놓은 것이 useLayoutEffect라고 보면 될 것 같다.

그럼 우리의 경우는 뭘 쓰면 될까?

우리 헤더의 경우 지금 하려고 하는 것이 이벤트리스너를 등록하는 일이다. 헤더의 높이를 계산하는 일도 있을 것이다. 아직은 감이 오지 않지만 우선 useEffect로 구현해보자. 헤더의 높이를 계산하는 일은 다른 useEffect로 따로 두면 될 것 같다.

useEffect로 이벤트리스너 등록하기

우선 필요한 이벤트에 대한 작업을 다음과 같이 둘로 나누어 본다.

  • 스크롤 이벤트 발생 시, 내리는 스크롤인지 올리는 스크롤인지 판단하기
    • 내리는 스크롤이라면 gnb를 헤더의 offsetHeight만큼 top값을 조정하여 화면 밖으로 사라지게 한다.
    • 올리는 스크롤이면 다시 top값을 0으로 만든다. 이 때 스크롤이 조금이라도 내려와 있다면 box-shadow를 준다.
  • 화면이 미디어 쿼리 max-width 경계선을 넘을 때 헤더 높이를 다시 계산하기
    • offsetHeight값을 다시 계산하여 스크롤 시 top값 조정에 반영되게 한다.

먼저 scroll에 대한 window.addEventListener를 해보자.

const Gnb: React.FC<GnbProps> = (props: GnbProps | null) => {
  const [prevScrollPosition, setPrevScrollPosition] = useState(8);
  const [navHeight, setNavHeight] = useState(
    () => document.querySelector("header")?.offsetHeight
  );
  const headerRef = useRef<HTMLElement>(null);

  const handleScroll = () => {
    let currentScrollPosition = window.scrollY;
    console.log(currentScrollPosition, prevScrollPosition);
    if (prevScrollPosition > currentScrollPosition) {
      if (headerRef.current) {
        console.log("scrolling up");
        headerRef.current.style.top = "0";
        headerRef.current.classList.add("shadow");
      }
    } else {
      if (headerRef.current) {
        console.log("scrolling down");
        headerRef.current.style.top = `-${navHeight}px`;
        headerRef.current.classList.remove("shadow");
      }
    }

    if (currentScrollPosition === 0) {
      if (headerRef.current) {
        headerRef.current.classList.remove("shadow");
      }
    }
    setPrevScrollPosition(currentScrollPosition);
  };

이렇게 하니까 정상 작동하지 않는다. prevScrollPosition이 갱신되지 않고있다. 개발자 도구로 스테이트값을 보니까 갱신은 잘 되고 있는 것 같아 보인다. 저 handleScroll함수에서 참조하는 값만 갱신이 안되는 듯 하다. 또한 navHeight 스테이트도 처음에 undefined로 계산된다. 아마 useState에서 document.querySelector를 하는데 header가 렌더링 되기 이전에 querySelector로 접근하려 해서 초기값 계산이 안되나 보다. 화면 사이즈를 줄여서 matchMedia가 change되었을 때는 계산이 된다.

prevScrollPosition 갱신 문제 해결

addEventListener에 등록된 핸들러는 갱신되지 않는다는 것을 알게 되었다. 즉 한 번 등록되었으면 그 함수를 계속 쓴다는 것이다. 그리고 handleScroll의 prevScrollPosition은 처음에 0을 참조한다. 이 값이 바뀌더라도 handleScroll은 0으로 인식한다는 것이다. 해결을 위해 useEffect를 prevScrollPosition이 바뀔때마다 호출하게 하였다.

  const [prevScrollPosition, setPrevScrollPosition] = useState(0);
  const [navHeight, setNavHeight] = useState(0);
  const headerRef = useRef<HTMLElement>(null);

  const handleScroll = () => {
    let currentScrollPosition = window.scrollY;
    console.log(currentScrollPosition, prevScrollPosition);
    if (prevScrollPosition > currentScrollPosition) {
      if (headerRef.current) {
        console.log("scrolling up");
        headerRef.current.style.top = "0";
        headerRef.current.classList.add("shadow");
      }
    } else {
      if (headerRef.current) {
        console.log("scrolling down");
        headerRef.current.style.top = `-${navHeight}px`;
        headerRef.current.classList.remove("shadow");
      }
    }

    if (currentScrollPosition === 0) {
      if (headerRef.current) {
        headerRef.current.classList.remove("shadow");
      }
    }
    setPrevScrollPosition(currentScrollPosition);
  };

  const handleMediaChange = () => {
    setNavHeight(document.querySelector("header").offsetHeight);
  };

  useEffect(() => {
    const mq = window.matchMedia("(max-width: 767px)");
    window.addEventListener("scroll", handleScroll);
    mq.addEventListener("change", handleMediaChange);
    return () => {
      window.removeEventListener("scroll", handleScroll);
      mq.removeEventListener("change", handleMediaChange);
    };
  });

그런데 코드를 보면 볼 수록 뭔가 이상하다. addEventListener를 렌더링 마다 반복하게 된다. useEffect의 deps배열에 prevScrollPosition을 추가해도 마찬가지다. 어차피 scroll 이벤트가 prevScrollPosition을 갱신할테고 그러면 스크롤 후 렌더링 때마다 useEffect가 실행되게 된다. 어떻게 고칠 수 있을지 계속 고민해본 결과, 아예 처음부터 설계를 잘못하고 있었다는 것을 알게 되었다.

이벤트 리스너는 최초에 한 번만 등록하고, 핸들러는 그냥 현재 스크롤값을 setState만 해주면 되는게 아닐까...? 핸들러가 스테이트를 참조하게 만드는 순간 모든 문제가 발생했다.

그럼 우리의 로직은 아래와 같이 조금 달라진다.
1. 이벤트 리스너는 한 번만 등록한다.
2. 핸들러는 스크롤 위치만 setState한다.
3. 컴포넌트는 스테이트를 4개 관리한다. prevScrollPos, currentScrollPos, show, navHeight이다.

  • show: gnb가 숨겨질지 보여질지 알려주는 불린 타입 스테이트
  • navHeight: 헤더의 높이를 가지고 있다. 미디어쿼리가 바뀔 때만 계산한다.

이렇게 스테이트는 컴포넌트 안에서 관리하고, 스크롤에 반응하는 이벤트리스너는 그저 currentScrollPos만 갱신한다. currentScrollPos가 변하면 따로 둔 useEffect에서 위, 아래 스크롤을 판단하여 show값을 갱신한다. 그리고 실제로 show값에 때라 움직이는 부분은 styled component에 prop으로 전달해주면서 이루어진다.

처음에 navHeight가 undefined가 되어있는 현상을 고치기 위해 useEffect에서 계산해주는 것으로 했다. 처음에 useState가 실행될 때는 컴포넌트가 실제 DOM에 없기 때문에 초깃값 계산식으로 document.querySelector("header").offsetHeight는 소용이 없다. 그래서 useEffect에서 계산해주면 된다. useEffect는 브라우저 페인팅 작업 이후에 실행되기 때문에 실행되는 시점에서는 navHeight를 계산할 수 있다.

최종 완성 코드

import React, { useCallback, useEffect, useRef, useState } from "react";
import styled, { css } from "styled-components";
import { colors } from "../../styles/colors";

interface GnbProps {
  profileImgSrc?: string;
  username: string;
  email: string;
}

const Gnb: React.FC<GnbProps> = (props: GnbProps | null) => {
  const [prevScrollPosition, setPrevScrollPosition] = useState(0);
  const [currentScrollPosition, setCurrentScrollPosition] = useState(0);
  const [navHeight, setNavHeight] = useState<number>(0);
  const [show, setShow] = useState(true);

  const handleScroll = () => {
    setCurrentScrollPosition(window.scrollY);
  };
  console.log("renders");
  const handleMediaChange = () => {
    const header = document.querySelector("header");
    if (header instanceof HTMLElement) {
      setNavHeight(header.offsetHeight);
    }
  };

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);

    const mq = window.matchMedia("(max-width: 767px)");
    mq.addEventListener("change", handleMediaChange);

    const headerElem = document.querySelector("header");
    if (headerElem instanceof HTMLElement) {
      setNavHeight(headerElem.offsetHeight);
    }

    return () => {
      window.removeEventListener("scroll", handleScroll);
      mq.removeEventListener("change", handleMediaChange);
    };
  }, []);

  //nav change useEffect
  useEffect(() => {
    // scroll down
    if (currentScrollPosition > prevScrollPosition) {
      setShow(false);
    } else {
      setShow(true);
    }
    setPrevScrollPosition(currentScrollPosition);
  }, [currentScrollPosition]);

  return (
    <SHeader
      show={show}
      isTop={currentScrollPosition === 0 ? true : false}
      navHeight={navHeight}
    >
      <SHeaderWrapper id="header-wrapper">
        <SNav>
          <SLogo id="logo" href="/">
            <img src="/src/assets/images/logo.svg" />
          </SLogo>
          <SLoginBtn href="signin.html"> 로그인 </SLoginBtn>
        </SNav>
      </SHeaderWrapper>
    </SHeader>
  );
};

const SHeader = styled.header<{
  show: boolean;
  isTop: boolean;
  navHeight: number;
}>`
  top: ${({ show, navHeight }) => (show ? "0px" : `-${navHeight}px`)};
  ${({ isTop, show }) =>
    !isTop &&
    show &&
    css`
      box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
    `}
`;

const SHeaderWrapper = styled.div`
  width: 100%;
  max-width: 120rem;
  height: 5.813rem;
  padding: 1.25rem 12.5rem;
  margin: 0 auto;
  background-color: ${colors.gray1};

  @media only screen and (max-width: 1200px) {
    display: flex;
    justify-content: center;
    padding: 1.5rem 0;
  }

  @media only screen and (max-width: 868px) {
    padding: 1.5rem 2rem;
  }

  @media only screen and (max-width: 767px) {
    width: 100%;
    height: 3.938rem;
    padding: 0.813rem 2rem;
    display: flex;
  }
`;
const SLogo = styled.a`
  cursor: pointer;

  @media only screen and (max-width: 767px) {
    img {
      width: 4.849rem;
      height: 0.875rem;
    }
  }
`;

const SNav = styled.nav`
  display: flex;
  justify-content: space-between;
  align-items: center;

  @media only screen and (max-width: 1200px) {
    width: 49.028rem;
  }
`;

const SLoginBtn = styled.a`
  width: 8rem;
  height: 3.313rem;
  border-radius: 0.5rem;
  text-decoration: none;
  padding: 1rem 2.531rem;
  font-size: 1.125rem;
  font-weight: 600;
  color: #f5f5f5;
  background-image: linear-gradient(
    90.99deg,
    ${colors.primary} 0.12%,
    #6ae3fe 101.84%
  );

  @media only screen and (max-width: 767px) {
    width: 5rem;
    height: 2.313rem;
    padding: 0.625rem 1.344rem;
    font-weight: 600;
    font-size: 0.875rem;
    line-height: 1.063rem;
  }
`;

export default Gnb;

마치며

위 코드는 개선할 여지가 남아있다. 스크롤에 의해 실제로 보는 화면이 바뀌는 상황은 스크롤 방향이 바뀔때이다. 즉 스크롤이 일어났냐가 중요한 것이 아닌데 스크롤이 일어날 때마다 currentScrollPos가 바뀌면서 재렌더링이 일어난다. 이 부분을 어떻게 고칠지 고민이 된다. 저번주에 navHeight계산의 경우 resize이벤트로 등록을 했다가 굳이 다시 게산하지 않아도 되는 걸 계속 계산하길래 matchMedia를 사용하여 max-width가 변하는 경계점에서만 다시 계산하게 했는데, 이 문제도 같은 맥락이다. 매 스크롤마다 재렌더링하는것은 비효율적이다. 추후에 꼭 개선해야한다.
두 번째로는 커스텀 훅을 만들어 스크롤 관련 코드를 모듈화 하는 것이다. 이 부분은 큰 어려움 없이 진행할 수 있을 것 같다.

참고자료
Event listeners in react components
useEffect vs useLayoutEffect

0개의 댓글