Initial Mount - React Core Deep Dive

류하준·2024년 4월 16일
0
post-thumbnail
post-custom-banner

jser.dev 블로그의 Initial Mount 챕터를 보며 번역하고 이해한것을 정리하였습니다. 해당 내용에 틀린점이나 뇌피셜이 포함되어있을 수 있습니다.

이전 Overview에서 React가 내부적으로 Fiber Tree를 사용하여 최소한의 DOM 업데이트를 계산하고 커밋 단계에서 실제 업데이트를 진행한다고 간략하게만 이야기했다. 이번에는 React가 초기 마운트(초기 렌더링)시에 정확히 어떤 동작을 하는지 알아보자.

1. Fiber Architecture에 대한 간략한 소개


(이미지 출처: jser.dev - Initial Mount)

Fiber는 간단하게는 React가 내부적으로 앱 상태를 어떻게 관리하는지에 대한 아키텍처이다. FiberRootNode와 FiberNodes로 구성된 트리형 구조이다. Fiber Tree에서 FiberRootNode를 제외한 모든 Node는 FiberNode이다. FiberRootNode와 FiberNode에 대해 더 자세히 살펴보자.

1.1 FiberRootNode

FiberRootNode는 React Root 역할을 하는 특수한 Node로, 전체 앱에 대한 필수 메타 정보를 보유한다. FiberRootNode의 current는 실제 Fiber Tree를 가리키며 새로운 Fiber Tree가 구성될 때마다 current는 새로운 HostRoot를 다시 가리키게 된다.

1.2 FiberNode

위에서 말했듯 FiberNode는 FiberRootNode 이외의 모든 Node를 의미한다. 이제 FiberNode의 중요한 프로퍼티들을 알아보자.

  • tag: FiberNode에는 tag로 구분되는 많은 하위 유형들이 있다. 예를 들면 FunctionComponent, HostRoot, ContextConsumer, MemoComponent, SUspenseComponent 등이 있다.
  • stateNode: stateNode는 다른 백업 데이터를 가리킨다. HostComponent의 경우를 살펴보자면 HostComponentstateNode는 실제 백업 DOM Node를 가리킨다.
  • child, sibling, return: 해당 요소들은 함께 트리 구조를 형성한다.
    (이 부분은 정확히 이해하지 못함)
  • elementType: 우리가 작성하는 함수 컴포넌트 또는 HTML tag이다.
    (이해하기로는 함수 컴포넌트명 또는 JSX에서 작성한 기본 HTML tag명)
  • flags: 커밋 단계에서 적용 할 업데이트를 나타낸다. subtreeFlags는 해당 Node의 하위 트리를 위한것이다.
  • lanes: 보류중인 업데이트의 우선순위를 나타낸다. childLanes이다는 해당 Node의 하위 트리를 위한것이다.
  • memoizedState: 추상적이지만 중요한 데이터를 가리키고 있다. 함수 컴포넌트에서는 hooks를 의미한다.
    (정확히 어떤 역할을 하는지는 정확히 이해하지 못했다. 추후 업데이트 해보자)

2. Trigger 단계의 Initial mount

우리가 잘 알고있는 React의 createRoot()함수는 더미 HostRoot FiberNode가 있는 React Root를 생성한다. 이 더미 HostRoot는 createRoot()가 생성한 root의 current로 참조할 수 있다.

자세한건 아래 ReactDOMRoot.js에서 주요 로직만을 골라낸 코드를 직접 살펴보자. 함수의 선언부와 호출 구문이 순서대로 되어있지는 않으나 주요한 내용은 모두 있으니 주석을 활용하며 꼼꼼히 살펴보자.

export function createRoot(
  container: Element | Document | DocumentFragment,
  options?: CreateRootOptions,
): RootType {
  let isStrictMode = false;
  let concurrentUpdatesByDefaultOverride = false;
  let identifierPrefix = '';
  let onRecoverableError = defaultOnRecoverableError;
  let transitionCallbacks = null;
  
  // 여기서 root 변수에는 FiberRootNode의 참조값이 담긴다.
  const root = createContainer(
    container,
    ConcurrentRoot,
    null,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks,
  );
  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);
}

export function createContainer(
  containerInfo: Container,
  tag: RootTag,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  concurrentUpdatesByDefaultOverride: null | boolean,
  identifierPrefix: string,
  onRecoverableError: (error: mixed) => void,
  transitionCallbacks: null | TransitionTracingCallbacks,
): OpaqueRoot {
  const hydrate = false;
  const initialChildren = null;
  // 위 코드에서 createContainer함수를 호출할때 createFiberRoot 함수의 return값이 반환된다.
  return createFiberRoot(
    containerInfo,
    tag,
    hydrate,
    initialChildren,
    hydrationCallbacks,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
    identifierPrefix,
    onRecoverableError,
    transitionCallbacks,
  );
}

export function createFiberRoot(
  containerInfo: Container,
  tag: RootTag,
  hydrate: boolean,
  initialChildren: ReactNodeList,
  hydrationCallbacks: null | SuspenseHydrationCallbacks,
  isStrictMode: boolean,
  concurrentUpdatesByDefaultOverride: null | boolean,
  identifierPrefix: string,
  onRecoverableError: null | ((error: mixed) => void),
  transitionCallbacks: null | TransitionTracingCallbacks,
): FiberRoot {
  // 실제로 FiberRootNode의 인스턴스가 생성되는 부분이다.
  const root: FiberRoot = (new FiberRootNode(
    containerInfo,
    tag,
    hydrate,
    identifierPrefix,
    onRecoverableError,
  ): any);
  const uninitializedFiber = createHostRootFiber(
    tag,
    isStrictMode,
    concurrentUpdatesByDefaultOverride,
  );
  
  // HostRoot의 FiberNode가 생성되어 FiberRootNode의 current에 할당된다.
  root.current = uninitializedFiber;

  uninitializedFiber.stateNode = root;
  ...
  initializeUpdateQueue(uninitializedFiber);
  return root;
}

root.render() 동작은 HostRoot에서 업데이트를 예약한다. 아래 코드는 render 함수의 동작이다.

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);
  };

root 함수의 동작에서 updateContainer 함수를 호출하고 있는데 아래 코드는 updateContainer 함수의 동작이다. 자세히 살펴보면 update라는 변수에 createUpdate 함수의 반환값을 담고 있는데 해당 반환값에 있는 payload라는 프로퍼티에 element 파라미터 값을 저장하고있다. 이때 element파라미터에 전달되는 인자는 render함수를 호출할때 전달되는 첫번째 인자값이다.

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function,
): Lane {
  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;
  }
  const update = createUpdate(lane);
  // Caution: React DevTools currently depends on this property
  // being called "element".
  
  // render() 함수를 호출할때 전달하는 첫번째 인자가 update객체의 payload 프로퍼티에 저장된다.
  update.payload = {element};
  
  // 이후 업데이트가 대기열에 추가되게 된다. 이게 어떻게 동작하는지는 해당 챕터에서 다루진 않는다. 다만 우선순위에 따라 업데이트가 처리되길 기다리고 있다는것만 알면된다.
  const root = enqueueUpdate(current, update, lane);

  if (root !== null) {
    scheduleUpdateOnFiber(root, current, lane);
    entangleTransitions(root, current, lane);
  }
  return lane;
}

3. Render 단계의 Initial mount

3.1 performConcurrentWorkOnRoot()

이전 Overview에서 다뤘듯이 PerformConcurrentWorkOnRoot()는 Initial Mount 및 Re-Render에 대한 렌더링을 시작하는 진입점이다.

한가지 주의해야 할 점은 동시성으로 이름이 지정되더라도 필요한 경우 내부적으로 여전히 동기적 모드로 돌아간다는것이다. Initial Mount는 DefaultLane이 Lane을 차단하고 있기 때문에 발생하는 경우 중 하나이다.

다음 코드를 살펴보자. 일부 코드가 생략되어 있지만 sudo 코드라고 생각하고 함수명과 변수명을 글을 읽듯이 해석한다고만 생각해보자.

shouldTimeSlice 변수는 boolean type인데 어떨때 true값이 담기는지 살펴보면 root에 blockingLane이 포함되지 않으면서 ExpiredLane이 포함되지 않고 workLoop의 스케줄러 타임아웃이 비활성화 되거나 titmeOut이 되지 않았을때이다. 말로 풀어쓰는게 사실 더 복잡하니 코드 자체를 보는걸 추천한다.

이후 shouldTimeSlice값에 따라 동시성 렌더를 할것인지 동기적 렌더링을 할것인지 결정하게 된다.

function performConcurrentWorkOnRoot(root, didTimeout) {
  ...
  // 저장된 필드를 사용하여 작업 할 다음 Lane을 결정한다.
  let lanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
  ...
  // 어떤 경우에는 time-slicing을 비활성화한다. 작업이 너무 오랫동안 CPU에 바인딩 되어 있거나 기본적으로 동기화 업데이트 모드에 있는 경우이다.
  const shouldTimeSlice =
    !includesBlockingLane(root, lanes) &&
    !includesExpiredLane(root, lanes) &&
    (disableSchedulerTimeoutInWorkLoop || !didTimeout);
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    : renderRootSync(root, lanes);
  ...
}
// Blocking means it is important, should not be interrupted.
export function includesBlockingLane(root: FiberRoot, lanes: Lanes) {
  //DefaultLane is blocking lane
  const SyncDefaultLanes =
    InputContinuousHydrationLane |
    InputContinuousLane |
    DefaultHydrationLane |
    DefaultLane;


  return (lanes & SyncDefaultLanes) !== NoLanes;
}

지속적으로 언급되는 lane에 대해 자세히 알아보고 싶다면 jser.dev블로그의 lanes-in-react포스트를 참고하세요.

위 코드에서 보이듯 Initial Mount시에는 DefaultLane이 사용되고 있기 때문에 동시 모드가 실제로는 사용되지 않음을 알 수 있다. 이는 Initial Mount시에는 최대한 빨리 UI를 렌더링 해야하는데 동시 모드를 사용하며 렌더링을 지연시키는게 이에 맞지 않기 때문이다.

3.2 renderRootSync()

renderRootSync는 내부적으로 그저 while loop를 돌고있는 함수이다.

function renderRootSync(root: FiberRoot, lanes: Lanes) {
  const prevExecutionContext = executionContext;
  executionContext |= RenderContext;
  const prevDispatcher = pushDispatcher();
  // 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) {
    if (enableUpdaterTracking) {
      if (isDevToolsPresent) {
        const memoizedUpdaters = root.memoizedUpdaters;
        if (memoizedUpdaters.size > 0) {
          restorePendingUpdaters(root, workInProgressRootRenderLanes);
          memoizedUpdaters.clear();
        }
        // At this point, move Fibers that scheduled the upcoming work from the Map to the Set.
        // If we bailout on this work, we'll move them back (like above).
        // It's important to move them now in case the work spawns more work at the same priority with different updaters.
        // That way we can keep the current update and future updates separate.
        movePendingFibersToMemoized(root, lanes);
      }
    }
    workInProgressTransitions = getTransitionsForLanes(root, lanes);
    // Root또는 Lane이 변경됐을 경우 새로운 Stack을 준비
    prepareFreshStack(root, lanes);
  }
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);
  resetContextDependencies();
  executionContext = prevExecutionContext;
  popDispatcher(prevDispatcher);
  // Set this to null to indicate there's no in-progress render.
  workInProgressRoot = null;
  workInProgressRootRenderLanes = NoLanes;
  return workInProgressRootExitStatus;
}
// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  // 해당 while문의 조건은 workInProgress가 존재하는지에 대한 여부이다.
  while (workInProgress !== null) {
    // 함수명처럼 하나의 Fiber Node 단위로 작동한다. 해당 함수는 3.3에서 더 자세히 알아보자.
    performUnitOfWork(workInProgress);
  }
}

3.2에서 중요한건 WorkInprogress가 무엇을 의미하는지 알아야한다. React코드를 살펴보다보면 currentworkInprogress prefix는 어디에나 있는데, React는 Fiber Tree를 사용하여 내부적으로 현재 상태를 나타내기 때문에 업데이트가 있을 때마다 React는 새로운 Tree를 구성하고 이전 Tree와 비교해야한다. 따라서 current는 UI에 그려지는 현재 버전을 의미하고 workInprogress는 빌드중인 다음 current 버전을 의미한다.

3.3 performUnitOfWork()

해당 함수는 React가 단일 Fiber Node에서 작동하여 수행할 작업이 있는지 확인한다.

function performUnitOfWork(unitOfWork: Fiber): void {
  // The current, flushed, state of this fiber is the alternate. Ideally
  // nothing should rely on this, but relying on it here means that we don't
  // need an additional field on the work in progress.
  const current = unitOfWork.alternate;
  let next;
  if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
    startProfilerTimer(unitOfWork);
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
    stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
  } else {
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
  }
  resetCurrentDebugFiberInDEV();
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
as mentioned, workLoopSync() is just a while loop

that keeps running completeUnitOfWork() on workInProgress

So assigning workInProgress here means setting next Fiber Node to work on

  }
  ReactCurrentOwner.current = null;
}

beginWork() 는 실제 렌더링이 일어나는 곳이다.

post-custom-banner

0개의 댓글