[번역] React.memo()는 어떻게 동작하나요?

[번역] React.memo()는 어떻게 동작하나요?

React Internals Deep Dive - EP14

·

5 min read

영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크:
https://jser.dev/react/2022/01/11/how-react-memo-works


ℹ️React Internals Deep Dive 에피소드 14, 유튜브에서 제가 설명하는 것을 시청해주세요.

React@18.2.0기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.

💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.

조정에서 React bailout은 어떻게 작동하는가에서 하위 트리에 변경된 것이 없다고 판단될 때 React가 어떻게 조정을 중지하는지 이해했습니다.

잠깐, React.memo()가 하는 일과 똑같지 않나요, 맞죠?

1. 데모 타임

다시 이전 데모를 열어 보겠습니다. 버튼을 클릭하면 C와 D가 모두 리렌더링됩니다.

이제 코드를 조금 변경하여 React.memo()를 D에 적용해 보겠습니다.

다음은 메모가 포함된 새 데모이며, 이 과정을 반복하면 예상했던 대로 D가 리렌더링되지 않는 것을 확인할 수 있습니다.

2. React.memo()는 새로운 엘리먼트를 만듭니다: REACT_MEMO_TYPE

먼저 React.memo() 가 사용되는 위치에 디버거를 설정하면 memo()의 소스 코드로 연결됩니다.

이것은 매우 간단합니다.

export function memo<Props>(
  type: React$ElementType,
  compare?: (oldProps: Props, newProps: Props) => boolean
) {
  const elementType = {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };
  return elementType;
}

코드에서 알 수 있듯이 memo( )는 다음과 같은 요소를 생성합니다.

  1. $$typeofREACT_MEMO_TYPE으로 설정하고

  2. 전달된 함수에 설정된 typeD()입니다.

  3. compare 함수를 제공합니다.

솔직히 말하면, 저는 React.memo()가 두 번째 인수를 받는다는 사실을 몰랐습니다.

이제 React.memo() 가 모든 것을 묶는(wrap) 새로운 파이버 노드를 생성한다는 것을 알았습니다. 간단해 보이는데, D()에서 리렌더링을 피하기 위해 추가 로직을 추가하려면, 해당 로직으로 묶을 무언가가 필요합니다.

3. MemoComponent의 조정

이제 다시 지난 포스트에서 설명한 대로 파이버 노드를 확인하고 업데이트하는 beginWork()로 이동해 보겠습니다.

REACT_MEMO_TYPE이 사용되는 위치를 쉽게 찾을 수 있습니다. 소스

case MemoComponent: {
  const type = workInProgress.type;
  const unresolvedProps = workInProgress.pendingProps;
  // Resolve outer props first, then resolve inner props.
  let resolvedProps = resolveDefaultProps(type, unresolvedProps);
  resolvedProps = resolveDefaultProps(type.type, resolvedProps);
  return updateMemoComponent(
    current,
    workInProgress,
    type,
    resolvedProps,
    renderLanes,
  );
}
case SimpleMemoComponent: {
  return updateSimpleMemoComponent(
    current,
    workInProgress,
    workInProgress.type,
    workInProgress.pendingProps,
    renderLanes,
  );
}

실제로는 두 가지 컴포넌트가 있는데, 하나는 MemoComponent이고 다른 하나는 SimpleMemoComponent인데, 그 이유는 곧 설명하겠습니다.

REACT_MEMO_TYPE이 아니라 MemoComponent인가요? REACT_MEMO_TYPE은 엘리먼트에 사용되는 $$typeof이고, MemoComponent는 함수입니다.

엘리먼트에서 파이버가 생성되면 태그는 여기에서 MemoComponent로 설정됩니다.

4. updateMemoComponent()

updateMemoComponent()React.memo()가 작동하는 방식의 핵심입니다. 소스

function updateMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes
): null | Fiber {
  if (current === null) {
  // ❗❗ ↖ 만약 최초 만운트인 경우
    const type = Component.type;
    if (
      isSimpleFunctionComponent(type) &&
      Component.compare === null &&
      // SimpleMemoComponent codepath doesn't resolve outer props either.
      Component.defaultProps === undefined
    ) {
      let resolvedType = type;
      // If this is a plain function component without default props,
      // and with only the default shallow comparison, we upgrade it
      // to a SimpleMemoComponent to allow fast path updates.
      workInProgress.tag = SimpleMemoComponent;
      workInProgress.type = resolvedType;
      return updateSimpleMemoComponent( // ❗❗
        current,
        workInProgress,
        resolvedType,
        nextProps,
        renderLanes
      ); // ❗❗
      // ❗❗ ↖ 최적화된 지점(branch)으로 이동
    }
    const child = createFiberFromTypeAndProps(
      Component.type,
      null,
      nextProps,
      workInProgress,
      workInProgress.mode,
      renderLanes
    );
    child.ref = workInProgress.ref;
    child.return = workInProgress;
    workInProgress.child = child;
    return child;
  }
  // ❗❗ ↖ 이제 아래는 리렌더링 지점입니다
  const currentChild = ((current.child: any): Fiber); // This is always exactly one child
  const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
    current,
    renderLanes
  );
  if (!hasScheduledUpdateOrContext) {
    // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
    // Default to shallow comparison
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
    // ❗❗ 만약 props가 변경되지 않으면 빠른 bailout을 시도합니다
  }
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  const newChild = createWorkInProgress(currentChild, nextProps);
  newChild.ref = workInProgress.ref;
  newChild.return = workInProgress;
  workInProgress.child = newChild;
  return newChild;
}

세부 사항에도 불구하고 기본 논리는 다음과 같습니다.

if (first time) {
  if (is simple) {
    update fiber tag to SimpleMemoComponent, so next time we go directly to updateSimpleMemoComponent()
    updateSimpleMemoComponent()
  } else {
    create child fibers
  }
} else {
  if (we can bailout) {
    try bailout
  } else {
    reconcile children
  }
}

5. SimpleMemoComponent는 내부적으로 최적화됩니다

function updateSimpleMemoComponent(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  nextProps: any,
  renderLanes: Lanes
): null | Fiber {
  if (current !== null) {
    const prevProps = current.memoizedProps;
    if (
      shallowEqual(prevProps, nextProps) &&
      current.ref === workInProgress.ref &&
      // Prevent bailout if the implementation changed due to hot reload.
      (__DEV__ ? workInProgress.type === current.type : true)
    ) {
      didReceiveUpdate = false;
      if (!checkScheduledUpdateOrContext(current, renderLanes)) {
        workInProgress.lanes = current.lanes;
        return bailoutOnAlreadyFinishedWork(
          current,
          workInProgress,
          renderLanes
        );
      } else if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
        // This is a special case that only exists for legacy mode.
        // See https://github.com/facebook/react/pull/19216.
        didReceiveUpdate = true;
      }
    }
  }
  return updateFunctionComponent(
    current,
    workInProgress,
    Component,
    nextProps,
    renderLanes
  );
}

updateSimpleMemoComponent()가 훨씬 더 간단한데:

  1. props를 얕게 비교하고 bailout을 시도합니다.

  2. 새 파이버가 생성되지 않으므로, 파이버 트리에 D()가 없습니다.

콘솔에서 파이버 트리를 볼 수 있습니다. (방법을 보려면 여기에서 동영상을 보세요.)

예, 메모 엘리먼트의 type은 D()이지만 그 자식은 div로 바로 이동합니다.

compare 함수에 전달을 설정하면, React는 더 이상 단순하게 처리할 수 없습니다.

const  D  = React.memo(_D, (a, b) => true);

이제 파이버 트리를 살펴보면 상황이 달라집니다.

  1. tag는 14, 지금은 단순하지 않습니다.

  2. type이 더 이상 D가 아닙니다.

  3. 자식은 div이지만 D 입니다.

예, 간단히 말해, SimpleMemoComponent는 함수 컴포넌트에 대한 내부 최적화로, 메모 파이버와 래핑된 파이버를 하나로 합칩(merge)니다.

리액트 네이티브의 View Flattening과 비슷해 보입니다.

이 최적화에 대한 원래 홍보 자료는 여기에서 확인할 수 있습니다.

6. checkScheduledUpdateOrContext()는 항상 호출됩니다

updateMemoComponent()updateSimpleMemoComponent()에서는 props를 비교하지만, 일부 이벤트나 컨텍스트와 같은 다른 함수에 의해 래핑된 컴포넌트가 업데이트 예약을 받았을 수 있으므로 항상 checkScheduledUpdateOrContext()가 실행된다는 것을 알아두세요.

따라서 props는 동일하지만 bailout이 발생하지 않는 경우가 있습니다.

즉, React 홈페이지에 명시된 것처럼 React.memo() 는 성능 최적화를 위한 것이지 렌더링을 '방지'하기 위해 사용하지 마세요.

여기까지 React.memo()에 대해 알아봤습니다. 도움이 되길 바랍니다.

(원본 게시일: 2022-01-11)