영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/2023-07-08-how-does-useeffect-work
ℹ️ React Internals Deep Dive 에피소드 4, 유튜브에서 제가 설명하는 것을 시청해주세요.
⚠ React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.
useEffect()
는 React에서 useState()
를 제외하고 가장 많이 사용되는 훅입니다.
매우 강력하지만 때때로 혼란스러울 수 있는데, 내부적으로 어떻게 작동하는지 알아봅시다.
useEffect(() => {
// ...
}, [deps])
1. 최초 마운트에서 useEffect()
useEffect()
는 최초 마운트에서 mountEffect()
를 사용합니다.
💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
// ❗❗ ↖ 이 플래그를 사용하여 레이아웃 Effect와의 차이점을 구분할 수 있어 중요합니다.
// ❗❗ PassiveStaticEffect는 무엇일까요? 이 것은 다른 에피소드에서 다룰 가치가 있을거 같습니다.
HookPassive,
create,
deps,
);
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
const hook = mountWorkInProgressHook();
// ❗❗ ↗ 새로운 훅을 만듭니다.
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
// ❗❗ ↗ pushEffect()는 Effect 객체를 만들고, 그것을 훅에 설정합니다.
HookHasEffect | hookFlags,
// ❗❗ ↖ 이 플래그도 중요한데, 이것은 이 이팩트를 최초 마운트에서 실행해야 한다는 것을 의미합니다.
create,
undefined,
nextDeps,
);
}
function pushEffect(tag, create, destroy, deps) {
const effect: Effect = {
tag,
// ❗❗ ↖ tag는 중요한데, 이 Effect를 실행해야 하는지 여부를 표시하는 데 사용됩니다.
create,
// ❗❗ 우리가 전달한 콜백 입니다
destroy,
// ❗❗ 콜백에서 반환한 정리 함수 입니다.
deps,
// ❗❗ 우리가 전달한 deps 배열입니다.
// Circular
next: (null: any),
// ❗❗ 하나의 컴포넌트에 여러 Effect가 있을 수 있습니다. 그걸 체인으로 엮습니다.
};
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
// ❗❗ Effect는 fiber의 updateQueue에 저장됩니다.
// ❗❗ 이것은 훅의 memoizedState와는 다릅니다.
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;
}
최초 마운트의 경우, useEffect()가 필요한 플래그를 사용하여 Effect 객체를 생성하는 것을 볼 수 있습니다. effect 객체들은 다른 타이밍에 처리될 것입니다.
2. 리-렌더링의 useEffect()
function updateEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
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;
// ❗❗ ↗ memoizedState의 effect 훅이 Effect 객체라는 것을 기억하세요.
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
// ❗❗ deps가 변경되지 않으면, Effect 객체를 다시 생성하는 것만 수행합니다
// ❗❗ 여기서 다시 생성해야 하는 이유는 아마도 단순히 updateQueue를 다시 생성해야 하기 때문일겁니다.
// ❗❗ 그리고 우리는 업데이트 된 create()함수를 가져올 필요가 있습니다.
// ❗❗ 여기서는 이전 destory() 함수를 사용하고 있음도 알 수 있습니다.
}
}
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
// ❗❗ ↖ 만약 deps가 바뀌면, HookHasEffect 는 이 Effect를 실행 해야함을 나타내는 표시입니다.
create,
destroy,
nextDeps,
);
}
우리는 혼란스러운 deps 배열이 어떻게 작동하는지 알 수 있엇습니다. 리-렌더링할 때는 어떤 경우에는 Effect 객체를 다시 생성하지만, deps가 변경되면 생성된 Effect가 이전 정리(destory()
) 함수를 사용하여 실행되도록 표시됩니다.
3. Effect는 언제, 어떻게 실행되고 정리되나요?
위에서 우리는 useEffect()
가 Fiber Node에 추가 데이터 구조를 생성할 뿐이라는 것을 알았습니다. 이제 이러한 Effect 객체가 어떻게 처리되는지 알아볼 필요가 있습니다.
3.1 패시브 Effect에 대한 flushing은 commitRoot()
내에서 트리거 됩니다.
두 개의 Fiber Tree(조정)를 비교하여 서로 다른 결과를 얻은 후 "Commit" 단계에서 호스트 DOM에 변경 사항을 반영합니다. 패시브 Effect의 의 flush를 시작한 코드를 쉽게 찾을 수 있습니다.
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.)
// TODO: Delete all other places that schedule the passive effect callback
// They're redundant.
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
pendingPassiveEffectsRemainingLanes = remainingLanes;
// workInProgressTransitions might be overwritten, so we want
// to store it in pendingPassiveTransitions until they get processed
// We need to pass this through as an argument to commitRoot
// because workInProgressTransitions might have changed between
// the previous render and commit if we throttle the commit
// with setTimeout
pendingPassiveTransitions = transitions;
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;
});
// ❗❗ 여기서 useEffect()에 의해 생겨난 패시드 Effect를 플러시 합니다.
// ❗❗ 이렇게 하면 당장이 아닌 다음 틱(tick)에 플러싱이 예약됩니다.
// ❗❗ 좀 더 자세한 정보는 EP20의 리액트 스케쥴러는 어떻게 동작하는가를 참고하세요
}
}
...
}
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;
// TODO: This is sometimes out of sync with rootWithPendingPassiveEffects.
// Figure out why and fix it. It's not causing any known issues (probably
// because it's only used for profiling), but it's a refactor hazard.
pendingPassiveEffectsLanes = NoLanes;
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
commitPassiveUnmountEffects(root.current);
commitPassiveMountEffects(root, root.current, lanes, transitions);
// ❗❗↗ 여기서 우리는 콜백이 실행되지 전에 Effect 정리가 먼저 실행되는 것을 명확하게 볼 수 있습니다.
...
}
3.3 commitPassiveUnmountEffects()
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveUnmountOnFiber(finishedWork); // ❗❗ commitPassiveUnmountOnFiber
resetCurrentDebugFiberInDEV();
}
function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraversePassiveUnmountEffects(finishedWork);
// ❗❗ ↗ 우리는 여기서 자식들의 effects들이 먼저 정리되는 것을 볼 수 있습니다.
if (finishedWork.flags & Passive) {
commitHookPassiveUnmountEffects( // ❗❗ commitHookPassiveUnmountEffects
finishedWork,
finishedWork.return,
HookPassive | HookHasEffect,
// ❗❗ ↗ 이 플래그(HookHasEffect)는 deps가 변경되지 않았을 때 훅이 실행되지 않는 것을 보장합니다
);
}
break;
}
...
}
}
function commitHookPassiveUnmountEffects(
finishedWork: Fiber,
nearestMountedAncestor: null | Fiber,
hookFlags: HookFlags,
) {
if (shouldProfile(finishedWork)) {
startPassiveEffectTimer();
commitHookEffectListUnmount( // ❗❗ commitHookEffectListUnmount
hookFlags,
finishedWork,
nearestMountedAncestor,
);
recordPassiveEffectDuration(finishedWork);
} else {
commitHookEffectListUnmount( // ❗❗ 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) { // ❗❗ (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를 단순히 루프 돌기만 하면 됩니다
// ❗❗ 그러면서 플래그를 통해 필요한 항목들을 필터링 합니다.
}
}
function safelyCallDestroy(
current: Fiber,
nearestMountedAncestor: Fiber | null,
destroy: () => void,
) {
try {
destroy();
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
}
3.4 commitPassiveMountEffects()
commitPassiveMountEffects()
는 같은 방식으로 작동합니다.
export function commitPassiveMountEffects(
root: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
setCurrentDebugFiberInDEV(finishedWork);
commitPassiveMountOnFiber( // ❗❗ commitPassiveMountOnFiber
root,
finishedWork,
committedLanes,
committedTransitions,
);
resetCurrentDebugFiberInDEV();
}
function commitPassiveMountOnFiber(
finishedRoot: FiberRoot,
finishedWork: Fiber,
committedLanes: Lanes,
committedTransitions: Array<Transition> | null,
): void {
// When updating this function, also update reconnectPassiveEffects, which does
// most of the same things when an offscreen tree goes from hidden -> visible,
// or when toggling effects inside a hidden tree.
const flags = finishedWork.flags;
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraversePassiveMountEffects(
// ❗❗ ↖ 우리는 여기서 자식들의 effects들이 먼저 실행되는 것을 볼 수 있습니다.
finishedRoot,
finishedWork,
committedLanes,
committedTransitions,
);
if (flags & Passive) {
commitHookPassiveMountEffects( // ❗❗ commitHookPassiveMountEffects
finishedWork,
HookPassive | HookHasEffect,
// ❗❗ ↗ 이 플래그(HookHasEffect)는 deps가 변경되지 않았을 때 훅이 실행되지 않는 것을 보장합니다
);
}
break;
}
...
}
}
function commitHookPassiveMountEffects(
finishedWork: Fiber,
hookFlags: HookFlags,
) {
if (shouldProfile(finishedWork)) {
startPassiveEffectTimer();
try {
commitHookEffectListMount(hookFlags, finishedWork); // ❗❗ commitHookEffectListMount
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
recordPassiveEffectDuration(finishedWork);
} else {
try {
commitHookEffectListMount(hookFlags, finishedWork); // ❗❗ commitHookEffectListMount
} 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 is run here!
inst.destroy = destroy;
}
effect = effect.next;
} while (effect !== firstEffect);
// ❗❗ 다시, 필요한 Effect들을 필터링하고 다음을 실행합니다.
}
}
4. 요약
소스 코드를 살펴본 결과, useEffect()
의 내부 구조는 매우 간단하는 것을 알 수 있었습니다.
useEffect()
는 Fiber에 저장되어 있는 Effect 객체를 생성합니다.Effect 는 실행이 필요한지 여부를 나타내는
tag
를 가집니다.Effect 는 첫번째 인수로 전달되는
create()
함수가 있습니다.Effect
create()
의 정리인destroy()
가 있고, 이것은create()
가 실행될 때만 설정됩니다.
useEffect()
는 매번 새로운 Effect 객체들을 만들어냅니다, 하지만 이것들은 만약 deps 배열이 변경되면 다른tag
를 설정하게 됩니다.호스트 DOM에 업데이트를 커밋할 때, 다음 틱(tick)의 작업은
tag
를 기반으로 모든 Effect를 다시 실행 하도록 예약됩니다.하위 컴포넌트의 Effect가 먼저 처리 됩니다.
정리가 먼저 실행됩니다.
5. 퀴즈 챌린지
이제 오늘 배운 내용을 바탕으로 BFE.dev의 React 퀴즈를 풀 수 있습니다.
원본 글 작성일: 2023-07-08