React-Router에 scroll restoration 적용하기
이번에 프로젝트를 진행하며 만들어본 약간 임시 방편일 수 있는 scroll restoration 기능을 글로 남겨봅니다.
이번 프로젝트에서 글 리스트가 있고, 리스트 중 하나를 클릭하면 글 페이지로 넘어가는 일반적인 웹 사이트를 만들고 있었다. 그런데 페이지를 클릭하고 뒤로 가기를 눌렀을 때 이전 scroll 위치로 돌아가지 않는 문제를 겪었다. 요즘은 SPA가 많아졌고 대부분의 브라우저가 history API를 이용해도 scroll restoration을 지원하기 때문에 당연히 될 줄 알았던 것이 안돼서 당황했다..
혹시나 해서 크롬으로 켰더니 잘 된다. 파이어폭스에서 문제는 내가 간 글 페이지의 window height에 스크롤이 없으면 뒤로 갔을 때(POP 일 때) 이전에 저장해두었을 스크롤 포지션을 적용시키지 못하는 것 같았다.
즉, 글 list -> 글 view (pop)-> 글 list 일 때, 글 view 페이지에서 스크롤이 없으면 글 list로 돌아왔을 때 scroll restoration이 되지 않는다!
이를 해결하기 위해 scroll restoration을 도와주는 컴포넌트로 page 라우팅하는 부분을 감싸주었다. 조금 임시 방편이긴 한데 해결하기 위해서는 괜찮은 방법이라고 생각한다.
프로젝트에서는 ReactTraning의 react-router와 history를 사용했기 때문에 밑에 코드도 그것에 맞게 작성되었다.
Scroll position 저장
restore을 하기 위해선 store를 해야한다. 현재 페이지에서 스크롤시마다 변경되는 offset을 저장하는 함수를 만들었다.
// scroll 시 현재 x, y offset를 history state에 저장.
const onScroll = useCallback(() => {
requestAnimationFrame(() => {
const { pageXOffset, pageYOffset, location } = window;
const { state: prevState = {} } = window.history;
window.history.replaceState(
{
...prevState,
scroll: {
x: pageXOffset,
y: pageYOffset,
},
},
'',
location.pathname,
);
});
}, []);
useWindowEvent('scroll', onScroll);
기본 scroll 이벤트는 너무 많이 발생하므로, requestAnimationFrame으로 throttle을 주었다.
window.history.replaceState로 현재 location의 state에 x, y offset을 업데이트/저장 하도록 한다.
맨 밑 useWindowEvent('scroll', onScroll);는 그냥 window 이벤트를 등록하는 커스텀 hook이므로 원하는 방법으로 등록하면 된다.
Scroll position 복구
이제 저장을 했으니 복구하는 로직을 구현해보자.
// scroll Sync를 requestAnimationFrame 단위로 시도.
// y가 전체 height보다 작고 x와 y가 다르면 재귀적으로 계속 시도해본다.
const syncScroll = useCallback(
debounce((x: number, y: number, attempt: number) => {
requestAnimationFrame(() => {
if (attempt < 1) {
return;
}
const { pageXOffset, pageYOffset } = window;
if (x !== pageXOffset || y !== pageYOffset) {
window.scrollTo(x, y);
syncScroll(x, y, attempt - 1);
}
});
}, 100),
[],
);
syncScroll은 스크롤 포지션 복구를 시도해보는 함수이다. 그 페이지에 비동기적인 처리 후에 레이아웃이 그려지는 로직이 있을 때를 처리하기 위하여 debounce와 requestAnimationFrame을 사용하였다. (고려하지 않으려면 없애도 된다.) 포지션 x와 y로, 임의로 정의해둔 attempt 횟수만큼 스크롤 복구를 재귀적으로 시도해보는 함수이다. (조금 hack 한 방법이다..)
history action에 따른 분기
이제 원하는 두 함수를 만들었으므로, history action에 따라 분류해보자!
만드려는 웹 페이지에 따라 차이는 있겠지만, PUSH일 때는 스크롤이 맨 위로 가고, POP일 때 복구하도록 하는게 기본적일 것이다.
// PUSH일 때는 top으로, POP일 때는 scrollSync를 시도.
// MAX_SYNC_ATTEMPT = 5
useEffect(() => {
const unlisten = history.listen((location, action) => {
const { state } = window.history;
if (action === 'PUSH') {
window.scrollTo(0, 0);
}
if (action === 'POP' && state && state.scroll) {
const { x, y, attempt = MAX_SYNC_ATTEMPT } = state.scroll;
syncScroll(x, y, attempt);
}
});
return unlisten;
}, []);
나는 위에서 말한 history 라이브러리의 리스너를 등록했지만, react-router의 history action을 받아서 사용해도 문제 없을 것이다.
현재 history의 action을 확인하여 분기 한다. PUSH일 경우 맨 위로 올려주고,POP일 경우 state에 들어있는 x, y값으로 스크롤 복구를 시도한다.
결론
hack한 방법이므로, 파이어폭스에서 제대로 지원해주면 좋겠다.