
createPortal의 주요 특징은 다음과 같다:
Portal을 통핸 렌더링된 컴포넌트는 DOM 상에서는 다른 위치에 있더라도, React 트리에서는 원래 위치의 컨텍스트와 이벤트 버블링을 유지한다.createPortal의 세부 구현을 보자:
export function createPortal(
children: ReactNodeList,
containerInfo: any,
// TODO: figure out the API for cross-renderer implementation.
implementation: any,
key: ?string = null,
): ReactPortal {
if (__DEV__) {
checkKeyStringCoercion(key);
}
return {
// This tag allow us to uniquely identify this as a React Portal
$$typeof: REACT_PORTAL_TYPE,
key: key == null ? null : '' + key,
children,
containerInfo,
implementation,
};
}
type이 REACT_PORTAL_TYPE인 리액트 노드를 생성해 반환하는 단순한 형태의 함수라는걸 알 수 있다. 그렇다면 REACT_PORTAL_TYPE을 어떻게 처리하는 이 객체를 처리하는 함수를 찾아봤다.
type에 대한 처리는 beginWork 함수에서 일어난다. beginWork는 React Fiber 아키텍처의 핵심 함수 중 하나다. 주요 역할은 다음과 같다:
주요한 특징은 다음과 같다:
Fiber 트리를 순회한다.DOM 업데이트는 일어나지 않는다.이 함수는 React의 재조정(Reconciliation) 프로세스의 중요한 부분이며, 효율적인 UI 업데이트를 가능하게 한다. 그렇다면 세부 구현을 간략하게 알아보자:
// 단순화된 beginWork 구현
function beginWork(current, workInProgress, renderLanes) {
// 1. 변경사항 체크 (최적화)
if (current !== null) {
// 이전 props와 현재 props 비교
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps === newProps && !hasContextChanged()) {
// 변경사항이 없으면 작업을 건너뛸 수 있음
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
// 2. 컴포넌트 타입에 따른 처리
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress, renderLanes);
case ClassComponent:
return updateClassComponent(current, workInProgress, renderLanes);
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
case PortalComponent:
return updatePortalComponent(current, workInProgress, renderLanes);
// ... 다른 타입들
}
}
이처럼 type에 따라 적절한 update 함수를 호출하는 모습을 볼 수 있다. createPortal을 통해 생성된 REACT_PORTAL_TYPE 타입의 컴포넌트는 updatePortalComponent를 통해 처리된다. updatePortalComponent는 Portal 타입의 Fiber 노드를 처리하는 함수다. 주요 역할은 다음과 같다:
Portal의 children을 재조정(reconcile) 한다.Portal의 container 정보를 관리한다.Portal의 자식들이 올바르게 렌더링되도록 준비한다.세부 구현을 보자:
function updatePortalComponent(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// Portal 컴포넌트의 containerInfo를 현재 작업 중인 Fiber의 context로 설정
// 이를 통해 Portal 내부의 컴포넌트들이 올바른 container context에서 렌더링됨
pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo);
// Portal의 children을 가져옴 (createPortal에서 전달된 children)
const nextChildren = workInProgress.pendingProps;
if (current === null) {
// 최초 마운트 시
// 일반적인 React 컴포넌트들은 마운트 단계에서 DOM에 추가되지만,
// Portal은 특별한 케이스로 commit 단계에서 DOM에 추가됨
// 이는 root가 null child로 시작하는 것과는 다른 방식
workInProgress.child = reconcileChildFibers(
workInProgress,
null, // 이전 자식이 없으므로 null
nextChildren, // 새로운 자식들
renderLanes,
);
} else {
// 업데이트 시
// 이전 Fiber(current)와 새로운 props(nextChildren)를 비교하여
// 필요한 변경사항을 계산하고 자식 Fiber들을 재조정
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
// 처리된 자식 Fiber를 반환
// 이 자식들은 나중에 commit 단계에서 실제 container에 마운트됨
return workInProgress.child;
}
여기서 중요한 포인트는 다음과 같다:
Portal은 React 트리 구조에서는 원래 위치에 존재하지만, DOM에서는 다른 위치에 렌더링된다.pushHostContainer를 통해 Portal의 container context를 설정해 자식 컴포넌트들이 올바른 context에서 렌더링 되도록 한다.Portal의 특별한 마운팅 방식 때문이다.DOM 조작은 이후의 commit 단계에서 이루어진다.reconcileChildren, reconcileChildFibers 함수는 새로운 children과 이전 children을 비교해 필요한 변경사항을 계산하고 새로운 Fiber 트리를 구성하는 함수다.
그렇다면 pushHostContainer는 무엇일까?
function pushHostContainer(fiber: Fiber, nextRootInstance: Container): void {
// 1. root 인스턴스를 스택에 푸시
// Portal이 팝될 때 root를 리셋할 수 있도록 함
push(rootInstanceStackCursor, nextRootInstance, fiber);
// 2. context를 제공한 Fiber를 추적
// 유니크한 context를 제공하는 Fiber만 팝하기 위함
push(contextFiberStackCursor, fiber, fiber);
// 3. host context 처리
// 에러 처리를 위해 먼저 빈 값을 푸시
push(contextStackCursor, null, fiber);
// 4. root context 가져오기
const nextRootContext = getRootHostContext(nextRootInstance);
// 5. 임시로 넣었던 null 값을 실제 context로 교체
pop(contextStackCursor, fiber);
push(contextStackCursor, nextRootContext, fiber);
}
주요 포인트는 다음과 같다:
1. Portal은 다른 DOM 트리에 렌더링되므로, 새로운 container context가 필요하다.
2. 이를 위해 3개의 스택을 관리한다:
rootInstanceStackCursor: container 인스턴스 추적contextFiberStackCursor: context 제공 Fiber 추적contextStackCursor: 실제 host context 관리null을 푸시했다가 실제 context로 교체하는 안전장치가 있다.지금까지의 살펴본 내용을 요약하면 다음과 같다:
createPortal을 호출하면 Portal 타입의 컴포넌트를 생성한다.beginWorks에서 type이 Portal인 컴포넌트는 updatePortalComponent를 통해 처리된다.updatePortalComponent에서는 Portal 컴포넌트의 재조정을 실행하고 pushHostContainer를 통해 해당 Portal 컴포넌트에 필요한 컨텍스트를 저장한다.여기까지가 렌더 단계에서의 Portal 처리에 대한 전체적인 흐름이다. 그렇다면 커밋 단계에서의 처리에 대해 살펴보자.
이에 대한 처리는 commitMutationEffectsOnFiber 함수에서 일어난다:
function commitMutationEffectsOnFiber(finishedWork: Fiber, root: FiberRoot, lanes: Lanes) {
// ... 다른 케이스들 ...
case HostPortal: {
if (supportsResources) {
// 리소스 지원시 hoistable root 처리
const previousHoistableRoot = currentHoistableRoot;
currentHoistableRoot = getHoistableRoot(
finishedWork.stateNode.containerInfo,
);
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork, lanes);
currentHoistableRoot = previousHoistableRoot;
} else {
// 일반적인 경우
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork, lanes);
}
// Update 플래그가 있는 경우
if (flags & Update) {
if (supportsPersistence) {
// Portal의 자식들을 container에 커밋
commitHostPortalContainerChildren(
finishedWork.stateNode,
finishedWork,
finishedWork.stateNode.pendingChildren,
);
}
}
break;
}
}
이 함수의 실행 순서와 각 단계의 역할이 매우 중요하다:
recursivelyTraverseMutationEffects: Portal의 자식들에 대한 mutation 효과를 재귀적으로 처리한다. 이 함수에서는 삭제(Deletion) 처리를 하며, 전체 Fiber 트리를 순회하면서 mutation 관련 플래그를 확인한다.commitReconciliationEffects: 이 함수에서는 삽입(Placement) 처리가 진행된다. 새로운 노드를 DOM 트리의 올바른 위치에 배치하는 작업을 수행한다.Update 플래그가 있는 경우:commitHostPortalContainerChildren을 통해 Portal 컴포넌트의 container에 대한 업데이트(Update) 처리가 진행된다.이해가 가지 않는 변수들이 있다. 하나하나 알아보자.
1. 리소스 지원 (supportsResources)
<script>, <style> 등)를 특별히 처리할 수 있게 해주는 기능이다.currentHoistableRoot를 통해 이러한 요소들이 어디에 배치되어야 하는지 추적된다.2. Mutation 처리 (recursivelyTraverseMutationEffects)
// 예시적인 mutation 타입들
const Placement = /* */ 0b0000000000000000010; // 새로운 노드 삽입
const Update = /* */ 0b0000000000000000100; // 노드 업데이트
const Deletion = /* */ 0b0000000000000001000; // 노드 삭제
recursivelyTraverseMutationEffects는 Fiber 트리를 순회하면서 이러한 mutation 효과들을 실행한다.3. Update 플래그
// React Fiber flags
const Update = 0b0000000000000000100; // 2진수 표현
commitHostPortalContainerChildren을 통해 변경된 자식들을 container에 반영한다.commitReconciliationEffects가 실행된다.4. Persistence 지원 (supportsPersistence)
전반적인 흐름을 다시 요약해보자.
렌더링 페이즈
1. createPortal을 호출하면 Portal 타입의 컴포넌트를 생성한다.
2. beginWorks에서 type이 Portal인 컴포넌트는 updatePortalComponent를 통해 처리된다.
3. updatePortalComponent에서는 Portal 컴포넌트의 재조정을 실행하고 pushHostContainer를 통해 해당 Portal 컴포넌트에 필요한 컨텍스트를 저장한다.

커밋 페이즈
1. commitMutationEffects가 호출되면, recursivelyTraverseMutationEffects를 통해 Fiber 트리를 재귀적으로 순회하면서 각 노드의 mutation effect를 처리한다.
2. Portal 컴포넌트에 도달하면:
recursivelyTraverseMutationEffects를 통해 자식 노드들의 mutation effect를 먼저 처리한다.commitReconciliationEffects를 통해 Portal 자체의 Placement 효과를 처리한다.Update 플래그가 있는 경우 commitHostPortalContainerChildren을 통해 Portal의 자식들을 지정된 container에 실제로 DOM 업데이트를 수행한다.recursivelyTraverseDeletionEffects를 통해 삭제될 노드의 자식들에 대한 cleanup을 재귀적으로 처리한다.commitDeletionEffects를 통해 실제 DOM에서 노드를 제거하고 cleanup effect를 실행한다.
이 createPortal을 파본 이유는 내가 createPortal의 동작 원리에 대해 잘못알고 있었기 때문이다. Portal을 사용하면 리액트 렌더 트리에서 벗어난다고 생각했는데, 그게 아니였고 실제로 소스코드를 분석해보니 오히려 Context를 저장시켜 기존 리액트 컨텍스트를 주입시키는 코드가 존재했다.
요즘 내 관심사는 React, Browser 크게 두 개의 동작 원리를 깊게 공부하는데 있다. 최근에는 특히 React에 대한 관심이 많이 생긴것 같다. 앞으로 계속 이렇게 소스코드를 분석해보며 소스코드 기반의 React Deepdive 시리즈를 만들어 봐야겠다는 목표가 생겼다.
특히 React Fiber 아키텍처 내에서 Portal이 어떻게 처리되는지, 렌더 페이즈와 커밋 페이즈에서 각각 어떤 단계를 거치는지 깔끔하게 정리해주셔서 큰 도움이 됐습니다.
저도 기환님 따라서 deep dive 좀 해야겠네요!
React deep dive 글 주제가 너무 좋네요 ㅎㅎ
createPortal에 대해서 잘 몰라서 깊게 공부하는 느낌으로 읽었어요!
저도 기회가 된다면 React 함수 뜯어보면서 공부해봐야겠네요 오늘도 좋은 글 감사해요 😄😄
와 기환님...! React 딥다이브 너무 좋은데요? createPortal의 사용법을 넘어서 어떻게 동작하는지 소스 코드를 까보는게 멋집니다!! 아직 커밋 페이즈에 대한 부분은 이해가 잘 되지 않지만 덕분에 렌더 페이즈까지 이해할 수 있는 시간이었습니다 👍 다음에 공부 어떻게 하시는지 팁 좀 알려주세요!!!