혼자 JS, Serverless 갖고 놀기 - 서버 사이드 렌더링편
서버 사이드 렌더링(SSR)을 위해선 각 페이지 api가 완전한 HTML 문서를 응답으로 보내줄 수 있어야한다.
다만 SSR이라고 해서 무조건 페이지가 이동할 때마다 새로운 HTML으로 refresh될 필요는 없다. 이는 너무 전통적 방식의 SSR이고 최근에는 로딩 시에도 문서 정보를 볼 수 있다는 SSR의 이점만 채택하고 클라이언트 사이드 렌더링(CSR)과 연계한다.
처음에 프론트 코드가 로딩된 이후 페이지 이동이 발생하면 서버에서 받은 HTML을 토대로 내부 HTML만 스위칭하도록 구현한다.
서버에서 API는 Express
로 간단하게 설정한다. 단순히 HTML text를 status code 200과 함께 응답하기만 하는 코드이다.
import { Router } from 'express';
const router = Router();
router.get('/profile', (req, res) => {
res.status(200).send(baseModel(profile));
});
export default router;
서버 세팅법은 세팅편에서 설명한다
위 코드 send
에서 사용하고 있는 baseModel
은 HTML text를 만들어서 반환하는 역할을 한다.
export const baseModel = (component) => `
<!DOCTYPE html>
<html lang="kr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width" />
<title>${ROUTE_TITLE[component.id]}</title>
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
<link rel="stylesheet" href="./src/normalize.css"/>
<link rel="stylesheet" href="./src/style.css"/>
${getStyleTag(component.id)}
</head>
<body>
<div id="app" class="app">
${sidebar}
<main id="main" class="main">
${component.content}
</main>
${scrollIndicator}
</div>
<script src="./src/main.js" type="module"></script>
</body>
</html>
`;
text를 그대로 브라우저에 전달하면 렌더링이 될 수 있도록 헤더의 스타일 태그와 메타 태그, 바디의 스크립트를 포함한다.
타이틀과 일부 스타일 및 메인의 내용은 컴포넌트마다 다르게 표시되어야 하므로 인자에 component
로 나타난 컴포넌트별 모델을 전달 받아 표시한다.
sidebar
나 scrollIndicator
의 경우 어떤 페이지에서도 공통적으로 표시할 일종의 Layout 컴포넌트이다.
app
과 main
의 경우 hydrate를 위해 id가 설정되어있다.
위 HTML text는 엄밀히 말하면 뷰 모델이다. 다만 이 프로젝트에서 모델은 상호작용 없이 단순한 편이고, 모델에서 뷰 모델 변환은 HTML 형식으로 나타내는 것 뿐이라 모델과 뷰 모델을 엄격하게 구분하지 않았다.
export const profile = {
id: '/profile',
content: `
<div id="profile" class="profile">
<section class="profile__intro">
<img src="${IMAGE_URL.PROFILE}" alt="프로필 이미지" class="profile__image" />
<p class="profile__intro-text">원리를 어쩌구~</p>
</section>
<section class="profile__text">
<p>소개 관련 내용</p>
</section>
</div>
`,
};
컴포넌트도 HTML 형식으로 작성한다.
첫 페이지 진입 때는 HTML에 써있는 대로 브라우저가 알아서 표시하고 JS파일과 CSS파일을 로딩한다. 이후 페이지 이동 때는 HTML을 변경하고 스타일 태그도 헤더에 추가해줘야 한다.
async loadPageData(pathname) {
try {
this.loadPageStylesheet(pathname);
const response = await fetch(getUrl(pathname));
if (!response.ok) {
throw new Error('something wrong');
}
const responseHtml = await response.text();
const mainInnerHTML = responseHtml.split('<main id="main" class="main">')[1].split('</main>')[0];
this.setHtmlData(mainInnerHTML);
this.setPathname(pathname);
} catch (err) {
console.error(err);
}
}
loadPageStylesheet(pathname) {
if (document.head.querySelector(getStyleTagId(pathname))) return;
const styleTag = getStyleTag(pathname);
document.head.insertAdjacentHTML('beforeend', styleTag);
}
페이지 데이터를 받으면 <main>
태그 내부 text를 분리해서 저장한다. 저장한 text는 이후 현제 DOM의 <main>
내부 HTML과 스위칭해서 사용한다.
스타일 태그도 HTML에서 분리한 뒤 헤더에 추가하는 방식을 사용할 수도 있다. 하지만 그러면 서버에 요청이 HTML 파일에서 한 번, CSS 파일에서 한 번, 총 두 번이 이루어진다. 그 결과로 로딩 시간도 두 배가 될 뿐더러 CSS 파일이 로딩되기 전까지 몬생긴 쌩짜 HTML이 노출된다.
이를 방지하기 위해 스타일 태그는 HTML 요청을 하기 전에 document head에 먼저 삽입해서 로딩이 HTML 요청과 동시에 일어날 수 있도록 한다.
메인 컴포넌트에서는 저장한 HTML로 <main>
태그 내부를 바꾸고 자식 컴포넌트도 페이지에 맞는 걸로 마운트 및 언마운트한다.
if (curComponent) {
curComponent.instance?.unmount();
curComponent.instance = null;
}
const parser = new DOMParser();
const newPageNode = parser.parseFromString(
this.props.mainInner,
'text/html',
);
this.$target.replaceChildren(newPageNode.body.childNodes[0]);
nextComponent.instance = this.addChild(
nextComponent.constructor,
pathnameToId(this.refs.curPagename),
componentProps,
);