useEffect()

Hee Suh·2024년 6월 6일
0
post-thumbnail

JSer.devReact Internals Deep Dive를 번역하여 정리한 글입니다.

⚠️ React@19commit 7608516을 기반으로 작성되었으며, 최신 버전에서는 구현이 변경되었을 수 있습니다.

📝 How does useEffect() work internally in React?

useEffect()useState() 다음으로 가장 많이 사용되는 hook일 것이다. 내부 동작 원리를 알아보자.

useEffect(() => {
	// ...
}, [deps]);

useEffect() 실행 시 render와 commit 단계에서 호출되는 주요 함수는 다음과 같다.

  • render phase
    ifif initial mount mountEffect() initial mount
    elseelse ifif re-render updateEffect()
  • commit phase
    commitPassiveUnmountEffects() (only if re-render) ⇒ commitPassiveMountEffects() (both initial mount & re-render)

1. useEffect() in initial mount.

useEffect()는 initial mount에서 mountEffect()를 사용한다.

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    PassiveEffect | PassiveStaticEffect, // 📌 이 flag는 Layout Effects와의 차이점을 구분하는 데 중요하다.
    HookPassive,
    create,
    deps,
  );
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 📌 새로운 hook을 생성한다.
  const hook = mountWorkInProgressHook();

  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  // 📌 pushEffect()가 만든 Effect 객체를 hook에 설정(set)한다.
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags, // 📌 HookHasEffect flag는 initial mount에 이 effect를 실행해야 한다는 것을 의미하기에 중요하다.
    create,
    undefined,
    nextDeps,
  );
}
function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,     // 📌 tag는 이 effect의 실행 여부를 표시하는 데 사용되기 때문에 중요하다.
    create,  // 📌 전달한 callback이다. 
    destroy, // 📌 callback에서 return한 cleanup 함수다.
    deps,    // 📌 전달한 dependency array다.
    // Circular
    next: (null: any),  // 📌 하나의 컴포넌트에 여러 effect가 있는 경우, 이를 연결(chain)한다.
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    // 📌 Effect는 fiber에 있는 updateQueue에 저장된다.
    // 이는 hooks의 memoizedState와 다르다는 점을 유의하자.
    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;
}

Initial mount의 경우, useEffect()가 필요한 flag를 사용하여 Effect 객체를 생성하는 것을 볼 수 있다. 여기에서 생성된 passive effects는 commit phase에서 스케줄러에 의해 처리된다.

2. useEffect() in re-render

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  // 📌 현재 current를 가져온다.
  const hook = updateWorkInProgressHook();

  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;
  if (currentHook !== null) {
    // 📌 effect hook의 memoizedState가 Effect 객체라는 사실을 기억하자.
    const prevEffect = currentHook.memoizedState;

    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 📌 deps가 변경되지 않으면, 아무것도 하지 않고 Effect 객체만 다시 생성한다.
      // Effect 객체를 다시 생성하는 이유는, updateQueue를 다시 생성하고
      // 업데이트된 create()를 가져와야 하기 때문이다.
      // 여기에서 이전 destroy()를 사용하고 있다는 점을 확인하자.
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags, // 📌 deps가 변경되면, HookHasEffect는 이 effect가 실행되어야함을 표시한다.
    create,
    destroy,
    nextDeps,
  );
}

deps 배열이 어떻게 작동하는지 확인했다. re-render에서는 항상 Effect 객체를 다시 생성하고, deps가 변경된 경우에만 생성된 Effect에 이전 cleanup 함수를 사용하여 실행되어야 함을 표시한다.

3. When and how does Effects get run and cleaned up?

useEffect()는 단지 fiber node에 추가 데이터 구조를 생성할 뿐이라는 것을 알았다.

이제 이러한 Effect 객체들이 어떻게 처리되는지 살펴보자.

3.1 Flusing of passive effects are triggered in commitRoot()

💡 Passive Effect

두 가지 유형의 effect가 존재한다.

  • useEffect = "effects"
  • useLayoutEffect = "layout effects"

“effects”라고 하면 둘 중에 어떤 effect를 의미하는 건지 불명확할 때가 있어서, useEffect로부터 생성된 effects를 “passive effects”라고 부르기도 한다.

Cf. https://github.com/reactwg/react-18/discussions/46#discussioncomment-847365

두 fiber 트리를 비교(reconciliation)하여 diffing 결과를 얻은 후, commit 단계에서 host DOM에 변경 사항을 반영해야 한다. passive effects의 flushing(한꺼번에 반영)을 시작하는 코드를 쉽게 찾을 수 있다.

function commitRootImpl(
  root: FiberRoot,
  recoverableErrors: null | Array<CapturedValue<mixed>>,
  transitions: Array<Transition> | null,
  renderPriorityLevel: EventPriority,
) {
  // If there are pending passive effects, schedule a callback to process them.
  // Do this as early as possible, so it is queued before anything else that
  // might get scheduled in the commit phase. (See #16714.)
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      pendingPassiveEffectsRemainingLanes = remainingLanes;
      ...
      // 📌 여기에서 useEffect()로부터 생성된 passive effects를 flush한다.
      // 스케줄러에서 지금 당장 말고, 다음 tick에서 flush하도록 예약한다.
      scheduleCallback(NormalSchedulerPriority, () => {
        flushPassiveEffects();
        // This render triggered passive effects: release the root cache pool
        // *after* passive effects fire to avoid freeing a cache pool that may
        // be referenced by a node in the tree (HostRoot, Cache boundary etc)
        return null;
      });
    }
  }
  ...
}

3.2 flushPassiveEffects()

function flushPassiveEffectsImpl() {
  if (rootWithPendingPassiveEffects === null) {
    return false;
  }
  
  // Cache and clear the transitions flag
  const transitions = pendingPassiveTransitions;
  pendingPassiveTransitions = null;
  
  const root = rootWithPendingPassiveEffects;
  const lanes = pendingPassiveEffectsLanes;
  rootWithPendingPassiveEffects = null;
  pendingPassiveEffectsLanes = NoLanes;
  
  const prevExecutionContext = executionContext;
  executionContext |= CommitContext;
  
  // 📌 여기에서 effect cleanup이 먼저 실행되고, callback이 나중에 실행된다는 것을 확인할 수 있다.
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions);
  ...
}

3.3 commitPassiveUnmountEffects()

export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
  setCurrentDebugFiberInDEV(finishedWork);
  commitPassiveUnmountOnFiber(finishedWork);
  resetCurrentDebugFiberInDEV();
}
function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 📌 children의 effect가 먼저 clean up된다는 것을 알 수 있다.
      recursivelyTraversePassiveUnmountEffects(finishedWork);

      if (finishedWork.flags & Passive) {
        commitHookPassiveUnmountEffects(
          finishedWork,
          finishedWork.return,
          HookPassive | HookHasEffect, // 📌 HookHasEffect flag는 deps가 변경되지 않으면, callback이 실행되지 않도록 한다.
        );
      }
      break;
    }
    ...
  }
}
function commitHookPassiveUnmountEffects(
  finishedWork: Fiber,
  nearestMountedAncestor: null | Fiber,
  hookFlags: HookFlags,
) {
  if (shouldProfile(finishedWork)) {
    startPassiveEffectTimer();
    commitHookEffectListUnmount(
      hookFlags,
      finishedWork,
      nearestMountedAncestor,
    );
    recordPassiveEffectDuration(finishedWork);
  } else {
    commitHookEffectListUnmount(
      hookFlags,
      finishedWork,
      nearestMountedAncestor,
    );
  }
}
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 inst = effect.inst;
        const destroy = inst.destroy;
        if (destroy !== undefined) {
          inst.destroy = undefined;
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
    // 📌 updateQueue의 있는 모든 Effect를 돌면서
    // flag를 이용하여 필요한 것들을 필터링한다.
  }
}
function safelyCallDestroy(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
  destroy: () => void,
) {
  try {
    destroy();
  } catch (error) {
    captureCommitPhaseError(current, nearestMountedAncestor, error);
  }
}

3.4 commitPassiveMountEffects()

commitPassiveMountEffects()commitPassiveUnmountEffects()와 같은 방식으로 작동한다.

export function commitPassiveMountEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
): void {
  setCurrentDebugFiberInDEV(finishedWork);
  commitPassiveMountOnFiber(
    root,
    finishedWork,
    committedLanes,
    committedTransitions,
  );
  resetCurrentDebugFiberInDEV();
}
function commitPassiveMountOnFiber(
  finishedRoot: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
): void {
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 📌 children의 effect가 먼저 실행된다는 것을 알 수 있다.
      recursivelyTraversePassiveMountEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
      );
      if (flags & Passive) {
        commitHookPassiveMountEffects(
          finishedWork,
          HookPassive | HookHasEffect, // 📌 HookHasEffect flag는 deps가 변경되지 않으면, callback이 실행되지 않도록 한다.
        );
      }
      break;
    }
    ...
  }
}
function commitHookPassiveMountEffects(
  finishedWork: Fiber,
  hookFlags: HookFlags,
) {
  ...
  try {
    commitHookEffectListMount(hookFlags, finishedWork);
  } catch (error) {
    captureCommitPhaseError(finishedWork, finishedWork.return, error);
  }
}
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;
        const inst = effect.inst;
        const destroy = create(); // 📌 callback이 여기에서 실행된다!
        inst.destroy = destroy;
      }
      effect = effect.next;
    } while (effect !== firstEffect);
    // 📌 여기에서도, 필요한 Effects만 필터링해서 실행한다.
  }
}

4. Summary

useEffect()의 작동 원리는 다음과 같다.

  1. useEffect()는 Effect 객체를 생성하고 fiber에 저장한다.

    • Effect의 tag 를 통해 해당 Effect의 실행이 필요한지 여부를 나타낸다.
    • Effect의 create()는 useEffect에 전달하는 첫 번째 인자인 callback이다.
    • Effect의 destroy()create()의 cleanup으로, create()가 실행될 때만 설정된다.
  2. useEffect()는 매번 새로운 Effect 객체를 생성하되, deps 배열이 변경될 때 다른 tag를 설정한다.

  3. host DOM에 업데이트를 commit할 때, 다음 tick의 job은 tag를 기반으로 모든 Effects를 다시 실행하도록 예약된다.

    • child 컴포넌트에 있는 Effects가 먼저 처리된다.
    • cleanup이 callback보다 먼저 실행된다.

5. Quiz Challenge

React quizzes | BFE.dev - prepare for Front-End job interviews.

Additional Resources

References

profile
원리를 파헤치는 것을 좋아하는 프론트엔드 개발자입니다 🏃🏻‍♀️

0개의 댓글