평소에 구현해보고 싶었던 Full Page의 기능을 중점으로
포트폴리오와 작성한 블로그들을 정리하기 위한 사이트
React (hooks)
TypeScript
Emotion
Gatsby
Graphql
1️⃣ Graphql을 이용한 Markdown 데이터 요청
2️⃣ Gatsby의 slug 기능을 이용하여 해당 Markdown 게시글과 연동
3️⃣ Full Page 구현
4️⃣ 무한 스크롤 구현
5️⃣ Fullpage 태블릿 & 모바일 환경 호환
Gatsby에서 제공해주는 Graphql을 이용하여 Markdown 데이터 소스를 얻고, 이를 html로 변환할 수 있다. 이를 위해서는 먼저 두 가지 플러그인이 필요하다.
플러그인을 설치한 후 gatsby-config에 다음과 같이 작성해준다.
// gatsby-config.js
module.exports = {
plugins: [
{
resolve: `gatsby-source-filesystem`,
options: {
name: `contents`,
path: `${__dirname}/contents`,
},
},
{
resolve: `gatsby-transformer-remark`,
options: {
plugins: [...],
},
}
],
};
로컬 파일 시스템의 파일을 File 노드로 생성하여 graphql을 통해 데이터를 얻을 수 있다.
단, 마크다운을 path에 지정한 경로에 저장해야한다.
마크다운 파일을 파싱하여 MarkdownRemark 노드로 생성한다. 생성된 노드는 graphql을 통해 데이터를 얻을 수 있다.
query getMarkdownData {
allMarkdownRemark {
edges {
node {
html
id
frontmatter {
title,
summary,
...
}
}
}
}
}
마크다운 데이터에 slug 필드를 추가하여 해당 게시물에 접근하기 위한 url을 생성한다.
해당 기능을 사용하기 위해선 onCreateNode라는 gatsby에서 제공하는 API를 사용한다.
// gatsby-node.js
exports.onCreateNode = ({ node, getNode, actions }) => {
const { createNodeField } = actions;
if (node.internal.type === `MarkdownRemark`) {
const slug = createFilePath({ node, getNode });
createNodeField({ node, name: 'slug', value: slug });
}
};
ex) contents/ShallWeSound.md -> domain/ShallWeSound
마크다운 데이터를 이용해 slug를 생성해주었으니, 이제는 이 slug를 이용해 접근할 페이지를 생성한다. 페이지 생성은 gatsby의 createPages API를 이용한다.
// gatsby-node.js
exports.createPages = async ({ actions, graphql, reporter }) => {
const { createPage } = actions;
const queryAllMarkdownData = await graphql(
`
{
allMarkdownRemark {
edges {
node {
fields {
slug
}
}
}
}
}
`,
);
if (queryAllMarkdownData.errors) {
reporter.panicOnBuild(`Query Error`);
return;
}
const PostTemplateComponent = path.resolve(__dirname, 'src/templates/post.tsx');
const generatePostPage = ({
node: {
fields: { slug },
},
}) => {
const pageOptions = {
path: slug,
component: PostTemplateComponent,
context: { slug },
};
createPage(pageOptions);
};
queryAllMarkdownData.data.allMarkdownRemark.edges.forEach(generatePostPage);
};
template 폴더에 post.tsx로 게시글 페이지의 템플릿을 정의한다. 그리고 그 템플릿 컴포넌트를 불러온다.
다음으로 pageOtions을 정의하고, 실제로 페이지를 생성해줄 generatePostPage 함수를 정의한다. 여기서 path는 페이지의 경로를 나타내고, component는 게시글을 렌더링할 컴포넌트를 의미한다. 마지막으로 context는 바로 앞서 정의한 component에 props로 넘겨줄 수 있는 값이다. 이 slug를 통해 해당 컴포넌트에서 맞는 마크다운 문서를 찾아 불러올 수 있다.
그리고 graphql을 통해 불러온 queryAllMarkdownData 값에서 markdown 데이터가 들어있는 edges를 반복하여 페이지 생성 함수를 실행해 준다.
// index.tsx
const Index: React.FC = () => {
const [outerRef, currentPageName, onClickNavBar] = useFullPage({ maxPageCount: 3 });
return (
<Background ref={outerRef} className="outer">
<NavBar currentPageName={currentPageName} onClickNavBar={onClickNavBar} />
<About />
<PostList />
<PostList />
<Contact />
</Background>
);
};
// useFullPage.tsx
const PAGE_NAMES = ['About', 'Project', 'Blog', 'Contact'];
const useFullPage = ({ maxPageCount }: { maxPageCount: number }) => {
const outerRef = useRef<any>();
const currentPage = useRef(0);
const [currentPageName, setCurrentPageName] = useState(PAGE_NAMES[currentPage.current]);
...
}
// useFullPage.tsx
const useFullPage = ({ maxPageCount }: { maxPageCount: number }) => {
...
const scrollToCurrentPage = () => {
outerRef.current.scrollTo({
top: window.innerHeight * currentPage.current,
left: 0,
behavior: 'smooth',
});
};
const scrollDown = () => {
currentPage.current += 1;
scrollToCurrentPage();
setCurrentPageName(PAGE_NAMES[currentPage.current]);
};
const scrollUp = () => {
currentPage.current -= 1;
scrollToCurrentPage();
setCurrentPageName(PAGE_NAMES[currentPage.current]);
};
}
// useFullPage.tsx
const useFullPage = ({ maxPageCount }: { maxPageCount: number }) => {
...
useEffect(() => {
const wheelHandler = debounce((e: WheelEvent) => {
e.preventDefault();
const { deltaY } = e;
if (deltaY > 0 && currentPage.current < maxPageCount) {
scrollDown();
} else if (deltaY < 0 && currentPage.current > 0) {
scrollUp();
}
}, 50);
outerRef.current?.addEventListener('wheel', wheelHandler);
return () => {
outerRef.current?.removeEventListener('wheel', wheelHandler);
};
}, []);
}
// debounce.ts
const debounce = (callback: (...arg: any) => void, delay: number) => {
let timer: NodeJS.Timeout;
return (...arg: any) => {
clearTimeout(timer);
timer = setTimeout(() => callback(...arg), delay);
};
};
export default debounce;
// useFullPage.tsx
useEffect(() => {
window.addEventListener('resize', scrollToCurrentPage);
return () => {
window.removeEventListener('resize', scrollToCurrentPage);
};
});
const BlogList: React.FC<Props> = ({ posts, selectedCategory }) => {
const { targetRef, postsByPage } = useInfiniteScroll({ posts, selectedCategory });
return (
<Wrapper ref={targetRef}>
{postsByPage.map(
({
node: {
id,
fields: { slug },
frontmatter,
},
}) => (
<PostItem key={id} link={slug} {...frontmatter} />
),
)}
</Wrapper>
);
};
// useInfiniteScroll.tsx
import { MutableRefObject, useState, useRef, useEffect, useMemo } from 'react';
import { PostListType } from 'types/post.types';
const POST_COUNT_BY_PAGE = 9;
const useInfiniteScroll = ({ posts, selectedCategory }: { posts: PostListType[], selectedCategory: string }) => {
const [page, setPage] = useState(1);
const targetRef: MutableRefObject<HTMLDivElement | null> = useRef < HTMLDivElement > null;
const filteredPosts = useMemo(
() =>
posts.filter(
({
node: {
frontmatter: { categories },
},
}) => selectedCategory === 'All' || categories.includes(selectedCategory),
),
[selectedCategory],
);
const observer = new IntersectionObserver(
(entries, observer) => {
if (!entries[0].isIntersecting) return;
setPage(prev => prev + 1);
observer.disconnect();
},
{
threshold: 1.0,
},
);
useEffect(() => setPage(1), [selectedCategory]);
useEffect(() => {
if (
!targetRef.current ||
page * POST_COUNT_BY_PAGE >= filteredPosts.length ||
targetRef.current.children.length <= 0
)
return;
observer.observe(targetRef.current.children[targetRef.current.children.length - 1]);
}, [page, selectedCategory]);
return {
targetRef,
postsByPage: filteredPosts.slice(0, page * POST_COUNT_BY_PAGE),
};
};
export default useInfiniteScroll;
useEffect(() => {
const setScreenSize = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setScreenSize();
window.addEventListener('resize', setScreenSize);
return () => {
window.removeEventListener('resize', setScreenSize);
};
}, []);
resize 이벤트 발생 시에 화면 크기가 변경되는 것을 고려하여 이벤트를 등록해준다.
–vh변수에 계산한 innerHeight 값을 저장한 뒤 다음과 같이 사용한다.
body {
height: calc(var(--vh, 1vh) * 100);
}
–vh가 존재하면 그 값을, 아니면 1vh를 사용한다.
모바일의 경우 가로, 세로 전환을 할 수 있다.
가로, 세로 전환 시 resize 이벤트가 2번 발생하는 경우가 발생했다.
이 문제 때문에 화면 높이가 제대로 잡히지 않는 현상이 발생했고 이를 해결하기 위해 debounce를 적용하여 마지막 이벤트만 수행하도록 했다.
useEffect(() => {
const setScreenSize = debounce(() => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
}, 50);
setScreenSize();
window.addEventListener('resize', setScreenSize);
return () => {
window.removeEventListener('resize', setScreenSize);
};
}, []);
const touchStartY = useRef(0);
const touchEndY = useRef(0);
useEffect(() => {
const touchStartHandler = debounce((e: TouchEvent) => {
e.preventDefault();
touchStartY.current = e.changedTouches[0].clientY;
}, 50);
const touchEndHandler = debounce((e: TouchEvent) => {
e.preventDefault();
touchEndY.current = e.changedTouches[0].clientY;
if (touchStartY.current < touchEndY.current && currentPage.current > 0) {
scrollUp();
} else if (touchStartY.current > touchEndY.current && currentPage.current < maxPageCount) {
scrollDown();
}
}, 50);
outerRef.current?.addEventListener('touchstart', touchStartHandler);
outerRef.current?.addEventListener('touchend', touchEndHandler);
return () => {
outerRef.current?.removeEventListener('touchstart', touchStartHandler);
outerRef.current?.removeEventListener('touchend', touchEndHandler);
};
}, []);
마지막으로 웹 브라우저에서는 다음과 같은 코드가 잘 동작한다.
outerRef.current?.scrollTo({
top: window.innerHeight * currentPage.current,
left: 0,
behavior: 'smooth',
});
하지만 모바일 브라우저에 따라서는 smooth 동작이 수행되지 않는 경우가 있다.
따라서 다음과 같은 패키지를 설치해준다.
npm install smoothscroll-polyfill --save
import smoothscroll from 'smoothscroll-polyfill';
smoothscroll.polyfill();