카테고리 없음

[FE닌자] useRef에서 renderWithHooks까지

Ahyeon, Jung 2024. 5. 22. 13:57

react-hook-form을 사용해보면서 ref, forwardRef를 계속해서 만나다보니, useRef가 어떤 역할을 하게 되는건지 궁금했다. 그런데 생각보다 Hook의 전체적인 관점에서 이해해야 했다. 사실 그냥 즉흥적으로 궁금해보이는 코드 들어가서 구경하다가 꽂히는 P 인간의 삽질일지도.. 금방 끝날 줄알고 잡았지..


useRef

resolveDispatcher를 통해 현재 활성화된 dispatch를 가져오고, dispatch의 useRef를 호출하여 useRef의 반환값인 current를 속성으로 가진 객체를 반환한다.

export function useRef<T>(initialValue: T): {current: T} {
  const dispatcher = resolveDispatcher();
  return dispatcher.useRef(initialValue);
}

resolveDispatcher

현재 활성화된 디스패처를 반환한다.

import ReactSharedInternals from 'shared/ReactSharedInternals'

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;  // ReactCurrentDispatcher 객체를 참조
  if (__DEV__) {
    if (dispatcher === null) {
      console.error(
        'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
          ' one of the following reasons:\n' +
          '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
          '2. You might be breaking the Rules of Hooks\n' +
          '3. You might have more than one copy of React in the same app\n' +
          'See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.',
      );
    }
  }

  return ((dispatcher: any): Dispatcher);
}

 

share/ReactSharedInternals 패키지에서 가져오면서 파일 연결이 끝난다. __DEV__는 개발 환경에서만 실행되는 리액트 코드베이스 전역 상수다. 컴파일 또는 번들링 과정에서 성정되어 개발 환경에서는 true, 프로덕션 환경에서는 false로 설정된다. 

Hook의 흐름

  1. 개발자: 훅을 사용하는 컴포넌트 코드를 작성
  2. react: 훅이 호출될 때 renderWithHooks 함수가 호출되어, ReactCurrentDispatcher의 resolveDispatcher를 통해 현재 활성화된 디스패처에 접근
  3. react/ReactHooks: 디스패처를 통해 훅 호출을 처리하고, 상태 및 기타 훅 관련 데이터를 관리
  4. react/ReactCurrentDispatcher: 현재 활성화된 디스패처를 추적. 컴포넌트의 마운트 또는 업데이트 시점에 적절한 디스패처를 설정하여 훅 호출을 관리
  5. react/ReactSharedInternals: ReactCurrentDispatcher를 포함한 리액트 내부 모듈 간에 공유되는 객체들을 포함
  6. shared/ReactSharedInternals: 리액트의 다양한 패키지와 모듈을 공통 객체와 상태에 접근할 수 있게함
  7. reconciler: 재조정을 통해 컴포넌트 트리의 변경 사항을 감지하고, 필요한 업데이트를 관리. 훅은 컴포넌트의 렌더링 과정에서 호출되므로, 재조정기는 각 훅 호출을 추적하고, 이를 기반으로 상태와 부수 효과를 관리

ReactSharedInternals

리액트 내부 모듈 간에 공유되는 객체들이다. 현재 활성화된 dispatch 등의 상태 정보를 담고 있으며, hook 시스템의 동작을 관리하는 데 중요한 역할을 한다. 즉, 포괄적으로 말하자면 다양한 리액트 패키지와 모듈이 공통된 상태와 데이터에 쉽게 접근할 수 있게 하는 사실상 리액트 내부 모듈 간의 공유 컨텍스트 역할을 한다.

const ReactSharedInternals =
  // 다양한 내부 상태와 설정을 포함한 내부 객체를 가져옴
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;

if (!ReactSharedInternals.hasOwnProperty('ReactCurrentDispatcher')) {
  ReactSharedInternals.ReactCurrentDispatcher = {
    current: null,
  };
}
if (!ReactSharedInternals.hasOwnProperty('ReactCurrentBatchConfig')) {
  ReactSharedInternals.ReactCurrentBatchConfig = {
    suspense: null,
  };
}

export default ReactSharedInternals;

renderWithHooks

컴포넌트를 렌더링할 때 훅 호출을 관리하고, 훅의 상태를 초기화하거나 업데이트하며, 렌더링 중에 발생한 업데이트를 처리하여 리액트가 컴포넌트의 상태와 부수 효과를 효과적으로 관리할 수 있게 돕는다.

  1. hook 초기화 및 업데이트 관리: 컴포넌트가 처음 마운트될 때와 업데이트될 때 훅의 상태를 초기화하거나 업데이트
  2. 현재 활성화된 dispatcher 설정: 컴포넌트가 렌더링되는 동안 현재 활성화된 dispatcher를 통해 훅의 상태와 호출 순서를 관리
  3. 렌더링 단계 업데이트 처리: 렌더링되는 동안 상태 업데이트가 발생하면 이를 처리, 렌더링 단계에서 업데이트가 발생하면 컴포넌트를 다시 렌더링하여 변경된 상태를 반영
  4. 상태 변수 초기화: 렌더링이 끝난 후 다음 렌더링을 위해 여러 상태 변수를 초기화
  5. dispatcher 복원: 렌더링이 끝난 후 디스패처를 기본 상태로 복원하여, 훅 호출이 컴포넌트 함수 내부에서만 일어나도록 보장
export function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  renderExpirationTime = nextRenderExpirationTime;
  currentlyRenderingFiber = workInProgress;
  // 현재 렌더링되는 컴포넌트의 훅 상태를 추적
  nextCurrentHook = current !== null ? current.memoizedState : null;

  // 훅의 타입과 훅 호출 순서를 추적하여 오류를 방지
  if (__DEV__) {
    hookTypesDev =
      current !== null
        ? ((current._debugHookTypes: any): Array<HookType>)
        : null;
    hookTypesUpdateIndexDev = -1;
    // Used for hot reloading:
    ignorePreviousDependencies =
      current !== null && current.type !== workInProgress.type;
  }

  // 현재 훅이 마운트되는지, 업데이트되는지에 따라 적절한 dispacher를 설정
  if (__DEV__) {
    if (nextCurrentHook !== null) {
      // 이미 훅이 존재하고 업데이트 중임
      // 현재 훅 상태를 업데이트 dispacher 설정
      ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
    } else if (hookTypesDev !== null) {
      // 훅이 존재하지 않지만 훅 타입이 추적되고 있음
      // 마운트 중인 훅 호출을 처리
      ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
    } else {
      // 훅이 처음 마운트되고 있으므로 마운트 중인 훅 호출을 처리
      ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
    }
  } else {
    // 프로덕션에서는 null인 경우 훅이 처음 마운트되고 있음 : 이미 훅이 존재하고 업데이트 중임
    ReactCurrentDispatcher.current =
      nextCurrentHook === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }

  // 설정된 디스패처를 사용하여 컴포넌트를 렌더링(훅 호출 및 설정된 dispatcher가 처리)
  // refOrContext는 props로 전달받은 속성 및 참조 또는 컨텍스트
  let children = Component(props, refOrContext);

  // 렌더링 단계에서 업데이트가 스케줄링되었다면, 이를 처리하기 위해 렌더링을 다시 시작
  if (didScheduleRenderPhaseUpdate) {
    do {
      // 스케줄링 플래그를 초기화하여 루프를 제어
      didScheduleRenderPhaseUpdate = false;
      // 재렌더링 횟수를 증가시킴
      numberOfReRenders += 1;
      
      if (__DEV__) {
        // Even when hot reloading, allow dependencies to stabilize
        // after first render to prevent infinite render phase updates.
        ignorePreviousDependencies = false;
      }

      // Start over from the beginning of the list
      nextCurrentHook = current !== null ? current.memoizedState : null;  // 현재 훅 초기화
      nextWorkInProgressHook = firstWorkInProgressHook;  // 진행 중인 훅 초기화

      currentHook = null;  // 현재 훅 초기화
      workInProgressHook = null;  // 진행 중인 훅 초기화
      componentUpdateQueue = null;  // 컴포넌트 업데이트 큐 초기화

      if (__DEV__) {
        // Also validate hook order for cascading updates.
        hookTypesUpdateIndexDev = -1;
      }

      ReactCurrentDispatcher.current = __DEV__
        ? HooksDispatcherOnUpdateInDEV
        : HooksDispatcherOnUpdate;

      // 컴포넌트 다시 렌더링
      children = Component(props, refOrContext);
    } while (didScheduleRenderPhaseUpdate);  // 렌더링 중에 추가 업데이트가 스케줄링되면 루프를 반복

    renderPhaseUpdates = null;
    numberOfReRenders = 0;
  }

  // 렌더링이 끝난 후, 디스패처를 ContextOnlyDispatcher로 복원
  // ContextOnlyDispatcher는 훅 호출이 발생하지 않도록 설정된 dispatcher
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;

  // 렌더링 중인 Fiber 노드의 훅 상태와 업데이트 큐를 저장
  const renderedWork: Fiber = (currentlyRenderingFiber: any);

  renderedWork.memoizedState = firstWorkInProgressHook;
  renderedWork.expirationTime = remainingExpirationTime;
  renderedWork.updateQueue = (componentUpdateQueue: any);
  renderedWork.effectTag |= sideEffectTag;

  if (__DEV__) {
    renderedWork._debugHookTypes = hookTypesDev;
  }

  // This check uses currentHook so that it works the same in DEV and prod bundles.
  // hookTypesDev could catch more cases (e.g. context) but only in DEV bundles.
  const didRenderTooFewHooks =
    currentHook !== null && currentHook.next !== null;

  // 다음 렌더링을 위해 여러 상태 변수를 초기화
  renderExpirationTime = NoWork;
  currentlyRenderingFiber = null;

  currentHook = null;
  nextCurrentHook = null;
  firstWorkInProgressHook = null;
  workInProgressHook = null;
  nextWorkInProgressHook = null;

  if (__DEV__) {
    currentHookNameInDev = null;
    hookTypesDev = null;
    hookTypesUpdateIndexDev = -1;
  }

  remainingExpirationTime = NoWork;
  componentUpdateQueue = null;
  sideEffectTag = 0;

  // These were reset above
  // didScheduleRenderPhaseUpdate = false;
  // renderPhaseUpdates = null;
  // numberOfReRenders = 0;

  // 렌더링된 훅의 수가 예상보다 적은 경우 에러 발생
  invariant(
    !didRenderTooFewHooks,
    'Rendered fewer hooks than expected. This may be caused by an accidental ' +
      'early return statement.',
  );

  return children;
}

뜻밖의 hook의 원리를 구경하게 되었다.. 그래서 useRef는 어떻게 구현되어 있는건데..?

 

useRef

hook이 ReactCurrentDispatcher를 통해 추적되기 때문에, useRef 역시 ReactCurrentDispatcher를 통해 추적되며 현재 활성화된 dispatcher의 current 형태의 객체를 반환한다. useRef의 dispatcher인 HooksDispatcherOnMount를 통해 처음 컴포넌트가 렌더링될 때 호출되어 초기값을 가진 객체를 생성(mountWorkInProgressHook)하고, 이를 hook의 memoizedState에 저장한다. 컴포넌트가 업데이트될 때 updateWorkInProgressHook(이전에 생성한 hook을 가져)을 통해 이전에 저장된 memoizedState를 반환함으로써 컴포넌트가 렌더링될 때 그전의 상태를 유지한다. 

 

즉, DOM 접근이 아니라 기존의 값을 유지하는데 목적이 있는 hook이다.

const Counter = () => {
  const countRef = useRef(0);

  const increment = () => {
    countRef.current += 1;
    console.log(countRef.current);
  };

  return (
    <div>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

 

그동안 DOM에 접근하는 경우에만 useRef를 사용하고 있었어서, 혼동이 있었다.

예시1

function MyComponent() {
  const myRef = useRef(null);

  useEffect(() => {
    console.log(myRef.current); // <input type="text" />
  }, []);

  return (
    <div>
      <input ref={myRef} type="text" />
    </div>
  );
}

 

  1. 컴포넌트가 마운트된다.
  2. useRef의 초기값이 null로 설정되고(), 이후 렌더링된 후 ref가 myRef의 초기값으로 들어가있기 때문에 input이 초기값으로 들어간다.
  3. useEffect로 재렌더링이 시작되며, input이 유지되므로 myRef.current의 값으로 input이 나온다.

예시2

function MeasureExample() {
  const [height, setHeight] = useState(0);

  const measuredRef = useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height);
    }
  }, []);

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  );
}
  1. 컴포넌트가 마운트된다.
  2. height의 값이 0, mesuredRef는 조건문에 들어가지 않고 끝나고 해당 콜백함수가 메모이제이션된다.
  3. 렌더링이 완료된 후 h1이 노드로 들어가며 콜백함수를 호출하여 node가 null이 아니므로 h1의 길이로 height가 업데이트된다.

forwardRef

리액트에서 컴포넌트에 ref를 전달할 수 있게 하는 유틸리티 함수이다. 부모 컴포넌트가 자식 컴포넌트 내부의 DOM 요소 또는 자식 컴포넌트 인스턴스에 접근할 수 있다. 부모 컴포넌트는 자식 컴포넌트의 내부 DOM 요소에 직접 접근할 수 없으므로, forwardRef 함수로 자식 컴포넌트를 감싸 자식 컴포넌트의 DOM 요소에 전달하면 부모 컴포넌트에서 자식 컴포넌트의 DOM 요소에 접근할 수 있다.


결론

리액트 훅 시스템의 동작 과정

  1. 컴포넌트가 마운트되면서 renderWithHooks가 호출된다.
  2. renderWithHooks 함수 내에서 resolveDispatcher 함수를 호출되어 현재 활성화된 dispatcher에 접근하여 처리한다.
  3. ReactSharedInternal를 통해서 dispatcher가 공유된다.
  4. 컴포넌트 내에서 훅이 호출될때마다 2-3이 반복된다 .
  5. 컴포넌트 상태가 변경되거나 업데이트가 필요할 때 reconciler가 작동하여 변경사항을 처리한다.
  6. reconciler가 작동하여 변경된 상태를 반영한 컴포넌트를 다시 렌더링한다.
  7. 렌더링 과정에서 hook은 다시 호출되며 dispatcher를 통해 상태가 유지되고 관리된다.

useRef

  • hook이 호출되는 과정에서 current에 기존의 값을 유지한다.

 


Reference

facebook/react: The library for web and native user interfaces. (github.com)

useRef – React

진짜 리액트는 어떻게 생겼나? (2) - renderWithHooks와 훅의 본체 — _0422의 생각 (tistory.com)

Hook의 동작원리 파헤쳐보기(React 코드 까보기) 02 - 외부 주입 역할을 하는(의존성 관리) ReactSharedInternals.js와 shared 패키지 (velog.io)


오,, 나는 그냥 useRef가 어떻게 생겼는지 궁금했을 뿐이었다. 근데 잘못 알고 있었고, hook의 동작을 이해하는게 필요했다,, 재조정과 파이버는 다음에 알아보겠다..