RSC(React Server Component) 는 도대체 뭘까? (2)

기운찬곰·2023년 7월 4일
0

React Core Concepts

목록 보기
7/7
post-thumbnail

Overview

RSC(React Server Component) 는 도대체 뭘까? 2편으로 돌아왔습니다. 저번 시간에는 JSX를 HTML로 변환하고, 컴포넌트로 분리하고, 비동기 컴포넌트까지 만들어봤습니다.

원본 글 : https://github.com/reactwg/server-components/discussions/5

이번 시간에는 남은 글에 대해 번역 및 실습을 진행해보도록 하겠습니다. 근데 1편보다 2편이 꽤 어렵더군요. 그래도 열심히 해보도록 하겠습니다.


Step 5: Let's preserve state on navigation

지금까지 서버는 HTML 문자열에 대한 경로만 렌더링할 수 있습니다:

async function sendHTML(res, jsx) {
  const html = await renderJSXToHTML(jsx);
  res.setHeader("Content-Type", "text/html");
  res.end(html);
}

브라우저는 HTML을 최대한 빨리 표시하도록 최적화되어 있기 때문에 이것은 초기 로드에 대해서는 휼룽합니다. 그러나 navigation 에 대해서는 이상적이지 않습니다. 변경된 부분에 대해서만 업데이트하여 내부와 주변(예: input, video, popup 등)의 클라이언트 측 상태를 유지할 수 있기를 바랍니다. 이는 또한 mutations(예: 블로그 게시물에 댓글 추가)를 사용자가 유동적으로 느끼게 할 것입니다.

문제를 설명하기 위해 BlogLayout 컴포넌트 JSX 내부에 <nav> 안에 <input /> 을 추가해봅시다.

<nav>
  <a href="/">Home</a>
  <hr />
  <input />
  <hr />
</nav>

블로그를 navigate 할때마다 input 상태가 어떻게 “날아가는지” 주목해봅시다.

이것은 간단한 블로그에 대해서는 괜찮을지 모르지만, 만약 당신이 더 많은 인터렉티브 한 앱을 구축하고 싶다면, 어느 시점에서 이런 동작은 장애 요인이 됩니다. 당신은 사용자가 지역 상태를 지속적으로 잃지 않고 앱을 탐색할 수 있도록 하길 원합니다.

이 문제는 3단계로 해결됩니다:

  1. 클라이언트 측 JS 로직을 추가하여 navigation 을 가로채십시오 (페이지를 다시 로드하지 않고 수동으로 콘텐츠를 다시 가져올 수 있습니다).
  2. 서버가 후속 navigation 을 위해 HTML 대신 유선으로 JSX를 제공하도록 서버에게 가르칩니다. ?? 🤔
  3. 클라이언트에게 DOM을 파괴하지 않고 JSX 업데이트를 적용하도록 가르칩니다(힌트: 해당 부분에 대해 React를 사용합니다).

Step 5.1: Let's intercept navigations

클라이언트 측 로직이 필요하므로 client.js라는 새 파일에 대해 <script> 태그를 추가합니다. 이 파일에서는 사이트 내 탐색에 대한 기본 동작을 재정의하여 navigate 이라는 자체 함수를 호출합니다:

// client.js 

async function navigate(pathname) {
  // TODO
}

window.addEventListener("click", (e) => {
  // Only listen to link clicks.
  if (e.target.tagName !== "A") {
    return;
  }
  // Ignore "open in a new tab".
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
    return;
  }
  // Ignore external URLs.
  const href = e.target.getAttribute("href");
  if (!href.startsWith("/")) {
    return;
  }
  // ✅ Prevent the browser from reloading the page but update the URL.
  e.preventDefault();
  window.history.pushState(null, null, href);
  // Call our custom logic.
  navigate(href);
}, true);

window.addEventListener("popstate", () => {
  // When the user presses Back/Forward, call our custom logic too.
  navigate(window.location.pathname);
});
  • e.preventDefault() : 기본 a 태그 동작을 막습니다. 페이지 reload를 방지합니다.
  • window.history.pushState : reload 없이 url 변경 시 사용합니다. state를 넘겨 줄 수도 있습니다.
  • popstate 이벤트 : pushState 를 했을때는 popstate 이벤트가 발생하지않고, 뒤 / 앞으로 가기를 클릭 했을때 popstate 이벤트가 발생하게 됩니다. - 참고

navigate 함수에서 다음 경로에 대한 HTML 응답을 fetch해서 DOM을 업데이트합니다: (-> 아. 그니까 body만 업데이트 하겠다는 거네요.)

let currentPathname = window.location.pathname;

async function navigate(pathname) {
  currentPathname = pathname;
  // Fetch HTML for the route we're navigating to.
  const response = await fetch(pathname);
  const html = await response.text();

  if (pathname === currentPathname) {
    // Get the part of HTML inside the <body> tag.
    const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
    const bodyEndIndex = html.lastIndexOf("</body>");
    const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);

    // Replace the content on the page.
    document.body.innerHTML = bodyHTML;
  }
}

이 코드는 운영 준비가 되어 있지 않지만(예를 들어, document.title을 변경하거나 경로 변경을 알리지 않음), 브라우저 navigation 동작을 성공적으로 재정의할 수 있음을 보여줍니다. 현재 다음 경로에 대한 HTML을 가져오는 중이므로 <input> 상태는 여전히 손실됩니다. 다음 단계에서는 서버에 HTML 대신 JSX를 제공하도록 가르칠 것입니다. 👀

Step 5.2: Let's send JSX over the wire

JSX가 생성하는 객체 트리에 대해서는 이전의 설명을 기억하십시오:

{
  $$typeof: Symbol.for("react.element"),
  type: 'html',
  props: {
    children: [
      {
        $$typeof: Symbol.for("react.element"),
        type: 'head',
        props: {
          // ... And so on ...

우리는 서버에 새로운 모드를 추가할 것입니다. request가 ?jsx로 끝나면, HTML 대신 이렇게 트리를 보내겠습니다. 이렇게 하면 클라이언트가 변경된 부분을 쉽게 확인하고 필요한 부분에만 DOM을 업데이트할 수 있습니다. 이것은 모든 navigation에서 <input> 상태를 잃는 우리의 당면한 문제를 해결할 것이지만, 우리가 이것을 하는 유일한 이유는 아닙니다. 다음 부분(지금 아님!)에서는 HTML 뿐만 아니라 서버에서 클라이언트로 새로운 정보를 전달하는 방법도 볼 수 있습니다.

// server.js 

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      // ...
    } else if (url.searchParams.has("jsx")) {
      url.searchParams.delete("jsx"); // Keep the url passed to the <Router> clean
      await sendJSX(res, <Router url={url} />);
    } else {
      await sendHTML(res, <Router url={url} />);
    }
    // ...

sendJSX에서 JSON.stringify(jsx)를 사용하여 위의 객체 트리를 네트워크를 통해 전달할 수 있는 JSON 문자열로 변환합니다:

// server.js

async function sendJSX(res, jsx) {
  const jsxString = JSON.stringify(jsx, null, 2); // Indent with two spaces.
  res.setHeader("Content-Type", "application/json");
  res.end(jsxString);
}

우리는 이것을 계속해서 "sending JSX"라고 부르겠지만, 우리는 JSX 구문 자체("<Foo />"와 같은)를 유선으로 보내지 않습니다. 우리는 JSX에서 생성된 객체 트리만 가져와서 JSON 형식의 문자열로 변환하고 있습니다. 그러나 정확한 전송 형식은 시간이 지남에 따라 변경될 것입니다(예를 들어, 실제 RSC 구현은 이 시리즈의 후반부에서 살펴볼 다른 형식을 사용합니다).

클라이언트 코드를 변경하여 네트워크를 통과하는 내용을 확인해 보겠습니다:

// client.js

async function navigate(pathname) {
  currentPathname = pathname;
  const response = await fetch(pathname + "?jsx");
  const jsonString = await response.text();
  if (pathname === currentPathname) {
    alert(jsonString);
  }
}

한 번 해보세요. 지금 인덱스 / 페이지를 로드한 다음 링크를 누르면 다음과 같은 개체가 포함된 경고가 표시됩니다:

이건 별로 유용하지 않습니다. 우리는 <html>...</html>과 같은 JSX 트리를 얻고 싶었습니다.... 뭐가 잘못됐었나요?

JSX는 처음에는 다음과 같이 표시됩니다:

<Router url="http://localhost:3000/hello-world" />
// {
//   $$typeof: Symbol.for('react.element'),
//   type: Router,
//   props: { url: "http://localhost:3000/hello-world" } },
//    ...
// }

Router가 렌더링 할 JSX가 무엇인지 알 수 없고 Router가 서버에만 존재하기 때문에 이 JSX를 클라이언트용 JSON으로 전환하는 것은 "너무 이르다"고 합니다. 우리는 client에게 어떤 JSX를 보내야 하는지 알기 위해 Router 컴포넌트를 호출해야 합니다.

props로 {url: "http://localhost:3000/hello-world"}이(가) 포함된 Router 함수를 호출하면 다음과 같은 JSX를 얻을 수 있습니다:

<BlogLayout>
  <BlogIndexPage />
</BlogLayout>

BlogLayout이 무엇을 렌더링하고자 하는지 알 수 없고 서버에만 존재하기 때문에 이 JSX를 클라이언트용 JSON으로 전환하는 것은 "너무 이르다"는 것입니다. 우리는 BlogLayout에도 호출를 해서 어떤 JSX를 client에게 전달하고 싶은지 등을 알아봐야 합니다.

(경험이 풍부한 React 개발자는 이의를 제기할 수 있습니다. “클라이언트가 코드를 실행할 수 있도록 클라이언트에 코드를 전송할 수 없습니까?” 이 시리즈의 다음 부분까지 그 생각을 보류하세요! 그러나 BlogIndexPage가 fs.readdir를 호출하기 때문에 이마저도 BlogLayout에서만 작동합니다.)

이 프로세스가 끝나면 서버 전용 코드를 참조하지 않는 JSX 트리가 나타납니다. 예:

<html>
  <head>...</head>
  <body>
    <nav>
      <a href="/">Home</a>
      <hr />
    </nav>
    <main>
    <section>
      <h1>Welcome to my blog</h1>
      <div>
        ...
      </div>
    </main>
    <footer>
      <hr />
      <p>
        <i>
          (c) Jae Doe 2003
        </i>
      </p>
    </footer>
  </body>
</html>

이것이 JSON.stringify에 전달하여 client에게 보낼 수 있는 트리입니다.

renderJSXToClientJSX라는 함수를 작성해 봅시다. JSX의 일부를 인수로 사용하고 클라이언트가 이해할 수 있는 JSX만 남을 때까지 (해당 컴포넌트를 호출하여) 서버 전용 부분을 "resolve"하려고 시도합니다.

구조적으로 이 함수는 renderJSXToHTML과 비슷하지만 HTML 대신 객체를 통과하고 반환합니다.

// server.js

async function renderJSXToClientJSX(jsx) {
  if (
    typeof jsx === "string" ||
    typeof jsx === "number" ||
    typeof jsx === "boolean" ||
    jsx == null
  ) {
    // Don't need to do anything special with these types.
    return jsx;
  } else if (Array.isArray(jsx)) {
    // Process each item in an array.
    return Promise.all(jsx.map((child) => renderJSXToClientJSX(child)));
  } else if (jsx != null && typeof jsx === "object") {
    if (jsx.$$typeof === Symbol.for("react.element")) {
      if (typeof jsx.type === "string") {
        // This is a component like <div />.
        // Go over its props to make sure they can be turned into JSON.
        return {
          ...jsx,
          props: await renderJSXToClientJSX(jsx.props),
        };
      } else if (typeof jsx.type === "function") {
        // This is a custom React component (like <Footer />).
        // Call its function, and repeat the procedure for the JSX it returns.
        const Component = jsx.type;
        const props = jsx.props;
        const returnedJsx = await Component(props);
        return renderJSXToClientJSX(returnedJsx);
      } else throw new Error("Not implemented.");
    } else {
      // This is an arbitrary object (for example, props, or something inside of them).
      // Go over every value inside, and process it too in case there's some JSX in it.
      return Object.fromEntries(
        await Promise.all(
          Object.entries(jsx).map(async ([propName, value]) => [
            propName,
            await renderJSXToClientJSX(value),
          ])
        )
      );
    }
  } else throw new Error("Not implemented");
}

다음으로 sendJSX를 편집하여 <Router />와 같은 JSX를 "client JSX"로 변환한 후 문자열화합니다:

async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, null, 2); // Indent with two spaces
  res.setHeader("Content-Type", "application/json");
  res.end(clientJSXString);
}

이제 링크를 클릭하면 HTML과 유사한 트리가 있는 alert이 표시됩니다. 즉, 이를 변경할 준비가 되었음을 의미합니다! (처음이랑 비교해서 제대로 jsx를 전달하는 것을 알 수 있습니다. )

부가설명) 현재 우리의 목표는 무언가를 작동시키는 것이지만 구현에는 부족한 점이 많습니다. 형식 자체가 매우 장황하고 반복적이기 때문에 실제 RSC는 보다 콤팩트한 형식을 사용합니다. 이전 HTML 생성과 마찬가지로 전체 응답이 한 번에 await 되고 있는 것은 좋지 않습니다. 이상적으로는 JSX를 사용할 수 있을 때 chunk로 스트리밍하여 클라이언트에서 함께 작업할 수 있기를 원합니다. 또한 공유 레이아웃의 일부(예: <html>, <nav>)가 변경되지 않았음을 알고 있는 경우에도 불행하게도 해당 부분을 다시 보냅니다. 전체 화면을 제자리에서 새로 고치는 기능이 중요하지만 단일 레이아웃 내의 탐색은 기본적으로 해당 레이아웃을 이상적으로 refetch 하지 않아야 합니다. production-ready RSC 구현은 이러한 결함으로 인해 어려움을 겪지 않지만 코드를 더 쉽게 소화할 수 있도록 현재로서는 이러한 결함을 수용할 것입니다.

그니까 현재 공통 레이아웃 부분도 지금은 새로 refetch되는 것도 그렇고, streaming 지원이 안되는 부분도 있지만 여기서는 그냥 넘어가도록 하겠다고 하네요.

Step 5.3: Let's apply JSX updates on the client

엄밀히 말하면, 우리는 diff JSX를 위해 React를 사용할 필요가 없습니다. 지금까지 JSX 노드에는 <nav>, <footer> 와 같은 내장 브라우저 컴포넌트만 포함되어 있습니다. 클라이언트 측 컴포넌트에 대한 개념이 전혀 없는 라이브러리에서 시작하여 JSX 업데이트를 diff 하고 적용하는 데 사용할 수 있습니다. 그러나 나중에는 풍부한 상호작업을 허용하고 싶으므로 처음부터 React를 사용할 것입니다.

우리 앱은 HTML로 서버 렌더링됩니다. 리액트가 생성하지 않은 DOM 노드(예: HTML에서 브라우저가 생성한 DOM 노드)를 관리하도록 요청하려면 해당 DOM 노드에 해당하는 초기 JSX를 리액트에 제공해야 합니다. 한 계약자가 수리를 하기 전에 집 설계도를 보여달라고 요청한다고 상상해 보세요. 그들은 미래의 변화를 안전하게 하기 위해 원래 계획을 알고 싶어합니다. 마찬가지로 React는 DOM 위에서 모든 DOM 노드가 JSX의 어느 부분에 해당하는지 확인합니다. 이렇게 하면 이벤트 핸들러를 DOM 노드에 연결하여 interactive로 만들거나 나중에 업데이트할 수 있습니다. 그들은 이제 물과 함께 살아나는 식물처럼 수분을 공급받습니다. → 가만보니 지금 hydration 설명을 하고 있군요.

전통적으로 서버 렌더링 마크업을 hydrate하려면 리액트로 관리할 DOM 노드와 서버에서 생성된 초기 JSX를 사용하여 hydrateRoot 를 호출합니다. 다음과 같이 보일 수 있습니다:

// Traditionally, you would hydrate like this
hydrateRoot(document, <App />);

문제는 클라이언트에 <App />과 같은 루트 컴포넌트가 전혀 없다는 것입니다! 고객의 관점에서 볼 때, 현재 우리의 전체 앱은 정확히 0개의 리액트 컴포넌트가 있는 JSX의 큰 덩어리 입니다. 그러나 React에 정말 필요한 것은 초기 HTML에 해당하는 JSX 트리입니다. <html>...</html>과 같은 "client JSX" 트리를 우리는 서버에게 제공해달라고 요청할 것입니다.

// client.js

import { hydrateRoot } from 'react-dom/client';

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  // TODO: return the <html>...</html> client JSX tree mathching the initial HTML
}

클라이언트 JSX 트리에 컴포넌트가 전혀 없기 때문에 매우 빠릅니다. React는 DOM 트리와 JSX 트리를 거의 즉시 실행하고 나중에 해당 트리를 업데이트하는 데 필요한 내부 데이터 구조를 구축합니다.

그런 다음 사용자가 탐색할 때마다 다음 페이지의 JSX를 가져와 root.render로 DOM을 업데이트합니다:

// client.js

async function navigate(pathname) {
  currentPathname = pathname;
  const clientJSX = await fetchClientJSX(pathname);
  if (pathname === currentPathname) {
    root.render(clientJSX);
  }
}

async function fetchClientJSX(pathname) {
  // TODO: fetch and return the <html>...</html> client JSX tree for the next route
}

이것은 우리가 원했던 것을 달성할 것입니다. 그것은 리액트가 보통 하는 것과 같은 방식으로 상태를 파괴하지 않고 DOM을 업데이트할 것입니다.

이제 이 두 가지 기능을 구현하는 방법을 알아보겠습니다.

Step 5.3.1: Let's fetch JSX from the server

구현하기 쉽기 때문에 fetchClientJSX부터 시작하겠습니다. 먼저 ?jsx 서버 엔드포인트의 작동 방식을 살펴보겠습니다:

// server.js
async function sendJSX(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX);
  res.setHeader("Content-Type", "application/json");
  res.end(clientJSXString);
}

클라이언트에서 이 엔드포인트를 호출한 다음 응답을 JSON.parse에 입력하여 JSX로 되돌립니다:

// client.js
async function fetchClientJSX(pathname) {
  const response = await fetch(pathname + "?jsx");
  const clientJSXString = await response.text();
  const clientJSX = JSON.parse(clientJSXString);
  return clientJSX;
}

실행을 해보면 링크를 클릭하고 가져온 JSX를 렌더링하려고 할 때마다 오류가 표시됩니다:

Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).

그 이유는 이렇습니다. JSON.stringify에 전달하는 개체는 다음과 같습니다:

{
  $$typeof: Symbol.for("react.element"),
  type: 'html',
  props: {
    // ...

그러나 클라이언트의 JSON.parse 결과를 보면 $$typof의 속성이 전송 중에 손실된 것 같습니다:

{
  type: 'html',
  props: {
    // ...

$$typeof: Symbol.for("react.element")이 없는 경우, 클라이언트에서 응답이 유효한 JSX 노드로 인식하기를 거부합니다.

이것은 의도적인 보안 메커니즘입니다. 기본적으로 React는 네트워크에서 가져온 임의의 JSON 개체를 JSX 태그로 처리하기를 거부합니다. 비결은 Symbol.for('react.element') 같은 Symbol 값은 JSON 직렬화를 "survive"하지 않으며 JSON.stringify에 의해 제거됩니다. 이 기능은 앱의 코드로 직접 생성되지 않은 JSX를 렌더링하는 것으로부터 앱을 보호합니다.

그러나 실제로 서버에 이러한 JSX 노드를 생성했으며 클라이언트에서 렌더링하기를 원합니다. 따라서 그것을 JSON 직렬화할 수 없음에도 불구하고 다음과 같이 $$typeof: Symbol.for("react.element")을 "이행"하도록 로직을 조정해야 합니다:

다행히도 이것은 수정하기에 그리 어렵지 않습니다. JSON.stringify는 JSON이 생성되는 방식을 사용자 정의할 수 있는 대체 함수를 허용합니다. 서버에서, 우리는 Symbol.for('react.element')을 "$RE"와 같은 특수 문자열로 대체할 것입니다.

// server.js 
async function sendJSX(res, jsx) {
  // ...
  const clientJSXString = JSON.stringify(clientJSX, stringifyJSX); // Notice the second argument
  // ...
}

function stringifyJSX(key, value) {
  if (value === Symbol.for("react.element")) {
    // We can't pass a symbol, so pass our magic string instead.
    return "$RE"; // Could be arbitrary. I picked RE for React Element.
  } else if (typeof value === "string" && value.startsWith("$")) {
    // To avoid clashes, prepend an extra $ to any string already starting with $.
    return "$" + value;
  } else {
    return value;
  }
}

클라이언트에서 JSON.parse에 reviver 함수를 전달하여 "$RE"를 Symbol.for('react.element')로 다시 대체합니다.

// client.js 

async function fetchClientJSX(pathname) {
  // ...
  const clientJSX = JSON.parse(clientJSXString, parseJSX); // Notice the second argument
  // ...
}

function parseJSX(key, value) {
  if (value === "$RE") {
    // This is our special marker we added on the server.
    // Restore the Symbol to tell React that this is valid JSX.
    return Symbol.for("react.element");
  } else if (typeof value === "string" && value.startsWith("$$")) {
    // This is a string starting with $. Remove the extra $ added by the server.
    return value.slice(1);
  } else {
    return value;
  }
}

이제 페이지 사이를 다시 탐색할 수 있지만 업데이트는 JSX로 가져와 클라이언트에 적용됩니다!

<input>을 입력하고 링크를 클릭하면 첫 번째 탐색을 제외한 모든 탐색에서 상태가 유지된다는 것을 알 수 있습니다. 페이지의 초기 JSX가 무엇인지 React에게 알려주지 않아 서버 HTML에 제대로 첨부할 수 없기 때문입니다.

Step 5.3.2: Let's inline the initial JSX into the HTML

우리는 여전히 다음과 같은 코드를 가지고 있습니다:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  return null; // TODO
}

초기 클라이언트 JSX로 root를 hydrate시켜야 하는데 클라이언트에서 JSX를 어디서 구하나요?

우리 페이지는 HTML로 서버 렌더링되지만, 추가 탐색을 위해 리액트에게 페이지의 초기 JSX가 무엇인지 알려주어야 합니다. 경우에 따라 HTML에서 부분적으로 재구성할 수 있지만 항상 재구성할 수는 없습니다. 특히 이 시리즈의 다음 부분에서 interactive 기능을 추가하기 시작할 때 그렇습니다. 우리는 또한 그것이 불필요한 waterfall를 만들 것이기 때문에 그것을 fetch 하고 싶지 않습니다.

React를 사용하는 기존 SSR에서도 데이터와 유사한 문제가 발생합니다. 컴포넌트가 hydrate를 하고 초기 JSX를 반환할 수 있도록 페이지에 대한 데이터가 있어야 합니다. 이 경우 현재까지 페이지에 컴포넌트가 없으므로(적어도 브라우저에서 실행되는 컴포넌트는 없음) 아무것도 실행할 필요가 없습니다. 그러나 클라이언트에는 초기 JSX를 생성하는 방법을 아는 코드도 없습니다.

이 문제를 해결하기 위해 클라이언트에서 초기 JSX가 있는 문자열을 글로벌 변수로 사용할 수 있다고 가정합니다:

const root = hydrateRoot(document, getInitialClientJSX());

function getInitialClientJSX() {
  const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, reviveJSX);
  return clientJSX;
}

서버에서, 우리는 우리의 앱을 client JSX에 렌더링하도록 sendHTML를 수정하고 HTML의 끝에 인라인 할 것입니다.

async function sendHTML(res, jsx) {
  let html = await renderJSXToHTML(jsx);

  // Serialize the JSX payload after the HTML to avoid blocking paint:
  const clientJSX = await renderJSXToClientJSX(jsx);
  const clientJSXString = JSON.stringify(clientJSX, stringifyJSX);
  html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
  html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
  html += `</script>`;
  // ...

마지막으로, React가 텍스트 노드를 hydrate시킬 수 있도록 텍스트 노드에 대한 HTML을 생성하는 방법에 대한 몇 가지 작은 조정이 필요합니다.

이제 input 을 입력할 수 있으며 탐색 중에 해당 상태가 더 이상 손실되지 않습니다:

그것이 우리가 원래 성취하기 위해 세운 목표입니다! 물론 이 특정 입력의 상태를 유지하는 것이 중요한 것은 아닙니다. 중요한 부분은 이제 앱이 모든 페이지에서 "in place"로 새로 고쳐지고 탐색할 수 있다는 것이며, 상태가 파괴되는 것에 대해 어떤 것도 걱정할 필요가 없다는 것입니다.

부가설명) 실제 RSC 구현체는 HTML 페이로드에서 JSX를 인코딩하지만 몇 가지 중요한 차이점이 있습니다. production-ready RSC 설정은 마지막에 하나의 큰 blob 대신 만들어진 JSX chunk를 보냅니다. React가 로드되면 hydration가 즉시 시작될 수 있습니다. React는 모든 JSX chunk가 도착하기를 기다리는 대신 이미 사용 가능한 JSX chunk를 사용하여 트리를 탐색하기 시작합니다. 또한 RSC를 사용하여 일부 컴포넌트를 클라이언트 컴포넌트로 표시할 수 있습니다. 즉, 이 컴포넌트는 여전히 SSR을 HTML로 가져오지만 코드는 번들에 포함됩니다. 클라이언트 컴포넌트의 경우 해당 props의 JSON만 직렬화됩니다.


Step 6: Let's clean things up

이제 우리의 코드가 실제로 작동하기 때문에, 우리는 아키텍처를 실제 RSC에 조금 더 가깝게 옮길 것입니다. 우리는 아직 스트리밍과 같은 복잡한 메커니즘을 구현하지는 않겠지만, 몇 가지 결함을 수정하고 다음 streaming을 준비할 것입니다.

Step 6.1: Let's avoid duplicating work

초기 HTML을 생성하는 방법을 다시 한 번 확인해 보십시오:

async function sendHTML(res, jsx) {
  // We need to turn <Router /> into "<html>...</html>" (a string):
  let html = await renderJSXToHTML(jsx);

  // We *also* need to turn <Router /> into <html>...</html> (an object):
  const clientJSX = await renderJSXToClientJSX(jsx);

여기서 jsx가 <Router url="https://localhost:3000/" /> 이라고 가정합니다.

먼저, 우리는 renderJSXToHTML을 호출하는데, 이것은 HTML 문자열을 만들 때 Router와 다른 구성 요소들을 재귀적으로 호출할 것입니다. 그러나 초기 클라이언트 JSX도 전송해야 하므로 클라이언트 JSX에 즉시 renderJSXToClientJSX를 호출하고, 클라이언트 JSX는 다시 Router와 다른 모든 컴포넌트를 호출합니다.

즉, 모든 컴포넌트를 두 번 호출합니다! 이것은 느릴 뿐만 아니라, 잠재적으로 부정확할 수도 있습니다. 예를 들어 Feed 컴포넌트를 렌더링하는 경우 이러한 기능에서 다른 출력을 얻을 수 있습니다. 우리는 데이터가 어떻게 흘러가는지 다시 생각해 볼 필요가 있습니다.

클라이언트 JSX 트리를 먼저 생성하면 어떨까요?

async function sendHTML(res, jsx) {
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);

이 시점에서 우리의 모든 컴포넌트가 실행되었습니다. 그런 다음 해당 트리에서 HTML을 생성합니다:

async function sendHTML(res, jsx) {
  // 1. Let's turn <Router /> into <html>...</html> (an object) first:
  const clientJSX = await renderJSXToClientJSX(jsx);
  // 2. Turn that <html>...</html> into "<html>...</html>" (a string):
  let html = await renderJSXToHTML(clientJSX);
  // ...

이제 컴포넌트는 요청당 한 번만 호출됩니다.

Step 6.2: Let's use React to render HTML

처음에는 컴포넌트를 실행하는 방법을 제어할 수 있도록 커스텀 renderJSXToHTML 구현이 필요했습니다. 예를 들어, 비동기 함수에 대한 지원을 추가해야 합니다. 그러나 이제는 사전 계산된 클라이언트 JSX 트리를 전달하므로 custom 구현을 유지할 필요가 없습니다. 그것을 제거하고 React의 내장 renderToString을 대신 사용하겠습니다:

import { renderToString } from 'react-dom/server';

// ...

async function sendHTML(res, jsx) {
  const clientJSX = await renderJSXToClientJSX(jsx);
  let html = renderToString(clientJSX);
  // ...

클라이언트 코드와 유사합니다. 비동기 컴포넌트와 같은 새로운 기능을 구현했지만 renderToString 또는 hydrateRoot와 같은 기존 React API는 여전히 사용할 수 있습니다. 단지 우리가 그것들을 사용하는 방식이 다르다는 것입니다.

전통적인 서버 렌더링 리액트 앱에서는, renderToString을 호출하고 root <App /> 컴포넌트를 hydrateRoot해야 했습니다. 그러나 우리의 접근 방식에서 우리는 먼저 renderJSXToClientJSX를 사용하여 "server” JSX 트리를 평가하고 그 출력을 React API로 전달합니다.

기존의 서버 렌더링 리액트 앱에서 컴포넌트는 서버와 클라이언트 모두에서 동일한 방식으로 실행됩니다. 그러나 우리의 접근 방식에서 Router, BlogIndexPage 및 Footer과 같은 컴포넌트는 효과적으로 서버 전용입니다(적어도 현재로서는).

renderToString과 hydrateRoot에 관한 한, Router, BlogIndexPage 및 Footer는 애초에 존재하지 않았던 것과 거의 같습니다. 그때쯤이면, 그들은 이미 트리에서 "녹아서", 그들의 산출물만 남기고 떠났습니다. (??)

Step 6.3: Let's split the server in two

이전 단계에서는 실행 중인 컴포넌트가 HTML을 생성하지 않도록 분리했습니다:

  • 먼저, renderJSXToClientJSX 는 클라이언트 JSX를 생성하기 위해 우리의 컴포넌트를 실행합니다.
  • 그런 다음, 리액트의 renderToString 가 클라이언트 JSX를 HTML로 변환합니다.

이러한 단계는 독립적이기 때문에 동일한 프로세스 또는 동일한 기계에서 수행할 필요가 없습니다.

이를 입증하기 위해 server.js를 두 개의 파일로 분할합니다:

  • server/rsc.js: 이 서버에서 컴포넌트를 실행합니다. HTML이 아닌 JSX를 항상 출력합니다. 컴포넌트가 데이터베이스에 액세스하는 경우 지연 시간이 짧아지도록 데이터 센터 근처에서 이 서버를 실행하는 것이 좋습니다.
  • server/ssr.js: 이 서버는 HTML을 생성합니다. HTML을 생성하고 정적 assets을 제공하는 "edge"에서 존재할 수 있습니다.

우리는 그것들을 우리의 패키지에서 병렬로 실행할 것입니다.

"scripts": {
  "start": "concurrently \"npm run start:ssr\" \"npm run start:rsc\"",
  "start:rsc": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js",
  "start:ssr": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"
},

이 예에서는 두 시스템이 동일한 시스템에 있지만 별도로 호스트할 수 있습니다.

RSC 서버는 우리의 컴포넌트를 렌더링하는 서버입니다. JSX 출력만 처리할 수 있습니다:

// server/rsc.js

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    await sendJSX(res, <Router url={url} />);
  } catch (err) {
    console.error(err);
    res.statusCode = err.statusCode ?? 500;
    res.end();
  }
}).listen(8081);

function Router({ url }) {
  // ...
}

// ...
// ... All other components we have so far ...
// ...

async function sendJSX(res, jsx) {
  // ...
}

function stringifyJSX(key, value) {
  // ...
}

async function renderJSXToClientJSX(jsx) {
  // ...
}

다른 서버는 SSR 서버입니다. SSR 서버는 사용자가 hit 할 서버입니다. RSC 서버에 JSX를 요청한 다음 JSX를 문자열로 사용하거나 HTML로 변환합니다:

// server/ssr.js

createServer(async (req, res) => {
  try {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === "/client.js") {
      // ...
    }
    // Get the serialized JSX response from the RSC server
    const response = await fetch("http://127.0.0.1:8081" + url.pathname);
    if (!response.ok) {
      res.statusCode = response.status;
      res.end();
      return;
    }
    const clientJSXString = await response.text();
    if (url.searchParams.has("jsx")) {
      // If the user is navigating between pages, send that serialized JSX as is
      res.setHeader("Content-Type", "application/json");
      res.end(clientJSXString);
    } else {
      // If this is an initial page load, revive the tree and turn it into HTML
      const clientJSX = JSON.parse(clientJSXString, parseJSX);
      let html = renderToString(clientJSX);
      html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
      html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
      html += `</script>`;
      // ...
      res.setHeader("Content-Type", "text/html");
      res.end(html);
    }
  } catch (err) {
    // ...
  }
}).listen(8080);

우리는 이 시리즈를 통해 RSC와 "나머지 세계"(SSR 및 사용자 시스템) 간의 분리를 유지할 것입니다. 우리가 이 두 세계에 특징을 추가하고 그것들을 연결하기 시작하면 다음 부분에서 그것의 중요성은 더 분명해질 것입니다.

(엄밀히 말하면, RSC와 SSR를 동일한 프로세스 내에서 실행하는 것은 기술적으로 가능하지만 모듈 환경은 서로 격리되어야 합니다. 이것은 고급 주제이며 이 게시물의 범위를 벗어납니다.)


Recap

오늘은 여기까지! 우리가 많은 코드를 작성한 것처럼 보일 수 있지만 실제로는 그렇지 않습니다.

  • server/rsc.js is 160 lines of code, out of which 80 are our own components.
  • server/ssr.js is 60 lines of code.
  • client.js is 60 lines of code.

그것들을 읽어보세요. 데이터 흐름에 대한 이해를 돕기 위해 몇 가지 도표를 그려 봅시다.

첫 페이지 로드 중에 발생하는 작업은 다음과 같습니다:

페이지 사이를 이동하면 다음과 같은 작업이 수행할 작업은 다음과 같습니다:

마지막으로, 몇 가지 용어를 정의해 보겠습니다:

  • React Server(또는 단순히 대문자로 표시된 Server)는 RSC 서버 환경만을 의미합니다. RSC 서버에만 존재하는 컴포넌트(이 예에서는 지금까지의 컴포넌트가 전부임)를 서버 컴포넌트라고 합니다.
  • React Server 출력을 사용하는 모든 환경을 React Client(또는 대문자로 표시된 Client)라고 합니다. 방금 보셨듯이 SSR은 React Client이며 브라우저도 마찬가지입니다. 아직 클라이언트에서 컴포넌트를 지원하지는 않지만, 다음에 컴포넌트를 구축할 것입니다. 하지만 클라이언트 컴포넌트라고 부르는 것은 큰 문제가 되지 않습니다.

Challenges

만약 이 게시물을 읽는 것이 여러분의 호기심을 만족시키기에 충분하지 않다면, 최종 코드를 가지고 노는 것은 어떨까요?

다음은 여러분이 시도할 수 있는 몇 가지 아이디어입니다:

  • 페이지의 <body>에 임의의 배경색을 추가하고 배경색에 전환을 추가합니다. 페이지 사이를 탐색할 때 배경색이 애니메이션으로 표시됩니다.
  • RSC 렌더러에서 fragment()에 대한 지원을 구현합니다. 이 작업에는 몇 줄의 코드만 필요하지만, 코드를 어디에 배치하고 무엇을 해야 하는지 파악해야 합니다.
  • 그렇게 하면 블로그를 변경하여 반응 마크다운의 구성 요소를 사용하여 블로그 게시물을 마크다운으로 포맷합니다. 네, 우리의 기존 코드가 그것을 처리할 수 있어야 합니다!
  • react-markdown 구성 요소는 서로 다른 태그에 대한 사용자 지정 구현 지정을 지원합니다. 예를 들어 자신만의 이미지 구성요소를 만들어 로 전달할 수 있습니다. 이미지 치수를 측정하고(일부 npm 패키지를 사용할 수 있음) 너비와 높이를 자동으로 방출하는 이미지 구성 요소를 작성합니다.
  • 각 블로그 게시물에 주석 섹션을 추가합니다. 주석을 디스크의 JSON 파일에 저장합니다. 의견을 제출하려면 을 사용해야 합니다. 추가적인 문제로 client.js의 논리를 확장하여 양식 제출을 가로채고 페이지를 다시 로드하지 않도록 합니다. 대신, 양식을 제출한 후 JSX 페이지를 다시 가져와 주석 목록이 제자리에서 업데이트되도록 합니다.
  • JSX를 직렬화하는 형식은 현재 매우 반복적입니다. 좀 더 컴팩트하게 만드는 방법에 대한 아이디어가 있나요? Next.js App Router와 같은 운영 준비가 된 RSC 프레임워크나 공식 논-프레임워크 RSC 데모에서 영감을 얻을 수 있습니다. 스트리밍을 구현하지 않더라도 최소한 JSX 요소를 좀 더 콤팩트하게 표현하는 것이 좋을 것이다.

마치면서

2편부터는 정말 쉽지 않습니다. 😂

중간부터 이해하기 쉽지 않지만, 계속해서 곱씹어보면서 이해해보려고 노력하고 있습니다. 그래도 뭐.. 초반에 가지고 있었던 RSC에 대한 의문점이 어느정도 해소가 되는 느낌은 들긴 합니다.

근데 애매하네요. React는 더 이상 프론트엔드에 한정되어있지 않다는 생각도 드는거 같습니다. 이게 맞나... 음...🤔


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글