forwardRef의 내부 동작 원리

우혁·2024년 9월 10일
35

React

목록 보기
7/10

forwardRef란

부모 컴포넌트가 자식 컴포넌트의 DOM 요소에 직접 접근할 수 있도록 도와주는 기능이다.

사용 방법

forwardRef(render)

  • render: 컴포넌트의 렌더링 함수이다. 리액트는 컴포넌트가 부모로부터 받은 propsref로 이 함수를 호출한다.
// props: 
// 
const MyInput = forwardRef(function MyInput(props, ref) {
  return (
    <label>
      {props.label}
      <input ref={ref} />
    </label>
  );
});

render 함수

  • props: 부모 컴포넌트가 전달한 Props이다.
  • ref: 부모 컴포넌트가 전달한 ref 어트리뷰트이다. ref는 객체나 함수일 수 있다.
    전달받은 ref를 다른 컴포넌트에 전달하거나 useImperativeHandle에 전달해야 한다.

💡 useImperativeHandle 란?
부모 컴포넌트가 자식 컴포넌트의 특정 기능이 상태에 접근할 수 있게 해주는 훅이다.
이는 리액트의 단방향 데이터 흐름과는 다르므로 신중하게 사용해야 한다.

반환 값

JSX에서 렌더링할 수 있는 리액트 컴포넌트를 반환한다. 일반 함수로 정의된 리액트 컴포넌트와는 다르게, forwardRef가 반환하는 컴포넌트는 ref prop도 받을 수 있다.


forwardRef가 필요한 이유

함수형 컴포넌트에서는 클래스 컴포넌트와 달리, 인스턴스를 생성하지 않는다.
인스턴스가 없다는 것은 this를 통해 접근할 수 있는 컴포넌트 자체의 참조가 없다는 의미이다.

ref는 일반적으로 컴포넌트의 인스턴스(상태, 생명주기 메서드 등)나 DOM 요소를 참조하는 데 사용되므로, 인스턴스가 없는 함수 컴포넌트에서는 ref를 직접적으로 사용하기 어렵다.

ref를 직접적으로 사용할 수 없기에 props로 전달해야 하는데, 리액트는 다음과 같은 이유들로 refprops에 포함하지 않았다.

1. 특별한 처리
ref는 일반적인 props와 다르게 리액트에 의해 특수하게 처리되어야 한다.
ex) ref는 컴포넌트가 마운트된 후에 설정되고, 언마운트되기 전에 null로 재설정 되어야 한다.

2. 일관성 유지
클래스 컴포넌트에서 refthis.refs를 통해 접근되며, props의 일부가 아니다.
함수형 컴포넌트에서도 이와 유사한 동작을 유지하고자 했다.

3. 의도적인 사용 제한
ref의 직접적인 사용을 제한함으로써, 개발자들이 ref를 신중하게 사용하도록 유도했다.
이는 선언적 프로그래밍 패러다임을 따르는 리액트의 철학과 일치한다.

리액트는 이 밖에 여러 이유들로 refprops에 포함시키지 않았다.

function Component(props) {
  return <div>
    Props: {JSON.stringify(props)}
  </div>
}
export default function App() {
  return <Component a={1} ref={() => {}}/>
}

자식 컴포넌트에 ref를 전달하고 자식 컴포넌트에서 Props를 직렬화 해보면 아래와 같은 결과를 얻을 수 있다.

Props: {"a":1} - 직렬화한 Propsref는 존재하지 않는 것을 확인할 수 있다.

컴파일한 리액트 앨리먼트를 확인 해보면 다음과 같이 Propsref가 따로 처리된다.

{
  $$typeof: Symbol("react.element"), 
  key: null, 
  props: {a: 1}, 
  ref: () => {}, 
  type: function Component(props) {...}, 
  _owner: ...,
  _store: ...,
}

forwardRef의 내부 동작 원리

forwardRef는 특수한 리액트 앨리먼트 타입을 반환한다.

export const REACT_ELEMENT_TYPE: symbol = Symbol.for('react.element');
export const REACT_FORWARD_REF_TYPE: symbol = Symbol.for('react.forward_ref');
export const REACT_SUSPENSE_TYPE: symbol = Symbol.for('react.suspense');
// 이외에도 REACT_PORTAL_TYPE, REACT_FRAGMENT_TYPE 등 다양한 타입들이 존재한다.

REACT_ELEMENT_TYPE는 사용자 정의 함수/ 클래스 요소와 내장 HTML 태그를 다룬다.

REACT_FORWARD_REF_TYPE과 같은 타입들은 특별한 로직을 가진 특수한 타입이다.

이러한 타입들은 리액트가 내부적으로 컴포넌트의 종류를 식별하고 적절히 처리하는 데 사용된다.

export function forwardRef<Props, ElementType: React$ElementType>(
  render: (props: Props, ref: React$Ref<ElementType>) => React$Node
) {
  const elementType = {
    $$typeof: REACT_FORWARD_REF_TYPE,
    render, // 컴포넌트의 렌더링 함수
  };

  return elementType;
}

forwardRef()는 함수 컴포넌트 자체를 반환하지 않지만, 나중에 특수한 Fiber 노드 타입으로 매핑 될 특수한 리액트 앨리먼트 타입을 반환한다.

export const FunctionComponent = 0;
export const ClassComponent = 1;
export const HostComponent = 5;
export const ForwardRef = 11;
export const LazyComponent = 16;
// 이외에도 HostPortal, HostRoot 등 다양한 태그들이 존재한다.

Fiber 영역에는 위와 같은 Fiber 노드 타입(Tag)들이 있다.

리액트 앨리먼트 타입(JSX에서 사용하는 타입)과 Fiber 타입은 완전히 1:1로 대응되지 않는다.

즉, 모든 리액트 앨리먼트 타입이 그대로 Fiber 타입으로 변환되지 않는다는 것이다.
(일부 Fiber 타입은 리액트 앨리먼트에 존재하지 않는 Fiber 시스템 전용 타입이다)

리액트 앨리먼트 타입과 Fiber 노드 타입이 다르기 때문에 매핑하는 과정이 필요하다.

아래는 리액트 앨리먼트 타입을 Fiber 노드로 매핑하는 코드이다.

export function createFiberFromElement(
  element: ReactElement,
  mode: TypeOfMode,
  lanes: Lanes
): Fiber {
  let owner = null;

  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(
    type,
    key,
    pendingProps,
    owner,
    mode,
    lanes
  );

  return fiber;
}

export function createFiberFromTypeAndProps(
  type: any, // React$ElementType
  key: null | string,
  pendingProps: any,
  owner: null | Fiber,
  mode: TypeOfMode,
  lanes: Lanes
): Fiber {
  let fiberTag = IndeterminateComponent;
  let resolvedType = type;
  if (typeof type === "function") {
    if (shouldConstruct(type)) { // 클래스 컴포넌트
      fiberTag = ClassComponent;
    }
  } else if (typeof type === "string") { // HTML 태그
    fiberTag = HostComponent;
  } else {
    getTag: switch (type) {
      default: {
        if (typeof type === "object" && type !== null) {
          switch (type.$$typeof) {
            case REACT_PROVIDER_TYPE:
              fiberTag = ContextProvider;
              break getTag;
            case REACT_CONTEXT_TYPE:
              fiberTag = ContextConsumer;
              break getTag;
            case REACT_FORWARD_REF_TYPE: // forwardRef 매핑
              fiberTag = ForwardRef;
              break getTag;
            // another code...
          }
        }
      }
    }
  }
  const fiber = createFiber(fiberTag, pendingProps, key, mode);
  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.lanes = lanes;
  return fiber;
}

다시 처음부터 흐름을 살펴보면 forwardRef() 함수를 통해 리액트 앨리먼트 타입(REACT_FORWARD_REF_TYPE)을 설정하였고, 이렇게 설정한 앨리먼트 타입 기반으로 Fiber 노드 타입(ForwardRef)으로 매핑하여 Fiber 노드를 생성한 것이다.

forwardRef 컴포넌트 렌더링

function beginWork(
  current: Fiber | null, // 현재 화면에 렌더링된 Fiber 노드
  workInProgress: Fiber, // 작업 중인 새로운 Fiber 노드
  renderLanes: Lanes // 렌더링 우선 순위
): Fiber | null {
  switch (workInProgress.tag) {
    case ForwardRef: { 
      const type = workInProgress.type; // 컴포넌트 타입
      const unresolvedProps = workInProgress.pendingProps; // 아직 처리되지 않은 Props
      
      // elementType과 type이 같다면 unresolvedProps를 그대로 사용(컴포넌트의 변경이 없다)
      // 다르면 resolveDefaultProps 함수를 통해 defaultProps 해결
      const resolvedProps =
        workInProgress.elementType === type 
        ? unresolvedProps : resolveDefaultProps(type, unresolvedProps);
      
      return updateForwardRef(
        current, 
        workInProgress, 
        type, 
        resolvedProps, 
        renderLanes
      );
    }
  }
}

beginWork 함수는 초기 렌더링과 리렌더링에 발생하며, Fiber 노드의 타입(workInProgress.tag)에 따라 적절한 업데이트 로직을 선택한다.

Fiber 노드 타입이 ForwardRef이기 때문에 updateForwardRef 함수를 호출한다.

function updateForwardRef(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any, // forwardRef 컴포넌트
  nextProps: any, // 새로운 props
  renderLanes: Lanes
) {
  const render = Component.render; // 컴포넌트 렌더링 함수
  const ref = workInProgress.ref; // 현재 Fiber 노드의 ref
  // 일반 컴포넌트 함수에서도 동일하게 처리되는 항목
  let nextChildren;
  let hasId;

  prepareToReadContext(workInProgress, renderLanes);
    
  // 실제로 컴포넌트가 실행되는 곳(render 함수 실행)
  nextChildren = renderWithHooks(
    current, 
    workInProgress, 
    render, 
    nextProps, 
    ref, // secondArg
    renderLanes
  );
    
  hasId = checkDidRenderIdHook();

  if (current !== null && !didReceiveUpdate) { // bailout(최적화) 관련 로직
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  }
  if (getIsHydrating() && hasId) { // 하이드레이션 관련 로직
    pushMaterializedTreeId(workInProgress);
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
    
  return workInProgress.child;
}


export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any, // render 함수
  props: Props,
  secondArg: SecondArg, // 두 번째 인자, ref
  nextRenderLanes: Lanes,
): any {
  // props와 ref가 전달되어 컴포넌트가 렌더링된다.
  let children = Component(props, secondArg); 
  return children;
}

renderWithHooks 함수에서 두 번째 인자(secondArg)는 레거시 항목으로 일반적인 함수 컴포넌트의 경우에서는 사용되지 않는다.

renderWithHooks 함수의 두 번째 인자라는 말이 이해가 안갈 수 있는데, renderWithHooks 함수에서 ref를 전달하고 있는 다섯 번째 인자의 변수 이름이 secondArg 이고, forwardRef 함수의 두 번째 인자로 ref를 전달하기 때문에 두 번째 인자라고 표현하고 있다.

  • updateFunctionComponent 함수(일반적인 함수 컴포넌트)
function updateFunctionComponent(
  current: null | Fiber,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes
) {
  let context;
  
  if (!disableLegacyContext && !disableLegacyContextForFunctionComponents) {
    const unmaskedContext = getUnmaskedContext(workInProgress, Component, true);
    context = getMaskedContext(workInProgress, unmaskedContext);
  }

  nextChildren = renderWithHooks(
    current, 
    workInProgress, 
    Component, 
    nextProps, 
    context, // 레거시 컨텍스트, secondArg
    renderLanes
  );
}

다섯 번째 인자로 context를 넘겨주고 있는데, 이 컨텍스트는 더 이상 사용되지 않는 레거시 컨텍스트이다.

💡 여기서 레거시 컨텍스트는 리액트 초기 버전에서 사용되던 컨텍스트 시스템을 말한다.
현재는 createContext()를 사용하는 새로운 컨텍스트 API로 대체되었다.
아직도 존재하는 이유는 하위 호환성을 위해 유지되고 있다고 한다.


정리하기

1. 일반적인 함수 컴포넌트와 다르게 처리하기 위해 forwardRef 함수를 통해 리액트 앨리먼트 타입을 REACT_FORWARD_REF_TYPE으로 설정한다.

2. 설정한 리액트 앨리먼트 타입을 기반으로 Fiber 노드 타입 ForwardRef을 매핑한다.

3. beginWork 함수에서 Fiber 노드 타입이 ForwardRef일 경우, updateForwardRef 함수가 호출된다.

4. updateForwardRef 함수 내에서 renderWithHooks 함수를 사용하여 forwardRef 컴포넌트를 렌더링 한다. 이 때 두 번째 인자로 ref를 전달한다.

5. forwardRef 컴포넌트의 render 함수는 propsref 두 개의 인자를 받아 실행하여 개발자는 ref를 원하는 내부 요소에 전달할 수 있다.

💡 요약하기

  • 리액트 엘리먼트에서 refprops와 별개로 처리된다.
  • 일반 함수 컴포넌트는 두 번째 인자(ref)를 사용하지 않는다.
  • forwardRef로 특별한 Fiber 노드를 생성하여 ref를 두 번째 인자로 전달한다.

React19에서는 forwardRef가 필요없다.

리액트 공식문서 - 리액트 19의 개선 사항

리액트 19부터는 함수형 컴포넌트의 prop으로 ref에 접근할 수 있고, 향후 버전에서는 forwardRef를 더 이상 사용하지 않고 제거 할 예정이라고 한다.

💡 클래스형 컴포넌트에 전달된 ref는 컴포넌트 인스턴스를 참조하기 때문에 컴포넌트의 메서드를 직접 호출하거나 내부 상태에 접근할 수 있어 props로 전달되지 않는다.


🙃 도움이 되었던 자료들

How forwardRef() works internally in React?
리액트 공식 문서 - forwardRef
리액트 공식 문서 - useImperativeHandle

profile
🏁

2개의 댓글

comment-user-thumbnail
2024년 9월 16일

This article is so deep but i have one thing that can make you headache this game Five Nights at Freddy's, it is so deep that it can make you scared. try it!

1개의 답글