웹앱을 개발할 때, 항상 존재하는 메인 페이지를 두고, 그 위에 서브 페이지를 보이게 하라는 요청이 있었습니다. 하지만 이 경우에는 뒤로가기시 스크롤의 위치가 정확하게 동작하지 않았습니다. 이에 대한 해결 방법을 찾아보면서 알아낸 방법을 정리합니다.
전체 코드
main.jsx
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router'
import './index.css'
import MainPage from './main-page.jsx'
import SubPage from './pages/sub-page.jsx'
import ScrollRestore from './scroll-restore.jsx'
export const router = createBrowserRouter([
{
path: "/",
Component: MainPage,
children: [
{
Component: ScrollRestore,
children: [
{
path: "/sub/:id",
Component: SubPage
}
]
}
]
},
]);
const root = document.getElementById('root')
createRoot(root).render(
<RouterProvider router={router} />
)
app.jsx
import { Outlet, useNavigate } from 'react-router'
function App() {
const navigate = useNavigate()
const openSubPage = (id) => {
navigate(`/sub/${id}`);
};
return (
<div>
<h1>메인 페이지</h1>
<ul>
{Array.from({ length: 1000 }, (_, i) => (
<li key={i} style={{ padding: "20px", border: "1px solid gray" }}>
항목 {i + 1} <button onClick={() => openSubPage(i + 1)}>서브 페이지 열기</button>
</li>
))}
</ul>
<Outlet />
</div>
)
}
export default App
sub-page.jsx
import { useContext } from "react";
import { useNavigate, useParams } from "react-router";
import { ScrollRestore } from "../scroll-restore";
const SubPage = () => {
const { page } = useContext(ScrollRestore)
const navigate = useNavigate();
const { id } = useParams();
const closeSubPage = () => {
navigate(-1);
};
const openSubPage = (id) => {
navigate(`/sub/${id}`);
};
return (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "white",
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<div ref={page} style={{ height: '100%', padding: "20px", borderRadius: "10px", overflow: 'auto' }}>
<h2>서브 페이지 {id}</h2>
<button onClick={closeSubPage}>닫기</button>
<ul>
{Array.from({ length: 1000 }, (_, i) => (
<li key={i} style={{ padding: "20px", border: "1px solid gray" }}>
항목 {i + 1} <button onClick={() => openSubPage(i + 1)}>서브 페이지 열기</button>
</li>
))}
</ul>
</div>
</div>
);
};
export default SubPage
scroll-restore.jsx
import { createContext, useLayoutEffect, useRef } from "react";
import { Outlet, useLocation } from "react-router";
import { router } from "./main";
/**
* useScrollRestore 훅
* - 페이지 이동 시 현재 페이지의 스크롤 위치를 저장하고, 다시 방문하면 해당 위치로 복원
* - `useRef`를 사용하여 스크롤 위치 데이터를 저장하고, `useLayoutEffect`를 활용하여 스크롤 동작을 제어
*/
function useScrollRestore() {
const location = useLocation(); // 현재 라우트의 위치 정보를 가져옴
const page = useRef(); // 페이지 컨테이너의 DOM 요소를 저장할 ref
const historyData = useRef([]); // 방문한 페이지의 스크롤 기록을 저장하는 배열
const scrollTopData = useRef({}); // 페이지별 스크롤 위치를 저장하는 객체
/**
* 첫 번째 useLayoutEffect
* - 페이지 이동(PUSH) 시 현재 페이지의 스크롤 위치를 저장
*/
useLayoutEffect(() => {
const { key, pathname, search } = location; // 현재 라우트의 키(key), 경로(pathname), 검색쿼리(search) 가져오기
const element = page.current; // 현재 페이지의 DOM 요소
const historyArray = historyData.current; // 방문 기록 배열
const scrollTop = scrollTopData.current; // 스크롤 위치 저장 객체
let isAction = false; // 중복 실행을 방지하기 위한 플래그
if (!element) return; // 페이지 요소가 없으면 실행하지 않음
// 라우터의 상태 변경을 감지하는 구독(subscription)
const unsubscribe = router.subscribe((history) => {
// PUSH 액션이 아니거나 이미 실행된 경우 실행하지 않음
if (!element || history.historyAction !== "PUSH" || isAction) return;
isAction = true; // 실행됨을 표시
// 현재 페이지의 스크롤 위치 저장
historyArray.push({
key,
pathname,
search,
top: element.scrollTop, // 현재 스크롤 위치 저장
});
// 현재 페이지의 키를 기준으로 스크롤 위치 저장
scrollTop[key] = element.scrollTop;
});
return unsubscribe; // 구독 해제 함수 반환
}, [location]);
/**
* 두 번째 useLayoutEffect
* - 페이지가 변경될 때 저장된 스크롤 위치로 복원
*/
useLayoutEffect(() => {
if (!page.current) return;
const element = page.current; // 현재 페이지의 DOM 요소
// 이전에 저장된 스크롤 위치 가져오기
const savedScrollTop =
scrollTopData.current[location.key] || (location.state && location.state.top);
// 저장된 스크롤 위치가 없으면 최상단으로 이동
if (!savedScrollTop) {
element.scroll({ top: 0, behavior: "instant" });
return;
} else {
// 저장된 스크롤 위치로 이동
element.scroll({ top: savedScrollTop, behavior: "instant" });
// 스크롤이 이미 설정된 경우 추가 작업 불필요
if (element.scrollTop === savedScrollTop) return;
}
/**
* restoreScroll 함수
* - 페이지 콘텐츠가 완전히 렌더링되지 않아 스크롤 복원이 실패한 경우 재시도
*/
const restoreScroll = () => {
if (element.scrollHeight > savedScrollTop) {
element.scroll({ top: savedScrollTop, behavior: "instant" });
observer.disconnect(); // MutationObserver 해제
}
};
// 페이지 요소 변경을 감지하는 MutationObserver 생성
const observer = new MutationObserver(() => {
restoreScroll(); // 변경이 감지되면 스크롤 복원 시도
});
// MutationObserver를 사용하여 DOM 변경 감지
observer.observe(element, { childList: true, subtree: true });
return () => {
observer.disconnect(); // 컴포넌트 언마운트 시 MutationObserver 해제
};
}, [location.key]);
// page와 historyData를 반환하여 컨텍스트에서 활용 가능하게 함
return {
page,
historyData,
};
}
// 스크롤 복원 기능을 제공하는 Context 생성
export const ScrollRestore = createContext();
/**
* Observer 컴포넌트
* - useScrollRestore를 호출하여 스크롤 상태를 관리하고, 하위 컴포넌트에 제공
*/
export default function Observer() {
const { page, historyData } = useScrollRestore(); // useScrollRestore 훅 실행
return (
<ScrollRestore.Provider value={{ page, historyData }}>
<Outlet /> {/* 하위 라우트 컴포넌트들을 렌더링 */}
</ScrollRestore.Provider>
);
}
문제점
로딩이 오래걸리면 스크롤은 콘텐츠가 다 생성되지 않아서 기억하고 있는 스크롤 값이 height값을 넘어가는 경우가 있다. 당연히 이 경우에 제대로 동작하지 않는다.
해결 방법
- restoreScroll 함수는 위 문제를 해결하기 위해서 만든 함수이지만, 이것으로는 해결이 안되었음
- 로드가 된 다음에 동작하게 하는 방법 (이렇게 하지는 않았지만 이렇게 하면 해결이 될 것이라고 생각)
- 보통 이미지가 늦게 로드 되는 경우가 많았다. 이 경우에 이미지에 미리 고정적인 크기를 주었더니 해결이 됨