JSer.dev의 React Internals Deep Dive를 번역하여 정리한 글입니다.
⚠️ React@19의 commit 7608516을 기반으로 작성되었으며, 최신 버전에서는 구현이 변경되었을 수 있습니다.
Fiber reconciler 가 각 fiber의 return child sibling 을 참조하여 트리를 탐색하는 순서를 시각화한 애니메이션
출처: https://blog.mathpresso.com/react-deep-dive-fiber-88860f6edbd0
Concurrency(동시성)란 두 개 이상의 task를 동시에 지원함을 뜻한다.
Parallelism(병렬성)이란 두 개 이상의 task를 동시에 실행할 수 있음을 뜻한다.
JavaScript는 single-thread 환경이라 여러 개의 thread를 사용하는 Parallelism이 불가능하고, Concurrency를 지원한다.
출처: https://www.baeldung.com/cs/concurrency-vs-parallelism
애플리케이션의 concurrent 렌더링이 가능하다는 것은 화면 렌더링 task에 우선순위를 매겨서 중요한 것을 먼저 처리하고 덜 중요한 것을 나중에 처리하는 것이 가능하다는 것을 의미한다. 리액트 공식 문서에서는 이것을 incremental rendering이라고 부르고, “일시 정지”, “재가동”, “우선순위”는 증분 렌더링을 위한 필수 기능이다.
리액트 라이브러리는 내부적인 구조 조정으로 성능을 위한 최적화를 하고 있고, 이를 위해 Fiber를 사용한다.
현 상태의 트리와 업데이트될 상태의 트리를 비교하기 위해서 reconciler(재조정)를 사용한다.
리액트 v16 이전에 사용되던 Stack reconciler와, 기존 reconciler의 취약점을 보완하기 위해서 리액트 팀에서 재작성한 Fiber reconciler를 비교해보자.
Stack reconciler는 virtual DOM 트리를 비교하고 화면에 변경 사항을 푸시하는 이 모든 작업을 동기적으로, 하나의 테스크로 실행한다. 이는 현 상태의 트리와 작업 중인 트리를 DFS 패턴으로 재귀적으로 탐색하며 굉장히 깊은 콜 스택을 만들곤 한다. 이런 작업은 일시 중지되거나 취소될 수 없어서, 이 콜 스택이 전부 처리되기 전까지 메인 스레드는 다른 작업을 할 수 없고, 앱은 일시적으로 무반응 상태가 되거나 버벅거리게 된다.
Fiber는 이러한 기존 reconciler의 취약점을 보완하기 위해서 리액트 팀에서 재작성한 리액트 코어의 reconciler 알고리즘이다. 렌더링 작업을 잘게 쪼개어 여러 프레임에 걸쳐 실행할 수 있고, 특정 작업에 “우선순위”를 매겨 작업의 작은 조각들을 concurrent하게 “일시 정지”, “재가동”할 수 있게 해준다. 주목할 점은 Fiber 트리에서는 각 노드가 return, sibling, child 포인터 값을 사용하여 체인 형태의 singly linked list를 이룬다는 점이다.
이 트리에서는 단순히 깊이 우선으로 탐색하는 것이 아니라, 각 노드의 return, sibling, child 포인터를 이용해서 child가 있으면 child, child가 없으면 sibling, sibling이 없으면 return… 의 순으로 다음 Fiber Node로 이동한다. 각 FiberNode
는 다음으로 처리해야 할 FiberNode
를 가리키고 있기 때문에, 이 긴 일련의 작업이 중간에 멈춰도, 지금 작업 중인 FiberNode
만 알고 있다면 돌아와서 같은 위치에서 작업을 이어가는 것이 가능하다. 각 FiberNode
는 이 과정에서 각자의 ‘변경 사항에 대한 정보 (effect)’를 들고 있고, 이를 DOM에 바로바로 반영하지 않고, 모아뒀다가 모든 FiberNode
탐색이 끝난 후, 마지막 commit 단계에서 한 번에 반영하기 때문에, reconciliation 작업이 commit 단계 전에 중단되어도 실제 렌더된 화면에는 영향을 미치지 않는다.
출처: https://blog.mathpresso.com/react-deep-dive-fiber-88860f6edbd0
💡 Fiber는 기존의 virtual DOM 트리의 화면 요소에 대응하는 역할도 하고, 작업(work)을 관리하는 가상 스택 프레임의 역할도 한다. Stack reconciler는 하나의 콜 스택에서 모든 작업을 한 번에 실행하고, Fiber reconciler는 제어 가능한 가상의 스택 프레임 구조를 만들어서 작업을 잘게 나누어서 실행한다.
리액트 앱 내 모든 요소에는 이에 대응하는 FiberNode
가 존재한다. Fiber는 인스턴스에 대한 정보뿐만 아니라 다음 Fiber Node로 향하는 포인터, 변경 사항에 대한 정보도 갖고 있다.
Cf. Fiber Node는 리액트 앱의 DOM 요소를 선택한 다음, __reactFiber$…
라는 키값을 콘솔로 찍으면 확인 가능하다.
콘솔에서 확인한 <h1>2. Initial Mount</h1> Fiber Node의 모습
FiberNode
에 있는 키값을 살펴보자.
type Fiber = {
// Instance
tag: WorkTag,
key: null | string,
...
type: any,
stateNode: any,
...
// Stack Frame
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
...
memoizedState: any,
...
// Effect
flags: Flags,
...
lanes: Lanes,
alternate: Fiber | null,
...
};
tag
: workTag
— FiberNode
를 구분하는 컴포넌트/요소 인스턴스의 유형, 0~25의 정수. 각 숫자는 FunctionComponent, HostComponent, ContextProvider, MemoComponent, SuspenseComponent, … 같은 이름에 할당되어 있다. (HostComponent <div/>
같은 html 요소를 의미한다.)
stateNode
: any
— 다른 백업 데이터를 가리키며, HostComponent
의 경우 stateNode
는 실제 백업 DOM 노드를 가리킨다.
key
: null | string
— FiberNode
가 가리키는 인스턴스인 child의 고유한 식별자.
type
: any
— 컴포넌트의 경우, 이 인스턴스를 만드는 함수나 클래스. HTML 요소의 경우, 'div'와 같은 DOM 요소를 나타내는 문자열. root fiber의 경우 null.
return
, child
, sibling
포인터들을 사용하면, 리액트의 트리 구조를 Singly Linked List로 탐색할 수 있고, 이를 하나의 가상 스택 프레임처럼 사용하게 된다.
return
: Fiber | null
— 현 FiberNode
의 작업 처리가 끝나면 돌아갈 FiberNode
를 가리킨다. 사실상 부모 fiber이지만, 컴포넌트/요소에는 부모가 한 개 이상 있을 수 있을 수 있으므로, stack frame의 주소와 더 유사하다.
child
: Fiber | null
— 첫 자식 FiberNode
에 대한 포인터다.
sibling
: Fiber | null
— 현 FiberNode
옆 형제 FiberNode
를 가리킨다. child FiberNode
가 없다면 sibling FiberNode
를 탐색한다.
Effect는 두 트리를 비교한 후 감지된 ‘바뀐 점, 바뀌어야 할 작업’을 뜻한다.
flags
: Flags
— 커밋 단계에서 적용할 업데이트를 나타낸다. Effect, 정수. 각 숫자는 Placement, Update, ChildDeletion, ContentReset, … 같은 이름에 할당되어 있다. 바이너리 숫자로 표기되어있고, bitewise OR assignment (|=
)를 사용해서 여러 변화의 조합을 workInProgress.flags |= Placement
이런 식으로 축적한다. 하위 트리인 경우 subtreeFlags
.
lanes
: Lanes
— 보류 중인 업데이트의 우선 순위를 나타낸다. 하위 트리의 경우 childLanes
.
alternate
: Fiber | null
— 해당 fiber의 교대 fiber다.
트리 비교 작업이 끝나고 변경된 사항이 render commit phase를 통해서 화면까지 반영이 되면, 현재의 앱 상태를 대변하는 fiber를 ‘flushed fiber’라고 부른다. 이와 반대로 아직 작업 중인 상태, 화면까지 반영되지 않은 fiber를 ‘workInProgress fiber’라고 부르는데, 즉 특정 컴포넌트에 대응하는 fiber가 최대 두 버전 존재한다는 뜻이다. (Double Buffering) flushed fiber의 alternate
는 workInProgress fiber를 가리키고, workInProgress fiber의 alternate
는 flushed fiber를 가리킨다.
memoizedState
: any
— 중요한 데이터를 가리키며, FunctionComponent의 경우 훅을 의미한다.
Fiber는 reconciliation 작업을 2단계로 나눠서 실행한다.
Phase 1 Render — 실제로 두 fiber 트리를 비교하고 변경된 Effect들을 수집하는 작업을 한다. 이 단계는 concurrent하게 일시 정지되고 재가동될 수 있다. 리액트 scheduler로 인해 허용되는 시간 동안 작업하고 수시로 멈춰서 메인 스레드에 user input, animation 같은 더 급한 작업이 있는지 확인해가며 실행되기 때문에 아무리 트리가 커도 비교 작업이 메인 스레드를 막을 걱정이 없다. Phase 1의 목적은 이펙트 정보를 포함한 새로운 fiber 트리를 만들어내는 것이다.
Phase 2 Commit — Phase 1에서 만든 트리에 표시된 이펙트들을 모아 실제 DOM에 반영하는 작업을 한다. 이 단계는 synchronous하게 한 번에 이루어지기 때문에 일시 정지하거나 취소할 수 없다.
References
앱이 mount 되는 시점에서 Fiber 트리의 생성과 비교 과정을 Trigger, Render, Commit 세 단계로 나눠서 소스 코드와 함께 확인해보자.
출처: https://react.dev/learn/render-and-commit Illustrated by Rachel Lee Nabors
👩🏻💻 User source code
ReactDOM.createRoot(document.getElementById('container')).render(<App />);
createRoot
를 사용하여 브라우저 DOM 노드 내에 React 컴포넌트를 표시하는 루트를 생성한다.
root.render
를 호출하여 JSX 조각("React 노드")을 React 루트의 브라우저 DOM 노드에 표시한다.
createRoot
부터 시작해서 코드를 하나씩 살펴보며, initial mount 내부 작동 원리를 이해해보자!
💻 Demo Code
// App.jsx import {useState} from 'react' function Link() { return <a href="https://jser.dev">jser.dev</a> } export default function App() { const [count, setCount] = useState(0) return ( <div> <p> <Link/> <br/> <button onClick={() => setCount(count => count + 1)}>click me - {count}</button> </p> </div> ); }
createRoot()
createRoot()
는 FiberRootNode
를 생성한다.
💡
FiberRootNode
React root 역할을 하는 특별한 노드로, 전체 앱에 대한 필요한 메타 정보를 보유한다. 이 노드의
current
는 실제 Fiber 트리를 가리키며, 새로운 Fiber 트리가 생성될 때마다current
가 새로운HostRoot
를 다시 가리키게 한다.
출처: https://jser.dev/2023-07-14-initial-mount/#how-react-does-initial-mount-first-time-render-
💻 src: createRoot, createContainer, createFiberRoot
function createRoot( container: Element | Document | DocumentFragment, options?: CreateRootOptions, ): RootType { // container 관련 Error / 경고 코드 ... // options(createContainer에 입력되는 parameter) 업데이트 코드 ... const root = createContainer( // 📌 FiberRootNode container, ConcurrentRoot, null, isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, onUncaughtError, onCaughtError, onRecoverableError, transitionCallbacks, ); markContainerAsRoot(root.current, container); const rootContainerElement: Document | Element | DocumentFragment = container.nodeType === COMMENT_NODE ? (container.parentNode: any) : container; listenToAllSupportedEvents(rootContainerElement); return new ReactDOMRoot(root); }
root.render()
root.render()
는 HostRoot 업데이트를 예약한다. element의 argument는 update payload에 저장된다.
💻 src: ReactDOMRoot, updateContainer, updateContainerImpl
function ReactDOMRoot(internalRoot: FiberRoot) { this._internalRoot = internalRoot; } ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render = function (children<: ReactNodeList): void { const root = this._internalRoot; if (root === null) { throw new Error('Cannot update an unmounted root.'); } updateContainer(children, root, null, null); };
function updateContainerImpl( rootFiber: Fiber, lane: Lane, element: ReactNodeList, container: OpaqueRoot, parentComponent: ?React$Component<any, any>, callback: ?Function, ): void { ... const update = createUpdate(lane); // 📌 render()의 argument가 update payload에 저장된다. update.payload = {element}; callback = callback === undefined ? null : callback; if (callback !== null) { update.callback = callback; } // 📌 update가 queue에 삽입되어 대기한다. const root = enqueueUpdate(rootFiber, update, lane); if (root !== null) { // 📌 업데이트를 예약한다. scheduleUpdateOnFiber(root, rootFiber, lane); entangleTransitions(root, rootFiber, lane); } }
container 업데이트 마지막 부분에 있는 scheduleUpdateOnFiber()
를 이용해 업데이트를 예약한다.
“작업 생성” 단계로 생각할 수 있으며, ensureRootIsScheduled()
가 수행되면 작업 생성이 완료되고, 해당 작업은 scheduleCallback()
에 의해 Scheduler로 전달되면서, performConcurrentWorkOnRoot()
이 생성된다.
💻 src: scheduleUpdateOnFiber, ensureRootIsScheduled
function scheduleUpdateOnFiber( root: FiberRoot, fiber: Fiber, lane: Lane, ) { // Check if the work loop is currently suspended and waiting for data to // finish loading. if ( // Suspended render phase (root === workInProgressRoot && workInProgressSuspendedReason === SuspendedOnData) || // Suspended commit phase root.cancelPendingCommit !== null ) { // The incoming update might unblock the current render. Interrupt the // current attempt and restart from the top. prepareFreshStack(root, NoLanes); markRootSuspended( root, workInProgressRootRenderLanes, workInProgressDeferredLane, ); } // Mark that the root has a pending update. markRootUpdated(root, lane); if ( (executionContext & RenderContext) !== NoLanes && root === workInProgressRoot ) { // Track lanes that were updated during the render phase workInProgressRootRenderPhaseUpdatedLanes = mergeLanes( workInProgressRootRenderPhaseUpdatedLanes, lane, ); } else { if (enableTransitionTracing) { const transition = ReactSharedInternals.T; if (transition !== null && transition.name != null) { if (transition.startTime === -1) { transition.startTime = now(); } addTransitionToLanesMap(root, transition, lane); } } if (root === workInProgressRoot) { // Received an update to a tree that's in the middle of rendering. Mark // that there was an interleaved update work on this root. if ((executionContext & RenderContext) === NoContext) { workInProgressRootInterleavedUpdatedLanes = mergeLanes( workInProgressRootInterleavedUpdatedLanes, lane, ); } if (workInProgressRootExitStatus === RootSuspendedWithDelay) { markRootSuspended( root, workInProgressRootRenderLanes, workInProgressDeferredLane, ); } } // 📌 ensureRootIsScheduled()가 수행되면 작업 생성이 완료된다. ensureRootIsScheduled(root); ... } }
performConcurrentWorkOnRoot()
performConcurrentWorkOnRoot()
는 initial mount과 re-render에 대한 렌더링을 시작하는 진입점이다.
한 가지 명심해야 할 점은 이름이 concurrent
로 지정되어 있더라도, 필요하다면 sync
로 돌아간다는 점이다. Initial mount는 DefaultLane이 blocking lane이기 때문에, sync로 작동하는 경우 중 하나다.
💻 src: performConcurrentWorkOnRoot, includesBlockingLane
function performConcurrentWorkOnRoot( root: FiberRoot, didTimeout: boolean, ): RenderTaskFn | null { ... let lanes = getNextLanes( root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes, ); ... const shouldTimeSlice = !includesBlockingLane(root, lanes) && !includesExpiredLane(root, lanes) && (disableSchedulerTimeoutInWorkLoop || !didTimeout); let exitStatus = shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes); ... }
// 📌 Blocking은 중요하다는 것을 의미하고, 방해되면(interrupted) 안 된다. function includesBlockingLane(root: FiberRoot, lanes: Lanes): boolean { ... const SyncDefaultLanes = InputContinuousHydrationLane | InputContinuousLane | DefaultHydrationLane | DefaultLane; // 📌 DefaultLane이 blocking lane이다. return (lanes & SyncDefaultLanes) !== NoLanes; }
💡
Lane
은 업데이트의 우선 순위를 표시하는 것으로, 작업의 우선 순위를 표시한다고도 할 수 있다. Cf. What are Lanes in React
위 코드에서 봤듯이, initial mount에서는 concurrent feature가 아직 사용되지 않는다. Initial mount에서는 UI를 가능한 빨리 그려야 하기 때문에, 이를 미루는 것은 도움이 되지 않는다.
renderRootSync()
renderRootSync()
는 내부적으로 단지 while loop일뿐이다. workInProgress
가 존재하면, 즉 아직 render phase라면 performUnitOfWork()
를 계속 반복한다.
💻 src: renderRootSync, workLoopSync
function renderRootSync(root: FiberRoot, lanes: Lanes) { const prevExecutionContext = executionContext; executionContext |= RenderContext; const prevDispatcher = pushDispatcher(root.containerInfo); const prevCacheDispatcher = pushCacheDispatcher(); // If the root or lanes have changed, throw out the existing stack // and prepare a fresh one. Otherwise we'll continue where we left off. if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) { ... workInProgressTransitions = getTransitionsForLanes(root, lanes); // 📌 새로운 렌더링이 시작될 때마다 생성되는 workInProgress의 root를 반환한다. prepareFreshStack(root, lanes); } ... outer: do { try { // work loop가 suspend된 경우, 일반 work loop를 돌기 전에 특정 작업 수행 ... workLoopSync(); break; } catch (thrownValue) { handleThrow(root, thrownValue); } } while (true); ... return workInProgressRootExitStatus; } // The work loop is an extremely hot path. Tell Closure not to inline it. /** @noinline */ function workLoopSync() { // Perform work without checking if we need to yield between fiber. // 📌 workInProgress가 존재하면, 즉 아직 render phase라면 // performUnitOfWork()를 계속 반복하는 while loop while (workInProgress !== null) { // 📌 하나의 Fiber Node 단위에서 작동한다. performUnitOfWork(workInProgress); } }
💡 workInProgress
React는 내부적으로 현재 상태를 표현하기 위해 Fiber 트리를 사용하기 때문에 업데이트가 있을 때마다 새로운 트리를 생성하고 이전 트리와 비교해야 한다. 따라서
current
는 UI에 표시되는 현재 버전을 의미하고,workInProgress
는 빌드 중이며 다음current
로 사용될 버전을 의미한다.
performUnitOfWork()
performUnitOfWork()
에서는 React가 하나의 Fiber Node 단위에서 작동하며, 수행해야 할 작업이 있는지 확인한다. beginWork()
를 통해 element type에 따라, 실제 렌더링을 시작한다.
💻 src: performUnitOfWork, beginWork
function performUnitOfWork(unitOfWork: Fiber): void { // The current, flushed, state of this fiber is the alternate. const current = unitOfWork.alternate; let next; ... next = beginWork(current, unitOfWork, entangledRenderLanes); unitOfWork.memoizedProps = unitOfWork.pendingProps; if (next === null) { // If this doesn't spawn new work, complete the current work. completeUnitOfWork(unitOfWork); } else { // 📌 앞서 언급했듯이, workLoopSync()는 단지 while loop로서 // workInProgress에서 completeUnitOfWork()를 계속 실행한다. // 따라서 여기서 workInProgress를 할당한다는 것은 // 작업할 다음 파이버 노드를 설정하는 것을 의미한다. workInProgress = next; } }
function beginWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { if (current !== null) { // 📌 current가 null이 아니라는 것은, initial mount가 아니라는 뜻이다. ... } else { // 📌 initial mount의 경우, 당연히 업데이트가 없다. didReceiveUpdate = false; ... } // Before entering the begin phase, clear pending update priority. workInProgress.lanes = NoLanes; // 📌 다른 타입의 element를 다르게 처리한다. switch (workInProgress.tag) { // 📌 우리가 작성한 커스텀 함수형 컴포넌트다. case FunctionComponent: { const Component = workInProgress.type; const unresolvedProps = workInProgress.pendingProps; const resolvedProps = disableDefaultPropsExceptForClasses || workInProgress.elementType === Component ? unresolvedProps : resolveDefaultPropsOnNonClassComponent(Component, unresolvedProps); return updateFunctionComponent( current, workInProgress, Component, resolvedProps, renderLanes, ); } // 📌 FiberRootNode 아래의 HostRoot다. case HostRoot: return updateHostRoot(current, workInProgress, renderLanes); // 📌 <p/>, <div/>와 같은 고유(intrinsic) HTML 태그다. case HostComponent: return updateHostComponent(current, workInProgress, renderLanes); // 📌 HTML text 노드다. case HostText: return updateHostText(current, workInProgress); // 📌 이외에도 다양한 타입이 존재한다. // e.g. SuspenseComponent, LazyComponent, ContextProvider, ... ... } ... }
prepareFreshStack()
renderRootSync()
에서 prepareFreshStack()
이 호출되었다.
💻 src: prepareFreshStack
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber { root.finishedWork = null; root.finishedLanes = NoLanes; ... workInProgressRoot = root; // 📌 root의 current는 HostRoot의 FiberNode다. const rootWorkInProgress = createWorkInProgress(root.current, null); ... finishQueueingConcurrentUpdates(); return rootWorkInProgress; }
prepareFreshStack()
은 새로운 렌더링이 시작될 때마다 생성되는 workInProgress
의 root를 반환한다. 이는 새로운 Fiber 트리의 root처럼 작동한다.
따라서 beginWork()
의 다음을 먼저 살펴보자.
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
updateHostRoot()
root.render()
에서 예약해둔 HostRoot 업데이트를 처리한다.
💻 src: updateHostRoot
function updateHostRoot( current: null | Fiber, workInProgress: Fiber, renderLanes: Lanes, ) { pushHostRootContext(workInProgress); const nextProps = workInProgress.pendingProps; const prevState = workInProgress.memoizedState; const prevChildren = prevState.element; cloneUpdateQueue(current, workInProgress); // 📌 root.render()에서 예약해둔 HostRoot 업데이트를 처리한다. // 예약된 업데이트가 처리되고 payload가 추출되면, // element는 메모화된 상태로 할당된다는 점을 기억하자. processUpdateQueue(workInProgress, nextProps, null, renderLanes); const nextState: RootState = workInProgress.memoizedState; const root: FiberRoot = workInProgress.stateNode; pushRootTransition(workInProgress, root, renderLanes); ... // Caution: React DevTools currently depends on this property // being called "element". // 📌 ReactDOMRoot.render()의 argument를 가져올 수 있게 되었다! const nextChildren = nextState.element; ... // 📌 여기에서 current와 workInProgress는 둘 다 child를 갖고 있지 않다. // 그리고 nextChildren은 <App/>이다. reconcileChildren(current, workInProgress, nextChildren, renderLanes); // 📌 reconciling 이후에, workInProgress를 위한 새로운 child가 생성된다. // 여기에서 child를 리턴한다는 것은 workLoopSync()가 다음에 처리한다는 것을 의미한다. return workInProgress.child; }
reconcileChildren()
React 내부에서 매우 중요한 함수다. 이름에 따라 대략적으로 reconcile을 diff라고 생각할 수 있다. 이 함수는 새 child와 이전 child를 비교하고 올바른 child
을 workInProgress
에 설정한다.
💻 src: reconcileChildren
function reconcileChildren( current: Fiber | null, workInProgress: Fiber, nextChildren: any, renderLanes: Lanes, ) { // 📌 current가 없다는 것은, initial mount라는 뜻이다. if (current === null) { workInProgress.child = mountChildFibers( workInProgress, null, nextChildren, renderLanes, ); // 📌 current가 있으면, re-render를 의미하고, reconcile(재조정)을 한다. } else { workInProgress.child = reconcileChildFibers( workInProgress, current.child, nextChildren, renderLanes, ); } }
위에서 언급했듯이 FiberRootNode는 항상 current
를 가지고 있으므로, reconcileChildFibers
로 이동한다. 그러나 이것은 initial mount이므로 그 자식인 current.child
는 null이다.
또한 workInProgress
가 생성 중이고 아직 child
가 없으므로 workInProgress
에 child
를 설정하고 있음을 알 수 있다.
reconcileChildFibers()
vs mountChildFibers()
reconcile
의 목표는 이미 가지고 있는 것을 재사용하는 것이며, mount
를 항상 모든 것을 refresh하는 특별한 원시 버전의 reconcile
로 취급할 수 있다.
export const reconcileChildFibers: ChildReconciler = createChildReconciler(true);
export const mountChildFibers: ChildReconciler = createChildReconciler(false);
사실 코드에서 이 둘은 동일한 클로저이지만, insertions과 같은 것들을 추적해야 하는지 여부를 제어하는 shouldTrackSideEffects
플래그만 다르다.
💻 src: createChildReconciler
전체 Fiber 트리를 구성해야 한다고 상상해보자. 모든 노드는 재조정 후 "needed to insert"로 표시되어야 할까? 꼭 그럴 필요는 없다. root만 삽입하면 끝이다! 따라서 mountChildFibers
는 상황을 더욱 명확하게 하기 위한 내부 개선이다.
💻 src: reconcileChildFibersImpl
function reconcileChildFibersImpl( returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any, lanes: Lanes, debugInfo: ReactDebugInfo | null, ): Fiber | null { ... // Handle object types if (typeof newChild === 'object' && newChild !== null) { // 📌 이 $$typeof는 React Element의 typeof를 의미한다. switch (newChild.$$typeof) { // 📌 children이 <App/>과 같은 React Element인 경우 case REACT_ELEMENT_TYPE: return placeSingleChild( reconcileSingleElement( returnFiber, currentFirstChild, newChild, lanes, mergeDebugInfo(debugInfo, newChild._debugInfo), ), ); ... } // 📌 children이 배열인 경우 if (isArray(newChild)) { return reconcileChildrenArray( returnFiber, currentFirstChild, newChild, lanes, mergeDebugInfo(debugInfo, newChild._debugInfo), ); } ... } // 📌 가장 원시적인(primitive) 경우를 처리한다. - Text Node 업데이트 if ( (typeof newChild === 'string' && newChild !== '') || typeof newChild === 'number' || typeof newChild === 'bigint' ) { return placeSingleChild( reconcileSingleTextNode( returnFiber, currentFirstChild, // $FlowFixMe[unsafe-addition] Flow doesn't want us to use `+` operator with string and bigint '' + newChild, lanes, ), ); } // Remaining cases are all treated as empty. return deleteRemainingChildren(returnFiber, currentFirstChild); }
placeSingleChild(reconcileXXX(…))
두 단계에서, reconcileXXX()
를 사용하여 차이점을 확인(diffing)하고 placeSingleChild()
를 사용하여 fiber가 DOM에 insertion을 해야함을 표시한다.
reconcileSingleElement()
💻 src: reconcileSingleElement
Initial mount를 위한 reconcileSingleElement()
는 매우 간단하다. 새로 생성된 Fiber 노드는 workInProgress
의 child
가 된다.
💻 src: createFiberFromElement, createFiberFromTypeAndProps
placeSingleChild()
reconcileSingleElement()
는 Fiber Node reconciliation만 수행하며 placeSingleChild()
는 child Fiber Node가 DOM에 삽입되도록 표시되는 곳이다.
💻 src: placeSingleChild
function placeSingleChild(newFiber: Fiber): Fiber { // This is simpler for the single child case. We only need to do a // placement for inserting new children. // 📌 shouldTrackSideEffects 플래그는 여기에서도 사용된다 (다른 곳에서도 사용된다) if (shouldTrackSideEffects && newFiber.alternate === null) { // 📌 Placement는 DOM sub-tree에 삽입이 필요하다는 것을 의미한다. newFiber.flags |= Placement | PlacementDEV; } return newFiber; }
이는 child
에서 수행되므로, initial mount에서 HostRoot
의 child가 Placement로 표시된다. Demo Code에서는 <App/>
이다.
updateHostComponent()
performUnitOfWork()
가 Fiber 트리를 탐색하며 계속 수행되는데, 다음 타자는 App()
이 리턴한 <div/>
다. <div/>
는 intrinsic HTML 태그여서, beginWork()
에서 HostComponent로 처리된다.
💻 src: updateHostComponent
function updateHostComponent( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ) { if (current === null) { // 📌 hydration은 How basic hydration works internally in React를 참조하자. // - https://jser.dev/react/2023/03/17/how-does-hydration-work-in-react/ tryToClaimNextHydratableInstance(workInProgress); } pushHostContext(workInProgress); const type = workInProgress.type; // 📌 pendingProps는 <div/>의 children인 <p/>를 보유한다. const nextProps = workInProgress.pendingProps; const prevProps = current !== null ? current.memoizedProps : null; let nextChildren = nextProps.children; // 📌 <a/>와 같이 children이 정적 텍스트인 경우 이는 개선된 사항이다. const isDirectTextChild = shouldSetTextContent(type, nextProps); if (isDirectTextChild) { nextChildren = null; } else if (prevProps !== null && shouldSetTextContent(type, prevProps)) { workInProgress.flags |= ContentReset; } ... markRef(current, workInProgress); reconcileChildren(current, workInProgress, nextChildren, renderLanes); return workInProgress.child; }
위 프로세스는 <p/>
에 대해 반복된다. 단, nextChildren
이 이제 배열이므로 reconcileChildrenArray()
가 reconcileChildFibers()
내부에서 시작된다.
reconcileChildrenArray()
는 key
가 있기 때문에 좀 더 복잡하다. 자세한 내용은 How does ‘key’ work internally? List diffing in React에서 확인하자.
key
처리 외에 기본적으로 첫 번째 child fiber를 반환하고 계속 진행되며, React가 트리 구조를 linked list로 평면화하므로 siblings는 나중에 처리된다. 자세한 내용은 How does React traverse Fiber tree internally에서 확인하자.
<Link/>
의 경우 <App/>
처럼 프로세스를 반복한다.
<a/>
및 <button/>
에 대해서는 해당 텍스트를 더 자세히 살펴보자.
둘은 약간 다른데, <a/>
에는 children으로 정적 텍스트가 있고, <button/>
에는 JSX 표현식 {count}
가 있다. 그렇기 때문에 Demo Code에서 <a/>
의 nextChildren
은 null이지만, <button/>
은 children으로 이어진다.
updateHostText()
<button/>
의 children은 배열이다 — [”click me - “, “0”]
, 둘 다 beginWork()
의 updateHostText()
에서 처리된다.
function updateHostText(current: null | Fiber, workInProgress: Fiber) {
if (current === null) {
tryToClaimNextHydratableTextInstance(workInProgress);
}
// Nothing to do here. This is terminal. We'll do the completion step
// immediately after.
return null;
}
하지만 실질적으로 updateHostText()
는 hydration 외에는 아무것도 하지 않는다. <a/>
및 <button/>
은 Commit 단계에서 처리된다.
completeWork()
에서 화면 밖에 생성되는 DOM 노드child
fiber가 없을 때까지 chlid
를 workInProgress
fiber로 설정하고 또다시 beginWork
를 실행하는 것을 반복하다가, child
가 없는 fiber에 다다르면 completeWork()
를 실행한다.
completeWork()
fiber 하위에 있는 모든 이펙트에 대한 정보를 모아준다. 해당 fiber에 sibling
이 있다면 해당 sibling
을 workInProgress
fiber로 설정하고 다시 beginWork
를 실행한다. 이렇게 작업을 반복하다 보면, 마지막 completeWork
를 실행하고 루트로 돌아오게 된다.
Cf. Parent fiber node가 beginWork
는 children보다 먼저 수행하지만 completeWork
는 나중에 수행한다. 각 노드는 beginWork
와 completeWork
를 실행하게 되는데, capture와 bubble 단계가 있는 DOM event로 생각할 수 있다. beginWork
가 capture 단계고, completeWork
가 bubble 단계에 해당한다.
https://jser.dev/react/2022/01/16/fiber-traversal-in-react/
Fiber Node에는 하나의 중요한 속성인 stateNode
가 있다. 이는 intrinsic HTML 태그의 경우 실제 DOM 노드를 참조한다. 그리고 실제 DOM 노드 생성은 completeWork()
에서 이루어진다.
💻 src: completeWork
function completeWork( current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes, ): Fiber | null { const newProps = workInProgress.pendingProps; popTreeContext(workInProgress); switch (workInProgress.tag) { case LazyComponent: case SimpleMemoComponent: case FunctionComponent: ... case HostRoot: { ... return null; } ... // 📌 HTML tags를 위한 type case HostComponent: { popHostContext(workInProgress); const type = workInProgress.type; // 📌 current 버전이 있다면, branch를 업데이트하러 간다. if (current !== null && workInProgress.stateNode != null) { updateHostComponent( current, workInProgress, type, newProps, renderLanes, ); // 📌 하지만 아직 current 버전이 없으므로, branch를 mount하러 간다. } else { ... if (wasHydrated) { ... } else { const rootContainerInstance = getRootHostContainer(); // 📌 실제 DOM 노드 const instance = createInstance( type, newProps, rootContainerInstance, currentHostContext, workInProgress, ); // 📌 appendAllChildren은 DOM 노드가 생성될 때 중요하다. // 하위 트리의 직접 연결된 모든 DOM 노드의 parent여야 한다. appendAllChildren(instance, workInProgress, false, false); workInProgress.stateNode = instance; if ( // 📌 <a/>가 텍스트 노드를 어떻게 처리하는지 확인할 때 살펴볼 함수 finalizeInitialChildren( instance, type, newProps, currentHostContext, ) ) { markUpdate(workInProgress); } } } bubbleProperties(workInProgress); ... return null; } case HostText: { const newText = newProps; if (current && workInProgress.stateNode != null) { const oldText = current.memoizedProps; // If we have an alternate, that means this is an update and we need // to schedule a side-effect to do the updates. updateHostText(current, workInProgress, oldText, newText); } else { ... const rootContainerInstance = getRootHostContainer(); const currentHostContext = getHostContext(); const wasHydrated = popHydrationState(workInProgress); if (wasHydrated) { prepareToHydrateHostTextInstance(workInProgress); } else { workInProgress.stateNode = createTextInstance( newText, rootContainerInstance, currentHostContext, workInProgress, ); } } bubbleProperties(workInProgress); return null; } } }
<a/>
와 <button/>
은 텍스트 노드를 다르게 처리한다.
<button/>
의 경우 HostText
분기로 이동하고 createTextInstance()
가 새 텍스트 노드를 생성한다. 그러나 <a/>
의 경우에는 조금 다르다. 더 깊이 들어가 보자.
completeWork()
의 HostComponent
에 finalizeInitialChildren()
이 있다.
💻 src: finalizeInitialChildren, setInitialProperties, setProp
function setProp( domElement: Element, tag: string, key: string, value: mixed, props: any, prevValue: mixed, ): void { switch (key) { // 📌 문자열이나 숫자인 children은 컴포넌트의 text content로 처리된다. // 표현식이 있는 children은 배열이므로 이 분기에 속하지 않는다. case 'children': { if (typeof value === 'string') { const canSetTextContent = tag !== 'body' && (tag !== 'textarea' || value !== ''); if (canSetTextContent) { setTextContent(domElement, value); } } else if (typeof value === 'number' || typeof value === 'bigint') { const canSetTextContent = tag !== 'body'; if (canSetTextContent) { setTextContent(domElement, '' + value); } } break; } // ... } }
지금까지:
workInProgress
버전의 Fiber 트리가 드디어 구축되었다!이제 React가 DOM을 어떻게 조작하는지 실제로 볼 시간이다.
commitMutationEffects()
commitMutationEffects()
는 DOM 조작을 처리한다.
💻 src: commitMutationEffects, commitMutationEffectsOnFiber, recursivelyTraverseMutationEffects
function commitMutationEffects( root: FiberRoot, finishedWork: Fiber, // 📌 새로 구축된 Fiber Tree를 보유하는 HostRoot의 Fiber Node committedLanes: Lanes, ) { inProgressLanes = committedLanes; inProgressRoot = root; setCurrentDebugFiberInDEV(finishedWork); commitMutationEffectsOnFiber(finishedWork, root, committedLanes); setCurrentDebugFiberInDEV(finishedWork); inProgressLanes = null; inProgressRoot = null; }
function commitMutationEffectsOnFiber( finishedWork: Fiber, root: FiberRoot, lanes: Lanes, ) { const current = finishedWork.alternate; const flags = finishedWork.flags; switch (finishedWork.tag) { case FunctionComponent: ... case SimpleMemoComponent: { // 📌 이 재귀 호출은 subtree가 먼저 처리되도록 한다. recursivelyTraverseMutationEffects(root, finishedWork, lanes); // 📌 ReconciliationEffects는 Insertion 등을 의미한다. commitReconciliationEffects(finishedWork); ... return; } ... case HostComponent: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); ... return; } case HostText: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); ... return; } case HostRoot: { if (supportsResources) { prepareToCommitHoistables(); const previousHoistableRoot = currentHoistableRoot; currentHoistableRoot = getHoistableRoot(root.containerInfo); recursivelyTraverseMutationEffects(root, finishedWork, lanes); currentHoistableRoot = previousHoistableRoot; commitReconciliationEffects(finishedWork); } else { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); } ... return; } ... default: { recursivelyTraverseMutationEffects(root, finishedWork, lanes); commitReconciliationEffects(finishedWork); return; } } }
commitReconciliationEffects()
commitReconciliationEffects()
는 삽입, 재정렬 등을 처리한다.
💻 src: commitReconciliationEffects
function commitReconciliationEffects(finishedWork: Fiber) { // Placement effects (insertions, reorders) can be scheduled on any fiber // type. They needs to happen after the children effects have fired, but // before the effects on this fiber have fired. const flags = finishedWork.flags; // 📌 플래그는 여기에서 체크한다! if (flags & Placement) { try { commitPlacement(finishedWork); } catch (error) { captureCommitPhaseError(finishedWork, finishedWork.return, error); } finishedWork.flags &= ~Placement; } if (flags & Hydrating) { finishedWork.flags &= ~Hydrating; } }
Demo Code에 있는 <App/>
의 Fiber Node가 실제로 커밋된다.
commitPlacement()
💻 src: commitPlacement
function commitPlacement(finishedWork: Fiber): void { ... // Recursively insert all host nodes into the parent. const parentFiber = getHostParentFiber(finishedWork); // 📌 여기에서 parentFiber의 타입을 체크하고 있다. // 왜냐하면 Insertion은 parent node에 수행되기 때문이다. switch (parentFiber.tag) { case HostSingleton: { if (supportsSingletons) { const parent: Instance = parentFiber.stateNode; const before = getHostSibling(finishedWork); // We only have the top Fiber that was inserted but we need to recurse down its // children to find all the terminal nodes. insertOrAppendPlacementNode(finishedWork, before, parent); break; } // Fall through } // 📌 Initial mount에서는 이 분기를 건드리지 않는다. case HostComponent: { const parent: Instance = parentFiber.stateNode; if (parentFiber.flags & ContentReset) { // Reset the text content of the parent before doing any insertions resetTextContent(parent); // Clear ContentReset from the effect tag parentFiber.flags &= ~ContentReset; } const before = getHostSibling(finishedWork); // We only have the top Fiber that was inserted but we need to recurse down its // children to find all the terminal nodes. insertOrAppendPlacementNode(finishedWork, before, parent); break; } // 📌 Initial mount를 위한 Placement 플래그를 가진 Fiber Node는 <App/>이다. // <App/>의 parentFiber는 HostRoot다. case HostRoot: case HostPortal: { // 📌 HostRoot의 stateNode는 FiberRootNode를 가리킨다. const parent: Container = parentFiber.stateNode.containerInfo; const before = getHostSibling(finishedWork); insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent); break; } default: throw new Error( 'Invalid host parent fiber. This error is likely caused by a bug ' + 'in React. Please file an issue.', ); } }
아이디어는 finishedWork
의 DOM을 parent container의 올바른 위치에 insert나 append하는 것이다.
💻 src: insertOrAppendPlacementNodeIntoContainer
function insertOrAppendPlacementNodeIntoContainer( node: Fiber, before: ?Instance, parent: Container, ): void { const {tag} = node; const isHost = tag === HostComponent || tag === HostText; if (isHost) { const stateNode = node.stateNode; // 📌 DOM element의 경우, 그냥 삽입하면 된다. if (before) { insertInContainerBefore(parent, stateNode, before); } else { appendChildToContainer(parent, stateNode); } } else if ( tag === HostPortal || (supportsSingletons ? tag === HostSingleton : false) ) { ... } else { // 📌 non-DOM element의 경우, 재귀적으로 children을 처리해야 한다. const child = node.child; if (child !== null) { insertOrAppendPlacementNodeIntoContainer(child, before, parent); let sibling = child.sibling; while (sibling !== null) { insertOrAppendPlacementNodeIntoContainer(sibling, before, parent); sibling = sibling.sibling; } } } }
그리고 여기에서 최종적으로 DOM이 삽입된다.
드디어 DOM이 어떻게 생성되고 컨테이너에 삽입되는지 살펴보았다.
따라서 initial mount의 경우,
Fiber 트리는 reconciliation 중에 lazy하게 생성되며, 동시에 backing DOM 노드가 생성 및 구성된다.
HostRoot
의 직계 child는 Placement
로 표시된다. (Demo Code에서는 <App/>
에 해당된다.)
Commit 단계에서는 Placement
를 통해 fiber를 찾는다. parent가 HostRoot이므로 해당 DOM 노드가 컨테이너에 삽입된다.
JSer가 그린 initial mount 순서도 슬라이드를 보면 이해에 큰 도움이 된다!
🔗 https://jser.dev/2023-07-14-initial-mount/#how-react-does-initial-mount-first-time-render-