(React) Hooks 파고들기

한중일 개발자·2024년 3월 4일
1

React Basics

목록 보기
11/11

보고있는 강의는 React 16버전을 기준으로 하고있어서, 본 포스팅에선 우선 16버전 기준으로 코드를 파고들어보고, 추후에 18버전 Lane 모델 도입 이후 변경 사항을 알아본다.

Hooks는 어디서 오는 걸까?


Hooks는 react에서 위처럼 보통 import해온다.

// Export all exports so that they're available in tests.
// We can't use export * from in Flow for some reason.
export {
  __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,
  ...
  useId,
  useCallback,
  useContext,
  useDebugValue,
  useDeferredValue,
  useEffect,
  experimental_useEffectEvent,
  useImperativeHandle,
  useInsertionEffect,
  useLayoutEffect,
  useMemo,
  useOptimistic,
  useSyncExternalStore,
  useReducer,
  useRef,
  useState,
  useTransition,
  version,
} from './src/ReactClient';

리액트 패키지의 index.js 를 보면 Hook들을 export해주고 있다. 더 파고들어가보면 ReactHooks.js 내부에서 아래같이 되어있다:

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  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.',
      );
    }
  }
  // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.
  return ((dispatcher: any): Dispatcher);
}


export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

더 파고들어가서 ReactCurrentDispatcher.js를 보면...

/**
 * Keeps track of the current dispatcher.
 */
const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
};

export default ReactCurrentDispatcher;

어라라. Hook에 대한 구현이 없다. 결국 결론은 React 코어는 Hook에 대한 구현이 없고, react element에 대한 정보만을 알고 있다는 것이다.

그럼 어떻게 되고 있는걸까? React element는 fiber 노드로 확장되어야 hook을 포함한 상태로 VDOM에 올라가게 되고, 이 확장은 reconciler가 담당한다.

따라서 React 코어는 hook을 사용하기 위해 외부에서 주입받음을 유추할 수 있다.

import ReactCurrentDispatcher from './ReactCurrentDispatcher';
import ReactCurrentCache from './ReactCurrentCache';
import ReactCurrentBatchConfig from './ReactCurrentBatchConfig';
import ReactCurrentActQueue from './ReactCurrentActQueue';
import ReactCurrentOwner from './ReactCurrentOwner';
import ReactDebugCurrentFrame from './ReactDebugCurrentFrame';

const ReactSharedInternals = {
  ReactCurrentDispatcher,
  ReactCurrentCache,
  ReactCurrentBatchConfig,
  ReactCurrentOwner,
};

위의 ReactSharedInternalsClient.js 파일을 보면 이 파일이 Reconciler로부터 Hook에 대한 정보를 받아와주는 통로가 이곳이란걸 알 수 있다. 이 파일은 injection을 기다리는 dependency들의 대기소라고 보면 되겠다.

React 패키지만 보면 결론적으로 현재까지 React <- ReactHooks <- ReactCurrentDispatcher <- ReactSharedInternals(Client/Server)로 Hooks가 주입되고 있다.

shared 패키지

근데 사실 shared라는, 모든 패키지가 공유하는 공통 패키지가 한개 더있다.

import * as React from 'react';

const ReactSharedInternals =
  React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;

export default ReactSharedInternals;

그 안에 보면 또 ReactSharedInternals 파일이 있다!

다시 한번 보면, Reconciler는 이 shared 패키지의 ReactSharedInternal에 먼저 Hook을 주입해준다.

최종적으로 훅이 전달되는 흐름은 아래와 같다:
reconciler -> shared/ReactSharedInternal -> react/ReactSharedInternal -> react/ReactCurrentDispatcher -> react/ReactHooks -> react -> 개발자

Reconciler 패키지

그럼 이제 주입해준다는 Reconciler 패키지로 가보자. 여기선 리액트 16버전의 파일을 본다.

// react/packages/react-reconciler/src/ReactFiberHooks.js

// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.
let currentHook: Hook | null = null;
let workInProgressHook: Hook | null = null;

...

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  renderExpirationTime = nextRenderExpirationTime;
  currentlyRenderingFiber = workInProgress; // 현재 작업중인 fiber를 전역 변수로 지정
  nextCurrentHook = current !== null ? current.memoizedState : null; // 훅 리스트 매핑
  
  ...
  
ReactCurrentDispatcher.current =
      nextCurrentHook === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate; // mount 상태인지 업데이트 상태인지, 훅 리스트가 null인지 여부로 판단
  
let children = Component(props, refOrContext); // 컴포넌트 호출
...

ReactFiberHooks.js 파일이다.

컴포넌트가 호출되고 마운트되어야 하면 firstWorkInProgressHook에 훅 리스트가 저장된다. 이 변수는 fiber의 memoizedState에 저장되고, 이는 컴포넌트와 훅을 매핑해준다.

그리고 우선 현재 작업중인 fiber를 전역 변수인 currentlyRenderingFiber에 할당한다.

nextCurrentHook는 current, 즉 이미 실제 DOM에 반영되어 있는 파이버 노드가 존재한다면 (즉 마운트 된 업데이트 상태면) 훅 리스트가 배정된다. 마운트가 아직 안된 상태면 null이 배정된다.

이후 memoizedState가 null이 아니라면 훅 리스트가 존재한다는 뜻이기에 컴포넌트는 마운트가 아닌 업데이트 상태임을 알 수 있다. 이제 이걸 기반으로 마운트될때는 마운트용 구현체인 HooksDispatcherOnMount를 사용, 이후 언마운트 전까지는 업데이트용 구현체인 HooksDispatcherOnUpdate를 사용한다.

// We can assume the previous dispatcher is always this one, since we set it
// at the beginning of the render phase and there's no re-entrancy.
ReactCurrentDispatcher.current = ContextOnlyDispatcher; // 에러 처리용

const renderedWork: Fiber = (currentlyRenderingFiber: any);

renderedWork.memoizedState = firstWorkInProgressHook;
  
...

currentlyRenderingFiber = null;

렌더링 되면 renderedWork에 저장되고, memoizedstate에 훅 리스트도 저장되며 current하게 렌더링되는 파이버도 초기화된다.

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
};

그렇게 타고 내려와서 훅 구현체를 찾았다.

훅의 생성

훅 객체 생성

위를 보면 만약 컴포넌트가 mount되면 memoizedState가 null이기에 HooksDispatcherOnMount가 사용되고, 이는 또 mountState라는 친구를 사용한다.

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
    const hook = mountWorkInProgressHook();
...
}

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    queue: null,
    baseUpdate: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    firstWorkInProgressHook = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

그친구는 또 mountWorkInProgressHook를 호출해 훅 개체를 만든다.

잘 알듯, 파이버는 링크드 리스트다. 그래서 훅 순서가 바뀌거나 하면 오류가 떴다. firstWorkInProgressHook도 링크드 리스트로, 훅 연결 리스트의 head 부분이다. 아까 컴포넌트 실행이 끝났을때 renderedWork.memoizedState = firstWorkInProgressHook; 식으로 파이버에 저장되어 컴포넌트와 훅 리스트를 연결해주었다. workInProgressHook는 현재 처리되는 훅을 나타내고,

참고자료

[React 까보기 시리즈] React 구성요소인 reconciler, renderer 등의 내부 패키지 & fiber, rendering 등의 용어 정의

React 파이버 아키텍처 분석

profile
한국에서 태어나고, 중국 베이징에서 대학을 졸업하여, 일본 도쿄에서 개발자로 일하고 있습니다. 유창한 한국어, 영어, 중국어, 일본어와 약간의 자바스크립트를 구사합니다.

0개의 댓글