이 글은 RSC From Scratch. Part 1: Server Components 를 공부하면서 번역한 글 입니다.
총 6개의 step중 현재 step 5.1 까지 번역이 완료되었습니다.
RSC From Scratch. Part 1: Server Components
우리는 지금부터 React Server Components (RSC)의 매우 단순화된 버전을 처음부터 구현해보겠습니다.
이 심도있는 탐구는 React Server Components의 이점, RSC를 사용하여 앱을 구현하는 방법 또는 이를 사용하는 framework를 구현하는 방법에 대해 설명하지 않습니다. 대신, 여기서는 완전히 처음부터 스스로 "발명"하는 과정을 안내합니다.
이것은 새로운 기술을 처음부터 구현하여 배우는 것을 좋아하는 사람들을 위한 심도 있는 탐구입니다.
웹 프로그래밍에 대한 어느정도의 배경 지식과 React에 어느 정도의 익숨함이 전제되어야 합니다.
이 심도 있는 탐구는 Server Components를 사용하는 방법에 대한 입문서로서의 의도는 아닙니다. 우리는 React 웹사이트에서 Server Components를 문서화하는 작업을 하고 있습니다. 그동 안에는 만약 여러분의 프레임워크가 Server Components를 지원한다면 해당 문서를 참조해 주세요.
교육적인 이유로, 우리의 구현은 React에서 사용되는 실제 구현보다 효율성이 크게 떨어질 것 입니다.
향후 최적화 기회는 텍스트에 명시적으로 기록할 것이지만, 효율성보다는 개념적 명확성을 강조할 것입니다.
과거로 점프!
어느 날 아침에 눈을 뜨고 달력을 보니 2003년 입니다. 웹 개발은 아직 초창기입니다. 서버에서 텍스트 파일의 내용을 보여주는 개인 블로그 웹사이트를 만들고 싶다고 해봅시다. PHP로 작성한다면 아래처럼 될 것 입니다.
<?php
$author = "Jae Doe";
$post_content = @file_get_contents("./posts/hello-world.txt");
?>
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr>
</nav>
<article>
<?php echo htmlspecialchars($post_content); ?>
</article>
<footer>
<hr>
<p><i>(c) <?php echo htmlspecialchars($author); ?>, <?php echo date("Y"); ?></i></p>
</footer>
</body>
</html>
(HTML을 쉽게 읽을 수 있도록 `<nav>`, `<article>`, `<footer>`와 같은 태그가 이미 존재한다고 가정합니다.)
브라우저에서 `http://locahost:3000/hello-world` 주소를 열면, 이 PHP 스크립트는 `./posts/hello-world.txt`에서 블로그 글을 가져와 HTML 페이지를 반환합니다. 오늘날의 Node.js APIs를 사용하여 작성한 동등한 Node.js 스크립트는 이렇게 될 것입니다:
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import escapeHtml from 'escape-html';
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
`<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
${escapeHtml(postContent)}
</article>
<footer>
<hr>
<p><i>(c) ${escapeHtml(author)}, ${new Date().getFullYear()}</i></p>
</footer>
</body>
</html>`
);
}).listen(8080);
function sendHTML(res, html) {
res.setHeader("Content-Type", "text/html");
res.end(html);
}
상상해보세요. 작동하는 Node.js 엔진이 탑재된 CD-ROM을 가지고 2003년으로 돌아간다고 합시다. 이 코드를 서버에서 실행할 수 있다면, 그 세계에 React 스타일의 패러타임을 가져오려면 어떤 기능을 어떤 순서로 추가할까요?
step 1 : JSX 발명하기
위의 코드에서 아쉬운점은 직접적인 문자열 조작입니다. postContent에서 내용을 가져와서 그대로 HTML로 취급하지 않도록 `escapeHtml(postContent)`를 호출한 것을 주목하세요.
이 문제를 해결하는 한 가지 방법은 "템플릿"에서 로직을 분리하고 동적 값이나 속성을 삽일할 수 있는 별도의 템플릿 언어를 도입하는 것입니다. 이 템플릿 언어는 텍스트와 속성에 대한 동적 값 삽입을 안전하게 처리하며, 조건문과 반복문을 위한 도메인 특화 구문을 제공합니다. 이는 2000년대에 가장 인기 있는 서버 중심 프레임워크에서 채택한 방식입니다.
하지만 React에 대한 기존 지식이 있다면 아마 다음과 같이 할 것입니다.
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<footer>
<hr />
<p><i>(c) {author}, {new Date().getFullYear()}</i></p>
</footer>
</body>
</html>
);
}).listen(8080);
이 코드는 비슷해 보이지만, "템플릿"은 더 이상 문자열이 아닙니다. 문자열 보간 코드를 작성하는 대신, JavaScript에 XML의 일부분을 넣어줍니다. 다시 말해, 우리는 방금 JSX를 "발명"한 것입니다. JSX를 사용하면 마크업을 관련된 렌더링 로직과 가까이 두지만, 문자열 보간과 달린 open/close HTML 태그의 일치하지 않는 문제나 텍스트내용의 이스케이프를 잊는 실수와 같은 문제를 방지할 수 있습니다.
아래를 보시면 JSX는 다음과 같은 객체 트리를 생성합니다.
// Slightly simplified
{
$$typeof: Symbol.for("react.element"), // Tells React it's a JSX element (e.g. <html>)
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
children: {
$$typeof: Symbol.for("react.element"),
type: 'title',
props: { children: 'My blog' }
}
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'body',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'nav',
props: {
children: [{
$$typeof: Symbol.for("react.element"),
type: 'a',
props: { href: '/', children: 'Home' }
}, {
$$typeof: Symbol.for("react.element"),
type: 'hr',
props: null
}]
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'article',
props: {
children: postContent
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'footer',
props: {
/* ...And so on... */
}
}
]
}
}
]
}
}
그러나 결국 브라우저로 보내야 할 것은 JSON 트리가 아니라 HTML입니다.
이제 JSX를 HTML 문자열로 변환하는 함수를 작성해 보겠습니다. 이를 위해 다른 유형의 노드(문자열, 숫자, 배열 또는 자식을 가진 JSX노드)가 어떻게 HTML 조각으로 변환되어야 하는지를 지정해야 합니다.
function renderJSXToHTML(jsx) {
if (typeof jsx === "string" || typeof jsx === "number") {
// 이것은 문자열입니다. 이스케이프 처리하고 HTML에 바로 집어 넣으면 됩니다.
return escapeHtml(jsx);
} else if (jsx == null || typeof jsx === "boolean") {
// 이것은 비어있는 노드입니다. HTML에 아무것도 보낼 필요가 없습니다.
return "";
} else if (Array.isArray(jsx)) {
// 이것은 배열 타입의 노드입니다. 배열 내 아이템들을 HTML로 변환하고 합치면 됩니다.
return jsx.map((child) => renderJSXToHTML(child)).join("");
} else if (typeof jsx === "object") {
// 객체가 React JSX 엘리먼트인지 확인합니다(e.g. <div/>)
if (jsx.$$typeof === Symbol.for("react.element")) {
// HTML 태그로 변환합니다.
let html = "<" + jsx.type;
for (const propName in jsx.props) {
if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
html += " ";
html += propName;
html += "=";
html += escapeHtml(jsx.props[propName]);
}
}
html += ">";
html += renderJSXToHTML(jsx.props.children);
html += "</" + jsx.type + ">";
return html;
} else throw new Error("Cannot render an object.");
} else throw new Error("Not implemented.");
}
sandbox에서 시도해보고 HTML이 렌더링되고 제공되는 것을 확인해보세요!
JSX를 HTML 문자열로 변환하는 것은 일반적으로 '서버 사이드 렌더링' (SSR)으로 알려져 있습니다. RSC와 SSR은 두 가지 매우 다른 것이라는 점을 중요하게 알아두어야 합니다 (일반적으로 함께 사용됩니다). 이 글에서는 SSR부터 시작할겁니다. 왜냐하면 서버 환경에서 시도해볼 수 있는 첫 걸음이기 때문입니다. 그러나 이것은 단순히 첫 번째 단계일 뿐이며, 나중에 중요한 차이점을 보게 될 것입니다.
step 2: 컴포넌트 발명하기
JSX 이후, 아마도 원하게 될 다음 기능은 컴포넌트일 것입니다. 클라이언트나 서버에서 코드를 실행하더라도, UI를 서로 다른 부분으로 분리하고 이름을 부여하며, props를 통해 정보를 전달하는 것이 합리적입니다.
이전 예제를 BlogPostPage와 Footer라는 두 개의 컴포넌트로 나누어 보겠습니다
function BlogPostPage({ postContent, author }) {
return (
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<Footer author={author} />
</body>
</html>
);
}
function Footer({ author }) {
return (
<footer>
<hr />
<p>
<i>
(c) {author} {new Date().getFullYear()}
</i>
</p>
</footer>
);
}
그 다음, 우리가 가진 인라인 JSX 트리를 `<BlogPostPage postContent={postContent} author={author} />`로 대체해 봅시다.
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<BlogPostPage
postContent={postContent}
author={author}
/>
);
}).listen(8080);
만약 renderJSXToHTML 구현에 어떤 변경도 가하지 않고 이 코드를 실행하려고 하면, 생성된 HTML은 깨져 보일 것입니다.
<!-- 이건 전혀 정상적인 HTML로 보이지 않습니다... -->
<function BlogPostPage({postContent,author}) {...}>
</function BlogPostPage({postContent,author}) {...}>
문제는 JSX를 HTML로 변환하는 `renderJSXToHTML` 함수가 jsx.type을 항상 HTML 태그 이름 (예: `"html"`, `"footer"`, 또는 `"p"`)인 문자열로 가정한다는 것입니다.
if (jsx.$$typeof === Symbol.for("react.element")) {
// HTML 태그를 처리하는 코드.(<p>같은)
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
}
하지만 여기서 `BlogPostPage`는 함수이므로 `"<" + jsx.type + ">"`을 사용하면 해당 함수의 소스 코드가 출력됩니다. HTML 태그 이름에는 해당 함수의 코드를 전송하고 싶지 않습니다. 대신, 이 함수를 호출하고 반환된 JSX를 HTML로 직렬화하겠습니다.
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") { // 이게 <div> 같은 태그입니까?
// HTML 태그를 처리하는 기존 코드(<p> 같은).
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
} else if (typeof jsx.type === "function") { // 이게 <BlogPostPage> 같은 컴포넌트입니까?
// 컴포넌트를 컴포넌트가 가지고 있는 props와 함께 실행시키고, 반환 되는 JSX를 HTML로 변환
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props);
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
}
이제 HTML을 생성하는 동안 `<BlogPostPage author="Jae Doe" />`와 같은 JSX 요소를 만나면, 해당 함수로 BlogPostPage를 호출하고 `{ author: "Jae Doe" }`를 그 함수에 전달할 것입니다. 그 함수는 더 많은 JSX를 반환할 것입니다. 그리고 이미 JSX를 다루는 방법을 알고 있습니다 - 해당 JSX를 다시 `renderJSXToHTML`로 전달하여 계속해서 HTML을 생성합니다.
이 변경만으로도 구성 요소와 props 전달을 지원하는 것으로 충분합니다
step 3: 라우팅 추가해보기
이제 기본 구성 요소 지원이 작동하기 시작했으므로 블로그에 몇 가지 더 많은 페이지를 추가하는 것이 좋겠습니다.
예를 들어 `/hello-world`와 같은 URL은 `./posts/hello-world.txt`에서 내용을 가져와 개별 블로그 게시물 페이지를 표시해야 하며, 루트 `/` URL을 요청하면 모든 블로그 게시물의 내용을 표시해야 합니다. 이것은 새로운 `BlogIndexPage`를 추가하려는 의미입니다. 이 페이지는 레이아웃을 `BlogPostPage`와 공유하지만 내부 내용은 다릅니다.
현재 `BlogPostPage` 구성 요소는 `<html>` 루트부터 전체 페이지를 나타냅니다. 페이지 간에 공유되는 UI 부분 (header and footer )을 `BlogPostPage`에서 재사용 가능한 `BlogLayout` 구성 요소로 추출해보겠습니다
function BlogLayout({ children }) {
const author = "Jae Doe";
return (
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<main>
{children}
</main>
<Footer author={author} />
</body>
</html>
);
}
`BlogPostPage` 구성 요소를 변경하여 레이아웃 내에 삽입하려는 내용만 포함하도록 변경할 것입니다:
function BlogPostPage({ postSlug, postContent }) {
return (
<section>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContent}</article>
</section>
);
}
`<BlogPostPage>`가 `<BlogLayout>` 내에 중첩되었을 때 다음과 같이 보일 것입니다:
또한 `"./posts/*.txt"`에 있는 모든 게시물을 연이어 하나씩 표시하는 새로운 `BlogIndexPage` 컴포넌트를 추가해 보겠습니다:
function BlogIndexPage({ postSlugs, postContents }) {
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((postSlug, index) => (
<section key={postSlug}>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContents[index]}</article>
</section>
))}
</div>
</section>
);
}
그런 다음 BlogLayout 내부에 중첩하여 동일한 header와 footer를 가지도록 할 수 있습니다:
마지막으로, 서버 핸들러를 URL을 기반으로 페이지를 선택하고 해당 페이지의 데이터를 로드하고 레이아웃 내에서 해당 페이지를 렌더링하도록 변경해 봅시다:
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
// URL과 페이지를 매치시키고, 필요한 데이터를 로드합니다.
const page = await matchRoute(url);
// 매치 된 페이지를 공유 레이아웃으로 감싸줍니다.
sendHTML(res, <BlogLayout>{page}</BlogLayout>);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
async function matchRoute(url) {
if (url.pathname === "/") {
// 모든 블로그 포스트를 보여주는 인덱스 루트에 도달했습니다.
// 포스트가 저장 된 파일들을 읽어서 컨텐츠들을 가져옵시다.
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) => file.slice(0, file.lastIndexOf(".")));
const postContents = await Promise.all(
postSlugs.map((postSlug) =>
readFile("./posts/" + postSlug + ".txt", "utf8")
)
);
return <BlogIndexPage postSlugs={postSlugs} postContents={postContents} />;
} else {
// 각각의 블로그 포스트를 보여줍니다.
// 블로그 포스트가 저장 된 폴더에서 알맞는 파일을 읽어옵니다.
const postSlug = sanitizeFilename(url.pathname.slice(1));
try {
const postContent = await readFile("./posts/" + postSlug + ".txt", "utf8");
return <BlogPostPage postSlug={postSlug} postContent={postContent} />;
} catch (err) {
throwNotFound(err);
}
}
}
function throwNotFound(cause) {
const notFound = new Error("Not found.", { cause });
notFound.statusCode = 404;
throw notFound;
}
이제 블로그를 탐색할 수 있습니다. 그러나 코드가 조금 길고 서투르게 느껴집니다. 다음에는 이 문제를 해결하겠습니다.
step 4: 비동기 컴포넌트 만들기
아마도 `BlogIndexPage`와 `BlogPostPage` 컴포넌트의 이 부분이 정확히 같다는 것을 주목하셨을 것입니다:
이것을 재사용 가능한 컴포넌트로 만들 수 있다면 좋을 것입니다. 그러나 렌더링 로직을 별도의 `Post` 컴포넌트로 추출한다고 해도 각 개별 게시물의 내용을 어떻게든 "전달"해야 할 것입니다:
function Post({ slug, content }) { // // 누군가가 파일로 부터 'content' prop을 내려줘야 합니다 :-(
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
현재 게시물 콘텐츠를 로드하는 로직이 여기와 여기 두 곳에서 중복되고 있습니다. 파일을 로드할 때 `readFile` API가 비동기적으로 작동하기 때문에 이를 컴포넌트 트리에서 직접 사용할 수 없습니다. (`fs` API에 동기적인 버전이 있다는 점을 무시하겠습니다. 이것은 데이터베이스에서 읽거나 비동기적인 서드파티 라이브러리를 호출한 것일 수도 있습니다.)
이 문제를 해결할 수 없을까요?...
client-side React를 사용해 보셨다면 `fs.readFile`과 같은 API를 컴포넌트에서 호출할 수 없다는 아이디어에 익숙할 것입니다. 전통적인 React SSR(서버 렌더링)조차도 기존 직감이 각 컴포넌트가 브라우저에서 실행될 수 있어야 한다는 생각 때문에 서버 전용 API인 `fs.readFile`이 작동하지 않을 것으로 생각할 수 있습니다.
그러나 2003년에 이것을 누군가에게 설명하려고 하면 이러한 제한이 상당히 이상하게 느껴질 것입니다. 정말로 fs.readFile을 할 수 없나요?
우리는 모든 것을 기본 원리에 입각하여 다루고 있다는 것을 기억하세요. 현재로서는 서버 환경만을 대상으로 하고 있으므로 컴포넌트를 브라우저에서 실행되는 코드로 제한할 필요가 없습니다. 컴포넌트가 비동기적일 수도 있으며, 서버는 데이터가로드되어 표시 준비가 되었을 때까지 HTML을 발행하기만 하면 됩니다.
`content` prop을 제거하고 대신 `Post`를 비동기 함수로 만들어 `await readFile()` 호출을 통해 파일 콘텐츠를 로드하도록 해봅시다:
async function Post({ slug }) {
let content;
try {
content = await readFile("./posts/" + slug + ".txt", "utf8");
} catch (err) {
throwNotFound(err);
}
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
마찬가지로, `BlogIndexPage`를 `async` 함수로 만들어 `await readdir()`을 사용하여 게시물을 나열하는 작업을 처리하도록 하겠습니다:
async function BlogIndexPage() {
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) =>
file.slice(0, file.lastIndexOf("."))
);
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((slug) => (
<Post key={slug} slug={slug} />
))}
</div>
</section>
);
}
이제 `Post`와 `BlogIndexPage`가 자체 데이터를 로드하므로 `matchRoute`를 `<Router>` 컴포넌트로 대체할 수 있습니다:
function Router({ url }) {
let page;
if (url.pathname === "/") {
page = <BlogIndexPage />;
} else {
const postSlug = sanitizeFilename(url.pathname.slice(1));
page = <BlogPostPage postSlug={postSlug} />;
}
return <BlogLayout>{page}</BlogLayout>;
}
마지막으로, 최상위 서버 핸들러는 모든 렌더링을 `<Router>`로 위임할 수 있습니다:
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendHTML(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
그러나 실제로 컴포넌트 내에서 `async/await`를 작동하게 만들어야 합니다. 어떻게 해야 할까요?
`renderJSXToHTML` 구현에서 컴포넌트 함수를 호출하는 위치를 찾아봅시다:
} else if (typeof jsx.type === "function") {
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props); //<--- 여기가 우리가 컴포넌트 함수들을 호출하던 곳입니다.
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
이제 컴포넌트 함수가 비동기일 수 있으므로 이곳에 `await`를 추가해 봅시다:
// ...
const returnedJsx = await Component(props);
// ...
이는 `renderJSXToHTML` 자체가 이제 `async` 함수여야 하며 호출은 기다려야 한다는 의미입니다.
async function renderJSXToHTML(jsx) {
// ...
}
이 변경으로 인해 트리 내의 모든 컴포넌트가 `async`일 수 있으며, 그 결과 HTML은 그들이 해결될 때까지 "대기"합니다.
새로운 코드에서 볼 수 있듯이 루프에서 `BlogIndexPage`의 모든 파일 내용을 "준비"하는 특별한 로직이 없습니다. 여전히 `BlogIndexPage`는 `Post` 컴포넌트 배열을 렌더링하지만 이제 각 `Post`는 자체 파일을 읽는 방법을 알고 있습니다.
이 구현은 각 `await`가 "블로킹"되기 때문에 이상적이지 않습니다. 예를 들어, 모든 것이 생성될 때까지 HTML을 전송을 시작할 수 없습니다. 이상적으로는 생성 중에 서버 페이로드를 스트리밍하고 싶습니다. 이것은 더 복잡하며 이 단계에서는 이것을 다루지 않을 것입니다. 현재는 데이터 흐름에만 집중하겠습니다. 그러나 컴포넌트 자체에는 변경이 필요하지 않으므로 나중에 스트리밍을 추가할 수 있다는 점을 강조해야 합니다. 각 컴포넌트는 자체 데이터를 기다리기 위해 `await`를 사용합니다(이를 피할 수 없음), 그러나 부모 컴포넌트는 자식 컴포넌트를 기다릴 필요가 없습니다. 심지어 자식이 비동기인 경우에도 그렇습니다. 이것이 React가 자식 컴포넌트가 렌더링을 완료하기 전에 부모 컴포넌트의 출력을 스트리밍할 수 있는 이유입니다.
step 5: State를 네비게이션에서 유지시켜보기
지금까지, 우리의 서버는 라우트를 HTML 문자열로만 렌더링할 수 있습니다:
async function sendHTML(res, jsx) {
const html = await renderJSXToHTML(jsx);
res.setHeader("Content-Type", "text/html");
res.end(html);
}
이것은 처음 로드하는 데는 훌륭합니다. 브라우저는 가능한 빠르게 HTML을 표시하도록 최적화되어 있습니다. 그러나 탐색에는 이상적이지 않습니다. "변경된 부분만" 업데이트하고 클라이언트 측 상태를 그 준변 및 내부에 보존하고자 합니다(ex: input, video, popup). 이것은 또한 변이(ex: 블로그 게시물에 댓글 추가)를 순발력 있게 느끼게 합니다.
문제를 설명하기 위해 `BlogLayout` 컴포넌트 JSX 내부의 `<nav>`에 `<input />`을 추가해 보겠습니다:
<nav>
<a href="/">Home</a>
<hr />
<input />
<hr />
</nav>
블로그를 탐색할 때마다 입력 상태가 "제거"되는 것을 주목하세요:
이것은 간단한 블로그에는 괜찮을 수 있지만, 더 인터랙티브한 앱을 개발하고 싶다면 어느 시점에서는 이러한 동작이 무리하게 느껴질 것입니다. 사용자가 로컬 상태를 계속 잃지 않고 앱 내에서 탐색할 수 있도록하려면 합니다.
이 문제를 해결하려면 세 가지 단계로 진행할 것입니다:
1. 탐색을 가로채기 위한 클라이언트 측 JS 논리를 추가합니다(페이지를 다시로드하지 않고 수동으로 내용을 다시 가져올 수 있도록).
2. 서버에게 후속 탐색을 위해 HTML 대신 JSX를 전송하도록 가르치기.
3. 클라이언트에게 DOM을 파괴하지 않고 JSX 업데이트를 적용하도록 가르치기(힌트: 이 부분에서 React를 사용할 것입니다).
Step 5.1: 네비게이션 가로채기
클라이언트 측 로직이 필요하므로 `client.js`라는 새 파일을 위한 `<script>` 태그를 추가할 것입니다. 이 파일에서는 사이트 내에서의 탐색에 대한 기본 동작을 재정의하여 `navigate`라는 자체 함수를 호출하도록합니다:
async function navigate(pathname) {
// TODO
}
window.addEventListener("click", (e) => {
// // 링크 클릭만 신경씁니다.
if (e.target.tagName !== "A") {
return;
}
// "새창에서 열기"는 무시합니다.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// 외부 URL은 무시합니다.
const href = e.target.getAttribute("href");
if (!href.startsWith("/")) {
return;
}
// 브라우저의 URL은 업데이트 할 수 있도록 냅두고, 새로고침은 하지 못하도록 합니다.
e.preventDefault();
window.history.pushState(null, null, href);
// 우리의 함수를 실행 시킵니다.
navigate(href);
}, true);
window.addEventListener("popstate", () => {
// 유저가 뒤로가기/앞으로가기 를 누른다면, 마찬가지로 우리의 함수를 실행시킵니다.
navigate(window.location.pathname);
});
`navigate` 함수에서는 다음 경로에 대한 HTML 응답을 가져와 DOM을 업데이트할 것입니다:
let currentPathname = window.location.pathname;
async function navigate(pathname) {
currentPathname = pathname;
// 이동할 라우트에 해당하는 HTML을 요청합니다.
const response = await fetch(pathname);
const html = await response.text();
if (pathname === currentPathname) {
// 받아온 HTML에서 <body> 태그 부분만 가져옵니다.
const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
const bodyEndIndex = html.lastIndexOf("</body>");
const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);
// 페이지의 컨텐츠를 바꿔줍니다.
document.body.innerHTML = bodyHTML;
}
}
이 코드는 아직 제대로 된 프로덕션용이 아닙니다(예: `document.title`을 변경하지 않거나 경로 변경을 알리지 않음). 그러나 브라우저 탐색 동작을 성공적으로 무시할 수 있음을 보여줍니다. 현재로서는 다음 경로에 대한 HTML을 가져오고 있으므로 `<input>` 상태는 여전히 손실됩니다. 다음 단계에서는 서버에게 탐색을 위해 HTML 대신 JSX를 제공하는 방법을 가르쳐볼 것입니다.
'React-JS > 개념' 카테고리의 다른 글
[React] styled-compoents (1) | 2023.02.20 |
---|---|
[React] react / react-dom (0) | 2023.02.16 |
[React] Hook ( useState / useEffect ) (0) | 2023.02.11 |
[React] Function and Class Components (0) | 2023.02.11 |
[React] create react app (0) | 2023.02.10 |