[번역] React 에서 useLayoutEffect()는 어떻게 동작하나요?

[번역] React 에서 useLayoutEffect()는 어떻게 동작하나요?

React Internals Deep Dive - EP10

·

8 min read

영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크:
https://jser.dev/react/2021/12/04/how-does-useLayoutEffect-work


ℹ️React Internals Deep Dive 에피소드 10, 유튜브에서 제가 설명하는 것을 시청해주세요.

React@18.2.0기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.

💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.

useLayoutEffect()는 DOM이 업데이트된 직후 일부 코드를 동기적으로 실행할 수 있는 기회를 제공합니다. 어떻게 작동하는지 알아봅시다.

대부분의 훅의 경우 내부적으로 mountXXX()updateXXX()의 두 가지 구현이 있습니다.

1. 레이아웃 이펙트는 어떻게 마운트 되나요?

function mountLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  let fiberFlags: Flags = UpdateEffect;
  if (enableSuspenseLayoutEffectSemantics) {
    fiberFlags |= LayoutStaticEffect;
  }
  return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}
function updateLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}

useEffect()에서 동일하게 사용하는 함수인 mountEffectImpl()updateEffectImpl()이 차례로 사용되며, 다른 점은 두 번째 인수인 hookFlags입니다.

function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps
  );
}
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
    // ❗❗ ↖ 이게 목록에 있는 첫 훅입니다
  } else {
    workInProgressHook = workInProgressHook.next = hook;
    // ❗❗ ↖ 목록의 가장 마지막에 삽입합니다.
  }
  return workInProgressHook;
}

mountWorkInProgressHook() 은 새로운 빈 훅을 생성하고 이 파이버의 훅 목록에 추가합니다. pushEffect()는 파이버의 updateQueue에 effect 객체를 생성하며, 이들은 memoizedState로 연결됩니다.

function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}

모든 훅이 이펙트 훅인 것은 아닙니다. 예를 들어 useState() 는 그렇지 않습니다. Effect는 실행되어야 하는 side effect를 의미하며, 다른 위치에 있을 수 없는 이유가 보이지 않기 때문에 updateQueue에 배치된 것 같습니다.

Effecttag 속성이 중요한데, 레이아웃 이펙트를 마운트하는 경우는 HookHasEffect | HookLayout입니다.

마운팅은 이것으로 끝이며, updateQueue를 설정하고 새 hook을 생성합니다.

2. 레이아웃 이펙트는 어떻게 업데이트 되나요?

updateLayoutEffect() 도 이와 비슷할 겁니다.

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;
  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags, // ❗❗ HookHasEffect 
    create,
    destroy,
    nextDeps
  );
}

함수는 새로운 파이버 트리가 생성되는 조정(reconciliation) 단계에서 실행됩니다. current가 존재하는 항목의 접두사인 경우, updateWorkInProgressHook()currentWork를 앞으로 이동하고 생성 중인 버전도 반환합니다.

props가 변경되었는지 여부에 따라 areHookInputsEqual(nextDeps, prevDeps)로 변경 감지가 이루어지고, memoizedState는 이펙트로 설정되지만 플래그가 다르며, 변경이 없는 경우 HookHasEffect가 추가되지 않는 것을 볼 수 있습니다.

따라서 HookHasEffect는 이펙트를 실행해야 하는지 여부를 나타내는 중요한 요소입니다.

3. 레이아웃 이펙트는 실제로 언제 실행될까요?

좋은 질문입니다. useEffect()에 의한 패시브 이펙트나 레이아웃 이펙트의 경우, 전달된 함수는 실제로 생성 함수이고, 생성 함수가 반환하는 클로저는 파괴(정리) 함수입니다.

const effect: Effect = {
  tag,
  create,
  destroy,
  deps,
  // Circular
  next: (null: any),
};

위는 Effect의 구조로, createdestory를 볼 수 있습니다.

이펙트는 DOM 변이 후에 실행되므로, 커밋 단계에 있어야 하며, 아래가 시작되는 부분에 대한 내용입니다.

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<mixed>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
    ....
    // The next phase is the mutation phase, where we mutate the host tree.
    commitMutationEffects(root, finishedWork, lanes);
    commitLayoutEffects(finishedWork, root, lanes); // ❗❗
    ...
}

commitLayoutEffects()는 보류 중인 모든 레이아웃 이펙트를 실행하려고 시도합니다. 이 역시 일종의 트리를 순회하는 것이므로 동일한 순회 알고리즘이 사용되며, 완료 단계를 살펴봅시다.

function commitLayoutMountEffects_complete(
  subtreeRoot: Fiber,
  root: FiberRoot,
  committedLanes: Lanes
) {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    if ((fiber.flags & LayoutMask) !== NoFlags) {
      const current = fiber.alternate;
      setCurrentDebugFiberInDEV(fiber);
      try {
        commitLayoutEffectOnFiber(root, current, fiber, committedLanes); // ❗❗
      } catch (error) {
        captureCommitPhaseError(fiber, fiber.return, error);
      }
      resetCurrentDebugFiberInDEV();
    }
    if (fiber === subtreeRoot) {
      nextEffect = null;
      return;
    }
    const sibling = fiber.sibling;
    if (sibling !== null) {
      sibling.return = fiber.return;
      nextEffect = sibling;
      return;
    }
    nextEffect = fiber.return;
  }
}

기본적으로 이 파이버에 레이아웃 효과를 실행할 필요가 있는지 commitLayoutEffectOnFiber()를 통해 확인한 후, 형제 파이버 또는 부모 파이버로 이동합니다.

function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  if ((finishedWork.flags & LayoutMask) !== NoFlags) {
    switch (finishedWork.tag) {
      case FunctionComponent:
      case ForwardRef:
      case SimpleMemoComponent: {
        if (
          !enableSuspenseLayoutEffectSemantics ||
          !offscreenSubtreeWasHidden
        ) {
          // At this point layout effects have already been destroyed (during mutation phase).
          // This is done to prevent 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();
              commitHookEffectListMount( // ❗❗
                HookLayout | HookHasEffect,
                finishedWork,
              );
            } finally {
              recordLayoutEffectDuration(finishedWork);
            }
          } else {
            commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork); // ❗❗ 
          }
        }
        break;
      }
  ...

commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork)를 찾을 수 있습니다. 이 중에

  1. HookLayout -> 이 이펙트는 레이아웃 이펙트입니다.

  2. HookHasEffect -> 이 이펙트를 실행해야 합니다.

function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
  const updateQueue: FunctionComponentUpdateQueue | null =
    (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Mount
        const create = effect.create;
        effect.destroy = create();
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

commitHookEffectListMount()는 간단합니다. 이펙트 태그를 확인하고 create() 함수를 실행하고 destroy를 설정합니다.

4. 레이아웃 이펙트는 언제 정리되나요?

정리 함수 destory가 어떻게 설정되는지 살펴봤는데, 언제 실행될까요? 실제로는 그 이전인commitMutationEffects()에서 발생합니다.

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 reconcilation, 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);
      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;
    }
    ...

중간에 있는 주석을 참조하세요. commitHookEffectListUnmount()는 정리를 실행하는 역할을 합니다.

💬 주석 번역
레이아웃 효과는 변이(mutation) 단계에서 소멸되므로 모든 파이버에 대한 모든 소멸 함수가 생성 함수보다 먼저 호출됩니다.
이렇게 하면 형제 컴포넌트 이펙트가 서로 간섭하는 것을 방지할 수 있습니다. 예를 들어 한 컴포넌트의 destroy 함수가 동일한 커밋 중에 다른 컴포넌트의 create 함수가 설정한 참조를 재정의해서는 안 됩니다.

function commitHookEffectListUnmount(
  flags: HookFlags,
  finishedWork: Fiber,
  nearestMountedAncestor: Fiber | null
) {
  const updateQueue: FunctionComponentUpdateQueue | null =
    (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

commitHookEffectListUnmount()도 간단합니다. 이펙트에 대한 destroy 함수를 가져온 다음 실행하기만 하면 됩니다.

deps가 변경되면 이 이펙트는 이펙트의 HookHasEffect와 함께 설정된다는 것을 기억하세요.

5. 하지만 컴포넌트가 언마운트되면 어떻게 정리가 실행되나요?

좋은 질문입니다. 정리를 트리거하는 deps 변경 외에도 구성 요소 언마운트도 정리로 이어지며, 파이버 세계에서는 파이버 제거를 의미합니다.

위의 코드 조각에서 recursivelyTraverseMutationEffects()가 있는데, 실제로 파이버 삭제의 경우 이펙트가 거기서 정리됩니다.

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

parentFiber.deletions의 모든 하위 파이버를 탐색하고 정리 함수를 실행하는 것을 볼 수 있습니다.

삭제된 파이버는 최종 파이버 트리에 포함되지 않으므로, 참조를 위해 부모 파이버에 넣습니다.

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

그리고 커밋하기 전에 조정하는 동안 deletions가 설정됩니다.

(원본 게시일: 2021-12-04)