[꿀팁] BFcache를 극복해보자

in-ch·2024년 2월 3일
1

꿀팁

목록 보기
7/14
post-thumbnail
post-custom-banner

서론


BFCache는 "Back-Forward Cache"의 약자로, 브라우저가 이전에 방문한 페이지의 상태를 캐시해두고 뒤로가기 또는 앞으로 가기 버튼을 눌렀을 때 캐시된 상태를 불러와 페이지를 더 빠르게 로딩하는 기술이다.

페이지를 이동하는 순간 페이지의 전체 스냅숏(자바스크립트 힙 포함)을 저장해주는 메모리 내부의 캐시를 저장하게 되는데, 사용자가 뒤로가기를 눌러 다시 돌아오려 할 때, 브라우저는 메모리에 저장해둔 전체 페이지를 사용해서 페이지를 빠르고 쉽게 복원해준다.

그러나 BFCache는 Next.js에서와 같은 SPA (Single Page Application)의 환경에서는 정상적으로 동작하지 않을 수 있다.

SPA에서는 페이지가 전체적으로 다시 로드되는 것이 아니라 필요한 부분만 업데이트되기 때문이다.

또한, 일반적인 window의 scroll 위치가 아닌 overflow:scroll 속성이 추가된 div의 경우 당연히 BFCache라는 스냅샷이 저장될 수 없다.

따라서, 다음과 같은 문제가 발생할 수 있다.

원래 시나리오

  1. Scrolling 할 수 있는 Todo 아이템들이 나타난다.
  2. Todo 아이템을 클릭할 시 상세 페이지로 이동한다.
  3. 상세 페이지에서 뒤로 가기를 클릭한다.
  4. 뒤로 가기 시 이전 페이지의 BFCache를 통해 원래 스크롤 위치로 이동한다.

실제 시나리오

  1. Scrolling 할 수 있는 Todo 아이템들이 나타난다.
  2. Todo 아이템을 클릭할 시 상세 페이지로 이동한다.
  3. 상세 페이지에서 뒤로 가기를 클릭한다.
  4. 페이지가 초기화되면서 이전 페이지의 최상단 페이지로 다시 이동한다.

해결 방법


window의 scroll 위치 복원

참고 예제

useEffect를 활용해 Custom hook을 만들자 !

_참고로 Page Router를 기준으로 작성되었다.
App Router의 경우에는 next/navigation 모듈에서 events 객체가 없어졌기 때문에 해당 방법을 사용할 수 없습니다.
해당 이슈

import { useEffect } from 'react';

import Router from 'next/router';

function saveScrollPos(url) {
    const scrollPos = { x: window.scrollX, y: window.scrollY };
    sessionStorage.setItem(url, JSON.stringify(scrollPos));
}

function restoreScrollPos(url) {
    const scrollPos = JSON.parse(sessionStorage.getItem(url));
    if (scrollPos) {
        window.scrollTo(scrollPos.x, scrollPos.y);
    }
}

export default function useScrollRestoration(router) {
    useEffect(() => {
        if ('scrollRestoration' in window.history) {
            let shouldScrollRestore = false;
            window.history.scrollRestoration = 'manual';
            restoreScrollPos(router.asPath);

            const onBeforeUnload = event => {
                saveScrollPos(router.asPath);
                delete event['returnValue'];
            };

            const onRouteChangeStart = () => {
                saveScrollPos(router.asPath);
            };

            const onRouteChangeComplete = url => {
                if (shouldScrollRestore) {
                    shouldScrollRestore = false;
                    restoreScrollPos(url);
                }
            };

            window.addEventListener('beforeunload', onBeforeUnload);
            Router.events.on('routeChangeStart', onRouteChangeStart);
            Router.events.on('routeChangeComplete', onRouteChangeComplete);
            Router.beforePopState(() => {
                shouldScrollRestore = true;
                return true;
            });

            return () => {
                window.removeEventListener('beforeunload', onBeforeUnload);
                Router.events.off('routeChangeStart', onRouteChangeStart);
                Router.events.off('routeChangeComplete', onRouteChangeComplete);
                Router.beforePopState(() => true);
            };
        }
    }, [router]);
}
  • useEffect: 컴포넌트가 마운트될 때 실행된다.
  • window.history.scrollRestoration: BFCache와 관련이 있는데, 페이지 전환 시 브라우저에게 수동으로 스크롤 위치를 알려준다.
    여기서 manual은 자동 복원을 비활성화하고, 수동으로 스크롤 위치를 관리하겠다는 의미이다.
  • restoreScrollPos: sessionStorage를 사용하여 현재 페이지의 스크롤 위치를 저장하고, 이를 복원하는 함수
  • onBeforeUnload 이벤트 핸들러: 페이지가 언로드되기 전에 호출되는 이벤트로, 현재 페이지의 스크롤 위치를 저장한다.
  • onRouteChangeStart 이벤트 핸들러: 페이지 전환이 시작되기 전에 호출되는 이벤트로, 현재 페이지의 스크롤 위치를 저장
  • onRouteChangeComplete 이벤트 핸들러: 페이지 전환이 완료된 후 호출되는 이벤트로, 이전 페이지로 돌아왔을 때 스크롤 위치를 복원한다.
  • Router.beforePopState: 뒤로가기 또는 앞으로 가기 버튼을 클릭할 때 발생하는 이벤트로, 페이지를 이동하기 전에 스크롤 위치를 복원하기 위해 사용한다.

이제 이 커스텀 훅을 App.tsx에 적용하면 된다.

import useScrollRestoration from "utils/hooks/useScrollRestoration";

const App = ({ Component, pageProps, router }) => {
    useScrollRestoration(router);
    return <Component {...pageProps} />;
};

export default App;

특정 div의 scroll 위치 복원

그런데 만약에 window가 아닌 특정 div의 scroll을 유지하려고 하려면 어떻게 할까?

useRef를 활용해보자.

import { useEffect, useRef } from 'react';
import Router from 'next/router';

function useScrollRestoration(router) {
  const scrollableDivRef = useRef(null);

  const saveScrollPos = (url) => {
    const scrollableDiv = scrollableDivRef.current;
    if (scrollableDiv) {
      const scrollPos = { x: scrollableDiv.scrollLeft, y: scrollableDiv.scrollTop };
      sessionStorage.setItem(url, JSON.stringify(scrollPos));
    }
  };

  const restoreScrollPos = (url) => {
    const scrollableDiv = scrollableDivRef.current;
    if (scrollableDiv) {
      const scrollPos = JSON.parse(sessionStorage.getItem(url));
      if (scrollPos) {
        scrollableDiv.scrollLeft = scrollPos.x;
        scrollableDiv.scrollTop = scrollPos.y;
      }
    }
  };

  useEffect(() => {
    if ('scrollRestoration' in window.history) {
      let shouldScrollRestore = false;
      window.history.scrollRestoration = 'manual';
      restoreScrollPos(router.asPath);

      const onBeforeUnload = (event) => {
        saveScrollPos(router.asPath);
        delete event['returnValue'];
      };

      const onRouteChangeStart = () => {
        saveScrollPos(router.asPath);
      };

      const onRouteChangeComplete = (url) => {
        if (shouldScrollRestore) {
          shouldScrollRestore = false;
          restoreScrollPos(url);
        }
      };

      window.addEventListener('beforeunload', onBeforeUnload);
      Router.events.on('routeChangeStart', onRouteChangeStart);
      Router.events.on('routeChangeComplete', onRouteChangeComplete);
      Router.beforePopState(() => {
        shouldScrollRestore = true;
        return true;
      });

      return () => {
        window.removeEventListener('beforeunload', onBeforeUnload);
        Router.events.off('routeChangeStart', onRouteChangeStart);
        Router.events.off('routeChangeComplete', onRouteChangeComplete);
        Router.beforePopState(() => true);
      };
    }
  }, [router]);

  return scrollableDivRef;
}

export default useScrollRestoration;

마무리


바로 다음 글에서 최신 방법에 맞게 Page Router를 사용하지 않고
App Router에 맞게 끔 코드를 수정한 후 적용해보자 !
그 후 useEffectuseLayoutEffect를 비교해보고 왜 더 나은 방법을 제공해 줄 수 있는지 공유하도록 하겠다 ..!!

이어서 계속..

profile
인치
post-custom-banner

0개의 댓글