RSC 처음부터 시작하기 (1)

Sooo·2023년 9월 3일
0

Next.js

목록 보기
4/4

RSC from scratch 읽고 공부 (1)

서버에서 브라우저로 html콘텐츠를 보낼 때의 기본 예시

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);
}

아래의 단계로 하나씩 발명해나간다.

1. JSX

  • JSX이전의, html을 문자열로 조작하는 방식의 단점: 모든 내용이 하나의 문자열로 관리되어 실수에 취약함.
    • 태그 여닫음
    • 텍스트 콘텐츠를 이스케이프해야 함
  • JSX는 기존의 문자열을 템플릿과 로직으로 분리해, 태그는 태그대로의 의미를 살린 방식으로 가지고(문자열X), 텍스트 콘텐츠는 안전하게 이스케이프한다.
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);

위의 예시에서 문자열로 가지고있던 콘텐츠를 XML 방식으로 (즉 JSX와 같음) 변경했다.
이제 브라우저가 이 XML마크업을 다시 html로 이해할 수 있게 문자열로 바꿔주는 작업이 필요하다.
renderJSXToHTML함수를 작성하고, 브라우저로 콘텐츠를 전송하기 전에 이 함수를 이용해 작성한xml(jsx)를 문자열로 변환한다.

컴포넌트

각각의 관심사 별 구성요소를 다른 함수로 분리해 컴포넌트화 하고, 필요한 인자를 props로 전달한다.
기존 renderJSXToHTML함수는 <>안의 컨텐츠가 항상 html태그 이름 텍스트('p','footer'등)라고 가정했지만, 이제 컴포넌트 함수도 들어가므로 그 부분의 코드가 수정된다.

if (jsx.$$typeof === Symbol.for("react.element")) {
  if (typeof jsx.type === "string") { // Is this a tag like <div>?
    // Existing code that handles HTML tags (like <p>).
    let html = "<" + jsx.type;
    // ...
    html += "</" + jsx.type + ">";
    return html;
  } else if (typeof jsx.type === "function") { // Is it a component like <BlogPostPage>?
    // Call the component with its props, and turn its returned JSX into HTML.
    const Component = jsx.type;
    const props = jsx.props;
    const returnedJsx = Component(props);
    return renderJSXToHTML(returnedJsx); 
  } else throw new Error("Not implemented.");
}

이제 html을 파싱하다가 JSX요소를 만나면 해당 컴포넌트 함수를 호출한다. 컴포넌트 함수는 또 JSX를 반환하고, 이 JSX도 똑같이 html로 렌더링된다. (트리 구조)

라우팅

재사용될 BlogLayout과 BlogIndexPage, BlogPostPage(콘텐츠) 컴포넌트를 분리하고, BlogLayout가 children을 감싸는 중첩된(nested) 구조로 만든다.
서버에서는 url의 pathname을 가져와서 해당하는 url에 맞는 페이지를 보여주는 matchRoute함수를 만든다

async function matchRoute(url) {
  if (url.pathname === "/") {
    // We're on the index route which shows every blog post one by one.
    // Read all the files in the posts folder, and load their contents.
    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 {
    // We're showing an individual blog post.
    // Read the corresponding file from the posts folder.
    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);
    }
  }
}

async 컴포넌트

BlogIndexPage와 BlogPostPage는 같은 UI를 사용하지만, 재사용 가능한 컴포넌트를 만들고 싶어도 content라는 prop을 받아와야 한다.
두 컴포넌트를 다시 쪼개서 Post컴포넌트를 만들더라도, content를 prop으로 받아오는 부분이 필요하다.

    function Post({ slug, content }) { // Someone needs to pass down the `content` prop from the file :-(
  return (
    <section>
      <h2>
        <a href={"/" + slug}>{slug}</a>
      </h2>
      <article>{content}</article>
    </section>
  )
}

사실 예시에서 이게 왜 문제인지 모르겠음... props drilling의 문제가 걸리신걸까? 아무튼 이번 발명의 포인트는 기존의 페이지 최상단에서 데이터를 받아와서 뿌려주던 방식에서, 컴포넌트 안에서 데이터를 호출하는 방식으로 변경한다는 점이다.

현재 posts의 contents를 준비하는 코드가 루트 페이지인 경우와 아닌 경우로 나누어져 있다.(matchRoute 함수의 if문. 위의 bold)
api호출은 비동기적이므로 컴포넌트 계층 구조 바깥에서 호출되고, 컴포넌트 안에서 직접 호출될 수 없다...
정말 그럴까?

일반적인 리액트와 리액트SSR의 환경이라면 브라우저에서 실행되므로 (fs.readfile과 같은) 서버 전용 api를 사용할 수 없고, api를 계층구조 안에서 호출하면 안되지만, 위의 예시는 서버에서 모든 html요소를 다루고 있어서 위의 상황에 해당이 안된다. api가 비동기적으로 동작해도, 데이터가 로드되고 보여질 준비가 될 때까지 브라우저에 html 보내기를 지연시킬 수 있기 때문에 문제가 되지 않는다.

각각의 컴포넌트가 보여줘야하는 데이터 api를 직접 호출하게 한다

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>
  )
}
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>
  );
}

이제 컴포넌트가 비동기처리되어야하므로, renderJSXToHTML함수에도 async를 추가한다.
따라서 이 html 트리의 모든 컴포넌트들은, html 결과물이 비동기처리되어 렌더링될 때까지 대기하다가, 렌더링이 끝난 뒤에 전송된다.
(*현재 예시에서는 데이터의 흐름에 집중하고 있고, html을 스트리밍하는 구현이 포함되지 않아서 blocking이슈가 있다. 중요한 점은 나중에 컴포넌트 자체의 변경 없이 스트리밍을 추가할 수 있다는 것이다. 각각의 컴포넌트는 본인이 호출하는 데이터만 기다리고, 부모 컴포넌트가 자식의 비동기 처리를 기다리는 일은 없다.)

0개의 댓글