(번역) hydration은 React 내부에서 어떻게 동작할까?

Taegyu Hwang·2024년 7월 16일
1
  • Jser.dev의 React Internals Deep Dive 시리즈How basic hydration works internally in React?를 번역한 글입니다.
  • 원글, 본글 모두 react 18.2.0 버전 기준입니다.
  • React 코드의 주석이 아닌, 저자가 추가한 주석엔 '저자'를 앞에 붙였습니다.

모두가 React 서버 컴포넌트에 대해 이야기하지만, 그 이야기를 하려면 먼저 해야할 한 가지 에피소드가 있습니다. 오늘은 hydration에 대해 살펴봅시다.

1. 첫 렌더링(mount) 때 DOM 트리가 어떻게 구성되는지 다시 떠올려봅시다.

React가 어떻게 초기 mount를 수행하는지에 대해 이야기한 적이 있었죠. 몇 가지 주요 사항은 다음과 같습니다.

  1. backing DOM 노드가 필요한 각 fiber 노드는 stateNode라는 이름으로 DOM 노드에 대한 property를 가지고 있습니다.
  2. React는 begionWork()completeWork() 두 단계로 Fiber 노드를 DFS 방식으로 재귀 처리합니다. 이는 'How does React traverse Fiber tree internally?' 포스트에 설명되어 있습니다. 이는 네 단계로 요약될 수 있습니다. beginWork()를 자신에게 적용 → beginWork()를 자식에게 적용 → completeWork()를 자신에게 적용 → beginWork()를 형제에게 적용 또는 부모에서 completeWork()(return)
  3. completeWork() 단계에서 React는 실제 DOM 노드를 생성하고 stateNode를 설정하며 생성된 자식들을 해당 노드에 추가합니다. 아래는 그 코드입니다.
function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  switch (workInProgress.tag) {
    case HostComponent: {
      if (wasHydrated) {
        ...
      } else {
        const rootContainerInstance = getRootHostContainer();
        const instance = createInstance(
          type,
          newProps,
          rootContainerInstance,
          currentHostContext,
          workInProgress
        );
        appendAllChildren(instance, workInProgress, false, false);
        workInProgress.stateNode = instance;
        ...
      }
    }
  }
}

HostComponent는 DOM의 네이티브 컴포넌트를 의미하며, 명확하게 아래와 같습니다.

  1. DOM은 createInstance()에 의해 생성됩니다.
  2. 자식들은 appendALlChildren()에 의해 추가됩니다.

wasHydrated if 분기에 주목해보세요. 이는 오늘 주제인 hydration과 관련이 있습니다. 곧 다시 다루겠습니다.

위 단계를 거쳐 React는 fiber 트리를 DOM 트리로 변환합니다.

Contextd와 같이 backing DOM 노드가 필요하지 않은 fiber 노드들이 있다는 점에 주목하세요. appendAllChildren()은 어떤 자식을 추가할지 어떻게 알 수 있을까요?

코드를 보면, fiber 트리를 다시 순회하여 최상위 노드를 찾는 것을 알 수 있습니다. 간단하죠.

2. 좋아요. 그래서 hydration이 뭐죠?

hydration(수화) - 물을 흡수하게 하는 과정

이 네이밍은 정말 멋지다고 생각합니다. 실제로 일어나는 일을 생생하게 묘사합니다. hydrateRoot()공식 가이드를 보면, hydration이란 사전 렌더된 DOM을 기반으로 React 컴포넌트를 렌더하는 것이라는 점을 쉽게 알수 있습니다. 이는 SSR(서버 사이드 렌더링)을 가능하게 합니다. 서버는 상호작용이 불가능한(dehydrated) HTML을 만들어 낼 수 있고, 클라이언트 사이드에서 hydrate하여 앱이 인터렉티브하도록 만들 수 있습니다.

예시를 보시죠, hydration 없이 일반 렌더링을 하는 데모입니다.

<div id="container"><button>0</button></div>
<script type="text/babel">
  const useState = React.useState;
  function App() {
    const [state, setState] = useState(0);
    return (
      <button onClick={() => setState((state) => state + 1)}>{state}</button>
    );
  }
  const rootElement = document.getElementById("container");
  const originalButton = rootElement.firstChild;
  ReactDOM.createRoot(rootElement).render(<App />);
  setTimeout(
    () =>
      console.assert(
        originalButton === rootElement.firstChild,
        "DOM is reused?"
      ),
    0
  );
</script>

버튼이 이미 컨테이너 안에 있으며 <button> DOM 노드가 재사용되는지 확인하는 assertion이 있습니다. 데모 페이지에서 콘솔을 열어보면 DOM이 재사용되지 않고 버려졌다는 에러를 볼 수 있습니다.

이제 hydrateRoot()로 전환해 보겠습니다.

<div id="container"><button>0</button></div>
<script type="text/babel">
  const useState = React.useState;
  const hydrateRoot = ReactDOM.hydrateRoot;
  function App() {
    const [state, setState] = useState(0);
    return (
      <button onClick={() => setState((state) => state + 1)}>{state}</button>
    );
  }
  const rootElement = document.getElementById("container");
  const originalButton = rootElement.firstChild;
  hydrateRoot(rootElement, <App />);
  setTimeout(
    () =>
      console.assert(
        originalButton === rootElement.firstChild,
        "DOM is reused"
      ),
    0
  );
</script>

데모 페이지를 열어보면 에러가 다시 표시되지 않음을 확인할 수 있습니다. 이는 기존 DOM이 재사용 되었음을 의미합니다.

기존의 DOM 노드를 재사용하려고 시도하는 것, 이것이 hydration입니다.

3. hydration은 React에서 어떻게 동작할까?

아이디어는 꽤 직관적입니다. 우리는 이미 DOM 트리를 생성하는 프로세스와 기존 DOM 트리도 갖고 있기 때문에 필요한 것은 다음과 같습니다.

기존 DOM 트리에 커서를 유지한 채, 새 DOM 노드를 만들어야 할 때마다 이와 비교하여 새로운 노드를 생성하지 않고 stateNode로 바로 사용하는 것입니다.

위에서 언급했듯이 모든 fiber 노드는 beginWork()completeWork()에서 두 번 순회됩니다. 이 것은 각각 enteringleaving을 뜻합니다. 따라서 기존 DOM 트리의 커서를 동기화하는 것도 필요합니다.

3.1 beginWork()에서의 hydration

updateHostComponent() 함수 내의 이 코드 라인에 타겟팅 해봅시다. (코드).


function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  ...
  switch (workInProgress.tag) {
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
  }
  ...
}
function updateHostComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
) {
  pushHostContext(workInProgress);
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }
  ....
  return workInProgress.child;
}

HostComponent는 클라이언트 네이티브 컴포넌트, 즉 DOM을 의미합니다. 함수 이름에서 알 수 있듯이, tyToClaimNextHydratableInstance()(코드)는 다음 기존 DOM 노드를 재사용하려고 시도합니다.

function tryToClaimNextHydratableInstance(fiber: Fiber): void {
  if (!isHydrating) {
    return;
  }
  if (enableFloat) {
    if (!isHydratableType(fiber.type, fiber.pendingProps)) {
      // This fiber never hydrates from the DOM and always does an insert
      fiber.flags = (fiber.flags & ~Hydrating) | Placement;
      isHydrating = false;
      hydrationParentFiber = fiber;
      return;
    }
  }
  const initialInstance = nextHydratableInstance;
  if (rootOrSingletonContext) {
    // We may need to skip past certain nodes in these contexts
    advanceToFirstAttemptableInstance(fiber);
  }
  const nextInstance = nextHydratableInstance;
  if (!nextInstance) {
    if (shouldClientRenderOnMismatch(fiber)) {
      warnNonhydratedInstance((hydrationParentFiber: any), fiber);
      throwOnHydrationMismatch(fiber);
    }
    // Nothing to hydrate. Make it an insertion.
    insertNonHydratedInstance((hydrationParentFiber: any), fiber);
    isHydrating = false;
    hydrationParentFiber = fiber;
    nextHydratableInstance = initialInstance;
    return;
  }
  const firstAttemptedInstance = nextInstance;
  if (!tryHydrateInstance(fiber, nextInstance)) {
    if (shouldClientRenderOnMismatch(fiber)) {
      warnNonhydratedInstance((hydrationParentFiber: any), fiber);
      throwOnHydrationMismatch(fiber);
    }
    // If we can't hydrate this instance let's try the next one.
    // We use this as a heuristic. It's based on intuition and not data so it
    // might be flawed or unnecessary.
    nextHydratableInstance = getNextHydratableSibling(nextInstance);
    const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
    if (rootOrSingletonContext) {
      // We may need to skip past certain nodes in these contexts
      advanceToFirstAttemptableInstance(fiber);
    }
    if (
      !nextHydratableInstance ||
      !tryHydrateInstance(fiber, nextHydratableInstance)
    ) {
      // Nothing to hydrate. Make it an insertion.
      insertNonHydratedInstance((hydrationParentFiber: any), fiber);
      isHydrating = false;
      hydrationParentFiber = fiber;
      nextHydratableInstance = initialInstance;
      return;
    }
    // We matched the next one, we'll now assume that the first one was
    // superfluous and we'll delete it. Since we can't eagerly delete it
    // we'll have to schedule a deletion. To do that, this node needs a dummy
    // fiber associated with it.
    deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
  }
}

tryHydrateInstance()는 기존 DOM과 비교하고 stateNode를 설정합니다.

function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
  // fiber is a HostComponent Fiber
  const instance = canHydrateInstance(
    nextInstance,
    fiber.type,
    fiber.pendingProps
  );
  if (instance !== null) {
    fiber.stateNode = (instance: Instance);
    hydrationParentFiber = fiber;
    nextHydratableInstance = getFirstHydratableChild(instance);
    rootOrSingletonContext = false;
    return true;
  }
  return false;
}

export function canHydrateInstance(
  instance: HydratableInstance,
  type: string,
  props: Props
): null | Instance {
  if (
    instance.nodeType !== ELEMENT_NODE ||
    instance.nodeName.toLowerCase() !== type.toLowerCase()
  ) {
    return null;
  } else {
    return ((instance: any): Instance);
  }
}

코드는 꽤 간단합니다.

마지막 몇 줄에 집중하세요.

  1. fiber.stateNode = (instance: Instance); : 가능한 경우 stateNode가 설정됩니다.
  2. nextHydratableInstance = getFirstHydratableChild(instance); : 기존 DOM에서 커서가 자식으로 이동합니다. 이는 'How does React traverse Fiber tree internally?'에서 설명한 것처럼 유지됩니다

3.2 completeWork()에서의 hydration

이 게시물의 시작 부분에서, 우리는 completeWork()의 일부 코드를 생략했습니다. 이제 더 많은 코드를 살펴보겠습니다. (코드)

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
   switch (workInProgress.tag) {
    case HostComponent: {
      ...
      if (current !== null && workInProgress.stateNode != null) {
       ...
      } else {
        ...
        const wasHydrated = popHydrationState(workInProgress);
        if (wasHydrated) {
          if (
            prepareToHydrateHostInstance(workInProgress, currentHostContext)
          ) {
            // If changes to the hydrated node need to be applied at the
            // commit-phase we mark this as such.
            markUpdate(workInProgress);
          }
        } else {
          const rootContainerInstance = getRootHostContainer();
          const instance = createInstance(
            type,
            newProps,
            rootContainerInstance,
            currentHostContext,
            workInProgress,
          );
          appendAllChildren(instance, workInProgress, false, false);
          workInProgress.stateNode = instance;
        }
      }
      return null;
    }
   }
  ...
}

fiber가 성공적으로 hydrate되면, wasHydrated
prepareToHydrateHostInstance()가 호출됩니다. 그런 다음 markUpdate()는 fiber 노드의 플래그를 업데이트하며, 커밋 단계에서 DOM 노드가 업데이트됩니다.

3.2.1 prepareToHydrateHostInstance()가 실질적인 hydration을 수행합니다.

prepareToHydrateHostInstance()hydrateInstance()에 의해 실질적으로 hydration이 수행되는 곳입니다.

function prepareToHydrateHostInstance(
  fiber: Fiber,
  hostContext: HostContext
): boolean {
  if (!supportsHydration) {
    throw new Error(
      "Expected prepareToHydrateHostInstance() to never be called. " +
        "This error is likely caused by a bug in React. Please file an issue."
    );
  }
  const instance: Instance = fiber.stateNode;
  const shouldWarnIfMismatchDev = !didSuspendOrErrorDEV;
  const updatePayload = hydrateInstance(
    instance,
    fiber.type,
    fiber.memoizedProps,
    hostContext,
    fiber,
    shouldWarnIfMismatchDev
  );
  // TODO: Type this specific to this type of component.
  fiber.updateQueue = (updatePayload: any);
  // If the update payload indicates that there is a change or if there
  // is a new ref we mark this as an update.
  if (updatePayload !== null) {
    return true;
  }
  return false;
}

hydrateInstance() > diffHydratedProperties()는 프로퍼티의 업데이트를 처리합니다. 코드를 참조하세요.

3.2.2 기존 DOM의 커서는 popHydrationState()에서 업데이트 됩니다.

function popHydrationState(fiber: Fiber): boolean {
  ...
  popToNextHostParent(fiber);
  if (fiber.tag === SuspenseComponent) {
    nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
  } else {
    nextHydratableInstance = hydrationParentFiber
      ? getNextHydratableSibling(fiber.stateNode)
      : null;
  }
  return true;
}

popToNextHostParent()(코드)는 경로를 따라 가장 가까운 host 컴포넌트를 찾아 hydrationParentFiber로 설정합니다.

4. 미스매치된 노드들의 처리

tryToClaimNextHydratableInstance() 함수에는 이런 케이스를 처리하는 몇 줄의 코드가 있습니다.

const nextInstance = nextHydratableInstance;
if (!nextInstance) {
  if (shouldClientRenderOnMismatch(fiber)) {
    warnNonhydratedInstance((hydrationParentFiber: any), fiber);
    throwOnHydrationMismatch(fiber);
  }
  // Nothing to hydrate. Make it an insertion.
  insertNonHydratedInstance((hydrationParentFiber: any), fiber);
  isHydrating = false;
  hydrationParentFiber = fiber;
  nextHydratableInstance = initialInstance;
  return;
}

첫 번째 예제는 일치하지 않는 노드가 있는 경우입니다. shouldClientRenderOnMismatch()를 체크한 후, 경고가 출력되고 에러가 던져집니다.

`shouldClientRenderOnMismatch() 검사가 있다는 점을 기억하세요. 이는 Suspense와 관련이 있는 것 같으며, 이는 나중에 다룰 예정입니다.

하지만 결국엔 실제로 렌더링되는 것을 볼 수 있습니다. 이는 React가 이런 종류의 에러를 복구하려고 시도하기 때문입니다. (코드)

if (exitStatus === RootErrored) {
  // If something threw an error, try rendering one more time. We'll
  // render synchronously to block concurrent data mutations, and we'll
  // includes all pending updates are included. If it still fails after
  // the second attempt, we'll give up and commit the resulting tree.
  const originallyAttemptedLanes = lanes;
  const errorRetryLanes = getLanesToRetrySynchronouslyOnError(
    root,
    originallyAttemptedLanes
  );
  if (errorRetryLanes !== NoLanes) {
    lanes = errorRetryLanes;
    exitStatus = recoverFromConcurrentError(
      root,
      originallyAttemptedLanes,
      errorRetryLanes
    );
  }
}
function recoverFromConcurrentError(
  root: FiberRoot,
  originallyAttemptedLanes: Lanes,
  errorRetryLanes: Lanes,
) {
  // If an error occurred during hydration, discard server response and fall
  // back to client side render.
  ...
}

5. 요약

전체적으로 React가 fiber 트리를 순회하는 방식을 이해하고 있으면, 기본적인 hydration은 이해하기 어렵지 않습니다.

우선, backing DOM 노드를 가진 파이버 노드는 stateNode가 실제 DOM 노드로 설정됩니다. hydration의 목적은 새로운 노드를 생성하는 대신 기존의 DOM 노드를 재사용하는 것입니다.

우리는 단순히 기존 DOM에 커서를 두고 fiber 트리를 순회하는 동안 DOM 트리 주위를 이동합니다. 새로운 DOM 노드를 생성하는 대신, 기존 DOM 노드가 일치하면 이를 사용하고 stateNode를 설정한 다음, 해당 fiber를 업데이트가 필요하도록 표시합니다.

React는 hydration에서 불일치가 발생하면 최선을 다해 클라이언트 측 렌더링으로 되돌아갑니다. 물론 이는 렌더링 성능에 큰 영향을 미칩니다.

여전히 여기서 다루지 않은 많은 내용이 있습니다. 예를 들어, Suspense는 hydration에 어떻게 대처하는지에 대해서는 다음 에피소드에서 다루겠습니다. 계속 지켜봐 주세요.

0개의 댓글