리액트 오픈 소스 분석하며 SPA(Single Page Application)직접 만들어보기, createRoot와 root.render()

김철준·2024년 11월 4일
1

위 이미지는 GPT께서 생성해주셨습니다.

SPA, 직접 만들어보자! 그런데..어디서부터 시작하지?

React와 Vue 같은 SPA 라이브러리를 직접 구현해보려 합니다. 이를 통해 React의 내부 구조를 깊이 이해하고, 실제 개발에서 더 잘 활용할 수 있을 것 같다는 생각이 들었습니다.

하지만 막상 시작하려고 보니 어디서부터 손을 대야 할지 막막함을 느꼈습니다. 리액트에는 Hook, Virtual DOM, Fiber 등 다양한 개념들이 존재하고, 이러한 부분들을 직접 구현하려니 자연스럽게 "어디서부터 시작해야 하지?", "내부적으로 어떻게 구현되어 있는지를 알아야 구현할 수 있지 않을까?" 하는 고민이 생겼습니다.

그래서 React 프로젝트의 시작점부터 리액트 오픈소스를 분석하고, 이를 따라 구현해 보면서 그 원리를 이해하려고 합니다. React의 구조와 핵심 개념들을 하나씩 따라가며 직접 만들어 보면, 더 깊이 있는 학습과 경험을 쌓을 수 있을 거라 기대하며 이는 React를 진정으로 이해하는 과정이 될 것이라 생각합니다.

작은 단위부터 시작해서 컴포넌트 렌더링, 상태 관리, 그리고 가상 DOM 등의 기능들을 차근차근 구현해 보면서, React의 철학과 설계 의도를 직접 경험해 보고 싶습니다.

그럼 시작해보겠습니다. 우선 리액트 프로젝트를 설치해보고 시작점부터 살펴보겠습니다.

첫번째 목표는 root.render()! 시작점, ReactDom.createRoot

리액트 프로젝트를 설치했을 때, src > index.tsx 부터 흐름이 시작됩니다.
코드 내용은 다음과 같습니다. 많이 익숙하시죠?

index.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

우선 코드 그대로를 분석해보자면 ReactDom.createRoot API는 index.html에 비어있는 div 태그(root)의 DOM을 인자로 할당받습니다. createRoot가 반환하는 객체에는 저희의 첫번째 목표인 render라는 메서드가 있는데 이 메서드에 루트 컴포넌트인 <App/> 컴포넌트를 인자로 할당합니다.

ReactDom 패키지의 역할과 createRoot의 역할은 무엇인가?

document.getElementById('root')로 조회한 DOM에 곧바로 <App/>컴포넌트를 할당하지않고 ReactDom.createRoot로 감싸주는 이유가 뭘까요?

ReactDom은 무엇이고 ReactDom.createRoot는 어떤 역할을 할까요?

ReactDom 패키지

오픈소스에 README를 확인해보면 ReactDom 패키지에 대해 다음과 같이 설명하고 있습니다.

This package serves as the entry point to the DOM and server renderers for React. It is intended to be paired with the generic React package, which is shipped as react to npm.

"이 패키지는 React의 DOM과 서버 렌더러의 진입점 역할을 합니다. 이는 npm에 'react'로 배포된 일반적인 React 패키지와 함께 사용하기 위해 만들어졌습니다."
라고 말해주고 있습니다.

위 설명가지고는 부족한데요.

조금 더 찾아본 결과, react-dom은 Virtual Dom을 비교하여 실제 DOM을 업데이트하는데 필요하다고 합니다.

Virtual DOM(가상돔)
Virtual DOM이란 DOM을 효율적으로 업데이트하기 위해서 사용하는 가상 DOM입니다. 가상 DOM을 사용하면 리렌더링될 때, 이전 가상 돔과 변경된 가상 돔을 비교하여 변경된 부분만을 반영하여 실제 DOM에 반영합니다.
즉, 가상 DOM은 효율적으로 변경 사항을 계산하고 최소한의 DOM 업데이트만 수행할 수 있습니다.

즉, Virtual DOM을 사용하여 실제 DOM에 반영하기위해 필요한 패키지라고 할 수 있겠네요.

react-dom/client

import ReactDOM from 'react-dom/client';
ReactDOM API는 react-dom/client 패키지로부터 불러오고 있는데요.
react-dom/client 패키지는 뭘까요?

공식문서에서는 간결히 다음과 같이 설명하고 있습니다.

react-dom/client API를 사용하면 클라이언트(브라우저)에서 React 컴포넌트를 렌더링 할 수 있습니다. 이러한 API는 일반적으로 앱의 최상위 수준에서 React 트리를 초기화하기 위해 사용됩니다.

브라우저에서 React 컴포넌트를 렌더링하기위해 사용하는 API라고 생각하면 되겠네요.

그렇다면 이제 분석을 한번 해볼까요?
리액트 오픈 소스를 fork 받아서 살펴보겠습니다.

createRoot

공식문서에서는 다음과 같이 설명하고 있습니다.

브라우저 DOM 노드 안에 React 컴포넌트를 표시하는 루트를 생성할 수 있습니다.

DOM에 컴포넌트를 사용할 수 있게 해주는 API인 것 같네요.
오픈소스의 README와 공식문서에서는 이에 대해 간략히만 설명해주고 있네요. 그렇다면 본격적으로 오픈소스를 분석해보겠습니다.

오픈소스 까기 > react-dom 패키지 > createRoot

react-dom 패키지에는 다음과 같이 많은 디렉터리와 파일들이 있습니다. 각 파일들의 코드들이 장난없이 많은데요.

보시기 전 주의사항
코드를 확인해보다면 꼬리의 꼬리를 엄청 물고 있습니다. 많이 정신없을 수 있다는 것을 예고합니다.

그래서 저는 react-dom의 createRoot를 살펴보겠습니다.
오픈소스에서 살펴보면 아래 위치에서 createRoot를 export 하고 있습니다.

  • packages > react-dom > src > client.js
/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

export {createRoot, hydrateRoot, version} from './src/client/ReactDOMClient';

packages/react-dom/src/client/ReactDOMRoot.js > createRoot

그리고 createRoot 함수의 내용은 다음 위치에 있습니다.

  • packages/react-dom/src/client/ReactDOMRoot.js

내용은 다음과 같습니다.

createRoot 전체 코드

쨘~ 코드를 확인해보시죠!

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }

  warnIfReactDOMContainerInDEV(container);

  const concurrentUpdatesByDefaultOverride = false;
  let isStrictMode = false;
  let identifierPrefix = '';
  let onUncaughtError = defaultOnUncaughtError;
  let onCaughtError = defaultOnCaughtError;
  let onRecoverableError = defaultOnRecoverableError;
  let transitionCallbacks = null;

  if (options !== null && options !== undefined) {
    if (__DEV__) {
      if ((options: any).hydrate) {
        console.warn(
          'hydrate through createRoot is deprecated. Use ReactDOMClient.hydrateRoot(container, <App />) instead.',
        );
      } else {
        if (
          typeof options === 'object' &&
          options !== null &&
          (options: any).$$typeof === REACT_ELEMENT_TYPE
        ) {
          console.error(
            'You passed a JSX element to createRoot. You probably meant to ' +
              'call root.render instead. ' +
              'Example usage:\n\n' +
              '  let root = createRoot(domContainer);\n' +
              '  root.render(<App />);',
          );
        }
      }
    }
    if (options.unstable_strictMode === true) {
      isStrictMode = true;
    }
    if (options.identifierPrefix !== undefined) {
      identifierPrefix = options.identifierPrefix;
    }
    if (options.onUncaughtError !== undefined) {
      onUncaughtError = options.onUncaughtError;
    }
    if (options.onCaughtError !== undefined) {
      onCaughtError = options.onCaughtError;
    }
    if (options.onRecoverableError !== undefined) {
      onRecoverableError = options.onRecoverableError;
    }
    if (options.unstable_transitionCallbacks !== undefined) {
      transitionCallbacks = options.unstable_transitionCallbacks;
    }
  }

  const root = createContainer(
    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);

  // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
  return new ReactDOMRoot(root);
}

엄청 길죠?
하나의 함수에 100줄 가량 코드가 있는데요. 예외처리하는 코드들이 많은 것 같은데 저는 예외처리 부분은 제외하고 반환하는 코드를 살펴보겠습니다.

createRoot 전체 코드 > 반환하는 부분

packages/react-dom/src/client/ReactDOMRoot.js > createRoot

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }
  ...


  const root = createContainer(
    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);

  // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
  return new ReactDOMRoot(root);
}

createRoot 함수는 ReactDOMRoot라는 생성자 함수로 인스턴스를 생성하여 반환하고 있습니다. ReactDOMRoot 생성자 함수는 createContainer가 반환하는 root라는 값을 인자로 할당받고 있구요.

그렇다면 그 다음에 봐야할 부분은 ReactDOMRoot 생성자 함수와 createContainer 함수를 살펴봐야겠네요.

ReactDOMRoot

packages/react-dom/src/client/ReactDOMRoot.js > ReactDOMRoot

// $FlowFixMe[missing-this-annot]
function ReactDOMRoot(internalRoot: FiberRoot) {
  this._internalRoot = internalRoot;
}

반갑게 ReactDOMRoot 생성자 함수는 코드가 짧네요!
FiberRoot 타입의 인자, internalRoot라는 것을 인자로 받고 있고 인스턴스의 _internalRoot 값에 그대로 인자값을 할당해주고 있어요.

FiberRoot

import type {
  FiberRoot,
  TransitionTracingCallbacks,
} from 'react-reconciler/src/ReactInternalTypes';
  
  // $FlowFixMe[missing-this-annot]
function ReactDOMRoot(internalRoot: FiberRoot) {
  this._internalRoot = internalRoot;
}
  
  • packages/react-reconciler/src/ReactInternalTypes.js > FiberRoot

FiberRoot 타입은 react-reconciler 패키지에서 가져오고 있네요. 살짝 봤을 때, 코드가 엄청 길고 해당 코드를 분석하려면 react-reconciler 패키지에 꼬리에 꼬리를 물 것 같아 우선은 넘어가보도록 하겠습니다. 뒤에 갔을 때, 꼭 봐야하는 부분이라면 다시 집고 넘어가도록 해볼게요.

createContainer

  • packages/react-reconciler/src/ReactFiberReconciler.js > createContainer
  const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    transitionCallbacks,
  );

createContainer 또한 react-reconciler라는 패키지에서 가져오고 있어요. createRoot는 reconciler라는 개념과 연관되어있나보네요!

packages/react-reconciler/src/ReactFiberReconciler.js > createContainer

export function createContainer(
  containerInfo: Container,
  tag: RootTag,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  // TODO: Remove `concurrentUpdatesByDefaultOverride`. It is now ignored.
  concurrentUpdatesByDefaultOverride: null | boolean,
  identifierPrefix: string,
  onUncaughtError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  onCaughtError: (
    error: mixed,
    errorInfo: {
      +componentStack?: ?string,
      +errorBoundary?: ?React$Component<any, any>,
    },
  ) => void,
  onRecoverableError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  transitionCallbacks: null | TransitionTracingCallbacks,
): OpaqueRoot {
  const hydrate = false;
  const initialChildren = null;
  return createFiberRoot(
    containerInfo,
    tag,
    hydrate,
    initialChildren,
    hydrationCallbacks,
    isStrictMode,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    transitionCallbacks,
    null,
  );
}

createContainer는 많은 인자들을 받고 있고 createFiberRoot를 반환하고 있어요. createFiberRoot를 찾으러 가보죠.

Fiber라는 단어가 자주 등장하네요.Fiber란 개념을 리액트 관련해서 종종 들었던 것 같아요. Fiber가 뭘까요?

createFiberRoot

createFiberRoot 함수는 다음 경로에 있어요.

  • packages/react-reconciler/src/ReactFiberRoot.js
export function createFiberRoot(
  containerInfo: Container,
  tag: RootTag,
  hydrate: boolean,
  initialChildren: ReactNodeList,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  // TODO: We have several of these arguments that are conceptually part of the
  // host config, but because they are passed in at runtime, we have to thread
  // them through the root constructor. Perhaps we should put them all into a
  // single type, like a DynamicHostConfig that is defined by the renderer.
  identifierPrefix: string,
  onUncaughtError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  onCaughtError: (
    error: mixed,
    errorInfo: {
      +componentStack?: ?string,
      +errorBoundary?: ?React$Component<any, any>,
    },
  ) => void,
  onRecoverableError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  transitionCallbacks: null | TransitionTracingCallbacks,
  formState: ReactFormState<any, any> | null,
): FiberRoot {
  // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
  const root: FiberRoot = (new FiberRootNode(
    containerInfo,
    tag,
    hydrate,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    formState,
  ): any);
  if (enableSuspenseCallback) {
    root.hydrationCallbacks = hydrationCallbacks;
  }

  if (enableTransitionTracing) {
    root.transitionCallbacks = transitionCallbacks;
  }

  // Cyclic construction. This cheats the type system right now because
  // stateNode is any.
  const uninitializedFiber = createHostRootFiber(tag, isStrictMode);
  root.current = uninitializedFiber;
  uninitializedFiber.stateNode = root;

  if (enableCache) {
    const initialCache = createCache();
    retainCache(initialCache);

    // The pooledCache is a fresh cache instance that is used temporarily
    // for newly mounted boundaries during a render. In general, the
    // pooledCache is always cleared from the root at the end of a render:
    // it is either released when render commits, or moved to an Offscreen
    // component if rendering suspends. Because the lifetime of the pooled
    // cache is distinct from the main memoizedState.cache, it must be
    // retained separately.
    root.pooledCache = initialCache;
    retainCache(initialCache);
    const initialState: RootState = {
      element: initialChildren,
      isDehydrated: hydrate,
      cache: initialCache,
    };
    uninitializedFiber.memoizedState = initialState;
  } else {
    const initialState: RootState = {
      element: initialChildren,
      isDehydrated: hydrate,
      cache: (null: any), // not enabled yet
    };
    uninitializedFiber.memoizedState = initialState;
  }

  initializeUpdateQueue(uninitializedFiber);

  return root;
}

createFiberRoot함수는 FiberRootNode 생성자 함수로 생성한 인스턴스를 root라는 변수로 할당하여 반환하고 있어요.

root에 코드 중간에 속성값을 추가하기도 하고 root를 다른 속성에 할당하기도 하네요. 이런저런 중요한 코드들이 많은 것 같습니다!

FiberRootNode

FiberRootNodecreateFiberRoot와 같이 다음 경로에 있어요.

  • packages/react-reconciler/src/ReactFiberRoot.js
function FiberRootNode(
  this: $FlowFixMe,
  containerInfo: any,
  // $FlowFixMe[missing-local-annot]
  tag,
  hydrate: any,
  identifierPrefix: any,
  onUncaughtError: any,
  onCaughtError: any,
  onRecoverableError: any,
  formState: ReactFormState<any, any> | null,
) {
  this.tag = disableLegacyMode ? ConcurrentRoot : tag;
  this.containerInfo = containerInfo;
  this.pendingChildren = null;
  this.current = null;
  this.pingCache = null;
  this.finishedWork = null;
  this.timeoutHandle = noTimeout;
  this.cancelPendingCommit = null;
  this.context = null;
  this.pendingContext = null;
  this.next = null;
  this.callbackNode = null;
  this.callbackPriority = NoLane;
  this.expirationTimes = createLaneMap(NoTimestamp);

  this.pendingLanes = NoLanes;
  this.suspendedLanes = NoLanes;
  this.pingedLanes = NoLanes;
  this.warmLanes = NoLanes;
  this.expiredLanes = NoLanes;
  this.finishedLanes = NoLanes;
  this.errorRecoveryDisabledLanes = NoLanes;
  this.shellSuspendCounter = 0;

  this.entangledLanes = NoLanes;
  this.entanglements = createLaneMap(NoLanes);

  this.hiddenUpdates = createLaneMap(null);

  this.identifierPrefix = identifierPrefix;
  this.onUncaughtError = onUncaughtError;
  this.onCaughtError = onCaughtError;
  this.onRecoverableError = onRecoverableError;

  if (enableCache) {
    this.pooledCache = null;
    this.pooledCacheLanes = NoLanes;
  }

  if (enableSuspenseCallback) {
    this.hydrationCallbacks = null;
  }

  this.formState = formState;

  this.incompleteTransitions = new Map();
  if (enableTransitionTracing) {
    this.transitionCallbacks = null;
    const transitionLanesMap = (this.transitionLanes = []);
    for (let i = 0; i < TotalLanes; i++) {
      transitionLanesMap.push(null);
    }
  }

  if (enableProfilerTimer && enableProfilerCommitHooks) {
    this.effectDuration = -0;
    this.passiveEffectDuration = -0;
  }

  if (enableUpdaterTracking) {
    this.memoizedUpdaters = new Set();
    const pendingUpdatersLaneMap = (this.pendingUpdatersLaneMap = []);
    for (let i = 0; i < TotalLanes; i++) {
      pendingUpdatersLaneMap.push(new Set());
    }
  }

  if (__DEV__) {
    if (disableLegacyMode) {
      // TODO: This varies by each renderer.
      this._debugRootType = hydrate ? 'hydrateRoot()' : 'createRoot()';
    } else {
      switch (tag) {
        case ConcurrentRoot:
          this._debugRootType = hydrate ? 'hydrateRoot()' : 'createRoot()';
          break;
        case LegacyRoot:
          this._debugRootType = hydrate ? 'hydrate()' : 'render()';
          break;
      }
    }
  }
}

createRoot의 반환 체이닝

createRoot를 살펴보니 계속해서 함수들을 호출하고 반환하면 결국 마지막에 FiberRootNode 생성자 함수를 끝으로 반환을 종료하고 있어요.

순서를 정리하자면 다음과 같아요.

  1. createRoot > return new ReactDOMRoot(root)
  2. createContainer > return createFiberRoot()
  3. createFiberRoot > return FiberRootNode()

createRoot(dom).render에 대한 코드는 어디에?

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

createRoot가 반환하는 인스턴스는 render라는 메서드를 가지고 있어야해요.
우리가 root.render()를 사용하기 위해서는 필요한 메서드이니깐요!
하지만createRoot부터 시작하여 FiberRootNode까지 타고타고 들어가봤는데 createRoot의 메서드 render가 보이지 않아요.

자세히 찾아보니 createRoot 함수가 선언된 파일에 render라는 메서드가 있었어요!

위치

  • packages/react-dom/src/client/ReactDOMRoot.js

코드는 다음과 같아요.


function ReactDOMRoot(internalRoot: FiberRoot) {
  this._internalRoot = internalRoot;
}

// $FlowFixMe[prop-missing] found when upgrading Flow
ReactDOMHydrationRoot.prototype.render = ReactDOMRoot.prototype.render =
  // $FlowFixMe[missing-this-annot]
  function (children: ReactNodeList): void {
    const root = this._internalRoot;
    if (root === null) {
      throw new Error('Cannot update an unmounted root.');
    }

    if (__DEV__) {
      if (typeof arguments[1] === 'function') {
        console.error(
          'does not support the second callback argument. ' +
            'To execute a side effect after rendering, declare it in a component body with useEffect().',
        );
      } else if (isValidContainer(arguments[1])) {
        console.error(
          'You passed a container to the second argument of root.render(...). ' +
            "You don't need to pass it again since you already passed it to create the root.",
        );
      } else if (typeof arguments[1] !== 'undefined') {
        console.error(
          'You passed a second argument to root.render(...) but it only accepts ' +
            'one argument.',
        );
      }
    }
    updateContainer(children, root, null, null);
  };


export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
...
  return new ReactDOMRoot(root);
}

아까 createRoot를 살펴봤을 때,createRoot에서 ReactDOMRoot 생성자 함수가 생성한 인스턴스를 반환했었죠?

ReactDOMRoot 생성자 함수의 prototype으로 render 메서드를 설정해주고 있는거였어요!

render 메서드의 내용은 예외처리를 한뒤, updateContainer 함수를 동작하고 있네요!

드디어 render()를 살펴봅시다!

드디어 render() 메서드를 찾아왔습니다!

import {
  createContainer,
  createHydrationContainer,
  updateContainer,
  updateContainerSync,
  flushSyncWork,
  isAlreadyRendering,
  defaultOnUncaughtError,
  defaultOnCaughtError,
  defaultOnRecoverableError,
} from 'react-reconciler/src/ReactFiberReconciler';
...

ReactDOMRoot.prototype.render =
  // $FlowFixMe[missing-this-annot]
  function (children: ReactNodeList): void {
    const root = this._internalRoot;
    if (root === null) {
      throw new Error('Cannot update an unmounted root.');
    }

    if (__DEV__) {
      if (typeof arguments[1] === 'function') {
        console.error(
          'does not support the second callback argument. ' +
            'To execute a side effect after rendering, declare it in a component body with useEffect().',
        );
      } else if (isValidContainer(arguments[1])) {
        console.error(
          'You passed a container to the second argument of root.render(...). ' +
            "You don't need to pass it again since you already passed it to create the root.",
        );
      } else if (typeof arguments[1] !== 'undefined') {
        console.error(
          'You passed a second argument to root.render(...) but it only accepts ' +
            'one argument.',
        );
      }
    }
    updateContainer(children, root, null, null);
  };

예외처리하는 코드는 제외하고 보겠습니다.
updateContainer함수를 호출하고 있네요.updateContainer함수를 살펴보러 가볼까요?

import {
  createContainer,
  createHydrationContainer,
  updateContainer,
  updateContainerSync,
  flushSyncWork,
  isAlreadyRendering,
  defaultOnUncaughtError,
  defaultOnCaughtError,
  defaultOnRecoverableError,
} from 'react-reconciler/src/ReactFiberReconciler';

updateContainer 함수는 react-reconciler 패키지에 있나봐요!
해당 패키지 source에 ReactFiberReconciler.js 파일로 넘어가보겠습니다.

updateContainer

ReactFiberReconciler.js 파일로 찾아왔습니다.

updateContainer 함수를 확인해보시죠.

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function,
): Lane {
  const current = container.current;
  const lane = requestUpdateLane(current);
  updateContainerImpl(
    current,
    lane,
    element,
    container,
    parentComponent,
    callback,
  );
  return lane;
}

이제는 코드가 짧으면 안도감이 드네요..ㅎ

두번째 매개변수인 containercurrent값을 새로운 변수에 할당하고 있고,
requestUpdateLane 함수의 인자로 할당해주고 있어요.

위에서 봤던 것 같은데 container와 container.current가 뭐였는지 다시 한번 확인하러 가시죠.

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

우선 container는 createRoot 함수에서 첫번째 인자로 받는 DOM 값이에요.
document.getElementById('root') as HTMLElement 즉, DOM을 인자로 받는거죠.

container.current 값은 FiberRootNode에서 container.current의 정체를 확인할 수 있어요!

FiberRootNode

function FiberRootNode(
  this: $FlowFixMe,
  containerInfo: any,
  // $FlowFixMe[missing-local-annot]
  tag,
  hydrate: any,
  identifierPrefix: any,
  onUncaughtError: any,
  onCaughtError: any,
  onRecoverableError: any,
  formState: ReactFormState<any, any> | null,
) {
  this.tag = disableLegacyMode ? ConcurrentRoot : tag;
  this.containerInfo = containerInfo;
  this.pendingChildren = null;
  this.current = null;
  this.pingCache = null;
  this.finishedWork = null;
  this.timeoutHandle = noTimeout;
....

FilberRootNode는 생성자 함수이고 this.crrent로 인스턴스의 속성값을 null로 지정해주고 있네요.

그럼 다시 updateContainer 함수로 돌아가보죠.

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function,
): Lane {
  const current = container.current;
  const lane = requestUpdateLane(current);
  updateContainerImpl(
    current,
    lane,
    element,
    container,
    parentComponent,
    callback,
  );
  return lane;
}

container.current 값은 null이겠고 requestUpdateLand 함수 null을 인자로 넣어주는 것이겠군요.

requestUpdateLand를 구경하러 가시죠.
requestUpdateLand는 다른 파일에 있습니다.

requestUpdateLand

export function requestUpdateLane(fiber: Fiber): Lane {
  // Special cases
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // This is a render phase update. These are not officially supported. The
    // old behavior is to give this the same "thread" (lanes) as
    // whatever is currently rendering. So if you call `setState` on a component
    // that happens later in the same render, it will flush. Ideally, we want to
    // remove the special case and treat them as if they came from an
    // interleaved event. Regardless, this pattern is not officially supported.
    // This behavior is only a fallback. The flag only exists until we can roll
    // out the setState warning, since existing code might accidentally rely on
    // the current behavior.
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }

  const transition = requestCurrentTransition();
  if (transition !== null) {
    if (__DEV__) {
      if (!transition._updatedFibers) {
        transition._updatedFibers = new Set();
      }
      transition._updatedFibers.add(fiber);
    }

    const actionScopeLane = peekEntangledActionLane();
    return actionScopeLane !== NoLane
      ? // We're inside an async action scope. Reuse the same lane.
        actionScopeLane
      : // We may or may not be inside an async action scope. If we are, this
        // is the first update in that scope. Either way, we need to get a
        // fresh transition lane.
        requestTransitionLane(transition);
  }

  return eventPriorityToLane(resolveUpdatePriority());
}

어? 근데 코드 초기를 보니 requestUpdateLane 매개변수 fiber의 속성값 mode를 조회하고 있습니다.

그런데 위에서 current 값은 null이지 않았나요?

null값의 없는 속성값을 조회하면 보통 다음과 같이에러가 납니다.

리액트에서 위와 같은 에러가 나지 않을테니, 그렇다면 분명 container.current 값에 null이 아닌 다른 값을 할당해주고 있을 것 같네요.

찾아보니 createFilberRoot에서 container.current 값을 null 아닌 값으로 할당해주고 있었네요.

export function createFiberRoot(
  containerInfo: Container,
  tag: RootTag,
  hydrate: boolean,
  initialChildren: ReactNodeList,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  // TODO: We have several of these arguments that are conceptually part of the
  // host config, but because they are passed in at runtime, we have to thread
  // them through the root constructor. Perhaps we should put them all into a
  // single type, like a DynamicHostConfig that is defined by the renderer.
  identifierPrefix: string,
  onUncaughtError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  onCaughtError: (
    error: mixed,
    errorInfo: {
      +componentStack?: ?string,
      +errorBoundary?: ?React$Component<any, any>,
    },
  ) => void,
  onRecoverableError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  transitionCallbacks: null | TransitionTracingCallbacks,
  formState: ReactFormState<any, any> | null,
): FiberRoot {
  // $FlowFixMe[invalid-constructor] Flow no longer supports calling new on functions
  const root: FiberRoot = (new FiberRootNode(
    containerInfo,
    tag,
    hydrate,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    formState,
  ): any);
  if (enableSuspenseCallback) {
    root.hydrationCallbacks = hydrationCallbacks;
  }

  if (enableTransitionTracing) {
    root.transitionCallbacks = transitionCallbacks;
  }

  // Cyclic construction. This cheats the type system right now because
  // stateNode is any.
  const uninitializedFiber = createHostRootFiber(tag, isStrictMode);
  root.current = uninitializedFiber;
  uninitializedFiber.stateNode = root;

FiberRootNode 생성자 함수에서는 this.current=nullcurrent 속성값을 null로 초기화를 해줬죠?

그리고 createHostRootFiber함수의 반환값을 root.current에 할당해주고 있어요.

  // Cyclic construction. This cheats the type system right now because
  // stateNode is any.
  const uninitializedFiber = createHostRootFiber(tag, isStrictMode);
  root.current = uninitializedFiber;
  uninitializedFiber.stateNode = root;

createHostRootFiber함수의 인자에는 tagisStrictMode라는 값을 할당해주고 있는데요.

tagisStrictMode라는 값이 뭔지 볼까요?

tag

  
export function createContainer(
  containerInfo: Container,
  tag: RootTag,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  // TODO: Remove `concurrentUpdatesByDefaultOverride`. It is now ignored.
  concurrentUpdatesByDefaultOverride: null | boolean,
  identifierPrefix: string,
  onUncaughtError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  onCaughtError: (
    error: mixed,
    errorInfo: {
      +componentStack?: ?string,
      +errorBoundary?: ?React$Component<any, any>,
    },
  ) => void,
  onRecoverableError: (
    error: mixed,
    errorInfo: {+componentStack?: ?string},
  ) => void,
  transitionCallbacks: null | TransitionTracingCallbacks,
)
  ...

const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    transitionCallbacks,
  );

creatRoot 함수 내부에서 createContainer 함수를 호출하는데 createContainer의 두번째 매개변수에 들어가는 값이 tag인가봐요.

그리고 createContainer를 호출할 때, ConCurrentRoot값을 할당해주고 있죠.

ConCurrentRoot

ConCurrentRootreact-reconciler 패키지 소스파일에 있네요.

1이라는 숫자값으로 할당하고 있습니다.

/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

export type RootTag = 0 | 1;

export const LegacyRoot = 0;
export const ConcurrentRoot = 1;

isStrictMode

isStrictMode값은 createRoot 함수내에서 isStrictMode라는 변수를 선언하고 값을 지정해주고 있어요.

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  if (!isValidContainer(container)) {
    throw new Error('Target container is not a DOM element.');
  }

  warnIfReactDOMContainerInDEV(container);

  const concurrentUpdatesByDefaultOverride = false;
  let isStrictMode = false;
...
  if (options !== null && options !== undefined) {
    if (__DEV__) {
      if ((options: any).hydrate) {
        console.warn(
          'hydrate through createRoot is deprecated. Use ReactDOMClient.hydrateRoot(container, <App />) instead.',
        );
      } else {
        if (
          typeof options === 'object' &&
          options !== null &&
          (options: any).$$typeof === REACT_ELEMENT_TYPE
        ) {
          console.error(
            'You passed a JSX element to createRoot. You probably meant to ' +
              'call root.render instead. ' +
              'Example usage:\n\n' +
              '  let root = createRoot(domContainer);\n' +
              '  root.render(<App />);',
          );
        }
      }
    }
    if (options.unstable_strictMode === true) {
      isStrictMode = true;
    }
...
  }

...

기본값으로 false로 지정해주고 있고 options.unstable_strictMode === true일 때, true로 변경해주나봐요.

하지만 보통 저희가 createRoot를 호출할 때, DOM값만 첫번째 인자에 넣고 options 값을 따로 넣어주진 않으니깐 isStrictMode값은 false라 생각하고 가겠습니다.

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);

그러면 다시 createHostRootFiber 함수로 되돌아가서 봅시다.

export function createHostRootFiber(
  tag: RootTag,
  isStrictMode: boolean,
): Fiber {
  let mode;
  if (disableLegacyMode || tag === ConcurrentRoot) {
    mode = ConcurrentMode;
    if (isStrictMode === true) {
      mode |= StrictLegacyMode | StrictEffectsMode;
    }
  } else {
    mode = NoMode;
  }

  if (enableProfilerTimer && isDevToolsPresent) {
    // Always collect profile timings when DevTools are present.
    // This enables DevTools to start capturing timing at any point–
    // Without some nodes in the tree having empty base times.
    mode |= ProfileMode;
  }

  return createFiber(HostRoot, null, null, mode);
}

mode라는 변수를 선언해줍니다.

disableLegacyMode이거나 tagConcurrentRoot이면 modeConcurrentMode로 할당해주겠다네요.

isStrictMode가 true이면 |=연산자를 통해 StrictLegacyMode 혹은 이 값이 없다면 StrictEffectsMode값을 할당해준다네요.

|= 연산자
비트 단위 OR 할당 연산자( |=) 는 두 피연산자에 대해 비트 단위 OR을 수행 하고 결과를 왼쪽 피연산자에 할당합니다.

let a = 5; // 00000000000000000000000000000101
a |= 3; // 00000000000000000000000000000011
console.log(a); // 00000000000000000000000000000111
// Expected output: 7

비트 단위 연산자는 피연산자에 대해서 2진수로 표현한 값입니다. 그리고 비트 단위 OR이란 양 옆의 2진수로 표현한 피연산자들을 서로 비교하여 1인 값들만 포지셔닝하여 새로운 2진수로 표현합니다.
위 예시로 확인해보면 5|=3 으로 두 피연산자를 비교한다면

  • 5는 1인 포지션이 뒤에서 세번째와 뒤에서 첫번째입니다.
  • 3은 1인 포지션인 뒤에서 두번째와 뒤에서 첫번째입니다.
    1인 포지션들만 추려서 해당 포지션들에 1을 넣어주면 111이 되면서 숫자값으로는 7이 됩니다.

isStrictMode를 false라 생각하였으니 그렇다면 modeConcurrentRoot값인 1로 보면 되겠군요.

  if (enableProfilerTimer && isDevToolsPresent) {
    // Always collect profile timings when DevTools are present.
    // This enables DevTools to start capturing timing at any point–
    // Without some nodes in the tree having empty base times.
    mode |= ProfileMode;
  }

위 코드는 react DevTools가 켜져있으면 작동하는 코드이며 켜져있다면mode값을 변경해주나봐요.

이제 드디어 container.current 값을 파악했어요. 결국에는 null 값이 아닌 mode라는 속성값을 가진 객체값을 가지고 있네요.

다시 requestUpdateLane함수로 되돌아가볼게요.

export function requestUpdateLane(fiber: Fiber): Lane {
  // Special cases
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // This is a render phase update. These are not officially supported. The
    // old behavior is to give this the same "thread" (lanes) as
    // whatever is currently rendering. So if you call `setState` on a component
    // that happens later in the same render, it will flush. Ideally, we want to
    // remove the special case and treat them as if they came from an
    // interleaved event. Regardless, this pattern is not officially supported.
    // This behavior is only a fallback. The flag only exists until we can roll
    // out the setState warning, since existing code might accidentally rely on
    // the current behavior.
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }

  const transition = requestCurrentTransition();
  if (transition !== null) {
    if (__DEV__) {
      if (!transition._updatedFibers) {
        transition._updatedFibers = new Set();
      }
      transition._updatedFibers.add(fiber);
    }

    const actionScopeLane = peekEntangledActionLane();
    return actionScopeLane !== NoLane
      ? // We're inside an async action scope. Reuse the same lane.
        actionScopeLane
      : // We may or may not be inside an async action scope. If we are, this
        // is the first update in that scope. Either way, we need to get a
        // fresh transition lane.
        requestTransitionLane(transition);
  }

  return eventPriorityToLane(resolveUpdatePriority());
}

requestUpdateLane의 매개변수는 fiber즉 , container.current입니다.
그리고 mode 속성 값을 조회하여 새로운 변수에 할당하고 있구요.

지금 현재 상태에서는 modeConcurrentRoot값,즉 1입니다.

requestUpdateLane의 첫번째 if문

requestUpdateLane의 첫번째 if문 살펴보죠.

export function requestUpdateLane(fiber: Fiber): Lane {
 // Special cases
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } 

우선 disableLegacyMode가 아니라면 조건으로 시작합니다.

disableLegacyMode은 또 다른 파일에서 가져오고 있습니다.

import {
 
  disableLegacyMode,

} from 'shared/ReactFeatureFlags';

어떤 값인지 확인해보죠.

disableLegacyMode

// Disables legacy mode
// This allows us to land breaking changes to remove legacy mode APIs in experimental builds
// before removing them in stable in the next Major
export const disableLegacyMode = true;

기본값으로 true로 되어있습니다.

disableLegacyMode 변수는 리액트의 실험적 빌드에서 레거시 모드를 비활성화하는 역할을 합니다. 이를 통해 기존의 레거시 API를 제거하고 새로운 기능이나 변경 사항을 도입할 수 있습니다.

이러한 변경은 실험적 빌드에서 먼저 적용되어 테스트되며, 이후 안정적인 버전의 다음 주요 릴리스에서 정식으로 반영됩니다. 따라서 disableLegacyMode를 true로 설정하면 레거시 모드가 비활성화되어 최신 기능과 변경 사항을 미리 경험하고 검증할 수 있습니다.

그렇다면 현재 버전에서는 requestUpdateLane의 첫번째 if문 첫번째 조건문은 실행되지 않겠네요.

아래 조건문 코드를 읽으면 레거시 모드가 활성화되있다면 이라는 해석이 되니까요.

export function requestUpdateLane(fiber: Fiber): Lane {
 // Special cases
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } 

그리고 뒤에 조건인

(mode & ConcurrentMode) === NoMode)

이 코드 또한 false입니다.
(mode & ConcurrentMode)이 코드는 결국에는 ConcurrentMode&ConcurrentMode로써 값이 1이고 NodeMode값은 0이거든요.

export const NoMode = /*                         */ 0b0000000;

그래서 첫번째 조건문은 조건이 성립이 안되므로 실행되지 않습니다.

다음으로 else if문을 살펴봅시다

requestUpdateLane 함수의 else if문 분석

else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // This is a render phase update. These are not officially supported. The
    // old behavior is to give this the same "thread" (lanes) as
    // whatever is currently rendering. So if you call `setState` on a component
    // that happens later in the same render, it will flush. Ideally, we want to
    // remove the special case and treat them as if they came from an
    // interleaved event. Regardless, this pattern is not officially supported.
    // This behavior is only a fallback. The flag only exists until we can roll
    // out the setState warning, since existing code might accidentally rely on
    // the current behavior.
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }
profile
FE DEVELOPER

0개의 댓글