(번역) React는 내부적으로 re-render를 어떻게 처리할까?

Taegyu Hwang·2024년 4월 23일
1
  • Jser.dev의 React Internals Deep Dive 시리즈How does React re-render internally?를 번역한 글입니다.
  • 원글, 본글 모두 react 18.2.0 버전 기준입니다.
  • React 코드의 주석이 아닌, 저자가 추가한 주석엔 '저자'를 앞에 붙였습니다.

React가 initial mount를 수행하고, 전체 DOM을 만드는 과정을 처음부터 살펴보았습니다. initial mount 이후, re-render를 할 때 React는 재조정 과정을 통해 가능한 DOM을 재사용하려고 합니다. 이번 글에선, React가 실제로 어떻게 re-render를 하는지, 아래 데모의 버튼을 누름으로써 알아봅시다.

import {useState} from 'react'
  function Link() {
    return <a href="https://jser.dev">jser.dev</a>;
  }

  function Component() {
    const [count, setCount] = useState(0);
    return (
      <div>
      <button onClick={() => setCount((count) => count + 1)}>
        click me - {count} 
      </button> ({count % 2 === 0 ? <span>even</span> : <b>odd</b>})
      </div>
    );
  }
  export default function App() {
    return (
      <div>
        <Link />
        <br />
        <Component />
      </div>
    );
  }

sandbox

1. Re-render의 Trigger 단계

React는 initial mount에서 Fiber 트리와 DOM 트리는 구성하며, mount가 완료되면, 아래와 같이 두 개의 트리가 생깁니다.

1.1 laneschildLanes

Lane은 보류 중인 작업의 우선순위입니다. Fiber 노드의 경우, 아래와 같습니다.

  1. lanes => 본인의 보류 중인 작업용
  2. childLanes => 하위 트리의 보류 중인 작업용

Lane에 대한 더 자세한 내용은 What are Lanes in React source code? 글을 참고해주세요.

버튼을 클릭하면, setState()가 호출됩니다.

  1. root에서 대상 Fiber까지의 경로는 다음 render에서 확인할 위치를 표시하기 위해, laneschildLanes로 표시합니다.
  2. 업데이트는 scheduleUpdateOnFiber()에 의해 스케줄링 되며, 이후에 ensureRootIsScheduled()가 호출되고, performConcurrentWorkOnRoot()가 스케줄러에서 예약됩니다. 이는 initial mount 때와 유사합니다.

한 가지 중요한 점은, 이벤트의 우선순위에 따라 업데이트의 우선순위가 결정된다는 점입니다. click 이벤트의 경우, 높은 우선순위의 SyncLane에 매핑되는 DiscreteEventPriority입니다.

useState()에 대한 더 자세한 내용은 How does useState() work internally in React? 글을 참고해주세요.

자세한 내용은 지금은 생략하고, 아래와 같은 Fiber 트리를 따라 작업하게 됩니다.

2. Re-render의 Render 단계

2.1 기본적인 렌더링 로직은 initial mount와 동일합니다.

click 이벤트에서, 렌더링 lane은 blocking lane인 SyncLane입니다. 그렇기 때문에, initial mount 때와 마찬가지로, performConcurrentWorkOnRoot() 안에서 '동시' 모드가 활성화되지 않습니다.

'동시' 모드가 활성화되는 케이스가 궁금하다면, How does useTransition() work internally in React? 글을 참고해주세요.

아래는 전체 과정을 요약한 코드입니다.

do {
  try {
    workLoopSync();
    break;
  } catch (thrownValue) {
    handleError(root, thrownValue);
  }
} while (true);

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  
  next = beginWork(current, unitOfWork, subtreeRenderLanes);

  // 저자) 이 줄은 중요합니다. 아래 2.5 memoizedProps vs pendingProps 에서 다룹니다.
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }
}

더 자세한 설명은 이전 글을 참고하세요. 여기서는 React가 Fiber 트리를 순회하며, 필요한 경우 업데이트한다는 것만 기억하세요.

2.2 React는 새로운 Fiber 노드를 생성하기 전에, 중복된 Fiber 노드를 재사용합니다.

initial mount에서 우리는 Fiber 노드가 처음부터 생성되는 것을 봤었습니다. 하지만, 원래 React는 Fiber 노드를 재사용하는 것을 시도합니다.

export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  // current는 현재 버전이고, current의 alternate는 그 이전 버전을 의미합니다.
  let workInProgress = current.alternate;

  // 저자) 처음부터 만들어야하는 경우
  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
    ...
    workInProgress.alternate = current;
    current.alternate = workInProgress;
    // 저자) 이전 것을 재사용할 수 있는 경우
  } else {
    // 저자) 재사용할 수 있기 때문에 Fiber 노드를 새로 만들지 않고, property들을 업데이트함으로써 재사용합니다.
    workInProgress.pendingProps = pendingProps;
    // Needed because Blocks store data on type.
    workInProgress.type = current.type;
    // We already have an alternate.
    // Reset the effect tag.
    workInProgress.flags = NoFlags;
    // The effects are no longer valid.
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }
  // Reset all effects except static ones.
  // Static effects are not specific to a render.
  workInProgress.flags = current.flags & StaticMask;
  // 저자) lanes와 childLanes는 복사됩니다.
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;
  ...
  return workInProgress;
}

FiberRootNode는 current를 통해 현재 Fiber 트리를 가리키기 때문에, 현재 트리에 없는 모든 Fiber 노드는 재사용될 수 있습니다.

재렌더링 과정에서, 중복된 HostRootprepareFreshStack()에서 재사용됩니다.

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);
  
  workInProgress = rootWorkInProgress;
  workInProgressRootRenderLanes = subtreeRenderLanes = workInProgressRootIncludedLanes = lanes;
  workInProgressRootExitStatus = RootInProgress;
  workInProgressRootFatalError = null;
  workInProgressRootSkippedLanes = NoLanes;
  workInProgressRootInterleavedUpdatedLanes = NoLanes;
  workInProgressRootRenderPhaseUpdatedLanes = NoLanes;
  workInProgressRootPingedLanes = NoLanes;
  workInProgressRootConcurrentErrors = null;
  workInProgressRootRecoverableErrors = null;
  
  finishQueueingConcurrentUpdates();
  return rootWorkInProgress;
}

그러므로, 우리는 다음 head부터 재렌더링을 시작합니다.

색을 좀 입혀봅시다.

2.3 beginWork()의 업데이트 분기

beginWork()에는 initial mount 글에서는 다루지 않았던, 업데이트를 처리하는 중요한 브랜치가 있습니다.

function beginWork(
  // 저자) current는 지금 paint된 현재 버전입니다.
  current: Fiber | null,
  // 저자) workInProgress는 앞으로 paint될 새 버전입니다.
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // 저자) current가 null이 아니라면, initial mount가 아니라는 것을 의미로, 이전 버전의 Fiber 노드를 가지고 있으며, HostComponent인 경우 DOM 노드도 가지고 있습니다. 그러므로, React는 서브트리 깊숙하게 들어가는 것을 피하도록 최적화할 수 있습니다. - 이를 bailout 이라고 합니다!
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    if (
      // 저자) React는 얕은 비교가 아닌 === 를 사용하는데, 이것이 React 렌더링의 중요 동작에 영향을 미칩니다.
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      // Force a re-render if the implementation changed due to hot reload:
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      // If props or context changed, mark the fiber as having performed work.
      // This may be unset if the props are determined to be equal later (memo).
      didReceiveUpdate = true;
    } else {
      // Neither props nor legacy context changes. Check if there's a pending
      // update or context change.
      // 저자) Fiber의 lane을 확인합니다.
      const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
        current,
        renderLanes,
      );
      if (
        !hasScheduledUpdateOrContext &&
        // If this is the second pass of an error or suspense boundary, there
        // may not be work scheduled on `current`, so we check for this flag.
        (workInProgress.flags & DidCapture) === NoFlags
      ) {
        // No pending updates or context. Bail out now.
        didReceiveUpdate = false;
        // 이 Fiber에 업데이트가 없는 경우, React는 bailout을 시도하지만, 이는 props나 context의 변화가 없을 때만 가능합니다.
        return attemptEarlyBailoutIfNoScheduledUpdate(
          current,
          workInProgress,
          renderLanes,
        );
      }
     ...
    }
  } else {
    // 저자) 전에 다뤘던 mount 분기입니다.
    didReceiveUpdate = false;
    ...
  }
  workInProgress.lanes = NoLanes;
    
  switch (workInProgress.tag) {
    case IndeterminateComponent: {
      return mountIndeterminateComponent(
        current,
        workInProgress,
        workInProgress.type,
        renderLanes,
      );
    }
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes,
      );
    }
    case HostRoot:
      return updateHostRoot(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      return updateHostText(current, workInProgress);
    ...
  }
}

2.4 attemptEarlyBailoutIfNoScheduledUpdate() 내부의 로직 살펴보기

이름대로, 이 함수는 렌더링이 불필요한 경우 가능한 빨리 중지하려고 노력합니다.

function attemptEarlyBailoutIfNoScheduledUpdate(
  current: Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  // This fiber does not have any pending work. Bailout without entering
  // the begin phase. There's still some bookkeeping we that needs to be done
  // in this optimized path, mostly pushing stuff onto the stack.
  switch (workInProgress.tag) {
    case HostRoot:
      pushHostRootContext(workInProgress);
      const root: FiberRoot = workInProgress.stateNode;
      pushRootTransition(workInProgress, root, renderLanes);
      
      if (enableCache) {
        const cache: Cache = current.memoizedState.cache;
        pushCacheProvider(workInProgress, cache);
      }
      resetHydrationState();
      break;
    case HostComponent:
      pushHostContext(workInProgress);
      break;
    ...
  }
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  if (current !== null) {
    // Reuse previous dependencies
    workInProgress.dependencies = current.dependencies;
  }
  // Check if the children have any pending work.
  // 저자) 여기서 childLane들이 체크되는 것을 볼 수 있습니다.
  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
    // The children don't have any work either. We can skip them.
    // TODO: Once we add back resuming, we should check if the children are
    // a work-in-progress set. If so, we need to transfer their effects.
    if (enableLazyContextPropagation && current !== null) {
      // Before bailing out, check if there are any context changes in
      // the children.
      lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
      if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
        return null;
      }
    } else {
      // 저자) 만약 fiber 자신과 그 서브 트리에 업데이트가 없다면, 당연히 더 깊게 갈 필요가 없기 때문에 null을 return 합니다.
      return null;
    }
  }
  // This fiber doesn't have work, but its subtree does. Clone the child
  // fibers and continue.
  // 저자) 비록 이름은 clone이지만, 실제로는 새 children 노드들을 만들거나 이전 노드들을 재사용합니다.
  cloneChildFibers(current, workInProgress);
  // 저자) child를 직접 반환하고, React는 이를 다음 fiber로 처리합니다.
  return workInProgress.child;
}

export function cloneChildFibers(
  current: Fiber | null,
  workInProgress: Fiber,
): void {
  if (current !== null && workInProgress.child !== current.child) {
    throw new Error('Resuming work not yet implemented.');
  }
  if (workInProgress.child === null) {
    return;
  }
  let currentChild = workInProgress.child;
  // 저자) cloneChildFibers() 함수에서는, 자식 Fiber들이 이전 버전에서 생성되지만, 조정 과정에서 설정된 새로운 pendingProps를 가집니다.
  let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);

  workInProgress.child = newChild;
  newChild.return = workInProgress;
  while (currentChild.sibling !== null) {
    currentChild = currentChild.sibling;
    newChild = newChild.sibling = createWorkInProgress(
      currentChild,
      currentChild.pendingProps,
    );
    newChild.return = workInProgress;
  }
  newChild.sibling = null;
}

bailout 과정을 요약해봅시다.

  1. Fiber가 props나 context의 변경이 없고, 보류 중인 작업이 없는 경우 (빈 lanes)
    1. children도 보류 중인 작업이 없는 경우 (빈 childLanes), bailout이 일어나며 React는 트리 깊숙한 곳으로 가지 않습니다.
    2. 그게 아니라면, React는 이 fiber를 재렌더링 하지 않고 곧장 children으로 넘어갑니다.
  2. 그게 아니라면, React는 이 fiber를 먼저 재렌더링하고, 이후 children으로 넘어갑니다.

    bailout에 대해 더 자세히 알고 싶다면, How does React bailout work in reconciliation 글을 참고하세요.

2.5 memoizedProps vs pendingProps

beginWork()에서 workInProgresscurrent와 비교됩니다. props의 경우, workInProgress.pendingPropscurrent.memoizedProps와 비교됩니다. memoizedProps를 현재 props로, pendingProps를 다음 버전의 props로 보면 됩니다.

React는 렌더 단계에서 새로운 Fiber 트리를 생성한 뒤 현재 Fiber 트리와 비교합니다. pendingProps는 실제로 workInProgress 생성을 위한 매개변수임을 알 수 있습니다.

export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  // 저자) current는 현재 버전이고, current의 alternate는 그 이전 버전을 의미합니다.
  let workInProgress = current.alternate;

  // 저자) 처음부터 만들어야하는 경우 
  if (workInProgress === null) {
    // We use a double buffering pooling technique because we know that we'll
    // only ever need at most two versions of a tree. We pool the "other" unused
    // node that we're free to reuse. This is lazily created to avoid allocating
    // extra objects for things that are never updated. It also allow us to
    // reclaim the extra memory if needed.
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
    ...
    workInProgress.alternate = current;
    current.alternate = workInProgress;
    // 저자) 이전 것을 재사용할 수 있는 경우
  } else {
    // 저자) 재사용할 수 있기 때문에 Fiber 노드를 새로 만들지 않고, property들을 업데이트함으로써 재사용합니다.
    workInProgress.pendingProps = pendingProps;
    // Needed because Blocks store data on type.
    workInProgress.type = current.type;
    // We already have an alternate.
    // Reset the effect tag.
    workInProgress.flags = NoFlags;
    // The effects are no longer valid.
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }
  // Reset all effects except static ones.
  // Static effects are not specific to a render.
  workInProgress.flags = current.flags & StaticMask;
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;
  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;
  // Clone the dependencies object. This is mutated during the render phase, so
  // it cannot be shared with the current fiber.
  const currentDependencies = current.dependencies;
  workInProgress.dependencies =
    currentDependencies === null
      ? null
      : {
          lanes: currentDependencies.lanes,
          firstContext: currentDependencies.firstContext,
        };
  // These will be overridden during the parent's reconciliation
  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;
  workInProgress.refCleanup = current.refCleanup;
  
  return workInProgress;
}

실제로, root FiberNode의 생성자에는 pendingProps가 매개변수로 있습니다.

function createFiber(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {
  // $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
  return new FiberNode(tag, pendingProps, key, mode);
}

function FiberNode(
  this: $FlowFixMe,
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  ....
}

Fiber 노드 생성은 아주 초반 작업이며 이후 추가 작업이 필요합니다.

그리고 memoizedPropsperformUnitOfWork() 내부에서 Fiber의 재렌더링이 완료될 때 pendingProps로 설정됩니다.

function performUnitOfWork(unitOfWork: Fiber): void {
  const current = unitOfWork.alternate;
  setCurrentDebugFiberInDEV(unitOfWork);
  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();
  // 저자) memoizedProps는 작업이 완료된 후에 업데이트 됩니다.
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

  ReactCurrentOwner.current = null;
}

이제 우리의 데모를 살펴봅시다.

  1. React는 HostRood(lanes: 0, childLanes: 1)에서 작업을 시작합니다. HostRoot는 props가 없고, memoizedPropspendingProps 모두 null이기 때문에, React는 자식인 복제된 <App/>으로 직행합니다.
  2. React는 <App/>(lanes: 0, childLanes: 1)에서 작업합니다. App 컴포넌트는 리렌더링되지 않아, memoizedPropspendingProps가 같으므로, React는 다시 복제된 자식인 div로 직행합니다.
  3. React는 <div/>(lanes: 0, childLanes: 1)에서 작업합니다. 이는 App에서 자식을 받지만, App은 재실행되지 않아 자식들(<Link/>, <br/>, <Component/>)은 변경되지 않습니다. 따라서 다시 React는 <Link/>로 직행합니다.
  4. React는 <Link/>(lanes: 0, childLanes: 0)에서 작업합니다. 이번에는 더 깊게 들어갈 필요도 없기 때문에 여기서 멈추고 형제인 <br/>로 이동합니다.
  5. React는 <br/>(lanes: 0, childLanes: 0)에서 작업하며, bailout이 또 발생해, <Component/>로 이동합니다.

이제 좀 달라집니다. <Component/>lanes 값이 1. 즉, React는 이 컴포넌트와 그 자식들을 재렌더링하고 조정해야합니다. 이 과정은 updateFunctionComponent(current, workInProgress)를 통해 수행됩니다.

지금까지의 상태는 다음과 같습니다.

2.6 함수 컴포넌트의 re-render와 children이 이뤄지는 updateFunctionComponent()

function updateFunctionComponent(
  current,
  workInProgress,
  Component,
  nextProps: any,
  renderLanes,
) {
  let context;
  if (!disableLegacyContext) {
    const unmaskedContext = getUnmaskedContext(workInProgress, Component, true);
    context = getMaskedContext(workInProgress, unmaskedContext);
  }
    
  let nextChildren;
  let hasId;
  prepareToReadContext(workInProgress, renderLanes);
    
  // 저자) 새 children을 생성하기 위해 컴포넌트가 실행됨을 의미합니다.
  nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes,
  );

  hasId = checkDidRenderIdHook();
  if (enableSchedulingProfiler) {
    markComponentRenderStopped();
  }
    
  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  // 저자) nextChildren을 전달하고 reconileChildren()이 호출됩니다.
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
    
  return workInProgress.child;
}

우리는 이전 글에서 reconcilChildren()을 만났습니다. 이 함수는 children의 타입에 따라 내부적으로 몇 가지 variation들이 있습니다. 그 중 세 가지에 집중해보겠습니다.

그들은 모두 새 자식 Fiber를 생성하지만, 기존 Fiber를 재사용하는 것을 시도한다는 점을 잊지 마세요.

function reconcileChildFibersImpl(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  ...
  // Handle object types
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          // 저자) child가 하나인 경우
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes,
          ),
        );
      case REACT_PORTAL_TYPE:
       ...
      case REACT_LAZY_TYPE:
        ...
    }
        
    if (isArray(newChild)) {
      // 저자) children이 배열인 경우
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes,
      );
    }
    ...
  }
    
  if (
    (typeof newChild === 'string' && newChild !== '') ||
    typeof newChild === 'number'
  ) {
    return placeSingleChild(
      // children이 text인 경우
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        lanes,
      ),
    );
  }
        
  // Remaining cases are all treated as empty.
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

<Component/>의 경우 단일 div를 반환합니다. 따라서 reconcileSingleElement()로 가보겠습니다.

2.7 reconcileSingleElement()

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  // 저자) Component()의 반환값인 <div/>입니다.
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  while (child !== null) {
    // TODO: If key === null and child.key === null, then this only applies to
    // the first item in the list.
    if (child.key === key) {
      const elementType = element.type;
      if (elementType === REACT_FRAGMENT_TYPE) {
        ...
      } else {
        if (
          // 저자) 타입들이 같다면, 재사용할 수 있습니다. 아니라면, 그냥 deleteChild() 해버립니다.
          child.elementType === elementType ||
          // Keep this check inline so it only runs on the false path:
          (__DEV__
            ? isCompatibleFamilyForHotReloading(child, element)
            : false) ||
          // Lazy types should reconcile their resolved type.
          // We need to do this after the Hot Reloading check above,
          // because hot reloading has different semantics than prod because
          // it doesn't resuspend. So we can't let the call below suspend.
          (typeof elementType === 'object' &&
            elementType !== null &&
            elementType.$$typeof === REACT_LAZY_TYPE &&
            resolveLazy(elementType) === child.type)
        ) {
          deleteRemainingChildren(returnFiber, child.sibling);
          // 저자) 기존에 존재하던 Fiber를 새 props와 함께 재사용합니다. props는 <div/>의 props입니다.
          const existing = useFiber(child, element.props);
          existing.ref = coerceRef(returnFiber, child, element);
          existing.return = returnFiber;
          return existing;
        }
      }
      // Didn't match.
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
    
  if (element.type === REACT_FRAGMENT_TYPE) {
    ...
  } else {
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;
  }
}

그리고 useFiber에서 React는 새로 생성하거나, 이전 버전을 재사용합니다. 앞서 말했듯, pendingProps(자식을 포함하는)가 설정됩니다.

function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
  // We currently set sibling to null and index to 0 here because it is easy
  // to forget to do before returning it. E.g. for the single child case.
  const clone = createWorkInProgress(fiber, pendingProps);
  clone.index = 0;
  clone.sibling = null;
  return clone;
}

따라서 컴포넌트가 재렌더링된 후, React는 새로운 자식인 <div/>로 이동하며, current 버전은 laneschildLanes 모두 비어있습니다.

2.8 컴포넌트가 재렌더링 되면, 하위 트리는 기본적으로 리렌더링됩니다.

<div/>와 그 자식들은 예정된 작업이 없으므로, bailout 된다고 생각할 수 있지만, 그렇지 않습니다.

beginWork()에서 memoizedPropspendingProps를 확인한다는 점을 기억하세요.

const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
  // 저자) 얕은 비교 대신, === 를 사용합니다.
  oldProps !== newProps ||
  hasLegacyContextChanged() ||
  // Force a re-render if the implementation changed due to hot reload:
  (__DEV__ ? workInProgress.type !== current.type : false)
) {
  // If props or context changed, mark the fiber as having performed work.
  // This may be unset if the props are determined to be equal later (memo).
  didReceiveUpdate = true;
} 

컴포넌트가 렌더링 될떄마다 React elements가 포함된 새 객체를 생성하기 때문에 pendingProps는 매번 새롭게 생성되며, props을 비교할 때는 얕은 비교를 하지 않는 다음 점에 유의하세요.

<div/>의 경우 Component()가 샐행되면, 항상 새로운 props를 받기 때문에 bailout이 발생할 수 없습니다.

그러므로, React는 updateHostComponent() 분기로 이동합니다.

2.9 updateHostComponent()

function updateHostComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  pushHostContext(workInProgress);
    
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }
    
  const type = workInProgress.type;
  const nextProps = workInProgress.pendingProps;
  const prevProps = current !== null ? current.memoizedProps : null;
  
  let nextChildren = nextProps.children;
  const isDirectTextChild = shouldSetTextContent(type, nextProps);
  if (isDirectTextChild) {
    // We special case a direct text child of a host node. This is a common
    // case. We won't handle it as a reified child. We will instead handle
    // this in the host environment that also has access to this prop. That
    // avoids allocating another HostText fiber and traversing it.
    nextChildren = null;
  } else if (prevProps !== null && shouldSetTextContent(type, prevProps)) {
    // If we're switching from a direct text child to a normal child, or to
    // empty, we need to schedule the text content to be reset.
    workInProgress.flags |= ContentReset;
  }
    
  markRef(current, workInProgress);
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

nextChildren 은 다음과 같습니다.

[
  {$$typeof: Symbol(react.element), type: 'button'},
  " (", 
  {$$typeof: Symbol(react.element), type: 'b'}, 
  ")"
]

따라서, 내부적으로 React는 reconcileChildrenArray()로 조정합니다.

그리고 currentmemoizedProps은 다음과 같습니다.

[
  {$$typeof: Symbol(react.element), type: 'button'},
  " (", 
  {$$typeof: Symbol(react.element), type: 'span'}, 
  ")"
]

2.10 reconcileChildrenArray()는 필요에 따라 fiber를 만들고 삭제합니다.

reconcileChildrenArray()는 다소 복잡합니다. key가 존재하면, element의 재배치가 있는지 확인하고, fiber를 재사용하려고 시도하며 추가적인 최적화를 진행합니다.

key에 대해 더 알고 싶다면, How does 'key' work internally? List diffing in React 글을 참고하세요.

그러나, 우리 데모에서는 key가 없기 때문에 기본 분기로 이동합니다.

function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<any>,
    lanes: Lanes,
  ): Fiber | null {
    let resultingFirstChild: Fiber | null = null;
    let previousNewFiber: Fiber | null = null;
      
    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;
    // 저자) children 요소들에 대한 current fiber 확인
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      // 저자) 목록의 각 fibersms 새 props과 함께 확인됩니다.
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        lanes,
      );
      if (newFiber === null) {
        // TODO: This breaks on empty slots like null children. That's
        // unfortunate because it triggers the slow path all the time. We need
        // a better way to communicate whether this was a miss or null,
        // boolean, undefined, etc.
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      if (shouldTrackSideEffects) {
        if (oldFiber && newFiber.alternate === null) {
          // We matched the slot, but we didn't reuse the existing fiber, so we
          // need to delete the existing child.
          // 저자) 만약 fiber가 재사용될 수 없다면, 'Deletion'으로 마크되어, commit 단계에서 해당 DOM이 삭제됩니다.
          deleteChild(returnFiber, oldFiber);
        }
      }
      // 저자) fiber를 'Insertion'으로 마크합니다.
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        // TODO: Move out of the loop. This only happens for the first run.
        resultingFirstChild = newFiber;
      } else {
        // TODO: Defer siblings if we're not at the right index for this slot.
        // I.e. if we had null values before, then we want to defer this
        // for each null value. However, we also don't want to call updateSlot
        // with the previous one.
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
    }
    if (newIdx === newChildren.length) {
      // We've reached the end of the new children. We can delete the rest.
      deleteRemainingChildren(returnFiber, oldFiber);
      if (getIsHydrating()) {
        const numberOfForks = newIdx;
        pushTreeFork(returnFiber, numberOfForks);
      }
      return resultingFirstChild;
    }
    ...
    return resultingFirstChild;
  }

updateSlot()은 기본적으로 key를 고려하여 새 props와 함께 fiber를 새로 생성하거나 재사용할 뿐입니다.

function updateSlot(
  returnFiber: Fiber,
  oldFiber: Fiber | null,
  newChild: any,
  lanes: Lanes,
): Fiber | null {
  // Update the fiber if the keys match, otherwise return null.
  const key = oldFiber !== null ? oldFiber.key : null;
  if (
    (typeof newChild === 'string' && newChild !== '') ||
    typeof newChild === 'number'
  ) {
    // Text nodes don't have keys. If the previous node is implicitly keyed
    // we can continue to replace it without aborting even if it is not a text
    // node.
    if (key !== null) {
      return null;
    }
    return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
  }
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        if (newChild.key === key) {
          return updateElement(returnFiber, oldFiber, newChild, lanes);
        } else {
          return null;
        }
      }
      ...
    }
  }
    
  return null;
}
    
function updateElement(
  returnFiber: Fiber,
  current: Fiber | null,
  element: ReactElement,
  lanes: Lanes,
): Fiber {
  const elementType = element.type;
  if (elementType === REACT_FRAGMENT_TYPE) {
    return updateFragment(
      returnFiber,
      current,
      element.props.children,
      lanes,
      element.key,
    );
  }
  if (current !== null) {
    if (
      // 저자) 재사용 될 수 있는 경우
      current.elementType === elementType ||
      // Keep this check inline so it only runs on the false path:
      (__DEV__
        ? isCompatibleFamilyForHotReloading(current, element)
        : false) ||
      // Lazy types should reconcile their resolved type.
      // We need to do this after the Hot Reloading check above,
      // because hot reloading has different semantics than prod because
      // it doesn't resuspend. So we can't let the call below suspend.
      (typeof elementType === 'object' &&
        elementType !== null &&
        elementType.$$typeof === REACT_LAZY_TYPE &&
        resolveLazy(elementType) === current.type)
    ) {
      // Move based on index
      // 저자) useFiber()가 다시 나오네요!
      const existing = useFiber(current, element.props);
      existing.ref = coerceRef(returnFiber, current, element);
      existing.return = returnFiber;
      return existing;
    }
  }
  // Insert
  // 저자) 타입이 달라서 재사용할 수 없는 경우 fiber를 처음부터 만듭니다.
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, current, element);
  created.return = returnFiber;
  return created;
}

따라서 <div/>의 경우, updateSlot()은 세 개의 자식은 성공적으로 재사용했지만, 네 번째는 아닙니다. 왜냐하면, 현재는 currentspan이지만, 우리는 b를 원하기 때문입니다. 그러므로 span의 fiber는 처음부터 새로 생성되고, b의 fiber는 deleteChild()에 의해 삭제됩니다. 새로 생성된 spanplaceChild()에 의해 마크됩니다.

2.11 placeChild()deleteChild()는 fiber를 flag로 표시합니다.

Component아래 <div/>의 자식에 대해 fiber 노드를 마킹하는 두 함수가 있습니다.

function placeChild(
  newFiber: Fiber,
  lastPlacedIndex: number,
  newIndex: number,
): number {
  newFiber.index = newIndex;
  if (!shouldTrackSideEffects) {
    // During hydration, the useId algorithm needs to know which fibers are
    // part of a list of children (arrays, iterators).
    newFiber.flags |= Forked;
    return lastPlacedIndex;
  }
  const current = newFiber.alternate;
  if (current !== null) {
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // This is a move.
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    } else {
      // This item can stay in place.
      return oldIndex;
    }
  } else {
    // This is an insertion.
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
  if (!shouldTrackSideEffects) {
    // Noop.
    return;
  }
  const deletions = returnFiber.deletions;
  if (deletions === null) {
    returnFiber.deletions = [childToDelete];
    returnFiber.flags |= ChildDeletion;
  } else {
    deletions.push(childToDelete);
  }
}

삭제되어야하는 fiber들이 부모 fiber의 배열에 임시로 배치되는 것을 볼 수 있습니다. 삭제 후에는 새 fiber 트리에는 더 이상 존재하지 않지만, commit 단계에서는 처리되어야하므로 어딘가에 저장하기 위해 필요합니다.

좋습니다. <div/> 작업이 끝났어요.

다음으로 React는 button으로 갑니다. 작업이 예약되지 않았다고 생각했지만, React는 여전히 updateHostComponent()에서 작동합니다. 왜냐하면, props가 ["click me-", "1"]에서 ["click me-", "2"]
로 변경되었기 때문입니다.

HostText의 경우 props가 문자열이기 때문에, 첫번 째 "click me-"는 bailout 됩니다. 그 다음, React는 updateHostText()로 다음 텍스트를 조정합니다.

2.12 updateHostText()

function updateHostText(current, workInProgress) {
  if (current === null) {
    tryToClaimNextHydratableInstance(workInProgress);
  }
  // Nothing to do here. This is terminal. We'll do the completion step
  // immediately after.
  return null;
}

Again it doesn’t nothing, because the updates are marked in complete phase - completeWork(). This is also explained in initial mount.

2.13 completeWork()는 HostComponet의 업데이트를 표시하고 필요시 DOM 노드를 생성합니다.

function completeWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  const newProps = workInProgress.pendingProps;
  // Note: This intentionally doesn't check if we're hydrating because comparing
  // to the current tree provider fiber is just as fast and less error-prone.
  // Ideally we would have a special version of the work loop only
  // for hydration.
  popTreeContext(workInProgress);
  switch (workInProgress.tag) {
    case IndeterminateComponent:
    case LazyComponent:
    case SimpleMemoComponent:
    case FunctionComponent:
    case ForwardRef:
    case Fragment:
    case Mode:
    case Profiler:
    case ContextConsumer:
    case MemoComponent:
      bubbleProperties(workInProgress);
      return null;
    case HostComponent: {
      popHostContext(workInProgress);
      const rootContainerInstance = getRootHostContainer();
      const type = workInProgress.type;
      // 저자) 이번에 우리가 타고 있는 업데이트 분기입니다.
      if (current !== null && workInProgress.stateNode != null) {
        updateHostComponent(
          current,
          workInProgress,
          type,
          newProps,
          rootContainerInstance,
        );
        if (current.ref !== workInProgress.ref) {
          markRef(workInProgress);
        }
        // 저자) 지난 글에서 탔던 initial mount 분기입니다.
      } else {
        ...
      }
      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);
        // 저자) 지난 글에서 탔던 initial mount 분기입니다.
      } else {
        ...
        if (wasHydrated) {
          if (prepareToHydrateHostTextInstance(workInProgress)) {
            markUpdate(workInProgress);
          }
        } else {
          workInProgress.stateNode = createTextInstance(
            newText,
            rootContainerInstance,
            currentHostContext,
            workInProgress,
          );
        }
      }
      bubbleProperties(workInProgress);
      return null;
    }
    ...
  }
}
// 저자) complete 단계의 updateHostText()와는 다릅니다.
updateHostText = function(
  current: Fiber,
  workInProgress: Fiber,
  oldText: string,
  newText: string,
) {
  // If the text differs, mark it as an update. All the work in done in commitWork.
  if (oldText !== newText) {
    markUpdate(workInProgress);
  }
};

updateHostComponent = function(
  current: Fiber,
  workInProgress: Fiber,
  type: Type,
  newProps: Props,
  rootContainerInstance: Container,
) {
  // If we have an alternate, that means this is an update and we need to
  // schedule a side-effect to do the updates.
  const oldProps = current.memoizedProps;
  if (oldProps === newProps) {
    // In mutation mode, this is sufficient for a bailout because
    // we won't touch this node even if children changed.
    return;
  }
  
  // If we get updated because one of our children updated, we don't
  // have newProps so we'll have to reuse them.
  // TODO: Split the update API as separate for the props vs. children.
  // Even better would be if children weren't special cased at all tho.
  const instance: Instance = workInProgress.stateNode;
  const currentHostContext = getHostContext();
  // TODO: Experiencing an error where oldProps is null. Suggests a host
  // component is hitting the resume path. Figure out why. Possibly
  // related to `hidden`.
  const updatePayload = prepareUpdate(
    instance,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
    currentHostContext,
  );
  // TODO: Type this specific to this type of component.
  // 저자) 업데이트는 updateQueue에 저장됩니다. Effect Hook과 같은 hook들에서도 사용됩니다.
  workInProgress.updateQueue = (updatePayload: any);
  // If the update payload indicates that there is a change or if there
  // is a new ref we mark this as an update. All the work is done in commitWork.
  if (updatePayload) {
    markUpdate(workInProgress);
  }
};
function markUpdate(workInProgress: Fiber) {
  // Tag the fiber with an update effect. This turns a Placement into
  // a PlacementAndUpdate.
  // 저자) 넵! 또 다른 플래그입니다.
  workInProgress.flags |= Update;
}

Render 단계가 끝났고, 다음과 같은 결과를 얻었습니다.

  1. b의 삽입
  2. span의 삭제
  3. HostText의 업데이트
  4. button의 업데이트 (내부적으론 비어 있음)

한 가지 찝고 싶은 것은, button과 그 부모인 div에 대해 prepareUpdate()가 실행되었지만 div에 대해서는 null을, button에 대해서는 []가 생성되었다는 점입니다. 이것은 여기선 다루지 않을 까다로운 엣지 케이스입니다.

Commit 단계에서 이 업데이트들을 커밋할 차례입니다.

3. Re-render의 Commit 단계

3.1 commitMutationEffectsOnFiber()가 삽입/삭제/업데이트 커밋을 시작합니다.

function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;
    
  // The effect flag should be checked *after* we refine the type of fiber,
  // because the fiber tag is more specific. An exception is any flag related
  // to reconciliation, because those can be set on all fiber types.
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      // 저자) 우선 children을 재귀적으로 처리합니다.
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      // 저자) 그리고 나서 삽입합니다.
      commitReconciliationEffects(finishedWork);
      // 저자) 마지막으로 업데이트합니다.
      if (flags & Update) {
        try {
          commitHookEffectListUnmount(
            HookInsertion | HookHasEffect,
            finishedWork,
            finishedWork.return,
          );
          commitHookEffectListMount(
            HookInsertion | HookHasEffect,
            finishedWork,
          );
        } catch (error) {
          captureCommitPhaseError(finishedWork, finishedWork.return, error);
        }
        ...
      }
      return;
    }
    case HostComponent: {
      // 저자) 먼저 children을 재귀적으로 처리합니다.
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      // 저자) 그리고 삽입합니다.
      commitReconciliationEffects(finishedWork);
      
      if (supportsMutation) {
        // TODO: ContentReset gets cleared by the children during the commit
        // phase. This is a refactor hazard because it means we must read
        // flags the flags after `commitReconciliationEffects` has already run;
        // the order matters. We should refactor so that ContentReset does not
        // rely on mutating the flag during commit. Like by setting a flag
        // during the render phase instead.
        if (finishedWork.flags & ContentReset) {
          const instance: Instance = finishedWork.stateNode;
          try {
            resetTextContent(instance);
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
        }
        // 저자) 마지막으로 업데이트 합니다.
        if (flags & Update) {
          const instance: Instance = finishedWork.stateNode;
          if (instance != null) {
            // Commit the work prepared earlier.
            const newProps = finishedWork.memoizedProps;
            // For hydration we reuse the update path but we treat the oldProps
            // as the newProps. The updatePayload will contain the real change in
            // this case.
            const oldProps =
              current !== null ? current.memoizedProps : newProps;
            const type = finishedWork.type;
            // TODO: Type the updateQueue to be specific to host components.
            const updatePayload: null | UpdatePayload = (finishedWork.updateQueue: any);
            finishedWork.updateQueue = null;
            if (updatePayload !== null) {
              try {
                // 저자) HostComponent의 경우 props 업데이트만 합니다.
                commitUpdate(
                  instance,
                  updatePayload,
                  type,
                  oldProps,
                  newProps,
                  finishedWork,
                );
              } catch (error) {
                captureCommitPhaseError(
                  finishedWork,
                  finishedWork.return,
                  error,
                );
              }
            }
          }
          
        }
      }
      return;
    }
    case HostText: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      if (flags & Update) {
        if (supportsMutation) {
          if (finishedWork.stateNode === null) {
            throw new Error(
              'This should have a text node initialized. This error is likely ' +
                'caused by a bug in React. Please file an issue.',
            );
          }
          
          const textInstance: TextInstance = finishedWork.stateNode;
          const newText: string = finishedWork.memoizedProps;
          // For hydration we reuse the update path but we treat the oldProps
          // as the newProps. The updatePayload will contain the real change in
          // this case.
          const oldText: string =
            current !== null ? current.memoizedProps : newText;
          
          try {
            // 저자) HostText의 경우, textContent를 업데이트 합니다.
            commitTextUpdate(textInstance, oldText, newText);
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
        }
      }
      return;
    }
  }
}

이는 재귀적인 과정임을 알 수 있으며, 각 타입의 변이를 자세히 살펴 보겠습니다.

3.2 자식과 본인을 처리하기 전에 먼저 삭제를 처리합니다.

function recursivelyTraverseMutationEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // Deletions effects can be scheduled on any fiber type. They need to happen
  // before the children effects hae fired.
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i];
      try {
        commitDeletionEffects(root, parentFiber, childToDelete);
      } catch (error) {
        captureCommitPhaseError(childToDelete, parentFiber, error);
      }
    }
  }
    
  const prevDebugFiber = getCurrentDebugFiberInDEV();
  if (parentFiber.subtreeFlags & MutationMask) {
    let child = parentFiber.child;
    while (child !== null) {
      setCurrentDebugFiberInDEV(child);
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
  setCurrentDebugFiberInDEV(prevDebugFiber);
}

삭제는 자식을 처리하기 전이라도 먼저 처리됩니다.

function commitDeletionEffects(
  root: FiberRoot,
  returnFiber: Fiber,
  deletedFiber: Fiber,
) {
  if (supportsMutation) {
    // We only have the top Fiber that was deleted but we need to recurse down its
    // children to find all the terminal nodes.
    // Recursively delete all host nodes from the parent, detach refs, clean
    // up mounted layout effects, and call componentWillUnmount.
    // We only need to remove the topmost host child in each branch. But then we
    // still need to keep traversing to unmount effects, refs, and cWU. TODO: We
    // could split this into two separate traversals functions, where the second
    // one doesn't include any removeChild logic. This is maybe the same
    // function as "disappearLayoutEffects" (or whatever that turns into after
    // the layout phase is refactored to use recursion).
    // Before starting, find the nearest host parent on the stack so we know
    // which instance/container to remove the children from.
    // TODO: Instead of searching up the fiber return path on every deletion, we
    // can track the nearest host component on the JS stack as we traverse the
    // tree during the commit phase. This would make insertions faster, too.
    let parent = returnFiber;
    // 저자) 부모 노드가 항상 backing DOM을 갖고 있지는 않으므로, 여기서 backing DOM을 가진 가장 가까운 Fiber 노드를 찾습니다.
    findParent: while (parent !== null) {
      switch (parent.tag) {
        case HostComponent: {
          hostParent = parent.stateNode;
          hostParentIsContainer = false;
          break findParent;
        }
        case HostRoot: {
          hostParent = parent.stateNode.containerInfo;
          hostParentIsContainer = true;
          break findParent;
        }
        case HostPortal: {
          hostParent = parent.stateNode.containerInfo;
          hostParentIsContainer = true;
          break findParent;
        }
      }
      parent = parent.return;
    }
    if (hostParent === null) {
      throw new Error(
        'Expected to find a host parent. This error is likely caused by ' +
          'a bug in React. Please file an issue.',
      );
    }
    commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber);
    hostParent = null;
    hostParentIsContainer = false;
  } else {
    // Detach refs and call componentWillUnmount() on the whole subtree.
    commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber);
  }
    
  detachFiberMutation(deletedFiber);
}
function commitDeletionEffectsOnFiber(
  finishedRoot: FiberRoot,
  nearestMountedAncestor: Fiber,
  deletedFiber: Fiber,
) {
  onCommitUnmount(deletedFiber);
    
  // The cases in this outer switch modify the stack before they traverse
  // into their subtree. There are simpler cases in the inner switch
  // that don't modify the stack.
  switch (deletedFiber.tag) {
    case HostComponent: {
      if (!offscreenSubtreeWasHidden) {
        safelyDetachRef(deletedFiber, nearestMountedAncestor);
      }
      // Intentional fallthrough to next branch
    }
    // eslint-disable-next-line-no-fallthrough
    case HostText: {
      // We only need to remove the nearest host child. Set the host parent
      // to `null` on the stack to indicate that nested children don't
      // need to be removed.
      if (supportsMutation) {
        const prevHostParent = hostParent;
        const prevHostParentIsContainer = hostParentIsContainer;
        hostParent = null;
        recursivelyTraverseDeletionEffects(
          finishedRoot,
          nearestMountedAncestor,
          deletedFiber,
        );
        hostParent = prevHostParent;
        hostParentIsContainer = prevHostParentIsContainer;
        if (hostParent !== null) {
          // Now that all the child effects have unmounted, we can remove the
          // node from the tree.
          if (hostParentIsContainer) {
            removeChildFromContainer(
              // 저자) 이 hostParent는 이전의 while 루프에서 다시 가져옵니다.
              ((hostParent: any): Container),
              (deletedFiber.stateNode: Instance | TextInstance),
            );
          } else {
            removeChild(
              ((hostParent: any): Instance),
              (deletedFiber.stateNode: Instance | TextInstance),
            );
          }
        }
      } else {
        recursivelyTraverseDeletionEffects(
          finishedRoot,
          nearestMountedAncestor,
          deletedFiber,
        );
      }
      return;
    }
    ...
    default: {
      recursivelyTraverseDeletionEffects(
        finishedRoot,
        nearestMountedAncestor,
        deletedFiber,
      );
      return;
    }
  }
}

3.3 삽입이 다음으로 처리됩니다.

새로 생성된 노드들이 트리 구조에 설정되기 위한 것입니다.

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);
    }
    // Clear the "placement" from effect tag so that we know that this is
    // inserted, before any life-cycles like componentDidMount gets called.
    // TODO: findDOMNode doesn't rely on this any more but isMounted does
    // and isMounted is deprecated anyway so we should be able to kill this.
    finishedWork.flags &= ~Placement;
  }
  if (flags & Hydrating) {
    finishedWork.flags &= ~Hydrating;
  }
}

function commitPlacement(finishedWork: Fiber): void {
  if (!supportsMutation) {
    return;
  }
  
  // Recursively insert all host nodes into the parent.
  const parentFiber = getHostParentFiber(finishedWork);

  // Note: these two variables *must* always be updated together.
  switch (parentFiber.tag) {
    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;
      }
      
      // 저자) 중요합니다. Node.insertBefore()는 형제노드가 필요합니다. 만약 찾이 못하면, 그냥 맨 끝에 추가합니다.
      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;
    }
    case HostRoot:
    case HostPortal: {
      const parent: Container = parentFiber.stateNode.containerInfo;
      const before = getHostSibling(finishedWork);
      insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
      break;
    }
    // eslint-disable-next-line-no-fallthrough
    default:
      throw new Error(
        'Invalid host parent fiber. This error is likely caused by a bug ' +
          'in React. Please file an issue.',
      );
  }
}

function insertOrAppendPlacementNodeIntoContainer(
  node: Fiber,
  before: ?Instance,
  parent: Container,
): void {
  const {tag} = node;
  const isHost = tag === HostComponent || tag === HostText;
  if (isHost) {
    const stateNode = node.stateNode;
    if (before) {
      insertInContainerBefore(parent, stateNode, before);
    } else {
      appendChildToContainer(parent, stateNode);
    }
  } else if (tag === HostPortal) {
    // If the insertion itself is a portal, then we don't want to traverse
    // down its children. Instead, we'll get insertions from each child in
    // the portal directly.
  } else {
    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;
      }
    }
  }
}

function insertOrAppendPlacementNode(
  node: Fiber,
  before: ?Instance,
  parent: Instance,
): void {
  const {tag} = node;
  const isHost = tag === HostComponent || tag === HostText;
  if (isHost) {
    const stateNode = node.stateNode;
    if (before) {
      insertBefore(parent, stateNode, before);
    } else {
      appendChild(parent, stateNode);
    }
  } else if (tag === HostPortal) {
    // If the insertion itself is a portal, then we don't want to traverse
    // down its children. Instead, we'll get insertions from each child in
    // the portal directly.
  } else {
    const child = node.child;
    if (child !== null) {
      insertOrAppendPlacementNode(child, before, parent);
      let sibling = child.sibling;
      while (sibling !== null) {
        insertOrAppendPlacementNode(sibling, before, parent);
        sibling = sibling.sibling;
      }
    }
  }
}

3.4 업데이트가 마지막으로 처리됩니다

업데이트 분기는 commitMutationEffectsOnFiber()안에 있습니다.

function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;
    
  // The effect flag should be checked *after* we refine the type of fiber,
  // because the fiber tag is more specific. An exception is any flag related
  // to reconciliation, because those can be set on all fiber types.
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      
      // 저자) FunctionComponent에선 hooks이 실행되어야함을 의미합니다.
      if (flags & Update) {
        try {
          commitHookEffectListUnmount(
            HookInsertion | HookHasEffect,
            finishedWork,
            finishedWork.return,
          );
          commitHookEffectListMount(
            HookInsertion | HookHasEffect,
            finishedWork,
          );
        } catch (error) {
          captureCommitPhaseError(finishedWork, finishedWork.return, error);
        }
        // Layout effects are destroyed during the mutation phase so that all
        // destroy functions for all fibers are called before any create functions.
        // This prevents sibling component effects from interfering with each other,
        // e.g. a destroy function in one component should never override a ref set
        // by a create function in another component during the same commit.
        if (
          enableProfilerTimer &&
          enableProfilerCommitHooks &&
          finishedWork.mode & ProfileMode
        ) {
          try {
            startLayoutEffectTimer();
            commitHookEffectListUnmount(
              HookLayout | HookHasEffect,
              finishedWork,
              finishedWork.return,
            );
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
          recordLayoutEffectDuration(finishedWork);
        } else {
          try {
            commitHookEffectListUnmount(
              HookLayout | HookHasEffect,
              finishedWork,
              finishedWork.return,
            );
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
        }
      }
      return;
    }
    case HostComponent: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      if (flags & Ref) {
        if (current !== null) {
          safelyDetachRef(current, current.return);
        }
      }
      if (supportsMutation) {
        // TODO: ContentReset gets cleared by the children during the commit
        // phase. This is a refactor hazard because it means we must read
        // flags the flags after `commitReconciliationEffects` has already run;
        // the order matters. We should refactor so that ContentReset does not
        // rely on mutating the flag during commit. Like by setting a flag
        // during the render phase instead.
        if (finishedWork.flags & ContentReset) {
          const instance: Instance = finishedWork.stateNode;
          try {
            resetTextContent(instance);
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
        }
        // 저자) HostComponent의 경우, element 속성들이 업데이트 되어야함을 의미합니다.
        if (flags & Update) {
          const instance: Instance = finishedWork.stateNode;
          if (instance != null) {
            // Commit the work prepared earlier.
            const newProps = finishedWork.memoizedProps;
            // For hydration we reuse the update path but we treat the oldProps
            // as the newProps. The updatePayload will contain the real change in
            // this case.
            const oldProps =
              current !== null ? current.memoizedProps : newProps;
            const type = finishedWork.type;
            // TODO: Type the updateQueue to be specific to host components.
            const updatePayload: null | UpdatePayload = (finishedWork.updateQueue: any);
            finishedWork.updateQueue = null;
            if (updatePayload !== null) {
              try {
                commitUpdate(
                  instance,
                  updatePayload,
                  type,
                  oldProps,
                  newProps,
                  finishedWork,
                );
              } catch (error) {
                captureCommitPhaseError(
                  finishedWork,
                  finishedWork.return,
                  error,
                );
              }
            }
          }
        }
      }
      return;
    }
    case HostText: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      
      if (flags & Update) {
        if (supportsMutation) {
          if (finishedWork.stateNode === null) {
            throw new Error(
              'This should have a text node initialized. This error is likely ' +
                'caused by a bug in React. Please file an issue.',
            );
          }
          const textInstance: TextInstance = finishedWork.stateNode;
          const newText: string = finishedWork.memoizedProps;
          // For hydration we reuse the update path but we treat the oldProps
          // as the newProps. The updatePayload will contain the real change in
          // this case.
          const oldText: string =
            current !== null ? current.memoizedProps : newText;
          try {
            commitTextUpdate(textInstance, oldText, newText);
          } catch (error) {
            captureCommitPhaseError(finishedWork, finishedWork.return, error);
          }
        }
      }
      return;
    }
    ...
    default: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);
      
      return;
    }
  }
}
export function commitUpdate(
  domElement: Instance,
  updatePayload: Array<mixed>,
  type: string,
  oldProps: Props,
  newProps: Props,
  internalInstanceHandle: Object,
): void {
  // Apply the diff to the DOM node.
  updateProperties(domElement, updatePayload, type, oldProps, newProps);
  // Update the props handle so that we know which props are the ones with
  // with current event handlers.
  updateFiberProps(domElement, newProps);
}

export function commitTextUpdate(
  textInstance: TextInstance,
  oldText: string,
  newText: string,
): void {
  textInstance.nodeValue = newText;
}

우리 데모에서는 트리 구조 때문에, 변이가 다음과 같은 순서로 처리됩니다.

  1. span의 삭제
  2. HostText의 업데이트
  3. button의 업데이트 (내부적으로는 비어 있음)
  4. b의 삽입

4. 요약

휴! 정말 많았네요! 재렌더링 과정을 러프하게 요약하면 다음과 같습니다.

  1. 상태가 변경되면, 타겟 Fiber 노드까지의 경로가 laneschildLanes로 표시되어, 해당 노드 또는 그 하위트리가 재렌더링이 필요한지를 나타냅니다.
  2. React는 불필요한 재렌더링을 피하기 위해 bailout 최적화와 함께 전체 Fiber 트리를 재렌더링합니다.
  3. 컴포넌트가 리렌더링되면 새로운 React 요소를 생성하고, 그 자식들은 모두 값이 같더라도 새로운 props를 받기 때문에 React는 기본적으로 전체 Fiber 트리를 재렌더링합니다. 이것이 useMemo()가 필요할 수 있는 이유입니다.
  4. 재렌더링에서 React는 current에서 새로운 Fiber 트리를 생성하고, 필요한 경우 Placement, ChildDeletion, Update 등의 플래그로 Fiber 노드에 마크합니다.
  5. 새 Fiber 트리가 완성되면, React는 Commit 단계에서 위 플래그를 가진 Fiber 노드를 처리하고 Host DOM에 변경을 적용합니다.
  6. 그 뒤엔 새로운 Fiber 트리가 current Fiber 트리로 지정됩니다. 이전 Fiber 트리의 노드는 다음 렌더링에서 재사용될 수 있습니다.

아래 슬라이드에 단계들을 정리했습니다. 도움이 되길 바랍니다.

원본 글의 슬라이드

0개의 댓글