[번역] React에서 Context는 어떻게 동작하나요?

[번역] React에서 Context는 어떻게 동작하나요?

React Internals Deep Dive - EP9

·

8 min read

영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크:
https://jser.dev/react/2021/07/28/how-does-context-work


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

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

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

컴포넌트 트리(tree of components)로 React 코드를 작성하면, props로 데이터를 전달할 수 있지만 트리가 매우 깊은 경우에는 번거롭습니다. 예를 들어, 모든 컴포넌트에 사용될 수 있는 전역 색을 정의하려면 결국 모든 컴포넌트가 이를 지원하기 위해 새로운 prop이 필요할 수 있습니다.

Context는 이 문제를 해결하기 위해 props 없이 하위 트리에 데이터를 전달할 수 있게 해줍니다.

1. React Context 데모

여기에 Context를 사용하는 간단한 리액트 앱이 있습니다. 이건 그저 JSer를 렌더링 합니다.

데모 링크(코드 샌드박스)

import { createContext } from 'react';

const Context = createContext("123");

function Component1() {
  return <Component2 />;
}

function Component2() {
  return <Context.Consumer>
    {(value) => value}
  </Context.Consumer>;
}

이 데모는 매우 간단한데, 자 이제 React Context가 내부적으로 어떻게 동작하는지 알아봅시다.

2. React.createContext()

import { REACT_PROVIDER_TYPE, REACT_CONTEXT_TYPE } from "shared/ReactSymbols";
import type { ReactContext } from "shared/ReactTypes";
export function createContext<T>(defaultValue: T): ReactContext<T> {
  const context: ReactContext<T> = {
    $$typeof: REACT_CONTEXT_TYPE,
    // As a workaround to support multiple concurrent renderers, we categorize
    // some renderers as primary and others as secondary. We only expect
    // there to be two concurrent renderers at most: React Native (primary) and
    // Fabric (secondary); React DOM (primary) and React ART (secondary).
    // Secondary renderers store their context values on separate fields.
    _currentValue: defaultValue,
    _currentValue2: defaultValue,
    // Used to track how many concurrent renderers this context currently
    // supports within in a single renderer. Such as parallel server rendering.
    _threadCount: 0,
    // These are circular
    Provider: (null: any),
    Consumer: (null: any),
  };
  context.Provider = { // ❗❗ Provider 
    $$typeof: REACT_PROVIDER_TYPE,
    _context: context,
    // ❗❗ ↖ Provider는 부모 ref로서 _context를 갖고 있습니다.
  };
  context.Consumer = context; // ❗❗ Consumer 
  return context;
}

createContext()는 기본값을 보유한 객체를 반환하고 ProviderConsumer를 노출하기만 하면 됩니다.

  1. ProviderREACT_PROVIDER_TYPE의 특수 엘리먼트 타입으로, 곧 다룰 예정입니다.

  2. Consumer는 흥미로운데, 이건 context에 설정됩니다.

위의 데모 코드와 마찬가지로 ProviderConsumer는 실제로 렌더링에 사용되므로 Context는 이들을 페어링하는 방법입니다.

3. Provider

여기서 엘리먼트 타입 REACT_PROVIDER_TYPE은 파이버 태그 ContextProvider에 매핑됩니다.

Provider의 유일한 목적은<Provider value={...}/> 구문에서 Consumer가 사용하는 값을 설정하는 것이며, 아래는 렌더링 중에 Provider가 처리되는 방식입니다.

ℹ 렌더링 내부에 대한 자세한 내용은 React가 최초 마운트를 수행하는 방법React가 리-렌더링하는 방법을 참조하십시오.

function beginWork() {
  case ContextProvider:
      return updateContextProvider(current, workInProgress, renderLanes);
}
function updateContextProvider(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
) {
  const providerType: ReactProviderType<any> = workInProgress.type;
  const context: ReactContext<any> = providerType._context;
    // ❗❗                                         ↗
    // ❗❗ 따라서 Privder 타입에서 내부 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)) { // ❗❗ is(oldValue, newValue)
        // No change. Bailout early if children are the same.
        if (
          oldProps.children === newProps.children && // ❗❗ oldProps.children === newProps.children
          !hasLegacyContextChanged()
        ) {
          return bailoutOnAlreadyFinishedWork(
            current,
            workInProgress,
            renderLanes,
          );
    // ❗❗ 컨텍스트 값과 자식 컴포넌트들에서 업데이트가 없으면, 리액트는 bail out 합니다.
    // ❗❗ 리액트가 서브트리의 렌더링을 건너 뛴다는 의미입니다.
        }
      } else {
        // The context value changed. Search for matching consumers and schedule
        // them to update.
        propagateContextChange(workInProgress, context, renderLanes);
        // ❗❗ ↖ 컨텍스트 값이 바뀌면, 리액트는 페어링된 Consumerd에게 업데이트하라고 알려야만 합니다.
      }
    }
  }
  const newChildren = newProps.children;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
  // ❗❗ ↖ 자식을 리턴함으로서 렌더링을 계속 진행합니다.
  // ❗❗ 자세한 내용은 React가 내부적으로 파이버 트리를 순회하는 방법을 참조하십시오.
}

렌더링 중에 Provider가 작동하는 방식을 요약해 보겠습니다.

  1. 호출하면 먼저 pushProvider()로 새 값을 업데이트합니다.

  2. 값이 변하지 않으면, bailout을 시도합니다

  3. 그렇지 않으면 propagateContextChange()로 Consumer를 업데이트하고 자식들을 렌더링합니다.

3.1 pushProvider()

export function pushProvider<T>(
  providerFiber: Fiber,
  context: ReactContext<T>,
  nextValue: T
): void {
  if (isPrimaryRenderer) {
    push(valueCursor, context._currentValue, providerFiber);
    // ❗❗ 값을 파이버 스택에 'push'하여 경로를 따라 컨텍스트 정보를 수집합니다.
    context._currentValue = nextValue;
    // ❗❗ 컨텍스트를 설정하여, Consumer가 쉽게 접근할 수 있도록 해줍니다.
  } else {
    ...
  }
}

이렇게 값이 Fiber 스택 구조로 푸시됩니다.

파이버 스택은 루트에서 현재 파이버까지의 경로를 따라 정보를 보관한다고 생각할 수 있습니다. 동일한 Context에 여러 Provider가 있는 경우 스택 구조는 Fiber 노드에 대해 가장 가까운 값을 사용하도록 합니다.

컨텍스트 자체는 최신 값으로 설정되어 있지만 Fiber 스택은 이전 값을 저장하고 있는데, 이는 컨텍스트에 기본값이 있기 때문입니다.

3.2 popProvider()

푸시(push)가 있는 곳에는 팝(pop)이 있습니다. completeWork()에서 popProvider()가 호출됩니다.

이는 파이버 트리가 workInProgress를 순회 중일 때 컨텍스트가 올바른 값을 반영하고 있는지 확인하기 위한 것입니다. 엘리먼트를 Provider의 형제(sibling)라고 생각하면, React가 이 파이버로 이동하면 Provider의 정보가 없어야 하므로, Context 정보를 팝(pop)해야 합니다.

Fiber 스택은 루트에서 현재 파이버 노드까지의 경로를 따라 컨텍스트 정보들을 보유한다는 점을 유의하세요.

export function popProvider(
  context: ReactContext<any>,
  providerFiber: Fiber
): void {
  const currentValue = valueCursor.current;
    // ❗❗             ↗ 파이버 스택의 Context 값은 이전(previous) 값입니다.
  pop(valueCursor, providerFiber);
  if (isPrimaryRenderer) {
    if (
      enableServerContext &&
      currentValue === REACT_SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED
    ) {
      context._currentValue = context._defaultValue;
    } else {
      context._currentValue = currentValue;
      // ❗❗ 컨텍스트 값을 이전 값으로 설정
    }
  } else {
    ...
  }
}

파이버 스택이 이전 값을 저장하기 때문에 popProvider()가 하는 일은 간단합니다. 컨텍스트에 설정하고 팝하기만 하면 끝입니다!

4. Consumer

propagateContextChange()의 작동 방식을 이해하려면 먼저 Consumer의 작동 방식을 이해해야 합니다.

위에서 언급했듯이, Consumer는 실제로 컨텍스트 자체이며 ContextConsumer파이버 태그가 사용됩니다(소스).

function updateContextConsumer(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
) {
  let context: ReactContext<any> = workInProgress.type;
  // ❗❗ ↗ Consumer 타입 자체가 컨텍스트 입니다!
  const newProps = workInProgress.pendingProps;
  const render = newProps.children;
  prepareToReadContext(workInProgress, renderLanes);

  const newValue = readContext(context);
  // ❗❗ ↗ Context Value를 읽습니다.
  let newChildren;
  newChildren = render(newValue);
  // ❗❗         ↗ Consumer는 Render Prop 패턴에 사용해야 합니다.
  // ❗❗ <Consumer>{(val) => ...}</Consumer>
  // React DevTools reads this flag.
  workInProgress.flags |= PerformedWork;
  reconcileChildren(current, workInProgress, newChildren, renderLanes);
  return workInProgress.child;
  // ❗❗ 자식들을 계속해서 렌더링합니다.
}

좋아요. 이것은 사실 아주 간단합니다.

  1. 먼저 prepareToReadContext()함수를 실행하고

  2. 그리고 readContext() 로 값을 읽고

  3. 소비자가 자식의 렌더링 prop을 예상하기 때문에 newChildren = render(newValue)로 설정합니다.

4.1 prepareToReadContext()

export function prepareToReadContext(
  workInProgress: Fiber,
  renderLanes: Lanes
): void {
  currentlyRenderingFiber = workInProgress;
  lastContextDependency = null;
  lastFullyObservedContext = null;
  const dependencies = workInProgress.dependencies;
    // ❗❗                             ↗ 
    // ❗❗ 컴포넌트는 여러 컨텍스트를 사용할 수 있으며,
    // ❗❗ dependencies는 사용 중인 컨텍스트를 의미합니다
  if (dependencies !== null) {
    if (enableLazyContextPropagation) {
      // Reset the work-in-progress list
      dependencies.firstContext = null;
    } else {
      const firstContext = dependencies.firstContext;
      if (firstContext !== null) {
        if (includesSomeLane(dependencies.lanes, renderLanes)) {
          // Context list has a pending update. Mark that this fiber performed work.
          markWorkInProgressReceivedUpdate();
        }
        // Reset the work-in-progress list
        dependencies.firstContext = null;
        // ❗❗         ↗ dependencies를 리셋합니다!
        // ❗❗ 이것은 컨텍스트 자체에 불과하기 때문에 <Consumer>{..}</Consumer> 에는 큰 의미가 없지만
        // ❗❗ 컴포넌트가 여러번 호출할 가능성이 있는 useContext()에는 더 유용합니다.
      }
    }
  }
}

prepareToReadContext()updateContextConsumer()에서만 호출되는 것이 아니라 기본적으로 모든 컴포넌트를 렌더링할 때 호출된다는 점을 기억하세요. dependencies는 컴포넌트에 사용된 모든 Context를 추적하여, 나중에 컨텍스트 값이 변경될 때, React가 어떤 컴포넌트를 업데이트할지 알 수 있도록 하기 위한 것임을 잊지 마세요.

4.2 readContext()

export function readContext<T>(context: ReactContext<T>): T {
  const value = isPrimaryRenderer
    ? context._currentValue
    : context._currentValue2;
  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;
      currentlyRenderingFiber.dependencies = { // ❗❗ dependencies 
        lanes: NoLanes,
        firstContext: contextItem,
      };
      if (enableLazyContextPropagation) {
        currentlyRenderingFiber.flags |= NeedsPropagation;
      }
    } else {
      // Append a new context item.
      lastContextDependency = lastContextDependency.next = contextItem;
                                              // ❗❗ ↗ dependencies는 링크드 리스트 입니다.
    }ㅋ
  }
  return value;
}

파이버의 경우 여러 컨텍스트를 사용할 수 있으므로, dependencies는 실제로 링크드 리스트입니다.

readContext()는 컨텍스트에서 값을 읽고 dependencies을 업데이트하기만 하면 됩니다.

4.3 useContext()readContext()의 alias 입니다.

const HooksDispatcherOnMount: Dispatcher = {
  useCallback: mountCallback,
  useContext: readContext, // ❗❗ 
  useEffect: mountEffect,
  ...
};
const HooksDispatcherOnUpdate: Dispatcher = {
  useCallback: updateCallback,
  useContext: readContext, // ❗❗ 
  useEffect: updateEffect,
  ...
};

네, 간단합니다.

5. propagateContextChange()

컨텍스트의 값이 변경되면, 모든 소비자에 대한 업데이트를 예약해야 하는데, 이는 업데이트가 어떻게든 건너뛰어지지 않도록 하기 위해서입니다.

업데이트 스케줄링은 기본적으로 루트에서 파이버 노드까지 경로의 모든 노드에 대한 laneschildLanes를 업데이트하는 것을 의미합니다. 이에 대한 자세한 내용은 조정에서 React bailout이 작동하는 방식에서 확인할 수 있습니다.

소스 코드에는 꽤 많은 줄이 있으므로 여기에 붙여넣지 않겠습니다.

아이디어는 꽤 간단한데, 코드를 보면, 이 Provider 아래의 하위 트리가 순회되고, 각 파이버에 대해 dependencies가 확인되며, 만약 여기서 컨텍스트가 사용되는 것을 발견되면 scheduleContextWorkOnParentPath()를 호출하여 일부 작업을 예약한다는 것을 쉽게 알 수 있습니다.

Provider의 beginWork() 단계에서 스캔될 때까지 기다렸다가, 소비자가 아직 트리에 없는 경우면 어떻게 하냐고 질문할 수 있습니다.

좋은 질문입니다. 언뜻 보기에는 Consumer 노드가 렌더링된 후에만 종속성이 업데이트되므로 순서가 조금 이상해 보입니다. 하지만 실제로는 렌더링 중에 Consumer 노드가 추가되면 자동으로 최신 값을 사용하므로 한 번만 스캔하면 됩니다.

6. 요약

컨텍스트는 사실 이해하기가 그리 복잡하지 않습니다. 핵심 아이디어는 경로를 따라 정보를 저장할 수 있는 파이버 스택입니다.

7. 코딩 챌린지

이제 이해를 돕기 위해 제가 만든 간단한 코딩 챌린지를 소개합니다. 즐겨보세요!

(원글 게시일 2021-07-28)