[React] 리액트를 처음부터 배워보자. — 01. React.createElement와 React.Component 그리고 ReactDOM.render의 동작 원리
일반적인 CRA(버전 5.0.1, Typescript)를 통해 리액트 환경을 구축하면 index.tsx에 다음과 같은 코드가 작성되어 있음을 확인할 수 있습니다.
// CRA로 만든 리액트 환경 - index.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement, ); root.render( <React.StrictMode> <App /> </React.StrictMode>, );
여기서 ReactDOM을 통해 root를 생성했고, 이 root 안에 App 컴포넌트를 렌더링한다. 라는 의미의 코드를 통해 많은 글에서 봐왔던 가상돔을 만들고 그 가상돔에 컴포넌트를 추가하는구나 라는 느낌으로 일단 받아드리기로 했습니다. 그런데 여기서 App은 함수 or 클래스로 작성된 코드인데 어떻게 <App/> 형식으로 작성될 수 있는걸까요? 이를 확인하기 위해 실제 React 코드를 살펴보기로 결정했습니다.
깃헙에 공개된 리액트 코드(24.01.17 기준)를 다운받은 뒤 다음 경로(client.js -> createRoot -> createRootImpl -> new ReactDOMRoot(root) -> ReactDOMRoot.prototype.render)에서 render 메서드를 확인할 수 있었습니다.
// ReactDOMRoot.js import { ... updateContainer, ... } from 'react-reconciler/src/ReactFiberReconciler'; function ReactDOMRoot(internalRoot: FiberRoot) { this._internalRoot = internalRoot; } ReactDOMRoot.prototype.render = function (children: ReactNodeList): void { const root = this._internalRoot; // FiberRoot를 가리킴. ... updateContainer(children, root, null, null); }; // shared/ReactTypes.js type ReactEmpty = null | void | boolean; ... type ReactNodeList = ReactEmpty | React$Node; // flow/lib/react.js 에서.. declare type React$Node = | null | boolean | number | string | React$Element<any> | React$Portal | Iterable<?React$Node>; /** * Type of a React element. React elements are commonly created using JSX * literals, which desugar to React.createElement calls (see below). */ declare type React$Element<+ElementType: React$ElementType> = {| +type: ElementType, +props: React$ElementProps<ElementType>, +key: React$Key | null, +ref: any, |};
여기서 보면 render 함수의 매개변수로 들어간 <App />은 ReactNodeList 타입이며 이는 falsy 값을 갖는 ReactEmpty와 의문의 React$Node 타입의 유니온 티입입니다. React$Node에 대한 정보는 여기를 통해 확인할 수 있었는데, 위와 같은 타입을 갖고 있다고 합니다.
이를 통해 <App />은 JSX를 통해 생성된 ReactElement라는 것을 확인할 수 있게 되었고, JSX로 표현된 값은 Babel을 통해 React.createElement를 호출하여 ReactElement가 됩니다.
우리가 작성한 JS코드는 Javascript 엔진을 통해 실행됩니다. 하지만, JSX는 온전한 Javascript 문법이 아니기 때문에 엔진으로 하여금 이해하지 못하죠. 때문에 Babel을 통해 트랜스파일링이 필요합니다.
그렇게 해서 바뀐 코드는 아래와 같습니다.
// 변환 전 class Hello extends React.Component { render() { return ( <div> <h1>hello world</h1> </div> ); } } <Hello />; // 변환 후 import { jsx as _jsx } from 'react/jsx-runtime'; class Hello extends React.Component { render() { return _jsx('div', { children: _jsx('h1', { children: 'hello world', }), }); } } _jsx(Hello, {}); // ReactJSX.js에서... function jsx(type, config, maybeKey) { ... if (type && type.defaultProps) { // ts 없이 개발할 때 유용하게 사용된 defaultProps의 정의가 여기서! const defaultProps = type.defaultProps; for (propName in defaultProps) { if (props[propName] === undefined) { props[propName] = defaultProps[propName]; } } } return ReactElement( ..., props, ); } // ReactJSXElement.js 에서... function ReactElement(type, key, ref, self, source, owner, props) { const element = { // This tag allows us to uniquely identify this as a React Element $typeof: REACT_ELEMENT_TYPE, // Built-in properties that belong on the element type, // ✅ 함수형인지 컴포넌트형인지 판단 key, ref, props, // Record the component responsible for creating this element. _owner: owner, }; if (__DEV__) { ... } return element; }
이렇게 javascript 코드로 변환된 jsx 함수를 통해 ReactElement로 변환된 후 리액트는 내부적으로 해당 컴포넌트가 Class형인지 함수형인지 판단합니다.
// ReactBaseClasses.js 에서... function Component(props, context, updater) { ... } Component.prototype.isReactComponent = {}; // isReactComponent를 객체(truth)로 할당 한 뒤 // ReactFiber.js 에서... function shouldConstruct(Component: Function) { const prototype = Component.prototype; return !!(prototype && prototype.isReactComponent); // } // 대부분의 시작 로직에서... if (typeof Component === 'function') { return shouldConstruct(Component) ? ClassComponent : FunctionComponent; }
위와 같은 코드를 통해 해당 컴포넌트가 Class형인지 함수형인지를 판별하며, Class형 컴포넌트일 경우엔 명시적으로 render 메서드를 호출하고 함수형 컴포넌트일 경우엔 renderWithHooks 메서드와 함께 해당 컴포넌트를 Component(props, secondArg);로 호출하여 렌더링 되도록 명시하고 있음을 확인했습니다.
클래스형 컴포넌트의 경우 처음 만들 때에 React.Component를 상속받지만 함수형 컴포넌트의 경우에는 그럴 필요가 없습니다. 단순히 일반 함수처럼 호출하면 되죠. 이렇게 직접 코드를 뜯어본 덕분에 왜 클래스형 컴포넌트의 경우에는 반드시 React.Component를 상속 받아야 하는지, 왜 클리스형 컴포넌트는 일반 함수로 작성해도 되는지 어렴풋이 이해할 수 있겠네요.
이제 리액트가 어떻게 Class인지 Function인지 구분하는지 까지는 이해하겠는데 그래서 얘가 ReactElement를 갖고 뭘 하지 않고 Fiber라는 녀석을 갖고 주물럭 거리고 있었습니다. Fiber는 한글로 섬유라는 뜻을 갖고 있는데 우리가 개발을 할 때 흔히 코드를 짜다라고 하는것 처럼 리액트에서는 리액트 앱 자체를 이 섬유(Fiber)들로 완성시킨다는 의도가 있지 않나 싶네요.
어쨌든 이 Fiber라는 객체를 어디서 만드는지 찾아보니 ReactDomRoot 객체를 생성할 때 매개변수로 FiberRoot 객체를 받고 있었습니다.
// ReactDOMRoot.js에서.. function ReactDOMRoot(internalRoot: FiberRoot) { this._internalRoot = internalRoot; } function createRoot( container: Element | Document | DocumentFragment, options?: CreateRootOptions, ): RootType { ... const root = createContainer(MANY_PARAMS); // root 생성 ... return new ReactDOMRoot(root); } // ReactFiberRoot.js에서.. function createContainer(MANY_PARAMS): OpaqueRoot { ... return createFiberRoot(MANY_PARAMS); } function createFiberRoot(MANY_PARAMS): FiberRoot { const root: FiberRoot = (new FiberRootNode(MANY_PARMAS): any); ... const uninitializedFiber = createHostRootFiber(tag, isStrictMode, concurrentUpdatesByDefaultOverride); root.current = uninitializedFiber; uninitializedFiber.stateNode = root; if (enableCache) { // 캐시를 설정했을 경우 } else { const initialState: RootState = { element: initialChildren, isDehydrated: hydrate, cache: (null: any), // not enabled yet }; uninitializedFiber.memoizedState = initialState; } initializeUpdateQueue(uninitializedFiber); // updateQueue를 할당. return root; }
new FiberRootNode()를 통해 생성된 root의 current는 처음에는 null이였지만, createHostRootFiber()를 통해 HostRoot가 할당됩니다. HostRoot 또한 단순히 FiberNode들 중 하나일뿐 React는 FiberNode의 type을 숫자(3)로 관리하여 이를 분리하고 있습니다.
이제 rootNode 하위에 노드들에 변화가 생기면 그 변화들을 차례대로 처리해줄 대기열이 필요하는데, 그러한 역할을 하는 것이 바로 updateQueue입니다.
updateQueue의 구조는 다음과 같습니다.
function initializeUpdateQueue<State>(fiber: Fiber): void { const queue: UpdateQueue<State> = { baseState: fiber.memoizedState, firstBaseUpdate: null, lastBaseUpdate: null, shared: { pending: null, lanes: NoLanes, hiddenCallbacks: null, }, callbacks: null, }; fiber.updateQueue = queue; }
다시 createRoot()로 돌아가면,
function createRoot( container: Element | Document | DocumentFragment, options?: CreateRootOptions, ): RootType { ... markContainerAsRoot(root.current, container); Dispatcher.current = ReactDOMClientDispatcher; const rootContainerElement: Document | Element | DocumentFragment = container.nodeType === COMMENT_NODE ? (container.parentNode: any) : container; listenToAllSupportedEvents(rootContainerElement); return new ReactDOMRoot(root); }
root를 생성하고 난 뒤 markContainerAsRoot() 메서드를 통해 이를 통해 이벤트 리스너를 body에 붙이지 않고 해당 리액트 컴포넌트 root에 붙일 수 있게 되었습니다.
이건 React17에서 업데이트된 사항인데, 기존에 모든 이벤트를 body에 붙였지만, React17부터는 <div id="root">에 이벤트를 붙일 수 있게 되었습니다. 이를 통해 좀 이벤트 관리를 효율적으로 활용할 수 있게 되었죠.

이렇게 생성된 ReactDOMRoot가 render() 메서드를 호출합니다.
ReactDOMRoot.prototype.render = function (children: ReactNodeList): void { const root = this._internalRoot; // FiberRoot를 가리킴. ... updateContainer(children, root, null, null); }; // ReactFiberReconciler.js에서... function updateContainer( element: ReactNodeList, container: OpaqueRoot, parentComponent: ?React$Component<any, any>, callback: ?Function, ): Lane { if (__DEV__) { onScheduleRoot(container, element); } const current = container.current; const lane = requestUpdateLane(current); if (enableSchedulingProfiler) { markRenderScheduled(lane); } const context = getContextForSubtree(parentComponent); if (container.context === null) { container.context = context; } else { container.pendingContext = context; } if (__DEV__) { ... } ... }
onScheduleRoot()는 개발 모드에서 React와 React Dev Tools, Redux DevTools 등의 다양한 third-party 툴들과 React를 직접 연결시키는 역할이라고 합니다. 이를 통해 디버깅이 더 수월해지는 장점이 있는데 이는 렌더링과 직접적인 연관이 없으니 생략하겠습니다.
container.current는 createHostRootFiber 메서드를 통해 생성된 HostRoot이고, lane은 React18에서 도입된 개념으로, 한글 뜻은 자동차 도로에서의 차선을 나타내는데 React에서는 업데이트 우선순위를 차선으로 표현한듯 하다.
1차선의 차량이 가장 빠르고, 뒤로 갈수록 점점 느려지는 것을 생각해보면 업데이트의 중요도에 따라 우선순위를 나눈 멀티 레벨 큐를 사용한 알고리즘으로 볼 수 있겠습니다. 이 Lane의 타입을 확인해보면 number 타입임을 알 수 있는데, 우선순위는 다음에 기회가 되면 자세히 알아보겠습니다.
다음은 enableSchedulingProfiler인데 이는 React Profiler를 통해 해당 컴포넌트가 렌더링 시점에서 얼마만큼의 비용(cost)가 드는지 확인해주는 녀석이라고 합니다.
getContextForSubtree()는 context 정보를 가져오는 역할이라고 합니다. useContext 또한 내부적으로 이 메서드를 통해 context 정보를 가져온다고 하네요. 여기서는 parentComponent가 null이라 패스하도록 하겠습니다.
그 아래 역시 렌더링에 영향을 주지 않는 에러 메시지기 떄문에 패스하고, 계속 가보자면...
// ReactFiberReconciler.js에서... function updateContainer( element: ReactNodeList, container: OpaqueRoot, parentComponent: ?React$Component<any, any>, callback: ?Function, ): Lane { ... const update = createUpdate(lane); // Caution: React DevTools currently depends on this property // being called "element". update.payload = { element }; callback = callback === undefined ? null : callback; if (callback !== null) { if (__DEV__) { if (typeof callback !== 'function') { console.error( 'render(...): Expected the last optional `callback` argument to be a ' + 'function. Instead received: %s.', callback, ); } } update.callback = callback; } const root = enqueueUpdate(current, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, current, lane); entangleTransitions(root, current, lane); } return lane; }
createUpdate()는 update라는 객체를 리턴해줍니다. 이는 단순히 lane 우선순위만 받은 update 객체를 내보내줍니다.
const UpdateState = 0; const ReplaceState = 1; const ForceUpdate = 2; const CaptureUpdate = 3; ... function createUpdate(lane: Lane): Update<mixed> { const update: Update<mixed> = { lane, tag: UpdateState, payload: null, callback: null, next: null, }; return update; }
update에 사용되는 queue가 Linked List이기 때문에 next라는 key를 가지고 있는듯 합니다. 해당 객체가 enqueueUpdate에 넘겨져서 렌더링 큐에 들어가 때문에 pointer로써 활용됩니다. update.payload에 할당되는 element는 ReactElement로 이는 render 하위에 매개변수로 입력한 컴포넌트들이 ReactElement 객체로 변환되어 할당되게 됩니다. 간단하게 <App /> 이라고 볼 수 있겠네요. callback은 null이니 패스되겠습니다.
이제 enqueueUpdate()에 전달되는 current는 HostRoot이고, update는 pointer 역할을 담당하는 Update 객체, 우선순위가 되겠습니다. 이 함수는 호출하면 FiberRoot 혹은 null을 리턴합니다. 그럼, enqueueUpdate()를 살펴보겠습니다.
function enqueueUpdate<State>( fiber: Fiber, update: Update<State>, lane: Lane, ): FiberRoot | null { const updateQueue = fiber.updateQueue; if (updateQueue === null) { // Only occurs if the fiber has been unmounted. return null; } const sharedQueue: SharedQueue<State> = (updateQueue: any).shared; if (isUnsafeClassRenderPhaseUpdate(fiber)) { // This is an unsafe render phase update. Add directly to the update // queue so we can process it immediately during the current render. const pending = sharedQueue.pending; if (pending === null) { // This is the first update. Create a circular list. update.next = update; } else { update.next = pending.next; pending.next = update; } sharedQueue.pending = update; // Update the childLanes even though we're most likely already rendering // this fiber. This is for backwards compatibility in the case where you // update a different component during render phase than the one that is // currently renderings (a pattern that is accompanied by a warning). return unsafe_markUpdateLaneFromFiberToRoot(fiber, lane); } else { return enqueueConcurrentClassUpdate(fiber, sharedQueue, update, lane); } }
위에서 HostRoot를 생성할 때 initializeUpdateQueue()를 호출하여 updateQueue라는 속성을 할당했었습니다. 그렇기 때문에 위 함수 안의 updateQueue는 기존의 Host.updateQueue 객체를 바라보게 되겠습니다.
최초 업데이트기때문에 isUnsafeClassRenderPhaseUpdate()는 false를 리턴해서 else문으로 갑니다. 그 전에 renderPhase가 무엇인지 찾아보았습니다.
리액트 렌더링은 두 단계의 phase로 나누어집니다. 하나는 방금 언급한 render phase이고 다른 하나는 commit phase입니다.
간단히 설명하면 render phase는 React가 DOM Tree와 VirtualDOM Tree를 비교하면서 work-in-progress queue를 생성해서 화면을 업데이트를 준비하는 과정입니다. 반면, commit phase는 render phase에서 작업된 변경상태들을 화면에 반영하는 단계입니다. 최초 렌더링이기 때문에 업데이트할게 없고, 따라서 work-in-progress queue가 필요 없는 상황이기 때문에, isUnsafeClassRenderPhaseUpdate()가 false를 리턴하는 것 이라고 합니다. state 변경으로 인해 update가 발생하더라도 문제가 없다면 false일 것 같네요.
그럼 이제 enqueueConcurrentClassUpdate()를 확인해보겠습니다.
function enqueueConcurrentClassUpdate<State>( fiber: Fiber, queue: ClassQueue<State>, update: ClassUpdate<State>, lane: Lane, ): FiberRoot | null { const concurrentQueue: ConcurrentQueue = (queue: any); const concurrentUpdate: ConcurrentUpdate = (update: any); enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane); return getRootForUpdatedFiber(fiber); }
fiber는 여전히 HostRoot이고, queue는 아래 객체입니다. 또한, update와 lane은 업데이트를 발생시킨 컴포넌트의 정보를 담고 있겠다. (그런데 update를 생성하는 과정에서 lane이 포함됐었는데 꼭 함께 전달해야할까..?)
shared: { pending: null, interleaved: null, lanes: NoLanes },
ReactFiberConcurrentUpdates.js안에는 지역 변수로 concurrentQueues 배열과 concurrentQueuesIndex 변수가 있는데 이를 통해 해당 큐 안에 업데이트 해야 할 정보들을 순차적으로 push되어 끝에 추가되는 구조를 갖고 있습니다.
function enqueueUpdate( fiber: Fiber, queue: ConcurrentQueue | null, update: ConcurrentUpdate | null, lane: Lane, ) { // Don't update the `childLanes` on the return path yet. If we already in // the middle of rendering, wait until after it has completed. concurrentQueues[concurrentQueuesIndex++] = fiber; concurrentQueues[concurrentQueuesIndex++] = queue; concurrentQueues[concurrentQueuesIndex++] = update; concurrentQueues[concurrentQueuesIndex++] = lane; concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane); // The fiber's `lane` field is used in some places to check if any work is // scheduled, to perform an eager bailout, so we need to update it immediately. // TODO: We should probably move this to the "shared" queue instead. fiber.lanes = mergeLanes(fiber.lanes, lane); const alternate = fiber.alternate; if (alternate !== null) { alternate.lanes = mergeLanes(alternate.lanes, lane); } }
concurrentlyUpdatedLanes 또한 지역 변수로 현재의 Lanes 우선순위를 저장하고 있는데, mergeLanes()를 통해 두 값의 bitWisre OR operator를 사용한 값입니다.
alternate도 fiber의 개념인데, commit phase가 지나고 DOM에 변경사항이 DOM Tree에 적용된 fiber를 current fiber라고 칭하고, 변경사항이 반영중인 fiber를 work-in-progress fiber라고 칭합니다. alternate은 각각 서로를 나타냅니다. current fiber의 alternate은 work-in-progress fiber이고, work-in-progress fiber의 alternate은 current fiber입니다.
function getRootForUpdatedFiber(sourceFiber: Fiber): FiberRoot | null { ... let node = sourceFiber; let parent = node.return; while (parent !== null) { node = parent; parent = node.return; } return node.tag === HostRoot ? (node.stateNode: FiberRoot) : null; }
return도 fiber의 개념인데, VirtualDOM Tree에서 본인의 부모 node에 해당하는 값을 나타낸다고 합니다. 하지만 굳이 return이라는 단어를 사용하는 이유는, 해당 fiber node에서 작업이 끝나면 return, 되돌아가서 처리할 node를 나타내기 때문입니다. 렌더링에서 업데이트는 자식노드(child node)를 모두 업데이트 한 후에 부모노드(return node)를 업데이트하기 때문에 return이라는 단어를 사용한 것 같네요. 개념과 유사하게 parent라는 변수에 해당 값을 할당합니다. while문을 사용해서 업데이트 할 lane을 배정한다고 볼 수 있겠습니다.
lane을 계산하는 과정이 모두 끝나게 되면 최초 렌더링 시에는 sourceFiber가 HostRoot이기 때문에 HostRoot의 stateNode인 FiberRoot를 리턴하게 되겠습니다.
이제 다시 updateContaierner()로 돌아가면...
function updateContainer( element: ReactNodeList, container: OpaqueRoot, parentComponent: ?React$Component<any, any>, callback: ?Function, ): Lane { ... const root = enqueueUpdate(current, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, current, lane); entangleTransitions(root, current, lane); } return lane; }
최초 렌더링 시점에서는 root가 FiberRoot이기 때문에 아래 두 함수를 실행하게 됩니다. scheduleUpdateOnFiber() 함수는 다음과 같습니다.
function scheduleUpdateOnFiber(root: FiberRoot, fiber: Fiber, lane: Lane) { if (__DEV__) { ... } ... // Mark that the root has a pending update. markRootUpdated(root, lane); if ( (executionContext & RenderContext) !== NoLanes && root === workInProgressRoot ) { ... } else { ... ensureRootIsScheduled(root); if ( lane === SyncLane && executionContext === NoContext && (fiber.mode & ConcurrentMode) === NoMode ) { if (__DEV__ && ReactCurrentActQueue.isBatchingLegacy) { // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode. } else { // Flush the synchronous work now, unless we're already working or inside // a batch. This is intentionally inside scheduleUpdateOnFiber instead of // scheduleCallbackForFiber to preserve the ability to schedule a callback // without immediately flushing it. We only do this for user-initiated // updates, to preserve historical behavior of legacy mode. resetRenderTimer(); flushSyncWorkOnLegacyRootsOnly(); } } } }
root는 FiberRoot이고, fiber인 HostRoot와 lane을 전달받고 있습니다. workInProgress prefix로 시작되는 변수들은 현재 리액트의 작업이 어디에서 진행되고 있는지에 관한 지표를 알려주고 있는데, 첫 렌더링 시점에서는 모두 falsy값으로 처리되기 때문에 일단은 패스하겠습니다.
또한 markRootUpdated()는 현재 root Fiber(Component)가 업데이트를 처리하고 있음을 root.pendingLanes와 할당 받은 lane의 bitwise OR연산을 통해 표시하고, 향후 lane이 유휴 상태(Ideal)가 아니라면 Susepned되거나 pinged된 laens이 없음을 표시해줍니다.
그 뒤에 ensureRootIsScheduled()를 통해 Linked-List에서 가장 마지막에 스케줄링 되어야 할 App(root) 컴포넌트의 스케쥴링을 최종적으로 등록하게 됩니다.
상태 업데이트와 관련있는 beginWork()라는 함수에 대해 언급하지 않고 마무리하려고 합니다. 해당 함수에 대해서는, setState()가 발생했을 때 리액트가 어떻게 화면을 업데이트 하는지에 대해 공부하면서 작성해보도록 하겠습니다.