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

Sooo·2023년 9월 16일
0

Next.js

목록 보기
3/4

RSC from scratch 읽고 공부. Step 5부터

해결 과제

input 필드의 값이 페이지를 이동해도 남아있도록 하기

페이지를 이동했을 때 전체가 reload되는 것이 아니라 변경된 DOM의 JSX만 업데이트되도록 한다. (client의 state유지)

5.1 탐색 가로채기

클릭 이벤트를 재정의해서 href의 url변경과 그에 따른 자동 페이지 호출을 막고, 대신 navigate 함수를 호출한다
navigate 함수는 서버에서 fetch한 html로 DOM을 업데이트 한다

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

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

5.2 HTML 대신 JSX 서빙

서버가 JSX을 서빙함으로써, 클라이언트는 DOM요소 전체가 아닌 트리에서 변경된 부분만 감지해 필요한 부분만 업데이트할 수 있다.

서버는 sendJSX를 통해 JSX(를 통해 나온 트리 JSON)을 string으로 변경해서 보낸다 (전송 포맷은 변경될 수 있음)

서버의 JSX컴포넌트를 클라이언트가 이해하게 만들려면, 두 가지 과정을 거쳐야 한다.

  1. Router 컴포넌트를 호출해서 해당 url일 때 어떤 JSX을 리턴하는지 알아야 한다.
예를 들어
<Router url="http://localhost:3000/hello-world" /> 를 호출했을 때,

<BlogLayout>
  <BlogIndexPage />
</BlogLayout>
가 리턴됨
  1. 1에서 리턴한 컴포넌트 (예시의 )가 어떤 JSX를 리턴하는지 알아야 한다.

1, 2의 정보는 모두 서버에만 있기 때문에, 클라이언트가 받을 JSX트리의 모든 코드는 서버에만 있는 코드를 참조하지 않도록 해야 한다. 이렇게 만든 후에 비로소 JSON.stringify()를 실행해 string화 한 코드를 클라이언트에 보낼 수 있다 => renderJSXToClientJSX함수 구현

(❓ 그냥 클라이언트 컴포넌트로 전환하면 안될까? 물론 가능한 경우도 있지만, 서버에서만 사용가능한 라이브러리나 함수를 쓰는 경우(e.g. fs.readdir)도 있기 때문에 이 해결책도 모든 경우에 적용될 수는 없다.)

완성된 코드는 구조적으로 renderJSXToHTML과 비슷하지만, HTML대신 클라이언트 JSX객체를 반환한다.

5.3 JSX가 클라이언트에서 업데이트 되도록 적용

리액트의 hydrateRoot, root.render 함수 호출해서 서버에서 받은 DOM노드가 hydration되도록 밑작업한다.

  • 예시에서는 브라우저 빌트인 컴포넌트들(nav, footer ...)만 JSX노드로 사용했기 때문에, 엄밀히 말해서 JSX를 diff하기 위해서 리액트를 쓸 필요는 없다.
  • 클라이언트 컴포넌트에 대한 개념이 없는 라이브러리를 사용 후 diff해서 JSX 업데이트를 할 수도 있지만, 나중에 풍부한 반응성을 적용하고 싶기 때문에 리액트를 시작부터 사용한다.

리액트가 생성하지 않은 DOM 노드의 관리를 넘겨받도록 요청하려면 그 DOM 노드에 해당하는 초기 JSX를 리액트에 제공해야 한다. 서버에서 렌더링된 마크업을 hydrate하려면 리액트로 관리하려는 DOM 노드와 서버에서 생성된 초기 JSX를 인자로 해서 hydrateRoot를 호출해야 한다.

  // Traditionally, you would hydrate like this
  // document에 그려진 DOM과 초기 JSX(마크업)을 비교
hydrateRoot(document, <App />);

그런데 클라이언트 입장에서 은 아직 정의되지 않은 그냥 JSX 덩어리이므로, 대신 리액트가 알아들을 수 있는 HTML에 해당하는 ... 형태의 클라이언트 JSX를 전달해야한다.

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
// **! 현재는 아무것도 리턴하지 않으므로, 루트 페이지는 hydrate되지 않는다**
}
  
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
  // **! 현재는 아무것도 리턴하지 않으므로, navigate된 페이지는 hydrate되지 않는다**
}

hydrateRoot은 초기 JSX를 받아서 DOM과 비교하고 hydrate을 하고, root.render은 페이지가 변경될 때 fetch로 받아온 JSX를 DOM과 비교해 업데이트한다. 비교하는 JSX는 모두 렌더하는 앱의 해당 페이지 전체에 대한 JSX이다.(일부X, 전체 가져와서 비교O)

이 시점에서는 아직 두 함수에 인자로 넣을 clientJSX를 서버에서 받아오지 않았으므로, hydration 되지 않는다.

5.3.1 서버에서 JSX 가져오기

5.2의 서버에서 받아온 clientJSX로 hydration하기

fetchClientJSX함수를 구현해
클라이언트에서는 navigate할 때마다 ?jsx 서치파라미터를 추가한 요청을 보내고, 서버에서는 이 요청에 대한 응답으로 sendJSX(서버jsx를 클라이언트jsx로 변환)한 값을 보낸다. 이렇게 받아온 클라이언트jsx를 통해 root.render에서 현재 DOM과 클라이언트jsx를 비교해 hydration할 수 있다.

이 때 JSON.stringify, JSON.parse 과정에서 $$typeof: Symbol.for("react.element"), 필드가 소실되는 이슈가 있는데, 이는 의도적인 보안 메커니즘으로 React는 네트워크에서 가져온 임의의 JSON 객체를 JSX 태그로 처리하는 것을 막는다. 앱 코드에서 직접 생성되지 않은 JSX를 렌더링하지 못하도록 앱을 보호하는 것이다.
따라서 서버렌더링한 JSX를 사용하려면 직렬화 불가능한 $$typeof: Symbol.for("react.element"),값을 어떻게든 클라이언트에 넘겨줘야 한다.

JSON.stringify 함수는 JSON이 어떻게 만들어졌는지에 대한 replacer 함수를 인자로 받으므로, Symbol.for('react.element') 필드를 $RE와 같은 다른 문자열로 대체하는 방법으로 이 이슈를 해결할 수 있다.

하지만 여전히 최초로 다른 페이지로 이동할 때는 input의 state가 유지되지 않는다. hydrateRoot에서 페이지의 초기 JSX가 무엇인지 리액트에게 알려주지 않았기 때문이다. (아직 getInitialClientJSX 구현 안함)

우리의 예시에서 모든 페이지는 서버에서 sendHTML(서버jsx를 html로 변환)를 통해 보내고있고 브라우저가 이를 받아서 보여준다. 따라서 리액트와 상관없이 UI는 잘 보인다.

단, 현재 상황에서 최초의 페이지는 서버에 이러한 ?jsx요청을 보내지 않았으므로 초기 JSX가 없어 hydration할 수 없다.

5.3.2 초기 JSX를 HTML에 인라인한다

그러면 초기 JSX를 어떻게 가져올 수 있을까? root.render에서 사용하는 fetchClientJSX 함수는 페이지가 이동될 때마다 fetch를 실행한다.

예시 페이지는 서버에서 렌더링된 HTML을 보여주고 있으므로 UI를 보여주는데는 초기 JSX가 필요 없지만, 리액트는 이후의 navigation에서 DOM을 비교해야하므로 초기 JSX가 필요하다. 불필요한 waterfall을 피하기 위해서 fetch하진 않는다.

리액트의 전통적인 SSR에서도 데이터에 대해서 비슷한 문제가 있다. 페이지가 데이터를 가지고 있어야지만 컴포넌트가 hydrate를 완료하고 해당 컴포넌트의 초기 JSX를 리턴할 수 있다. 우리 예시에서는 페이지에 리턴해야할 컴포넌트는 없지만, 초기 JSX를 어떻게 만들어야하는지 아는 클라이언트 코드도 없다.
(초기 JSX를 모르므로 처음으로 navigate할 때 비교할 JSX가 없어서 state가 들어있는 DOM을 포함해 새로 그리게 된다)

예시에서는 이러한 문제를 해결하기 위해 initial JSX를 클라이언트의 글로벌변수로 만들어(window.INITIAL_CLIENT_JSX_STRING) 사용하게 한다. 서버도

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>`;
// ...

예시와 실제 RSC의 차이점

  • 실제 RSC의 JSX는 streaming 방식을 사용하기 위해 json의 chunck(blob)으로 되어있다. 리액트는 JSX가 다 도착하기를 기다리지 않고, 우선 도착한 JSX 트리를 먼저 탐색한다. 이를 통해 리액트 로드가 완료되는 즉시 hydration을 시작할 수 있다. (waterfall 이슈 해결)

  • RSC 방식 SSR에도 클라이언트 컴포넌트가 있다. 클라이언트 컴포넌트는 (초기 페이지로 로드될 때) SSR되고, 그 코드가 JS번들에도 포함된다. 클라이언트 컴포넌트는 그 props의 JSON만이 직렬화된다. 미래에는 리액트에 HTML과 포함된 payload간의 데이터 중복을 제거할 메커니즘을 추가될 수 있다.

읽으면서 헷갈렸던 점

hydrateRoot(document, null)을 호출하면?

reactNode 자리에 null을 넣는다는 건, 리액트에게 아무것도 hydrate할 필요 없다고 알려주는 것이다.

처음에는 리액트가 server-rendered 된 결과물인 DOM(:document)과 마크업 reactNode가 같기를 기대하는 거라고 착각했는데, DOM과 hydrateRoot에서 나온 결과물이 같기를 기대하는 것이다. 리액트 문서에 따르면 reactNode자리에는 null이나 undefined가 올 수도 있다.

리액트의 hydrateRoot 예시를 보자.
hydrateRoot의 부분을 null로 바꿔도 화면은 그대로 보여지지만, 버튼의 이벤트 핸들러가 동작하지 않는다.
여기서 을 사용하지 않음에도 UI가 그대로 잘 보이는 이유는, 초기 화면의 html은 index.html에서 보여주기 때문이다. 은 hydrate하는 용도로만 사용되고, 둘의 내용은 완전히 같다.
DOM(index.html)과 JSX()중 둘 중 하나의 내용만 변경하면 서로 값이 다르기 때문에 에러가 보여진다.

❓ waterfall

  • ✅ waterfall: 전통적 리액트 SSR의 문제점. 서버에서 데이터를 가지고 오는 작업이 끝나야 서버에서 컴포넌트를 HTML로 렌더링할 수 있고, 클라이언트에 모든 js코드를 가져와야지 hydration을 할 수 있다. 그리고 모든 hydration이 끝나야 컴포넌트가 상호작용이 가능한 상태가 된다. 앞의 작업이 모두 끝나야지만 뒤의 작업을 실행할 수 있는 waterfall 방식의 렌더링 동작이 일을 더디게 한다. 서버 컴포넌트는 이 동작을 병행적으로 (cocurrent) 실행될 수 있게 해준다. 즉 페이지 단위가 아니라 컴포넌트 단위에서 이 단계들을 수행할 수 있게 해준다.

  • ❓ 왜 위의 상황에서 불필요한 waterfall이 생긴다고 얘기했을까?

    • ✅ 리액트가 로드되자마자 다시 초기 jsx를 fetch한다면 fetch가 끝나기 전까지 hydration할 수 없다.

❓ 그래서 전통적 리액트 SSR(next12)과 RSC 방식 SSR(next13)의 차이점은?

0개의 댓글