트리가 두 개 필요하다

ReactDOM.render를 호출해 업데이트 큐에 넣은 작업은 performWorkOnRoot에서 처리된다고 한다. 일단 받아들이자.

참고로 Stack trace를 보면 아래 경로로 호출됨을 확인할 수 있다.

performWorkOnRoot

export function performWorkOnRoot(
  root: FiberRoot,
  lanes: Lanes,
  forceSync: boolean,
): void {
  ...
  const shouldTimeSlice =
    (!forceSync &&
      // 초기 렌더에서는 blocking lane이 포함돼서 
      // shouldTimeSlice가 false가 되고...
      !includesBlockingLane(lanes) &&
      !includesExpiredLane(root, lanes)) ||
    (enableSiblingPrerendering && checkIfRootIsPrerendering(root, lanes));

  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)
    // 이게 실행된다.
    : renderRootSync(root, lanes, true);

  ...
}

lane은 렌더의 우선순위에 대한 것이라고만 알아두자. useTransition을 사용하면 우선순위가 밀리는 것처럼 렌더가 트리거된 이유에 따라 우선순위를 정하는 시스템이 리액트 내부에 존재한다.

renderRootSync

function renderRootSync(
  root: FiberRoot,
  lanes: Lanes,
  shouldYieldForPrerendering: boolean,
): RootExitStatus {
  ...
  if (...) {
    prepareFreshStack(root, lanes);
  }
  ...
  do {
    try {
      workLoopSync();
      break;
    } catch (thrownValue) {
      handleThrow(root, thrownValue);
    }
  } while (true);
  ...
}

prepareFreshStack 이후 workLoopSync를 호출한다. 각각을 살펴보자.

prepareFreshStack

리액트는 내부적으로 두 개의 Fiber 트리를 활용한다. 하나는 기존 UI, 다른 하나는 새로운 UI를 나타낸다.

리액트 소스코드에서 current는 기존 트리의 노드를 의미하고 workInProgress는 새로운 트리의 노드를 의미한다. 두 변수를 각 트리를 순회하기 위한 포인터 변수로서 활용한다.

상태에 업데이트가 발생하면 리액트는 이에 맞게 workInProgress 트리를 만들고 이를 기반으로 렌더링을 진행한다. 작업이 끝나면 workInProgress가 새로운 current가 된다.

prepareFreshStack에서는 본격적인 렌더를 시작하기 전에 workInProgress 변수를 초기화한다.

// react-reconciler/src/ReactFiberWorkLoop.js

// The fiber we're working on
let workInProgress: Fiber | null = null;
...
function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
  ...
  // `createContainer`에서 만든 HostRootFiber가...
  const rootWorkInProgress = createWorkInProgress(root.current, null);
  workInProgress = rootWorkInProgress;
  ...
}

// current로서 건네진다
export function createWorkInProgress(current: Fiber, pendingProps: any): Fibers {
  // alternate는 현재 노드의 이전 버전을 의미한다.
  let workInProgress = current.alternate;
  
  // 하지만 초기 렌더니 이전 버전이 있을리 없다.
  if (workInProgress === null) {
    workInProgress = createFiber(
        // HostRootFiber와 동일한 태그를 사용해 workInProgress를 만든다. 
        current.tag,
        pendingProps,
        current.key,
        current.mode,
      );
  }
  ...
}

alternate 필드는 해당 노드의 이전 버전을 의미함을 기억하자. 물론 지금은 첫 렌더니 존재하지 않는다.

workLoopSync

생략 없이 그대로 가져왔다. 아주 단순한 함수다!

// 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.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

저 while문이 무한루프는 아닐테니 performUnitOfWork 호출 후 어딘가에서는 workInProgress를 설정함을 의미한다. 보통 트리 순회는 재귀로 구현하는데 여기서는 while로 구현한게 인상깊다.

performUnitOfWork

performUnitOfWork에서는 현재 workInProgress가 가르키고 있는 노드에 대해 beginWork를 호출한다.

정황상 beginWork는 다음에 작업할 노드를 반환할 것이다. beginWork가 null을 반환하면, 즉 뿌리 노드라면 completeUnitOfWork를 호출한다.

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  const 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 {
    workInProgress = next;
  }
}

직접 확인해보기

예제 앱에서 performUnitOfWork의 인자 unitOfWork를 로그찍어보면 아래 순서로 순회됨을 확인할 수 있다. DFS 순회와 유사하다.

HostRoot
FunctionComponent (App)
HostComponent (div)
HostComponent (p)
FunctionComponent (Link)
HostComponent (a, completeUnitOfWork 호출됨)
HostComponent (br, completeUnitOfWork 호출됨)
HostComponent (button)
HostText (click me -, completeUnitOfWork 호출됨)
HostText ({count}, completeUnitOfWork 호출됨)

첫번째 순회 대상인 HostRoot가 어떻게 처리되는지 이어서 알아보자.

이전글목록으로다음글

로그인 중...

seongyeol