제 경험이 당신의 next.js 프로젝트에 도움이 되거나 영감을 주면 좋겠습니다.
가장 근본적인 이유는 퍼시스턴트하지 않으면 모든 DOM 노드가 파괴되고 다시 생성된다는 점 입니다.
- 이전 페이지의 DOM Node가 버려지기 때문에 항상 DOM 상태를 잃어버립니다. 예를들어 레이아웃에 흔하게 있을 수 있는 검색창 입력, 네비게이션 메뉴의 포커스 상태가 페이지 이동마다 사라집니다.
- 당연하게도 CSS Transition이 불가능합니다. 애니메이션이 필요하다면 전혀 다른 방법으로 구현해야 합니다.
위 이유로 인해 애플리케이션의 사용성과 접근성이 떨어질 수 있습니다.
Next.js 프로젝트를 아래와 같이 구성해봅시다.
// pages/index.js
const HomePage = () => (
<>
<header>
<h1>Awesome Title</h1>
</header>
<nav><input placeholder="Search ..." /></nav>
<main>
Home Page, <Link to="/second"><a>to Second Page</a></Link>
</main>
</>
)
// pages/second.js
const SecondPage = () => (
<>
<header>
<h1>Awesome Title</h1>
</header>
<nav><input placeholder="Search ..." /></nav>
<main>
Second Page, <Link to="/home"><a>to Home Page</a></Link>
</main>
</>
)
HomePage
와 SecondPage
는 같은 자식 엘리먼트 header
, nav
, main
을 가지지만 서로 다른 컴포넌트이기 때문에 다른 페이지로 서로 페이지를 이동할 때 모든 자식 엘리먼트는 버려지고 새로 생성됩니다.
next.js 공식문서에 적힌 대로 pages/_app.js
에 래퍼를 추가하는 것으로 가능합니다.
// pages/_app.js
const App = ({ Component, pageProps }) => (
<MyLayout>
<Component {...pageProps} />
</MyLayout>
);
const MyLayout = ({ children }) => (
<>
<header>
<h1>Puesdo GitHub</h1>
</header>
<nav><input placeholder="Search ..." /></nav>
<main>
{children}
</main>
</>
);
// pages/index.js
const HomePage = () =>
<>Home Page, <Link to="/second"><a>to Second Page</a></Link></>;
// pages/second.js
const SecondPage = () =>
<>Second Page, <Link to="/home"><a>to Home Page</a></Link></>;
위 코드처럼 작성하면 레이아웃의 header
, nav
, main
엘리먼트가 항상 유지됩니다.
하지만 모든 페이지가 동일한 레이아웃 구성을 가졌다고 가정할 수 없습니다.
예를 들어 여러분이 깃헙과 유사한 프로젝트를 개발한다고 합시다.
GitHub을 처음 접속했을 때 보이는 페이지와 Organization 페이지의 레이아웃을 떠올려봅시다.
상단 네비게이션을 제외하면 페이지의 나머지 부분은 일치하는 부분이 없습니다.
Organization 페이지는 Repository 목록 외에도 Packages, Projects 그리고 Settings 탭이 존재하고 각 탭은 레이아웃을 공유하고 고유 페이지로 존재합니다.
이 요구사항을 만족하기 위해 빠르게 구현해봅시다.
// pages/_app.js
const App = ({ Component, pageProps }) => {
const router = useRouter();
const Layout = DefaultLayout;
if (router.pathname.startsWith('/[owner]')) {
Layout = WorkspaceLayout;
}
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
const DefaultLayout = ({ children }) => (
<header>
<h1>Puesdo GitHub</h1>
</header>
<nav>
<Link href="/my-workspace">
<a>My Workspace</a>
</Link>
</nav>
<main>
{children}
</main>
);
const OrganizationLayout = ({ children }) => (
<>
<header>
<h1>Puesdo GitHub</h1>
</header>
<nav>
<a>Repositories</a>
<a>Packages</a>
<a>Settings</a>
</nav>
<main>
{children}
</main>
</>
);
위 예제로 레이아웃을 분리할 수 있게 됬지만 세 가지 문제점이 있습니다.
- 어떤 레이아웃이 렌더링되는지 페이지 컴포넌트만 보고 알 수 없습니다.
pages/_app.js
를 확인해야 알 수 있습니다. colocation을 할 수 없고, 페이지를 개발하는데 알아야 하는 내용이 많아져서 유지보수성이 떨어집니다. DefaultLayout
,OrganizationLayout
두 페이지의 레이아웃이 자식 엘리먼트들이 재사용되지 않습니다.- 레이아웃 단위에서 data fetching이 불가능합니다.
페이지 컴포넌트에 레이아웃을 명시하도록 바꿉니다. 다음과 같이 만들 수 있습니다:
// pages/index.js
const HomePage = () => {...};
HomePage.withLayout = {
render: (children) => (
<>
<header>
<h1>Puesdo GitHub</h1>
</header>
<nav>
<Link href="/my-workspace">
<a>My Workspace</a>
</Link>
</nav>
<main>
{children}
</main>
</>
)
}
// pages/[owner]/index.js
const OrganizationPage = () => {...};
OrganizationPage.withLayout = {
render: (children) => (
<>
<header>
<h1>Puesdo GitHub</h1>
</header>
<nav>
<a>Repositories</a>
<a>Packages</a>
<a>Settings</a>
</nav>
<main>
{children}
</main>
</>
)
}
// pages/_app.js
const App = ({ Component, pageProps, layoutData }) => {
const renderLayout = Component.withLayout?.render
|| (page) => page;
return renderLayout(<Component {...pageProps} />);
};
이렇게 구성하면 레이아웃을 페이지에서 명시할 수 있습니다. 그리고 레이아웃의 엘리먼트 위치가 트리 내에서 항상 일정하기 때문에 DOM 노드를 재사용하게 됩니다.
몇몇 레이아웃은 SSR중에 비동기적으로 데이터를 가져와야 할 수 있습니다.
아주 간단하게 getInitialProps
와 비슷한 녀석을 구현할 수 있습니다.
const App = ({ Component, pageProps, layoutData }) => {
const renderLayout = Component.withLayout?.render
|| (page) => page;
return renderLayout(<Component {...pageProps} />, layoutData);
};
App.getInitialProps = async ({ ctx, Component }) => {
const [pageProps, layoutData] = await Promise.all([
Component.getInitialProps?.(ctx),
Component.withLayout?.fetchInitialData?.(ctx),
]);
return { pageProps, layoutData };
}
7줄을 추가하여 아래와 같이 사용할 수 있습니다.
// pages/[owner]/index.js
const OrganizationPage = () => {...};
OrganizationPage.withLayout = {
render: (children, { status, organization }) => status === 'stand-by' ? (
<>
<header>
<h1>{organization.name}</h1>
<p>{organization.description}</p>
</header>
<nav>
<a>Repositories</a>
<a>Packages</a>
<a>Settings</a>
</nav>
<main>
{children}
</main>
</>
) : (
<>
<header>
<h1>404 Not Found</h1>
<p>Requested organization does not exist</p>
</header>
</>
),
fetchInitialData: async (context) => {
const ownerId = context.query.owner;
try {
const organization = await fetchOrganizationByOwnerId(ownerId);
return { organization, status: 'stand-by' };
} catch (error) {
if (error.response.status === 404) {
return { status: 'not-found' };
}
throw error;
}
},
}
이제 대부분의 레이아웃 요구사항이 충족되었습니다.
- SSR 과정중에 Data Fetching 가능
- 페이지 단위로 레이아웃을 명시할 수 있음
- 레이아웃 하위 요소가 재사용됨
이것만으로도 대부분의 상황에서 훌륭하게 페이지 레이아웃을 구성할 수 있습니다.
여러 레이아웃을 합성할 수 있으면 레이아웃을 분리하고 재사용하기 훨씬 쉬워집니다. 다음과 같은 레이아웃들이 있다고 해봅시다.
const DefaultLayout = {
render: (children) => (
<>
<TopNavigation />
<div>{children}</div>
</>
),
};
const OrganizationLayout = {
render: (children) => (
<>
<TopNavigation />
<WorkspaceSideNavigation />
{children}
</>
),
};
const OrganizationProjectLayout = {
render: (children) => (
<>
<TopNavigation />
<WorkspaceSideNavigation />
<div>
<ProjectNavigation />
{children}
</div>
</>
),
};
각 레이아웃이 이전 레이아웃과 굉장히 유사합니다.
OrganizationProjectLayout
는 OrganizationLayout
가 확장된 형태고
OrganizationLayout
는 DefaultLayout
가 확장된 형태입니다.
위 레이아웃에서 중복되는 부분을 분리해서 사용할 수 있으면 더욱 편리할 것입니다.
const DefaultLayout = {
render: (children) => (
<>
<TopNavigation />
{children}
</>
),
};
const OrganizationLayout = {
render: (children) => (
<>
<WorkspaceSideNavigation />
{children}
</>
),
parent: DefaultLayout,
};
const OrganizationProjectLayout = {
render: (children) => (
<div>
<ProjectNavigation />
{children}
</div>
),
parent: OrganizationLayout,
};
복잡한 CSS나 렌더링 로직이 포함되어 있지 않아 줄어든 부분이 부각되지 않지만,
일반적으로 문제를 분리할 수 있으면 단순화시켜서 더 쉽게 개발할 수 있듯이 레이아웃도 비슷하게 생각할 수 있습니다.
각 레이아웃별로 담당하는 부분은 다르지만 최종적으로 OrganizationProjectLayout
가 아래 처럼 결과를 반환하면 되는거죠.
// 실제론 이렇게 동작하지 않습니다.
// 우리가 정의한 Layout들은 Component가 아니니까요.
<DefaultLayout>
<OrganizationLayout>
<OrganizationProjectLayout>
{children}
</OrganizationProjectLayout>
</OrganizationLayout>
</DefaultLayout>
이렇게 되면 레이아웃 단계별로 관심사를 분리할 수 있게 됩니다. 아주 간단하게 구현할 수 있습니다.
// pages/_app.js
const App = ({ Component, pageProps, layoutDataList }) => {
const layouts = getLayoutList(Component.withLayout);
return layouts.reduce(
(children, { render }) => render(children, layoutDataList),
<Component {...pageProps}>,
);
};
App.getInitialProps = async ({ ctx, Component }) => {
const [pageProps, layoutDataList] = await Promise.all([
Component.getInitialProps?.(ctx),
// 비동기 처리에 유의하세요
Promise.all(
getLayoutList(Component).map(
layout => layout.withLayout?.fetchInitialData?.(ctx)
)
),
]);
return { pageProps, layoutDataList };
}
const getLayoutList = (layout) => {
const list = [];
let lastLayout = layout;
while (lastLayout) {
list.push(layout);
lastLayout = layout.parent;
}
return list;
}
이 글에서 다루지 않은 요청 중복 방지나 다른 요구사항도 있지만, 제 경험상 위 기능들만 충족되면 대부분의 레이아웃을 커버할 수 있었습니다.
방법이 없나.. 허허.. 아무튼 감사합니다 ㅎㅎ