React 톺아보기 - 05. Reconciler_1

모든 설명은 v16.12.0 버전 함수형 컴포넌트와 브라우저 환경을 기준으로 합니다. 버전에 따라 코드는 변경될 수 있으며 클래스 컴포넌트는 설명에서 제외됨을 알려 드립니다.

포스트별 주제

  1. 훅을 통해 컴포넌트 상태를 업데이트한다.
  2. 업데이트를 반영할 Workscheduler에게 전달하고 scheduler는 스케줄링된 Task를 적절한 시기에 실행한다.
  3. Work을 통해 VDOM 재조정 작업을 진행한다.

  4. Work를 진행하며 발생한 변경점을 적용한다.
  5. 사용자의 상호작용으로 이벤트가 발생하고 등록된 핸들러가 실행되면서 다시 1번으로 되돌아간다.

아마 이번 포스트에서 리액트에 대해 막연하게 생각하셨던 대부분을 다루게 될 것입니다. 그만큼 중추적인 역할을 하는 패키지가 reconciler입니다.

우리는 먼저 JSX로 작성한 컴포넌트들이 어떻게 React element로 변환되고, VDOM에 올리기 위해 어떤 방식으로 fiber로 확장하는지 알아볼 것입니다. 그 후에 컴포넌트로부터 발생한 변경점들을 VDOM에 적용하는 Render phase와 완성된 VDOM을 DOM에 적용하는 Commit phase를 순서대로 확인해보며 마무리할 것입니다.

1. React element

1 - 1 종류

리액트 컴포넌트는 크게 호스트 컴포넌트, 사용자 정의 컴포넌트커스텀 컴포넌트, 리액트에서 제공하는 컴포넌트스태틱 컴포넌트로 나눌 수 있습니다.

  • 호스트 컴포넌트는 div, input, button 등 HTML element에 대응하는 컴포넌트입니다.
  • 커스텀 컴포넌트는 일반적으로 개발자들이 만들게 되는 클래스, 함수형 컴포넌트입니다.
  • 스태틱 컴포넌트는 하나의 특수한 목적을 가진 컴포넌트로 Fragment, lazy, Context(Provider, Consumer), memo 등이 있습니다.

1 - 2 createElement()

JSX 문법으로 작성된 컴포넌트는 바벨을 통해 React.createElement()로 치환됩니다. 이러한 이유로 개발자는 React 모듈을 사용하지 않아도 JSX를 작성할 때는 항상 React를 import 해야합니다.

createElement()가 어떤 인자를 받는지부터 확인하겠습니다.

react > ReactElement.js

  
export function createElement(type, config, children) { 
  /*...*/
}

type: 컴포넌트의 종류에 따라 다음의 값이 사용됩니다.

  • 호스트 컴포넌트: 태그 이름
  • 커스텀 컴포넌트: 작성한 함수
  • 스태틱 컴포넌트: 미리 정의된 Symbol 또는 memo, lazy와 같이 함수 호출을 통해 만들어진 React element

config: props를 담고 있는 객체입니다.
children: 코드에서는 자식을 하나의 인자가 받도록 되어 있지만 실제로는 여러 자식이 올 경우 3 ~ n번째의 인자에 자식들이 들어옵니다. function createElement(type, config, children1, children2, ..childrenN)의 형태가 됩니다.

다음의 예제로 해당 내용을 확인해봅시다.

const CustomComponent = () => '';
const App = () => <><CustomComponent /><div id="host_component"></div></>

<>…</>는 <React.Fragment>…</React.Fragment>의 단축 형태입니다.

바벨을 통해 치환된 코드는 다음과 같습니다.

const App = () => (
  React.createElement(
    React.Fragment, // type: Symbol.for('react.fragment')
    {}, // config
    React.createElement( // 첫번째 자식
      CustomComponent, // type: function
      {}, // config
      // 자식은 없음
    ),
    React.createElement( // 두번째 자식
      "div", // type: tag name
      { id: "host_component" } // config
      // 자식은 없음
    ) 
  );
)

React element를 만드는 과정은 다음과 같습니다.

  1. React element에는 key와 ref라는 예약된 속성들이 존재합니다. config에 예약 속성을 제외한 나머지를 props 객체에 저장합니다.
  2. 복수의 자식이 넘어온다면 하나의 배열에 저장합니다.
  3. default props가 존재할 경우 props 객체에 적용합니다.
  4. 1 ~ 3에서 작성한 정보를 가지고 React element 객체를 만듭니다.

react > ReactElement.js

  
const RESERVED_PROPS = {
  key: true,
  ref: true,
};

function createElement(type, config, children) { 
  // 1
  let propName;

  const props = {};

  let key = null;
  let ref = null;

  if (config != null) {
    if (hasValidRef(config)) { // config.ref !== undefined;
      ref = config.ref;
    }
    if (hasValidKey(config)) { // config.key !== undefined;
      key = '' + config.key;
    }

    // 예약된 속성을 제외한 나머지를 props 객체에 저장
    for (propName in config) {
      if (
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }

  // 2
  const childrenLength = arguments.length - 2;
  if (childrenLength === 1) {
    props.children = children;
  } else if (childrenLength > 1) {
    const childArray = Array(childrenLength);
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    props.children = childArray;
  }

  // 3
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  // 4
  return ReactElement(
    type,
    key,
    ref,
    props,
    /*...*/
  );
}

만드는 과정은 확인했으니 ReactElement()가 무엇을 반환하는지 확인해봅시다.

react > ReactElement.js

  
const ReactElement = function(type, key, ref, props,...) {
  const element = {
    // This tag allows us to uniquely identify this as a React Element
    $$typeof: REACT_ELEMENT_TYPE,
    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,
  };
  return element;
};

특이하게 생긴 $$typeof가 있습니다. 이 속성은 fiber를 만들 때 element의 종류를 확인하는 데 쓰입니다. 기본적으로는 REACT_ELEMENT_TYPE이지만 스태틱 컴포넌트의 경우 심볼을 사용합니다.

스태틱 컴포넌트 중 간단하게 memo와 lazy만 확인해보겠습니다.

react > memo.js

  
import {REACT_MEMO_TYPE} from 'shared/ReactSymbols';

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

react > ReactLazy.js

  
import {REACT_LAZY_TYPE} from 'shared/ReactSymbols';

function lazy<T, R>(ctor: () => Thenable<T, R>): LazyComponent<T> {
  let lazyType = {
    $$typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    // React uses these fields to store the result.
    _status: -1,
    _result: null,
  };

  return lazyType;
}

리액트 코어 패키지가 하는 일은 대게 이런 리액트용 element를 만드는 거라고 생각하시면 됩니다.

Text는 React element로 변환하지 않습니다.

2. Fiber

React element의 종류를 type 심볼을 통해 판단했다면 fiber는 tag를 통해 판단합니다. 이 tag의 명칭은 Work tag로 각 fiber 종류에 따라 재조정 작업의 처리가 달라야 하기 때문에 붙은 명칭입니다.

2 - 1 createFiber()

몇몇 종류의 fiber들은 props를 조금 다른 의미로 다루고 있습니다. 그래서 fiber 생성을 위한 사전 준비가 살짝 일반 fiber와는 다릅니다. 이 부분을 처리하기 위해 createFiberFrom***()의 형식으로 함수가 파생되어 있기는 하지만 결국 fiber를 만드는 최종 함수로 createFiber()를 사용하므로 이 함수부터 확인하겠습니다.

reconciler > ReactFiber.js

  
const createFiber = function(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
): Fiber {
  return new FiberNode(tag, pendingProps, key, mode);
};
  • FiberNode의 설명은 Intro에서 언급한 내용으로 대체합니다.

props를 특별하게 다루는 fiber는 Fragment와 Text입니다. 이들을 먼저 확인해보고 난 후에 일반적인 fiber를 생성하는 createFiberFromElement()를 확인하겠습니다.

2 - 2 createFiberFromFragment(), createFiberFromText()

Fragment는 컴포넌트들을 하나로 묶어주는 포장지라고 생각하시면 됩니다. 그래서 props에는 다른 값을 다루지 않고 오로지 children만을 저장합니다.

// fragment fiber를 만드는 코드 중..
const created = createFiberFromFragment(
  element.props.children,  returnFiber.mode,
  expirationTime,
  element.key,
);
created.return = returnFiber;
return created;
  • createFiberFromFragment()를 호출할 때 props에서 children을 꺼내6 넘겨줍니다.

reconciler > ReactFiber.js

  
import { Fragment } from 'shared/ReactWorkTags';

function createFiberFromFragment(
  elements,  
  mode,  
  expirationTime,  
  key  
): Fiber {
  const fiber = createFiber(Fragment, elements, key, mode);
  fiber.expirationTime = expirationTime;
  return fiber;
}
  • children으로 넘어온 elementscreateFiber()의 두 번째 인자pendingProps로 넘겨주면서 fiber의 props에 자식만 저장합니다. 즉 Fragment는 props를 자식의 저장소로 사용합니다.

그렇다면 Text fiber의 props에는 무엇이 저장될까요?
Text는 그 자체가 데이터입니다. 다른 추가적이 요소가 필요 없습니다. 심지어 key도요. 그래서 props에는 Text 그 자체가 들어갑니다.

// Text fiber를 만드는 코드 중..
if (typeof newChild === 'string' || typeof newChild === 'number') {
  const created = createFiberFromText(
    '' + newChild,    returnFiber.mode,
    expirationTime,
  );
}
  • React element가 string, number일 경우 그 자체를 props 인자로 넘겨줍니다.

reconciler > ReactFiber.js

  
import { HostText } from 'shared/ReactWorkTags';

function createFiberFromText(
  content,
  mode,
  expirationTime,
): Fiber {
  const fiber = createFiber(HostText, content, null, mode);
  fiber.expirationTime = expirationTime;
  return fiber;
}
  • Fragment와 다른 점은 Work tag 말고도 key가 없다는 점입니다.

이 이외에도 props를 특별하게 다루는 fiber로 Portal이 있습니다.

다음은 일반적으로 Props를 사용하는 fiber 생성 함수 createFiberFromElement()입니다.

2 - 3 createFiberFromElement()

reconciler > ReactFiber.js

  
export function createFiberFromElement(
  element: ReactElement,
  mode: TypeOfMode,
  expirationTime: ExpirationTime,
): Fiber {
  const type = element.type;
  const key = element.key;
  const pendingProps = element.props;
  const fiber = createFiberFromTypeAndProps(
    type,
    key,
    pendingProps,
    mode,
    expirationTime,
  );
  return fiber;
}
  • 먼저 element에서 필요한 정보들을 꺼냅니다7 ~ 9. createFiberFromTypeAndProps()는 이 정보들을 이용하여 element의 type에 맞는 fiber로 확장합니다.

createFiberFromTypeAndProps()가 가장 먼저 해야 할 일은 여러 형태의 React element를 잘 분간하여 거기에 맞는 fiber tag를 찾아내는 것입니다.

reconciler > ReactFiber.js

  
import {
  IndeterminateComponent,
  ClassComponent,
  MemoComponent,
  /*...*/
} from 'shared/ReactWorkTags'; // fiber tag
import {
  REACT_FRAGMENT_TYPE,
  REACT_MEMO_TYPE,
  /*...*/
} from 'shared/ReactSymbols'; // 스태틱 컴포넌트

export function createFiberFromTypeAndProps(
  type: any,
  key: null | string,
  pendingProps: any,
  mode: TypeOfMode,
  expirationTime: ExpirationTime,
): Fiber {
  let fiber;
  let fiberTag = IndeterminateComponent; 
  let resolvedType = type;

  if (typeof type === 'function') {
    // class component
    if (shouldConstruct(type)) { // type.prototype && type.prototype.isReactComponent;
      fiberTag = ClassComponent;    }
//else?? 여기서는 함수형 컴포넌트라고 단정지을 수 없습니다.
    
  // type이 string이면 호스트 컴포넌트 입니다. ex) 'div', 'input'..
  } else if (typeof type === 'string') {
    fiberTag = HostComponent;
  // 이하 스태틱 컴포넌트
  } else {
    getTag: switch (type) {
      case REACT_FRAGMENT_TYPE:
        return createFiberFromFragment(
          pendingProps.children,
          mode,
          expirationTime,
          key,
        );
      case REACT_CONCURRENT_MODE_TYPE:
        fiberTag = Mode;        mode |= ConcurrentMode | BlockingMode | StrictMode;
        break;
      /*...*/
      default: {
        if (typeof type === 'object' && type !== null) {
          switch (type.$$typeof) {
            case REACT_MEMO_TYPE:
              fiberTag = MemoComponent;              break getTag;
            case REACT_LAZY_TYPE:
              fiberTag = LazyComponent;              resolvedType = null;
              break getTag;
            /*...*/
          }
        }
        let info = '';
        invariant(
          false,
          'Element type is invalid: expected a string (for built-in ' +
            'components) or a class/function (for composite components) ' +
            'but got: %s.%s',
          type == null ? type : typeof type,
          info,
        );
      }
    }
  }

  fiber = createFiber(fiberTag, pendingProps, key, mode);  fiber.elementType = type;
  fiber.type = resolvedType;
  fiber.expirationTime = expirationTime;

  return fiber;
}
  • 22: IndeterminateComponent tag는 현재 이 fiber가 클래스 컴포넌트인지 함수형 컴포넌트인지 확실히 알 수 없는 상태를 뜻합니다.
  • 30: type이 함수라면 클래스, 함수형 컴포넌트 중 하나입니다. 근데 이상한 점은 클래스 컴포넌트가 아님을 확인27 했는데도 함수형 컴포넌트 tag를 설정하는 부분이 보이지 않습니다. 왜 함수형 컴포넌트라고 단정 짓지 않는 것일까요?

    이유는 개발자의 실수로 다음과 같이 작성될 수 있기 때문입니다.

    function Foo() {
        return {
          render() {
            return "I'm Foo"
          }
        }
    }

    Foo는 render 메소드를 품고 있는 객체 리터럴을 반환하는 함수형 컴포넌트입니다. 하지만 이 반환되는 객체는 리액트 컴포넌트를 상속하지 않았기 때문에 내부에서 문제를 일으킬 소지가 있습니다.

    리액트는 이와 같은 케이스를 클래스 컴포넌트를 작성하려다 발생한 휴먼 에러라고 판단하고 내부에서 올바르게 동작하도록 Foo를 클래스 컴포넌트로 변환시킵니다. Component를 Foo에 상속시킴과 동시에 반환했던 객체 리터럴까지 Foo의 instance로 갈아 끼워 주면서 말이죠.

    문제는 이러한 케이스는 함수를 실행해 보기 전까지는 알 수 없다는 것입니다. createFiberFromTypeAndProps()는 fiber로 확장하는 책임만 가지고 있기 때문에 여기서는 이를 알 수 있는 방법이 없습니다. 그래서 컴포넌트를 호출할 수 있는 시점에 함수를 호출하여 다시 판단하도록 IndeterminateComponent tag를 달아주는 것입니다.

  • 54: switch case문39 ~ 49 은 쉽게 이해할 수 있지만 아마 default51는 그렇지 않을 것입니다. 지금까지 확인했던 React element의 type은 심볼, 텍스트, 함수가 전부 였습니다. 그렇다면 언제 type에 객체가 오는 것일까요?

    이를 이해하기 위해 리액트 코어 패키지의 memo 함수를 다시 한번 확인해 보고 오세요. 반환값이 React element입니다. 그리고 아래의 memo 사용 코드를 보시면 default의 내용이 이제는 이해가 가실 겁니다.

    const FC = React.memo(() => 'memo component');
    const App = () => <FC />;

    이해 가시나요? 우리는 첫 줄에서 이미 memo element를 만들었습니다. 그리고 이 element를 다시 JSX로 컴포넌트 선언을 하였기 때문에 type에는 memo element 객체가 들어가게 되는 것입니다.

React element와 fiber를 어떻게 생성하는지는 이 정도면 충분할 것 같습니다. React element가 언제 fiber로 확장되는지는 분석을 계속해서 진행하다 보면 자연스레 아시게 됩니다.

다음 주제는 이전 scheduler 포스트에서 스케줄링했던 Work 함수를 진행할 차례이지만, 아직까진 VDOM에 친숙하지 않습니다. Render phase에서는 VDOM 탐색 로직이 많기 때문에 분석할 때 헷갈리지 않도록 VDOM과 좀 더 친숙해지는 시간을 가지도록 하겠습니다.

3. Fiber Root Node

Intro에서 사용했던 이미지를 재활용하여 부족했던 설명을 보충하도록 하겠습니다.

vDOM
Virtual DOM

Root Noderoot는 VDOM을 대표하는 노드로서 FiberRootNode를 통해 생성됩니다.
root가 관리하는 정보를 몇 개만 추려보자면 현재 DOM에 적용된 VDOM 최상단 nodeHost rootcurrent, container element를 참조하는 containerInfo, scheduler에서 이미 다루었던 스케줄 정보 등이 있습니다.

root가 참조하는 current를 최상단 node라고 해서 <App />이라 생각할 수도 있는데 그렇지는 않고 일반적인 fiber와 같이 createFiber()로 생성되고 root를 참조하도록 만들어진 fiberHost root가 최상단 node입니다.

FiberRootNode
root의 속성

current의 return을 보면 null임을 확인할 수 있습니다. VDOM의 최상단이기 때문이며 root는 stateNode를 통해 가리키고 있습니다.

stateNode

호스트 컴포넌트의 stateNode는 DOM에 삽입된 HTML element를 가리키는 반면에 개발자가 작성한 커스텀 컴포넌트의 stateNode는 null입니다. DOM에 마운트되는게 아니기 때문이며 특별히 Host root만 DOM에 마운트 되지 않아도 root를 가리키도록 되어있습니다.

이제 본격적으로 다음의 간단한 Todo list 예제를 VDOM에 대입해보며 VDOM과 익숙해져 보겠습니다.

function Todo() {
  const [todos, push] = useState([]);
  return (
    <>
      <Input submit={todo => push(todos => [...todos, todo])} />
      <OrderList list={todos} />
    </>
  );
};

function Input({ submit }) {
  const [value, setValue] = useState("");
  return (
    <>
      <input value={value} onChange={e => setValue(e.target.value)} />
      <button
        onClick={() => {
          submit(value);
          setValue("");
        }}
      >
        submit
      </button>
    </>
  );
}

function OrderList({ list }) {
  return (
    <ol>
      {list.map(item => (
        <li>{item}</li>
      ))}
    </ol>
  );
}

function App() { return <Todo /> };
ReactDOM.render(<App />, container);

4. 트리 구조

트리에서 root 부분은 생략하고 바로 App부터 작성하도록 하겠습니다.

먼저 React element tree의 구조를 보고 VDOM으로 어떻게 변경되는지 확인해 봅시다.

react element tree

특별한 부분은 없으므로 바로 VDOM으로 넘어가겠습니다.

fiber tree

마찬가지로 Fragment가 사라진 부분을 제외하고는 특별한 부분은 없습니다.

Fragment는 포장지 역할만 하기 때문에 Fiber로 변환될 때 Fragment를 벗겨내고 자식들을 얻어와 fiber로 확장합니다. 하지만 Fragment도 React element이므로 key를 설정할 수 있습니다. 이 key가 설정된 Fragment는 단순한 포장지 역할을 넘어서 개발자가 의도적으로 컴포넌트를 판별하기 위한 장치로 사용되므로 제거하지 않고 Fragment fiber로 만듭니다. key가 설정되어 있는 Fragment만 createFiberFromTypeAndProps()의 39라인에 도달할 수 있습니다. 이 부분은 나중에 코드로 확인합니다.

만약 Fragment를 다음과 같이 정의했다면 Todo 컴포넌트 자식으로 Fragment가 살아있었을 것입니다.
<Fragment key=“foo”>

dom tree

VDOM을 DOM에 적용할 때는 HTML element에 대응하는 호스트 컴포넌트만 마운트됩니다.
개발자가 작성한 커스텀 컴포넌트는 React element를 반환하는 상태를 가진 콜백 함수 그 이상 그 이하도 아닙니다.

자 이제 사전준비는 모두 끝났습니다. 본격적으로 재조정 작업을 시작해 봅시다.

5. Sync Work, Async Work

Work 콜백을 스케줄링시켰던 ensureRootIsScheduled() 기억하시나요? 이때 여러 상황을 고려하여 재조정 작업을 비동기로 처리해야 할지 동기적으로 처리해야 할지 결정했습니다. 이 결정에 따라서 사용된 콜백이 각각 performConcurrentWorkOnRoot()와 performSyncWorkOnRoot()입니다.

비동기 작업에 대한 분석은 performConcurrentWorkOnRoot()의 초입부분 까지만 진행하고 이 이후로는 동기 작업에 대해서만 분석하도록 하겠습니다.

5 - 1 Async Work

비동기 작업에 대해 지난 포스트에서 언급만 하고 아직 코드로 확인하지 못했던 부분이 있었습니다. 바로 scheduler에게 잔여작업이 있는지 알려주는 부분22번 라인입니다.

performConcurrentWorkOnRoot()를 통해 확인해볼 텐데 이 부분을 제외하고는 performSyncWorkOnRoot()와 로직이 같으므로 중복되는 것은 동기 작업을 분석할 때 설명하도록 하겠습니다.

reconciler > ReactFiberWorkLoop.js

  
function performConcurrentWorkOnRoot(root, didTimeout) {
  // 처리해야 될 expirationTime을 가지고 온다.
  const expirationTime = getNextRootExpirationTimeToWorkOn(root); 
  if (expirationTime !== NoWork) {
    const originalCallbackNode = root.callbackNode;
    /*...*/

    /* Render phase 진입.. */

    if (workInProgress !== null) {
      /* 작업 중단에 따른 처리.. */
    } else {
      /* Commit phase 진입.. */
    }
    // 각 phase 진행 과정에서 추가적인 업데이트가 발생할 경우를 대비한 스케줄링 요청
    ensureRootIsScheduled(root); 
    if (root.callbackNode === originalCallbackNode) {
      // 해당 root에는 잔여 작업이 남아 있으므로 root를 잡아논 Work 콜백을 반환한다.
      return performConcurrentWorkOnRoot.bind(null, root); 
    }
  }
  return null;
}

root.callbackNode18는 Commit phase14를 지나면 null로 초기화됩니다. null이 아니라는 건 아래의 코드에서 Work를 진행하다 중지된 겁니다.

reconciler > ReactFiberWorkLoop.js

  
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {    workInProgress = performUnitOfWork(workInProgress);
  }
}

while 문이 중지되었다면 workInProgress는 비어있지 않을 것11이므로 Commit phase14에 도달하지 못해 callbackNode18는 초기화되지 않습니다.

그리고 한 가지 더 ensureRootIsScheduled()17를 실행했는데도 root가 가지고 있는 callbackNodeTask가 현재 Work를 진행한 originalCallbackNode6와 같다는 건18 현재 originalCallbackNode가 가장 우선순위가 높음을 나타내므로 계속해서 작업을 이어서 진행할 수 있다는 뜻입니다.

이 모든 근거를 바탕으로 performConcurrentWorkOnRoot()20를 반환해 scheduler에게 해당 root에 잔여 작업이 남아 있음을 알려줍니다. 이렇게 반환한 Work 콜백은 scheduler가 다음 프레임에 실행시켜 줄 것입니다.

concurrent mode에 대해서는 자세히 다루지는 않았지만 기반이 되는 몇몇 부분들을 확인해보았습니다. 이번 시리즈에서는 이 이상으로 분석하지는 않지만, 추후에 보편적으로 사용하게 되는 날이 오면 그때 가서 다루어 보도록 하겠습니다.

5 - 2 Sync Work

async Work와는 다르게 해당 Work는 한번 실행되면 모든 작업이 끝날때 까지 동기적으로 처리합니다.

reconciler > ReactFiberWorkLoop.js

  
function performSyncWorkOnRoot(root) {
  // Check if there's expired work on this root. Otherwise, render at Sync.
  const lastExpiredTime = root.lastExpiredTime;
  const expirationTime = lastExpiredTime !== NoWork ? lastExpiredTime : Sync;
  /*...*/
}

lastExpiredTime는 이전 Work가 만료되었을 때 할당되는 시간입니다. 이 부분은 위에서 performConcurrentWorkOnRoot()를 다룰 때 여러분의 이해를 위해 생략했던 부분으로 아래에서 확인해 볼 수 있습니다.

reconciler > ReactFiberWorkLoop.js

  
function performConcurrentWorkOnRoot(root, didTimeout) {
  if (didTimeout) {
    const currentTime = requestCurrentTimeForUpdate();
    markRootExpiredAtTime(root, currentTime);
    ensureRootIsScheduled(root);
    return null;
  }
    /*...*/
}

concurrent mode에서 비동기로 Work를 진행하다 만료가 될 경우(workLoop()의 20 ~ 22) root에 lastExpiredTime이 새겨지고4 다음 프레임에 동기로 처리하기 위해 performSyncWorkOnRoot()를 root에 스케줄링5 시킵니다.

reconciler > ReactFiberWorkLoop.js

  
function ensureRootIsScheduled(root: FiberRoot) {
  const lastExpiredTime = root.lastExpiredTime;
  if (lastExpiredTime !== NoWork) {
    // Special case: Expired work should flush synchronously.
    root.callbackExpirationTime = Sync;
    root.callbackPriority = ImmediatePriority;
    root.callbackNode = scheduleSyncCallback(
      performSyncWorkOnRoot.bind(null, root),
    );
    return;
  }
  /*...*/
}

정리하자면 sync work가 실행되는 경우는 두 가지입니다. concurrent -> sync와 처음부터 sync인 경우이며 이때 expirationTime을 구하는 방식이 다르기 때문에 아래 4번 라인이 필요한 것입니다.

reconciler > ReactFiberWorkLoop.js

  
function performSyncWorkOnRoot(root) {
  /*...*/
  // const expirationTime = lastExpiredTime !== NoWork ? lastExpiredTime : Sync;

  if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
    prepareFreshStack(root, expirationTime);
  }

  /*...*/
}

if 문6의 의미를 알아보겠습니다.

이 부분도 concurrent mode와 연관이 있는데 자바스크립트는 멀티 스레드 환경에서 실행되는 게 아니므로 리액트는 하나의 VDOM에 대한 Work만 진행할 수 있습니다. 그래서 reconciler가 작업 중인 root를 전역변수 workInProgressRoot로 잡아둘 수 있는 것입니다.

하지만 현재 가장 우선순위가 높은 Task의 root가 작업 중이던 workInProgressRoot와 다르다면6 reconciler의 컨텍스트는 새로운 작업을 진행하기 위해 초기화7 해야 합니다.

expirationTime !== renderExpirationTime도 root와 비슷한 의미로 다른 작업이 더 우선순위가 높아 이전 작업을 초기화 해야 함을 나타냅니다.

VDOM에는 하나의 root만 존재합니다. 이 말인즉슨 root가 다르다는 의미는 ReactDOM.render()를 통해 만들어진 다른 VDOM에서 발생한 작업이 더 우선순위가 높다는 말입니다. 그래서 새로운 VDOM root에 재조정 작업을 진행하기 위한 작업용 트리를 만드는 함수가 prepareFreshStack()입니다.

renderExpirationTime는 초기에는 noWork0입니다. 만약 맨 처음으로 performSyncWorkOnRoot()가 호출되는 경우 if문6은 true일 것이고(expirationTime !== noWork) 재조정 작업 전에 항상 초기화될 것입니다.

prepareFreshStack()은 current의 최상단 노드인 Host root를 복제하여 workInProgress tree의 Host root를 만듦과 동시에 reconciler의 컨텍스트 변수들을 초기화합니다.

reconciler > ReactFiberWorkLoop.js

  
function prepareFreshStack(root, expirationTime) {
  root.finishedWork = null; 
  root.finishedExpirationTime = NoWork;

  workInProgressRoot = root;
  workInProgress = createWorkInProgress(root.current, null, expirationTime);
  renderExpirationTime = expirationTime;
  workInProgressRootExitStatus = RootIncomplete;
  workInProgressRootFatalError = null;
  workInProgressRootLatestProcessedExpirationTime = Sync;
  workInProgressRootLatestSuspenseTimeout = Sync;
  /*...*/
}

workInProgress를 만들 때 매번 새로운 객체로 만들어내지 않습니다. 재조정 작업은 매우 반복적으로 발생하는 작업입니다. 그래서 매번 객체를 만들어 리소스를 낭비하지 않고 기존에 만들어진 객체를 재활용하고 속성만 초기화합니다.

export function createWorkInProgress(
  current: Fiber,
  pendingProps: any,
  expirationTime: ExpirationTime,
): Fiber {
  let workInProgress = current.alternate;
  // 새로 생성
  if (workInProgress === null) {
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode,
    );
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;

    workInProgress.alternate = current;
    current.alternate = workInProgress;

  // 재활용
  } else {
    // 기존 객체에서 재활용 하지 못하는 속성들은 초기화 해준다.
    workInProgress.pendingProps = pendingProps;
    workInProgress.effectTag = NoEffect;
    workInProgress.nextEffect = null;
    workInProgress.firstEffect = null;
    workInProgress.lastEffect = null;
  }
  // current와 workInProgress가 공유하는 속성들
  workInProgress.childExpirationTime = current.childExpirationTime;
  workInProgress.expirationTime = current.expirationTime;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  workInProgress.sibling = current.sibling;
  workInProgress.index = current.index;
  workInProgress.ref = current.ref;

  return workInProgress;
}

이쯤에서 한 가지 여러분에게 상기시켜 드릴 부분이 있습니다.

현재 위치는 재조정 작업을 진행하기 전 시작 위치이며 최상단 current을 복제한 workInProgressHost root를 만들었습니다. 여기까지만 놓고 봤을 때 컴포넌트의 상태가 변하면 항상 Host root부터 시작한다는 걸 어림짐작할 수 있습니다. 깊이가 깊은 자식의 상태 값이 바뀌어도 항상 Host root부터 시작하여 자식까지 workInProgress 트리를 만들어 가게 됩니다.

❗ workInProgress 트리만 만든다는 것이지 Host root부터 자식까지 컴포넌트을 호출하는게 아님을 유의하세요.

prepareFreshStack()이 실행된 후 VDOM의 상태는 다음과 같습니다.

prepare fresh stack

root node fiber === Host root

이렇게 작업용 VDOM의 entry를 만들었습니다.

다음 포스트에서는 본격적으로 Render phase에 진입하여 Work를 진행합니다. 이 과정에서 VDOM 탐색은 어떤 방식으로 구현되어있는지, 업데이트 컴포넌트를 어떻게 찾아내는지, 재조정 작업은 무엇인지 등을 확인하게 됩니다.


오픈소스를 톺아보며 매직 코드라 생각했던 부분들의 동작 원리와 의미, 의도를 파악해보고 서로의 생각을 나누기 위한 블로그