[React] Hook 낯설게하기 2

thru·2024년 9월 29일
2

React-Hook

목록 보기
2/2

훅 한 입

이전 글에선 리액트의 상태를 관리하는 useStateuseReducer를 알아보았다. 이번에는 상태를 전달하는 훅인 useContext와 상태를 벗어나는 훅인 useRef를 알아본다.


useContext

일반적으로 우리가 자식 컴포넌트로 상태를 전달하는 방법은 속성이다. 속성은 한 단계 아래로만 전달할 수 있는 단점이 있어서 이를 보완하기 위해 고안된 것이 Context다. 때문에 Context를 한 마디로 요약하면 컴포넌트 속성의 두 번째 버전이라고 할 수 있을 것이다. 동작 원리도 이와 유사하게 흘러간다.

동작 원리

createContext로 생성하는 context는 아주 간단한 객체로 시작한다.

/**@ react/src/ReactContext.js **/ 
export function createContext<T>(defaultValue: T): ReactContext<T> {
    const context: ReactContext<T> = {
    $$typeof: REACT_CONTEXT_TYPE,
    _currentValue: defaultValue,
    // These are circular
    Provider: (null: any),
    Consumer: (null: any),
  };

  if (enableRenderableContext) {
    context.Provider = context;
    context.Consumer = {
      $$typeof: REACT_CONSUMER_TYPE,
      _context: context,
    };
  } else {
    /**@ 생략 **/
  }
  
  return context;
}

context 객체는 _currentValue 속성에 우리가 Context 선언 시 전달하려는 값을 지정하는 value를 저장한다. Provider는 jsx 문법으로 Context를 선언할 때 사용하는 객체이다. enableRenderableContext는 현재 리액트 버전에서 true로 되어있어 Provider대신 context를 선언해도 동일하게 동작하도록 한다. Consumer는 16버전 이하에서 useContext 역할로 사용되던 레거시 기능이다.

선언된 Context는 VDOM에 Fiber로 존재하다가 reconciler가 렌더링 작업을 수행하는 Work에서 분기처리된다.

/**@ react-reconciler/src/ReactFiberBeginWork.js **/
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  
  /**@ 생략 **/
    
  switch (workInProgress.tag) {
    /**@ 생략 **/
    case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);

}
function updateContextProvider(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  let context: ReactContext<any>;
  if (enableRenderableContext) {
    context = workInProgress.type;
  } else {
    context = workInProgress.type._context;
  }
  const newProps = workInProgress.pendingProps;
  const oldProps = workInProgress.memoizedProps;

  const newValue = newProps.value;

  pushProvider(workInProgress, context, newValue);

  if (enableLazyContextPropagation) {
    // In the lazy propagation implementation, we don't scan for matching
    // consumers until something bails out, because until something bails out
    // we're going to visit those nodes, anyway. The trade-off is that it shifts
    // responsibility to the consumer to track whether something has changed.
  } else {
    if (oldProps !== null) {
      const oldValue = oldProps.value;
      if (is(oldValue, newValue)) {
        // No change. Bailout early if children are the same.
        /**@ 생략: bailout 로직 **/
      } else {
        // The context value changed. Search for matching consumers and schedule
        // them to update.
        propagateContextChange(workInProgress, context, renderLanes);
      }
    }
  }

  const newChildren = newProps.children;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
}

먼저 전역 공간에 존재하는 context 스택에 현재 Context를 push해 저장한다. 저장할 땐 Context 객체의 value 값을 갱신한다.

export function pushProvider<T>(
  providerFiber: Fiber,
  context: ReactContext<T>,
  nextValue: T,
): void {
  push(valueCursor, context._currentValue, providerFiber);
  
  context._currentValue = nextValue;

context 스택을 렌더링 중에 직접적으로 활용하는 부분은 찾지 못했다. 개발용 기능으로 추측된다.

그 뒤엔 enableLazyContextPropagation 플래그에 따라 Context 값 변화를 아래에 전파시킬지 결정하는데, 이 플래그는 true로 설정되어 있기 때문에 전파시키지 않는다. 주석에 설명되어 있는 것처럼 value 변화를 확인하는 주체를 Provider Fiber에서 소비하는 컴포넌트 Fiber로 변화시키기 위함이다. 트리의 상위에 위치하는 Provider에서 확인하면 이후 umount 등으로 해제될 Fiber node도 포함되어 비효율적인 면이 있었기 때문이라고 한다.

Provider에서 value 비교를 하지 않는다면 useContext가 할 것이라고 예상할 수 있다. 하지만 useContext는 value 값을 가져오는 역할만 수행한다.

const HooksDispatcherOnMount: Dispatcher = {
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  /**@ 생략 **/
}
export function readContext<T>(context: ReactContext<T>): T {
  return readContextForConsumer(currentlyRenderingFiber, context);
}
function readContextForConsumer<C>(
  consumer: Fiber | null,
  context: ReactContext<C>,
): C {
  const value = context._currentValue;

  if (lastFullyObservedContext === context) {
    // Nothing to do. We already observe everything in this context.
  } else {
    const contextItem = {
      context: ((context: any): ReactContext<mixed>),
      memoizedValue: value,
      next: null,
    };

    if (lastContextDependency === null) {
      // This is the first dependency for this component. Create a new list.
      lastContextDependency = contextItem;
      consumer.dependencies = {
        lanes: NoLanes,
        firstContext: contextItem,
      };
      if (enableLazyContextPropagation) {
        consumer.flags |= NeedsPropagation;
      }
    } else {
      // Append a new context item.
      lastContextDependency = lastContextDependency.next = contextItem;
    }
  }
  return value;
}

useContext 내부에서 하는 작업은 3가지이다. Context에 대한 정보를 Fiber의 dependencies속성에 링크드 리스트 형식으로 저장하고, NeedsPropagation 플래그를 설정하고, value를 반환한다. 반환된 value는 우리가 useContext에서 가져오는 값이다.

플래그는 차후 알아보고 dependencies를 비교하는 부분을 찾아본다.

function finishRenderingHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
): void {
  /**@ 생략 **/
  if (enableLazyContextPropagation) {
    if (current !== null) {
      if (!checkIfWorkInProgressReceivedUpdate()) {
        // If there were no changes to props or state, we need to check if there
        // was a context change. We didn't already do this because there's no
        // 1:1 correspondence between dependencies and hooks. Although, because
        // there almost always is in the common case (`readContext` is an
        // internal API), we could compare in there. OTOH, we only hit this case
        // if everything else bails out, so on the whole it might be better to
        // keep the comparison out of the common path.
        const currentDependencies = current.dependencies;
        if (
          currentDependencies !== null &&
          checkIfContextChanged(currentDependencies)
        ) {
          markWorkInProgressReceivedUpdate();
        }
      }
    }
  }
  /**@ 생략 **/
export function checkIfContextChanged(
  currentDependencies: Dependencies,
): boolean {
  if (!enableLazyContextPropagation) {
    return false;
  }
  // Iterate over the current dependencies to see if something changed. This
  // only gets called if props and state has already bailed out, so it's a
  // relatively uncommon path, except at the root of a changed subtree.
  // Alternatively, we could move these comparisons into `readContext`, but
  // that's a much hotter path, so I think this is an appropriate trade off.
  let dependency = currentDependencies.firstContext;
  while (dependency !== null) {
    const context = dependency.context;
    const newValue = context._currentValue;
    const oldValue = dependency.memoizedValue;
    if (
      /**@ 생략 **/
    } else {
      if (!is(newValue, oldValue)) {
        return true;
      }
    }
    dependency = dependency.next;
  }
  return false;
}

renderWithHooks 마지막에 실행되는 finishRenderingHooks에서 Update를 받지 않았을 경우, Context 변화를 확인하기 위해 dependencies 내부 Context를 순회하며 _currentValue끼리 Object.is 비교하는 것을 확인할 수 있다. 즉, 컴포넌트의 리렌더링을 위해 비교하는 값이 state, props 다음에 context value 이다. 위에서 컴포넌트 속성 두 번째 버전이라고 한 이유가 여기 있다.

useContext에서 비교하지 않는 이유는 두 주석이 모두 설명하고 있다. 상태와 속성이 모두 변하지 않아 업데이트가 계획되지 않은 bailout 과정이 useContext 보다 덜 실행되기 때문에 비교 로직을 bailout에 위치시키는게 성능에 이점을 볼 수 있었다고 한다.

여기까지 보면 LazyContextPropagation이라는 이름이 안 어울려 보인다. Context 리스트를 저장하고 있다가 사용할 때 비교할 뿐, 전파하고 있다고 보긴 어렵다. 해답은 위에서 넘어갔던 NeedsPropagation 플래그에 있다.

function propagateParentContextChanges(
  current: Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
  forcePropagateEntireTree: boolean,
) {
  if (!enableLazyContextPropagation) {
    return;
  }

  // Collect all the parent providers that changed. Since this is usually small
  // number, we use an Array instead of Set.
  let contexts = null;
  let parent: null | Fiber = workInProgress;
  let isInsidePropagationBailout = false;
  while (parent !== null) {
    if (!isInsidePropagationBailout) {
      if ((parent.flags & NeedsPropagation) !== NoFlags) {
        isInsidePropagationBailout = true;
      } else if ((parent.flags & DidPropagateContext) !== NoFlags) {
        break;
      }
    }
    
    /**@ 생략: context value 비교 로직 **/

이 함수는 Suspense를 위해 사용된다. 즉, Lazy Context Propagation은 성능을 위한 것이기도 하지만 Suspense에 Context 값 변화를 전파시키기 위한 목적이 더 크다고 볼 수 있다.

function updateDehydratedSuspenseComponent(
  /**@ 생략 **/
): null | Fiber {
  /**@ 생략 **/
    if (
      enableLazyContextPropagation &&
      // TODO: Factoring is a little weird, since we check this right below, too.
      // But don't want to re-arrange the if-else chain until/unless this
      // feature lands.
      !didReceiveUpdate
    ) {
      // We need to check if any children have context before we decide to bail
      // out, so propagate the changes now.
      lazilyPropagateParentContextChanges(current, workInProgress, renderLanes);
    }
export function lazilyPropagateParentContextChanges(
  current: Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  const forcePropagateEntireTree = false;
  propagateParentContextChanges(
    current,
    workInProgress,
    renderLanes,
    forcePropagateEntireTree,
  );
}

다른 Fiber와 달리 Suspense가 전파를 활용하는 이유는 분리되어 렌더링되면서 dependencies에 접근할 수 없기 때문으로 추측된다. 위 코드에서 생략된 context value 비교 로직에서는 기존 방법과 다르게 부모 노드를 순회하면서 context 목록을 수집한다.


useRef

컴포넌트의 상태는 렌더링을 촉발시키고 지역변수는 렌더링에 의존적이다. 렌더링과 관계없이 존재하는 변수를 만드려면 방법은 두가지이다. 바로 전역변수와 useRef다. 둘의 비교는 useRef의 원리를 보고 확인한다.

동작 원리

앞선 훅들은 낯설어지는 과정을 거쳤다면 useRef는 친숙하게 느껴질 정도로 간단하다. useState에서 렌더링과 관련된 모든 기능을 삭제하고 객체 하나만 남겨두면 된다.

function mountRef<T>(initialValue: T): {current: T} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {current: T} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

queue와 dispatch는 사라지고 current 속성을 가진 ref 객체가 memoizedState에 저장된다.


다만 ref.current 속성에 요소를 할당하는 과정은 예상과 다를 수 있다.

먼저 추측해보자면 JSX가 createElement로 변환되므로 내부에서 ref 속성이 지정된 경우에 요소를 넣어주면 될 것 같다. 하지만 createElement는 내부 속성에 전달 받은 ref를 저장하고 레거시 ref 기능을 조율할 뿐 current를 변경하지 않는다. 때문에 ref를 지정한 컴포넌트의 자식 컴포넌트에서 ref.current값을 렌더링 중에 출력하더라도 null이 나타난다.

function MenuButton() {
  const menuRef = useRef();
  console.log("ref set", menuRef.current);
  
  return (
    <MenuButton ref={menuRef}>
      <MenuInner menuRef={menuRef} />
    </MenuButton>
  );
}

function MenuInner({ menuRef }) {
  console.log("using ref", menuRef.current);
  return <div></div>;
}

/** 출력 **
 * ref set null
 * using ref null
 */

그렇지만 우리는 이벤트 콜백이나 useEffect 속에서 ref.current 값은 잘 써왔다. 즉 useRefcurrent값 할당 시기는 렌더링이 끝난 뒤다. 정확히는 Commit Phase이다.

/**@ `commitRoot` -> `commitRootImpl` -> `commitLayoutEffects` -> `commitLayoutEffectOnFiber` **/
function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
): void {
  /**@ 생략 **/
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    /**@ 생략 **/
    case HostComponent: {
    /**@ 생략 **/
      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
  }

ref.current에 요소를 할당하기 위해선 일단 컴포넌트의 인스턴스가 존재해야한다. div처럼 HostComponent인 경우엔 DOM 요소가 인스턴스이고, 클래스 컴포넌트에선 클래스로부터 생성되는 인스턴스 자체가 된다. 함수형 컴포넌트는 인스턴스가 없기 때문에 SafelyAttachRef를 실행시킬 수 없다.

대신 함수형 컴포넌트는 ForwardRef로 Ref를 전달해줄 수는 있다.

export function safelyAttachRef(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  try {
    commitAttachRef(current);
  }
  /**@ 생략 **/
}

function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    const instance = finishedWork.stateNode;
    let instanceToUse;
    switch (finishedWork.tag) {
      case HostHoistable:
      case HostSingleton:
      case HostComponent:
        instanceToUse = getPublicInstance(instance);
        break;
      default:
        instanceToUse = instance;
    }
    // Moved outside to ensure DCE works with this flag
    if (enableScopeAPI && finishedWork.tag === ScopeComponent) {
      instanceToUse = instance;
    }
    if (typeof ref === 'function') {
      finishedWork.refCleanup = ref(instanceToUse);
    } else {
      ref.current = instanceToUse;
    }
  }
}

Fiber의 stateNode에 저장한 인스턴스를 ref.current에 저장한다.

추가로 ref에 함수를 지정했다면 ref를 실행시키고 리턴하는 함수를 refCleanup에 저장한다. 저장된 refCleanup은 같은 Commit Phase이지만 LayoutEffect 직전에 일어나는 MutationEffect에서 실행된다. 따라서 Cleanup이라는 이름에 맞게 이전 함수 Ref의 뒷정리 작업을 수행할 수 있다.

function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  /**@ 생략 **/
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;

  switch (finishedWork.tag) {
    /**@ 생략 **/
    case HostComponent: {
      /**@ 생략 **/
      if (flags & Ref) {
        if (current !== null) {
          safelyDetachRef(current, current.return);
        }
      }

마찬가지로 Host 컴포넌트나 클래스 컴포넌트에서만 처리한다.

export function safelyDetachRef(
  current: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  const ref = current.ref;
  const refCleanup = current.refCleanup;

  if (ref !== null) {
    if (typeof refCleanup === 'function') {
      try {
        /**@ 생략 **/
        refCleanup();
        /**@ 생략 **/
      } finally {
        // `refCleanup` has been called. Nullify all references to it to prevent double invocation.
        current.refCleanup = null;
        const finishedWork = current.alternate;
        if (finishedWork != null) {
          finishedWork.refCleanup = null;
        }
      }

refCleanup을 실행한 뒤 제거해 나중에 다시 실행되는 일이 없도록 한다.

ref.current의 처리가 Commit Phase에 위치한 이유는 아마 위와 같은 함수 Ref의 처리 및 DOM 요소 접근이 필요하기 때문일 것이다.

safelyAttachRefsafelyDetachRef를 실행하기 전에 Fiber의 flagsRef인지 먼저 확인한다. 이 flags 설정은 markRef에서 주관한다.

function markRef(current: Fiber | null, workInProgress: Fiber) {
  const ref = workInProgress.ref;
  if (ref === null) {
    if (current !== null && current.ref !== null) {
      // Schedule a Ref effect
      workInProgress.flags |= Ref | RefStatic;
    }
  } else {
    if (typeof ref !== 'function' && typeof ref !== 'object') {
      throw new Error(
        'Expected ref to be a function, an object returned by React.createRef(), or undefined/null.',
      );
    }
    if (current === null || current.ref !== ref) {
      /**@ 생략 **/
      // Schedule a Ref effect
      workInProgress.flags |= Ref | RefStatic;
    }
  }
}

markRefbeginWork 내부에서 실행되는 updateHostComponentupdateClassComponent가 사용한다.

useRef vs 전역변수

앞서 React에서 렌더링과 관계없이 존재하는 변수는 useRef와 전역변수라고 했다. 두 변수의 차이는 존재 위치에서 찾을 수 있다. useRef는 위에서 살펴본 것처럼 Fiber의 memoizedState에 객체의 속성으로 값이 저장된다. 이는 useRef가 전역변수와 구분되는 두 가지 특성을 갖도록 한다.

첫 번째는 VDOM과 연결이다. 렌더링에 영향을 주거나 받지는 않지만 독립적으로 존재하지는 않는다. 렌더링의 기저라고 할 수 있는 VDOM의 Fiber에 연결되어 있으므로 React의 렌더링 방식이 변화해도 공존할 수 있다. 예시로 SSR을 들 수 있는데, 서버는 요청이 들어오는 다양한 클라이언트마다 각각의 VDOM을 생성해 전달해주어야 한다. useRef는 VDOM마다 별개로 존재하여 각 결과물끼리 영향을 주지 않는다. 전역변수는 서버의 전역 공간에 하나만 존재하므로 각 결과물이 하나의 전역변수를 재사용해 영향을 미치게 된다.

두 번째는 객체의 속성 형태이다. 값을 ref 자체가 아니라 ref.current 속성에 저장하므로 클로져를 벗어나 자유롭게 사용할 수 있다. 덕분에 이벤트나 useEffect 콜백 등에서 선언 당시 값이 아닌 사용 시점의 값에 접근이 가능하다.

물론 무조건 useRef가 전역변수 대비 우위라는 건 아니다. 클로져를 활용하기 위해 전역변수나 추가적인 과정을 사용하는 경우도 있다. 하지만 대부분의 경우 React와 조화를 이루는 방법은 useRef일 것이다.


참조

profile
프론트 공부 중

0개의 댓글