공부내용 공유하기

[Next.js] 페이지 스크롤 상태 유지하기

CRA 프로젝트를 진행하면서는 스크롤 상태 유지(scroll restoration)를 하기 용이했다.

 

근데 next로 넘어오면서 자체 라우팅 시스템을 사용함에 따라 react-router-dom을 사용한 기존 방식

(router에서 넘어온 history객체 내에 action을 감지해서 pop 식별 후 처리)을 사용할 수 없게 되었다.

 

Next 내부에서 기본적으로 스크롤 복원 기능을 제공하기는 한다.

그러나 내가 작업 중인 페이지에는 목록 내부 요소 크기가 스크립트를 통해 정해지기 때문에, 목록 -> 아이템 -> 뒤로 가기(목록) 작동을 했을 경우 정확한 스크롤 위치로 돌아오지 않는 이슈가 있었다.

 

이러나저러나 페이지 스크롤 복원 방법은 여기저기 널려있고, 그중에 괜찮은 것들을 짬뽕해서 구현했다.

 

목표는 커스텀 훅으로 스크롤 복원 로직을 단순화 시키는 것.

 

페이지 컴포넌트단에선 해당 로직에 신경쓸 필요가 없고, 원하는 곳에 save, load를 자유롭게 할 수 있게 하고자 했다.

 

스크롤 복원의 특성상, 뒤로가기(POP action) 외에는 작동하지 않게 구현할 필요도 있었다.

 

저장 공간은 로컬스토리지에 저장했다.

 

세션 스토리지에 저장하는 게 더 좋겠지만, redux persist랑 같이 보고 싶어서....

 

 

useScrollPos.ts

import {  useMemo } from "react";
import { useRouter } from "next/router";
import useLocalstorage from "@rooks/use-localstorage";
//내가 즐겨쓰는 rooks hook 시리즈인데, 
//IE11 polyfill 때문에 transcompile 모듈에 항상 추가해줘야 하는게 너무 귀찮다...
import { useDispatch, useSelector } from "react-redux";
import { RestorePages, restoreScrollPos } from "../../redux/actions/utils";
import ROUTE_URL from "../../routes/constants/urls";
import { RootState } from "../../redux/reducers";

// Route URL과 RestorePage 타입을 일치시킬 ENUMS
const RESTORE_URL_ENUMS: Record<string, RestorePages> = {
  [ROUTE_URL.NEW_CAR_LIST]: "NC_LIST",
  [ROUTE_URL.EVENT]: "EVENT",
};

// 간단하게 paht와 scroll position만 저장했다.
interface PosItem {
  pathName: string;
  pos: number;
}

function useScrollPos() {
  // ? useScroll 을 호출한 주소를 확인 후, state 에 저장
  // ? load 를 호출하면 주소에 저장된 Position 확인 후 불러오고 setPos함
  const { pathname } = useRouter();
  const dispatch = useDispatch();
  const { beforePopState } = useRouter();
  // 요놈이 핵심인데, next router에 들어있는 녀석으로, 
  // pop액션 직전에 이동하려는 url을 담고 호출된다
  const isServer = typeof window === "undefined";
  // 서버환경에서는 작동을 막는다.
  
  const restorePage = useSelector((state: RootState) => state.util.restorePage);
  // useSelector 훅을 사용해서 util 스토어에 있는 restorePage를 가져온다. 
  // 타입은 RestorePage이다.
  
  const [posListFromStorage, setPosListToStorage] = useLocalstorage(
    "posList",
    JSON.stringify([])
  );
  // 로컬스토리지에 저장한다. 이름은 posList

  const posList: PosItem[] = useMemo(() => JSON.parse(posListFromStorage), [
    posListFromStorage,
  ]);
  //꺼내서 쓸 때는 이렇게

  const setPosList = (posLists: PosItem[]) => {
    setPosListToStorage(JSON.stringify(posLists));
  };
  //저장(세팅) 할 때는 이렇게
  
   
  const savePos = () => {
    if (isServer) return;
	//서버면 종료
    
    let pageYOffset = window.pageYOffset;
	// 페이지 오프셋
    
    const pageIndex = posList.findIndex(
      ({ pathName }) => pathName === pathname
    );
    // find로 현재 페이지와 posList내의 저장된 페이지가 일치하는 값을 찾는다.
    
    if (pageIndex === -1) {
    // find는 값을 못 찾으면 -1 반환
      const newPosLists = posList.concat({
        pathName: pathname,
        pos: pageYOffset,
      });
      setPosList(newPosLists);
      // 그러면 새로 저장해
      
    } else {
    //찾았으면
    
      const newPosLists = posList.map((item) => {
        const { pathName } = item;
        return pathName === pathname ? { ...item, pos: pageYOffset } : item;
      });
      setPosList(newPosLists);
      //기존꺼에 pos만 바꿔서 새로 저장해
    }
  };

  const restorePos = () => {
    if (isServer) return;
    
    beforePopState(({ url }) => {
    // pop action으로 이동하려는 url가져오기
      const urlWithoutParams = url.split("?")[0];
      // 쿼리스트링까지 나오므로 거르고 url만 추출
      dispatch(restoreScrollPos(RESTORE_URL_ENUMS[urlWithoutParams]));
      // restoreScrollPos 액션에다가 RestorePages 타입을 반환한다.
      return true;
      // true를 반환해줘야 정상적으로 url로 넘어간다.
    });
  };

  const loadPos = () => {
    if (isServer) return;

    if (restorePage !== RESTORE_URL_ENUMS[pathname]) return;
	// util store에 있는 restorePage가 목록에 없으면 종료
    
    if (posList) {
    
      const position = posList.find(({ pathName }) => pathName === pathname)
        ?.pos;
	  //posList에서 현재 페이지랑 일치하는 아이템의 position가져옴
      
      if (position) {
        setTimeout(() => {
          window.scrollTo(0, position);
          return dispatch(restoreScrollPos(""));
        }, 0);
        // 이벤트루프에 넣으려고 setTimeout 사용
        // store에 있는 restoreScrollPosition을 비운다.(전용 액션 만들어서 할걸)
      }
    }
  };

  return { savePos, loadPos, restorePos };
  // 스크롤 위치의 저장, 불러오기, 저장된거 불러오는 요청.
}

export default useScrollPos;

 

action/util.ts

//util action

export type RestorePages = "" | "NC_LIST" | "EVENT";

export const restoreScrollPos = createAction(
  RESTORE_SCROLL_POS
)<RestorePages>();

 

reducer/util.ts

// util reducer


export type UtilState = {
  restorePage: RestorePages;
};

export const utilInitialState: UtilState = {
  restorePage: "",
};

const utilReducer = createReducer<UtilState, Action<string>>(utilInitialState)
  .handleAction(restoreScrollPos, (state, action) => ({
    ...state,
    restorePage: action.payload,
  }));

 

사용법 :

 

const { save, load, restorePos } = useScrollPos(); 으로 호출해서 꺼내고,

 

1. 목록 페이지에서 아이템 상세로 이동하는 순간 save() 호출.

2. 아이템 상세 컴포넌트 안에 restorePos() 놔두면 알아서 뒤로 가기 시에 action dispatch.

3. 목록 페이지에 load() 놔두면 알아서 스크롤 복원.

 

사실상 목록 페이지에 load 놔두고, 상세 페이지에 restorePos 놔두고, 상세페이지 클릭 시에만 save를 호출하면 된다.

 

Hook은 로직을 재사용하기 참 간단해서 좋다.

 

 


2023.07 기준 업데이트

 

1. persistant를 굳이 신경쓸 필요 없다. 메모리에 보관해도 충분. 스택 관리는 beforePopState 이벤트에서 pop으로 처리하는게 편할듯 싶다.

2. window.history.scrollRestoration을 manual로 바꿔두자.

3. 페이지에서 매번 훅 호출을 하는 방식보다는 _app 내부에 url 기준 커스텀 훅을 만드는게 아무래도 편하다.