일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 비동기
- useCallback
- useState
- 프로그래머스
- 그리디
- venv
- eslint
- web
- react
- seo
- 완전탐색
- Hook
- Permutations
- React.memo
- Custom Hook
- prettier
- react internals
- await
- useEffect
- VanillaJS
- webpack
- python
- useMemo
- 코딩테스트
- 코테
- 환경설정
- canvas
- VanillJS
- pjax
- BFS
- Today
- Total
Amada Coding Club
[React Core Deep Dive] How basic hydration works internally in React? 본문
[React Core Deep Dive] How basic hydration works internally in React?
아마다회장 2024. 7. 2. 12:38이 글은 Jser의 How basic hydration works internally in React를 직역한 글입니다.
모두가 React 서버 컴포넌트에 대해 이야기하고 있지만, 그 이야기를 하려면 먼저 한 가지 에피소드를 해야 하므로 오늘은 하이드레이션에 대해 살펴보겠습니다.
1. 첫 번째 렌더링(마운트)에서 DOM 트리가 어떻게 구성되는지 기억해 보겠습니다.
React가 초기 마운트를 수행하는 방법에 대해 이야기했는데, 여기에 몇 가지 핵심 사항이 있습니다.
1. 백킹 DOM 노드가 필요한 각 파이버 노드는 stateNode라는 이름으로 DOM 노드에 대한 프로퍼티를 가집니다.
2. React는 beginWork() 및 completeWork()의 2단계로 각 파이버 노드를 DFS 방식으로 재귀적으로 처리합니다. 이에 대한 설명은 제 블로그 포스트 React는 어떻게 파이버 트리를 트래버스하는가에서 확인할 수 있습니다. 4단계로 요약할 수 있습니다: self의 beginWork() → 자식의 beginWork() → self의 completeWork() → 자매의 beginWork() / 부모의 completeWork()(반환).
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의 네이티브 컴포넌트를 의미하며, 다음과 같이 명확하게 알 수 있습니다.
DOM은 createInstance()에 의해 생성되고
에 의해 생성되고, 자식은 appendAllChildren()에 의해 추가됩니다.
오늘 주제인 하이드레이션과 관련된 것으로 보이는 wasHydrate의 if 분기가 있는데,
곧 다시 다룰 예정입니다.
위의 단계를 통해 React는 파이버 트리를 DOM 트리로 변환합니다.
배킹 DOM 노드가 필요 없는 Context와 같은 파이버 노드가 있다는 것을 알 수 있는데, appendAllChildren()은 어떤 자식을 추가할지 어떻게 알 수 있을까요?
코드를 보면 다시 파이버 트리를 탐색하여 최상위 노드를 찾는다는 것을 알 수 있습니다.
2. 하이드레이션은 과연 무엇일까요?
Hydration - 무언가가 물을 흡수하게 하는 과정.
실제로 일어나는 일을 생생하게 묘사한 멋진 이름이라고 말하지 않을 수 없습니다. hydrateRoot()의 공식 가이드를 따라가다 보면, 하이드레이션이란 미리 렌더링된 DOM을 기반으로 React 컴포넌트를 렌더링하는 것을 의미한다는 것을 쉽게 알 수 있습니다. 이를 통해 SSR(서버 사이드 렌더링)이 가능합니다. 서버는 비대화형(dehydrate된) HTML을 출력할 수 있고, 클라이언트 측에서 이를 하이드레이트하여 앱이 대화형 앱이 되도록 할 수 있습니다.
하이드레이션을 적용하지 않은 일반 렌더링 데모를 예로 들어보겠습니다.
<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 노드가 재사용되는지 확인하는 선언이 있다는 것을 알 수 있습니다. 데모 페이지에서 콘솔을 열면 재사용되지 않았음을 나타내는 오류를 볼 수 있으며, 이는 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 노드를 재사용하려는 하이드레이션입니다.
3. 하이드레이션은 리액트에서 어떻게 동작하나용가리
아이디어는 매우 간단합니다. 이미 DOM 트리를 생성하는 프로세스가 있고 기존 DOM 트리도 있으므로 기존 DOM 트리에 커서를 유지한 다음 새 DOM 노드를 생성해야 할 때마다 비교한 다음 새로 만들지 않고 stateNode로 직접 사용하면 됩니다.
위에서 언급했듯이 모든 파이버 노드는 beginWork()과 completeWork(), 즉 진입과 퇴출을 의미하는 두 번의 탐색을 거치므로 기존 DOM 트리의 커서를 동기화 상태로 유지해야 합니다.
3.1 beginWork에서의 하이드레이션
그리고 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을 의미합니다. 함수 이름에서 알 수 있듯이 tryToClaimNextHydratableInstance()는 기존에 존재하는 다음 DOM 노드를 재사용하려고 시도합니다.
function tryToClaimNextHydratableInstance(fiber: Fiber): void {
if (!isHydrating) {
return;
}
if (enableFloat) {
if (!isHydratableType(fiber.type, fiber.pendingProps)) {
// 이 파이버는 DOM에서 하이드레이션하지 않으며 항상 삽입을 수행합니다.
fiber.flags = (fiber.flags & ~Hydrating) | Placement;
isHydrating = false;
hydrationParentFiber = fiber;
return;
}
}
const initialInstance = nextHydratableInstance;
if (rootOrSingletonContext) {
// 다음과 같은 상황에서는 특정 노드를 건너뛰어야 할 수도 있습니다.
advanceToFirstAttemptableInstance(fiber);
}
const nextInstance = nextHydratableInstance;
if (!nextInstance) {
if (shouldClientRenderOnMismatch(fiber)) {
warnNonhydratedInstance((hydrationParentFiber: any), fiber);
throwOnHydrationMismatch(fiber);
}
//하이드레이션할 필요가 없습니다. 삽입합니다.
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);
}
// 이 인스턴스에서 하이드레이션할 수 없다면 다음 인스턴스를 시도해 봅시다.
// 우리는 이것을 휴리스틱으로 사용합니다.
// 데이터가 아닌 직관에 기반하므로 결함이 있거나 불필요할 수 있습니다.
nextHydratableInstance = getNextHydratableSibling(nextInstance);
const prevHydrationParentFiber: Fiber = (hydrationParentFiber: any);
if (rootOrSingletonContext) {
// 다음과 같은 상황에서는 특정 노드를 건너뛰어야 할 수도 있습니다.
advanceToFirstAttemptableInstance(fiber);
}
if (
!nextHydratableInstance ||
!tryHydrateInstance(fiber, nextHydratableInstance)
) {
// 하이드레이션할 필요가 없습니다. 삽입합니다.
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
isHydrating = false;
hydrationParentFiber = fiber;
nextHydratableInstance = initialInstance;
return;
}
// 다음 항목을 일치시켰으므로 이제 첫 번째 항목이 불필요하다고 가정하고 삭제합니다.
// 성급하게 삭제할 수 없으므로 삭제 스케줄을 잡아야 합니다.
// 그러기 위해서는 이 노드와 연결된 더미 파이버가 필요합니다.
deleteHydratableInstance(prevHydrationParentFiber, firstAttemptedInstance);
}
}
tryHydrateInstance()는 기존 DOM과 비교하여 stateNode를 설정합니다.
function tryHydrateInstance(fiber: Fiber, nextInstance: any) {
// 파이퍼는 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의 커서가 그 자식으로 이동합니다. 이는 React가 파이버 트리를 트래버스하는 방법에서 설명한 대로 유지됩니다.
3. completeWork() 에서의 하이드레이션
이 글의 시작 부분에서 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)
) {
// 커밋 단계에서 하이드레이션된 노드에 변경 사항을 적용해야 하는 경우 이를 표시합니다.
markUpdate(workInProgress);
}
} else {
const rootContainerInstance = getRootHostContainer();
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
appendAllChildren(instance, workInProgress, false, false);
workInProgress.stateNode = instance;
}
}
return null;
}
}
...
}
파이버가 성공적으로 하이드레이션되면 wasHydrated가 호출되고, prepareToHydrateHostInstance()가 호출되며, markUpdate()가 파이버 노드의 플래그를 업데이트하고, 커밋 단계에서 DOM 노드가 업데이트됩니다.
3.2.1 prepareToHydrateHostInstance()가 실제 하이드레이션을 수행합니다.
hydrateInstance()를 통해 실제로 하이드레이션이 이루어지는 곳은 prepareToHydrateHostInstance()입니다.
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: 이 컴포넌트 유형에 맞게 입력해야 합니다.
fiber.updateQueue = (updatePayload: any);
// 업데이트 페이로드에 변경 사항이 있거나 새로운 참조가 있는 경우 이를 업데이트로 표시합니다.
if (updatePayload !== null) {
return true;
}
return false;
}
hydrateInstance() > diffHydratedProperties()는 속성 업데이트를 처리합니다(코드 참조).
3.2.2 popHydrationState()에서 기존 DOM의 커서가 업데이트됩니다.
function popHydrationState(fiber: Fiber): boolean {
...
popToNextHostParent(fiber);
if (fiber.tag === SuspenseComponent) {
nextHydratableInstance = skipPastDehydratedSuspenseInstance(fiber);
} else {
nextHydratableInstance = hydrationParentFiber
? getNextHydratableSibling(fiber.stateNode)
: null;
}
return true;
}
popToNextHostParent()는 경로를 따라 가장 가까운 호스트 컴포넌트를 찾아서 hydrationParentFiber를 설정합니다.
4. 일치하지 않는 노드 처리.
tryToClaimNextHydratableInstance()에는 이러한 경우를 처리하는 몇 줄의 코드가 있습니다.
const nextInstance = nextHydratableInstance;
if (!nextInstance) {
if (shouldClientRenderOnMismatch(fiber)) {
warnNonhydratedInstance((hydrationParentFiber: any), fiber);
throwOnHydrationMismatch(fiber);
}
// 하이드레이션할 필요가 없습니다. 삽입합니다.
insertNonHydratedInstance((hydrationParentFiber: any), fiber);
isHydrating = false;
hydrationParentFiber = fiber;
nextHydratableInstance = initialInstance;
return;
}
위와 같은 첫 번째 예는 일치하지 않는 노드가 있는 경우, shouldClientRenderOnMismatch() 확인 후 경고가 표시되고 오류가 발생하는 경우입니다.
앞으로 다룰 Suspense와 관련된 것으로 보이는 shouldClientRenderOnMismatch() 검사가 있다는 점에 유의하세요.
하지만 실제로는 렌더링되는 것을 볼 수 있는데, 이는 React가 이런 종류의 오류를 복구하려고 시도하기 때문입니다.
if (exitStatus === RootErrored) {
// 오류가 발생하면 렌더링을 한 번 더 시도해 보세요.
// 동시 데이터 변형을 차단하기 위해 동기식으로 렌더링하고 보류 중인 모든 업데이트를 포함할 것입니다.
// 두 번째 시도 후에도 여전히 실패하면 포기하고 결과 트리를 커밋합니다.
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,
) {
// 하이드레이션 중에 오류가 발생하면 서버 응답을 폐기하고
// 클라이언트 측 렌더링으로 돌아갑니다.
...
}
5. 요약
React가 파이버 트리를 통과하는 방법에 대한 지식만 있으면 기본적인 하이드레이션은 어렵지 않게 이해할 수 있습니다.
우선, 백킹 DOM 노드가 있는 파이버 노드는 stateNode가 실제 DOM 노드로 설정되어 있는데, 하이드레이션을 위해 새로운 DOM 노드를 생성하지 않고 기존 DOM 노드를 재사용하고자 합니다.
기존 DOM에 커서를 두고 파이버 트리를 따라 이동하면서 새로운 DOM 노드를 만드는 대신 기존 DOM 노드가 일치하면 이를 사용하고 stateNode를 설정한 다음 업데이트가 필요한 경우 파이버를 표시하면 됩니다.
수화는 최선의 노력이며, 불일치가 발생하면 React는 클라이언트 측 렌더링으로 되돌아가지만 물론 이는 렌더링 성능에 큰 영향을 미칩니다.
예를 들어 Suspense가 하이드레이션에 어떻게 대처하는지 등 여기서 언급하지 않은 내용이 아직 많이 있습니다. 다음 에피소드에서 다룰 예정이니 기대해 주세요.