Skip to content

스크롤 거꾸로 올릴 때 IntersectionObserver가 뷰포트 감지 못하는 현상

최수연 (SooYeon Choi) edited this page Aug 22, 2024 · 4 revisions

✅ 왜 LearnMore 컴포넌트에서 Rush 컴포넌트로 이동하면 헤더 색이 안바뀔까?

특정 컴포넌트 위치에서 헤더 스타일 다르게 적용하는 방법

위와 같이 구현했을 때 문제가 발생했다. Main 페이지 기준으로 봤을 때, LearnMore 페이지 컴포넌트에서는 헤더가 dark 모드이고 그 바로 스크롤 위에 있는 이전 컴포넌트인 Rush 컴포넌트에서는 헤더가 light 모드인데, LearnMore에서 Rush로 스크롤이 올라갔을 때 dark 모드에서 light 모드로 변경되지 않았다.

이 문제는 찾아보니 IntersectionObserver의 작동 방식 때문에 발생할 수 있는데, 실제로 IntersectionObserver는 요소가 뷰포트에 진입할 때만 콜백을 실행하고, 요소가 뷰포트를 벗어날 때는 콜백을 실행하지 않는다고 한다. 추측컨대, 스크롤을 내렸다가 다시 거꾸로 올라가면서 위에서 이미 거쳐온 뷰포트가 아직 벗어나는 중이거나 옵저버가 이미 지나온 뷰포트에 대해 인식을 제대로 하지 못하는 것 같았다.

따라서 본 문제를 해결하기 위해 다음과 같이 코드를 수정했다.

const { setActiveSection, setHeaderType } = useScrollHeaderStyleContext();
const containerRef = useRef<HTMLDivElement>(null);

// 현재 보이는 섹션들을 추적하기 위한 ref 추가
const visibleSectionsRef = useRef<Set<SectionKey>>(new Set());

// 모든 섹션을 추적하여 보이는 경우 visibleSections Set을 업데이트하는 함수
const updateVisibleSections = (
    entries: IntersectionObserverEntry[],
    newVisibleSections: Set<SectionKey>
) => {
    // 각 엔트리를 순회하면서 화면에 보이는 경우 visibleSections Set 업데이트
    entries.forEach((entry) => {
        const sectionId = entry.target.id as SectionKey;
        if (entry.isIntersecting) newVisibleSections.add(sectionId);
        else newVisibleSections.delete(sectionId);
    });
    // 참조 값을 새로운 visibleSections Set으로 변경
    visibleSectionsRef.current = newVisibleSections;
};

// 가장 위에 있는 섹션을 기반으로 헤더 스타일을 업데이트하는 함수
const updateHeaderStyle = (newVisibleSections: Set<SectionKey>) => {
    // 보이는 섹션 중 가장 첫 번째 섹션을 가져옴
    const mostTopSection = Array.from(newVisibleSections)[0];
    if (mostTopSection) {
        setActiveSection(mostTopSection); // activeSection 업데이트
        const newHeaderType: HeaderType = config.darkSections.includes(mostTopSection)
            ? "dark"
            : "light";
        setHeaderType(newHeaderType); // headerType 업데이트
    }
};

useEffect(() => {
    const observer = new IntersectionObserver(
        (entries) => {
            const newVisibleSections = new Set(visibleSectionsRef.current);
            updateVisibleSections(entries, newVisibleSections);
            updateHeaderStyle(newVisibleSections);
        },
        {
            root: containerRef.current,
            threshold: 0.8,
        }
    );
    ...
}, [setActiveSection, setHeaderType, config.darkSections]);
  1. 현재 뷰포트에 보이는 모든 섹션을 추적하는 visibleSections 상태 추가
  2. IntersectionObserver 콜백에서 섹션이 뷰포트에 들어오거나 나갈 때 visibleSections 업데이트
  3. 가장 위에 있는 보이는 섹션을 찾아 activeSection 설정, 이 섹션이 darkSections에 존재하는지에 따라 headerType 설정

이렇게 수정한 후 LearnMore 컴포넌트에서 Rush 컴포넌트로 스크롤을 거꾸로 올릴 때도 헤더 스타일이 올바르게 변경이 되는 것을 확인할 수 있다.

✅ visibleSections에서 Set을 사용한 이유?

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Set

우선 Set을 사용하면 중복된 값을 저장할 수 없기 때문에 각 섹션 별로 고유한 키 값을 저장해둘 수 있다. 그리고 자료구조 Set 자체가 추가, 삭제, 요소 확인하는 시간복잡도가 O(1)이기 때문에 배열을 사용해서 요소를 찾고 추가하고 삭제하는 과정보다 훨씬 빠르고 효율적이다. 그리고 무엇보다 ES6 이후부터는 Set는 삽입 순서를 보장해준다고 하여 가장 최근에 뷰포트에 들어온 섹션을 쉽게 찾을 수 있다.

예를 들어서 사용자가 빠르게 스크롤할 때 여러 섹션이 동시에 뷰포트에 들어오거나 나갈 수 있는데, 이때 Set을 사용하면 이러한 상황에서도 효율적으로 현재 보이는 섹션들의 목록을 유지할 수 있다.

따라서 useHeaderStyleObserver 훅의 전체 코드 구조는 다음과 같다.

import { useEffect, useRef } from "react";
import useScrollHeaderStyleContext from "@/hooks/useScrollHeaderStyleContext";
import { HeaderType } from "@/types/scrollHeaderStyle";
import { SectionKey } from "@/types/sections.ts";

interface HeaderStyleConfig {
    darkSections: SectionKey[];
}

export default function useHeaderStyleObserver(config: HeaderStyleConfig) {
    const { setActiveSection, setHeaderType } = useScrollHeaderStyleContext();
    const containerRef = useRef<HTMLDivElement>(null);
    const visibleSectionsRef = useRef<Set<SectionKey>>(new Set());

    const updateVisibleSections = (
        entries: IntersectionObserverEntry[],
        newVisibleSections: Set<SectionKey>
    ) => {
        entries.forEach((entry) => {
            const sectionId = entry.target.id as SectionKey;
            if (entry.isIntersecting) newVisibleSections.add(sectionId);
            else newVisibleSections.delete(sectionId);
        });
        visibleSectionsRef.current = newVisibleSections;
    };

    const updateHeaderStyle = (newVisibleSections: Set<SectionKey>) => {
        const mostTopSection = Array.from(newVisibleSections)[0];
        if (mostTopSection) {
            setActiveSection(mostTopSection);
            const newHeaderType: HeaderType = config.darkSections.includes(mostTopSection)
                ? "dark"
                : "light";
            setHeaderType(newHeaderType);
        }
    };

    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                const newVisibleSections = new Set(visibleSectionsRef.current);
                updateVisibleSections(entries, newVisibleSections);
                updateHeaderStyle(newVisibleSections);
            },
            {
                root: containerRef.current,
                threshold: 0.8,
            }
        );

        const sections = document.querySelectorAll("section");
        sections.forEach((section) => observer.observe(section));

        return () => observer.disconnect();
    }, [setActiveSection, setHeaderType, config.darkSections]);

    return containerRef;
}

추가 고민 거리

✅ 모든 섹션 별로 id를 지정하는 것이 과연 깔끔한 코드일까? 그냥 헤더를 darkMode로 적용할 섹션들만 배열로 받아서 처리할 수는 없을까?

✅ actionSection 초깃값을 각 페이지별로 다르게 초기화 할 수 있을까?

📚 학습 정리

🗂️ 멘토링

Clone this wiki locally