useEffect 동작원리와 중첩 컴포넌트에서의 effect 순서

HANITZ·2024년 2월 11일
0

React

목록 보기
7/8
post-thumbnail

useEffect는 React 공식문서에 따르면 Side Effect를 관리하기 위한 hook이라고 설명되어있다.

Side Effect: 주어진 컴포넌트 외부에 존재하는 데이터들이 내부의 데이터에 의해 값이 변경되는 일

Side Effect의 예시들로 데이터 가져오기, 구독(subscription) 설정하기, 수동으로 React 컴포넌트의 DOM을 수정하는 것이 있다.

React는 이런 부수효과에 의해 렌더링에 영향을 주는 것을 염려해 자체 컴포넌트 렌더링과 구분시켜서 따로 로직을 수행하는 useEffect를 만들었다.

작동 과정

useEffect도 이전의 useState처럼 mount와 update함수로 나눠 초기렌더링과 업데이트 구간을 구분시켰다.


function useEffect(create, deps) {
  var dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}
HooksDispatcherOnMountInDEV = {
  useEffect: function (create, deps) {
        currentHookNameInDev = 'useEffect';
        mountHookTypesDev();
        checkDepsAreArrayDev(deps);
        return mountEffect(create, deps);
      },
}

function mountEffect(create, deps) {
  if ( (currentlyRenderingFiber$1.mode & StrictEffectsMode) !== NoMode) {
    return mountEffectImpl(MountPassiveDev | Passive | PassiveStatic, Passive$1, create, deps);
  } else {
    return mountEffectImpl(Passive | PassiveStatic, Passive$1, create, deps);
  }
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  var hook = mountWorkInProgressHook();
  var nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber$1.flags |= fiberFlags;
  hook.memoizedState = pushEffect(HasEffect | hookFlags, create, undefined, nextDeps);
}

function pushEffect(tag, create, destroy, deps) {
  var effect = {
    tag: tag,
    create: create,
    destroy: destroy,
    deps: deps,
    // Circular
    next: null
  };
  var componentUpdateQueue = currentlyRenderingFiber$1.updateQueue;

  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber$1.updateQueue = componentUpdateQueue;
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    var lastEffect = componentUpdateQueue.lastEffect;

    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      var firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }

  return effect;
}

초기렌더링 시 mountEffectImpl에서 새로운 훅을 생성하고 pushEffect함수를 통해 effect의 원형리스트 componentUpdateQueue 를 생성시킨다.

컴포넌트 내에 다른 이펙트들을 연결시켜주기 위한 원형리스트이다.

리스트에 들어가는 이펙트 객체는 useEffect에 넣은 함수와 deps로 구성되어있다.

  var effect = {
    tag: tag,
    create: create,
    destroy: destroy,
    deps: deps,
    // Circular
    next: null
  };

create: useEffect 내부함수를 의미한다.

destroy: 클린업 함수로 내부함수의 return 함수

deps: 최적화를 위해 특정 변수들이 변경되는 경우에만 이펙트가 실행되도록 걸어놓은 장치이다. deps에 위치한 변수들의 값이 변경될 때만 이펙트가 재실행되고 빈 배열([ ])만 설정하면 초기렌더링에만 실행된다.


이후 이펙트는 렌더링 지연을 방지하기위해 render phase가 끝나고 commit이 완료된 후에 비동기로 실행된다.


function commitRootImpl(root, recoverableErrors, transitions, renderPriorityLevel) {
 .
 .
 .
      scheduleCallback$1(NormalPriority, function () {
        flushPassiveEffects(); 
        return null;
      });
    }
  }
 .
 .
 .
}

이해를 돕기위해 간략하게 이펙트가 실행되는 과정들을 정리해봤다

render phase가 끝나고 commitRoot가 실행되면서 commit phase를 진행한다.

그리고 페인트하기 전에 flushPassiveEffect함수를 postMessage해준다.


function flushPassiveEffects() {
  if (rootWithPendingPassiveEffects !== null) {
    .
    .

    try {
      .
      .
      return flushPassiveEffectsImpl();
    } finally {
     .
     .
    }
  }

  return false;
}


function flushPassiveEffectsImpl() {
 .
 .
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions); 
 .
 .
}

비동기로 실행된 flushPassiveEffects 함수는 flushPassiveEffectsImpl 함수를 반환해준다.

flushPassiveEffectsImpl 에서 commitPassiveUnmountEffects commitPassiveMountEffects 순서로 실행이된다.

사진에서 봤듯이 commitPassiveUnmountEffects 는 언마운트시에 실행되는 클린업 함수로 useEffect 내의 함수가 반환해주는 함수를 말한다.

commitPassiveMountEffects 는 내부함수가 실행된다.

function commitHookEffectListMount(flags, finishedWork) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      	.
        .
        var create = effect.create;
      	.
        .
        effect.destroy = create();
		.
        .
        var destroy = effect.destroy;
        .
        .
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

마운트될 때 effect.createcreate 로 내부함수 effect.destroycreate() 로 내부함수의 반환함수로 저장된다.

결국 저장된 함수들은 이펙트가 실행이 될때 destroy, create순서로 실행이 되면서 클린업이 먼저 진행되고 내부함수를 다시 실행시키게된다.

이펙트의 실행 순서

이펙트와 클린업함수가 여러개인 경우 렌더링 때 연결리스트에 모두 축적이되고 커밋이 완료되고 모두 한번에 실행이된다.

function commitHookEffectListUnmount(flags, finishedWork, nearestMountedAncestor) {
  var updateQueue = finishedWork.updateQueue;
  var lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;

  if (lastEffect !== null) {
    var firstEffect = lastEffect.next;
    var effect = firstEffect;

    do {
      if ((effect.tag & flags) === flags) {
        // Unmount
        var destroy = effect.destroy;
        effect.destroy = undefined;

        if (destroy !== undefined) {
          {
            if ((flags & Passive$1) !== NoFlags$1) {
              markComponentPassiveEffectUnmountStarted(finishedWork);
            } else if ((flags & Layout) !== NoFlags$1) {
              markComponentLayoutEffectUnmountStarted(finishedWork);
            }
          }

          {
            if ((flags & Insertion) !== NoFlags$1) {
              setIsRunningInsertionEffect(true);
            }
          }

          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);

          {
            if ((flags & Insertion) !== NoFlags$1) {
              setIsRunningInsertionEffect(false);
            }
          }

          {
            if ((flags & Passive$1) !== NoFlags$1) {
              markComponentPassiveEffectUnmountStopped();
            } else if ((flags & Layout) !== NoFlags$1) {
              markComponentLayoutEffectUnmountStopped();
            }
          }
        }
      }

      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

컴포넌트가 중첩되어 있을 때의 경우 재귀적으로 하위 컴포넌트의 렌더링부터 먼저 완료되기 때문에 이펙트 또한 자식컴포넌트의 이펙트가 먼저 실행된다.

이해를 돕기위해 예시코드를 짜봤다.

function Child() {
  useEffect(() => {
    console.log("3 Child effect");

    return () => {
      console.log("3 Child clean up");
    };
  });

  useEffect(() => {
    console.log("4 Child effect");

    return () => {
      console.log("4 Child clean up");
    };
  });

  return <div>1</div>;
}

function App() {
  const [flag, setFlag] = useState(false);

  console.log("App Render Start");
  useEffect(() => {
    console.log("1 App effect");
    return () => {
      console.log("1 App clean up");
    };
  });

  useEffect(() => {
    console.log("2 App effect");

    return () => {
      console.log("2 App clean up");
    };
  });

  return (
    <div className="App">
      <div className="container1">
        <button
          onClick={() => {
            setFlag((a) => !a);
          }}
        >
        </button>
        <Child />
      </div>
    </div>
  );
}

초기 렌더링의 경우 destroy함수가 아직 정의되지 않았기 때문에 이펙트의 함수만 실행된다.

App렌더링 시작, Child 렌더링 시작, Child 커밋 후 Child의 이펙트가 순서대로 실행이 되고 App 커밋 후 App의 이펙트가 실행되었다.

업데이트 단계에서는 destroy가 작동한다.

App 렌더링 시작, Child 렌더링 시작, Child 커밋 후 순서대로 클린업, App 순서대로 클린업, Child 순서대로 이펙트 실행, App 순서대로 이펙트 실행하면서 마무리 된다.

0개의 댓글

관련 채용 정보