React 톺아보기 - 05. Reconciler_2

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

6. Render phase

Render phase는 변경된 상태 값을 반영한 workInProgress 트리 구축 과정을 일컫습니다.

  1. Host root부터 업데이트 컴포넌트까지 current를 복제하여 workInProgress 트리를 만든다.
  2. 업데이트 컴포넌트를 호출하여 변경된 상태 값을 반영한 React element를 fiber로 확장한다.
  3. 2의 과정을 leaf 노드까지 반복하여 workInProgress 트리를 완성한다.

간략한 흐름은 위와 같지만, 세부적으로는 좀 더 복잡한 작업들이 존재합니다. React 톺아보기의 이름에 맞게 개념적인 설명으로 끝내지 않고 어떤 복잡한 작업들이 존재하는지 같이 한 번 뜯어봅시다.

reconciler > ReactFiberWorkLoop.js

  
function performSyncWorkOnRoot(root) {
  /*...*/
  // if (..) prepareFreshStack(root, expirationTime);

  if (workInProgress !== null) { 
    const prevExecutionContext = executionContext
    executionContext |= RenderContext
    do {
      try {
        workLoopSync() // 개별 컴포넌트에 Work 진행
        break
      } catch (thrownValue) {
        handleError(root, thrownValue)
      }
    } while (true)
    executionContext = prevExecutionContext

    if (workInProgress !== null) {
      // This is a sync render, so we should have finished the whole tree.
        invariant(
          false,
          'Cannot commit an incomplete root. This error is likely caused by a ' +
          'bug in React. Please file an issue.',
        );
    } else {
      /* Commit phase.. */
    }
  }

  return null // sync work는 잔여 작업이 없으므로 null을 리턴.
}
  • workInProgress6는 prepareFreshStack() 이나 async Work -> sync Work로 넘어오면서 잡아두게 됩니다. 만약 이 변수가 null이라면 이는 Work를 진행할 대상이 없다는 뜻으로 Render phase나 Commit phase에 진입하지 않습니다.
  • Render phase에 진입했다는 걸 기록8하고 workLoopSync()를 통해 본격적으로 Work를 진행합니다.

아래는 Work의 흐름을 나타냅니다.

render phase flow

6 - 1 workLoop()

reconciler > ReactFiberWorkLoop.js

  
function workLoopSync() {
  while (workInProgress !== null) {
    workInProgress = performUnitOfWork(workInProgress)
  }
}

workInProgressperformUnitOfWork()가 뱉어내는 fiber를 받습니다. 이 fiber는 이전 workInProgress가 반환한 자식이며 다음 Work를 진행할 대상입니다.

concurrent mode용 workLoopConcurrent()와는 다르게 제어권을 yield하는 부분이 없으므로 동기적으로 Render phase가 진행될 것이라는 걸 알 수 있습니다.

6 - 2 performUnitOfWork()

reconciler > ReactFiberWorkLoop.js

  
function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
  const current = unitOfWork.alternate

  let next = beginWork(current, unitOfWork, renderExpirationTime) // 자식 반환

  unitOfWork.memoizedProps = unitOfWork.pendingProps
  if (next === null) {
    // 더이상 반환되는 자식이 없다면 현재 fiber를 마무리 한다.
    next = completeUnitOfWork(unitOfWork) // 형제 반환
  }

  return next
}

beginWork()5는 컴포넌트를 호출해 자식을 반환하고 completeUnitOfWork()10는 형제를 반환합니다.
beginWork()가 끝나면 해당 fiber의 props로 인해 더 이상 영향을 받는 부분이 없으므로 확정7지어 줍니다.

pendingProps와 memoizedProps

props에 의해 컴포넌트가 영향을 받거나 주는 경우는 부모가 리-렌더링이 되어 전달받는 props가 변경되거나 props 값을 자식에 넘겨줄 때입니다.

performUnitOfWork()가 호출될 때의 시점을 보면 unitOfWork의 부모는 이미 자식을 반환한 상태이므로 props는 변경될 일이 없습니다. 또한 beginWork()가 끝났다면 자신도 마찬가지로 자식을 반환한 후이므로 더이상 props로 영향을 줄 만한 상황이 남아있지 않습니다. 즉 7 라인에 도달하면 props로 영향을 받거나 줄 수 있는 상황은 일어나지 않으므로 더 이상 props를 pending 상태로 두지 않고 확정 지어 주는 것입니다.

6 - 3 beginWork()

beginWork()의 목적은 workInProgress의 컴포넌트를 호출해야 하는지 판단하는 겁니다.
그렇다면 컴포넌트 호출 판단 기준은 무엇일까요? 다음 두 가지에 해당하면 컴포넌트를 호출해야 합니다.

  1. 컴포넌트 상태 변경
  2. props 변경

1) 컴포넌트의 상태 변경 판단

fiber를 가지고 컴포넌트 상태 변경을 어떻게 판단할까요? 간단하게 업데이트가 발생한 컴포넌트에 플래그 하나만 달아주면 됩니다. 우리는 이 장치를 이미 markUpdateTimeFromFiberToRoot()를 통해 해놨습니다. 그것도 두 개나 설정해놨죠. 바로 expirationTime과 childExpirationTime입니다.

업데이트가 발생한 fiber에는 expirationTime을, 자손에서 업데이트가 발생한 경우에는 childExpirationTime을 새겨놨습니다. 이는 상태가 변경된 컴포넌트를 찾는데 매우 유용하게 사용됩니다.

현재 fiber에 childExpirationTime만 설정되어 있다면 자손이 업데이트되었음을 나타내므로 해당 컴포넌트의 호출은 생략하고 밑으로 내려가기만 하면 됩니다. 이때 밑으로 내려간다는 말은 자식 current를 단순 복제하여 workInProgress 트리를 구축하며 내려간다는 뜻입니다. 이를 반복하여 expirationTime이 renderExpirationTime과 같은 컴포넌트를 찾습니다. 해당 컴포넌트가 현재 진행되는 Work를 발생시킨 대상이며 재호출이 필요한 컴포넌트입니다.

2) props 변경 판단

props가 변경되는 경우는 부모의 재호출밖에 없습니다. 부모가 호출되었으니 당연히 자식도 호출되겠죠?
근데 왜 제가 당연하다고 했을까요? 단순히 다음과 같이 정의된 자식도 부모가 호출되면 재호출 될까요?

<Foo />

대답은 아래 코드를 확인하고 나서 하도록 하겠습니다. 이미 답을 알고 계신 분들도 있을 것이고 그중에서도 왜 그러한지 정확히 모르시는 분들도 계실 거라 생각됩니다.

3) beginWork()

ㅤㅤ

reconciler > ReactFiberBeginWork.js

  
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime
): Fiber | null {
  const updateExpirationTime = workInProgress.expirationTime

  // 1. 컴포넌트 마운트 여부 확인
  if (current !== null) {
    const oldProps = current.memoizedProps
    const newProps = workInProgress.pendingProps

    // 2. 컴포넌트 호출 대상 판단 1
    if (oldProps !== newProps) {
      didReceiveUpdate = true

    // 2. 컴포넌트 호출 대상 판단 3
    } else if (updateExpirationTime < renderExpirationTime) {
      didReceiveUpdate = false

      // 더이상 Work를 진행하지 않고 자식의 current를 복제한 workInProgress만 만들어 반환
      return bailoutOnAlreadyFinishedWork(
        current,
        workInProgress,
        renderExpirationTime
      )

    // 2. 컴포넌트 호출 대상 판단 3
    } else {
      didReceiveUpdate = false
    }

  // 1. 컴포넌트 마운트 여부 확인
  } else {
    didReceiveUpdate = false
  }
  
  workInProgress.expirationTime = NoWork

  // fiber에 맞는 재조정 작업 라우팅
  switch (workInProgress.tag) {
    /*...*/
  }
}

didReceiveUpdate는 컴포넌트의 업데이트를 나타냅니다. 여기서 업데이트란 컴포넌트의 props, state 변경을 뜻합니다.

  • 10: current가 없다면 현재 fiber는 마운트되는 컴포넌트를 뜻합니다.

    • 15: 컴포넌트를 호출해야 하는 조건 중 하나는 props의 변경이었습니다. props는 객체이므로 참조 값 비교를 통해 변경 여부를 판단합니다.
    • 19: updateExpirationTime7은 workInProgress의 expirationTime이고 renderExpirationTime은 현재 Work를 발생시킨 컴포넌트의 expirationTime입니다. expirationTime은 시간의 개념과는 반대로 큰 수가 더 이전에 발생한 이벤트입니다.

      이 내용을 바탕으로 조건을 해석하자면 renderExpirationTime이 updateExpirationTime 보다 더 이전에 발생한 것이라면 workInProgress는 지금 처리될 차례가 아니라는 뜻이 됩니다.

    • 23: workInProgress는 현재 처리될 차례가 아니지만, 자손에서 업데이트가 발생하였을 때는 밑으로 내려갈 수 있도록 workInProgress 트리를 이어주어야 합니다. 다시 말해 workInProgress가 호출 대상이 아니라면 자식 또한 변경된 사항이 없는 것으로 현재 컴포넌트에서 Work를 중단해도 되지만 자손에서 이번 Work를 예약한 것이므로 해당 자손까지 진행될 수 있도록 자식 current를 단순복제하여 workInProgress를 만들어주는 역할을 합니다.
    • 31: 해당 컴포넌트에서 Work를 예약했지만19 props는 변경된 것이 없음15을 나타냅니다.
  • 36: 컴포넌트가 마운트 될 때는 앞뒤 잴 거 없이 fiber던 HTML element던 그냥 다 생성하고 삽입만 하면 됩니다. 그래서 didReceiveUpdate 플래그 또한 설정36해줄 필요가 없습니다.
  • 42: fiber의 종류에 맞게 Work를 시작합니다.

6 - 4 bailoutOnAlreadyFinishedWork()

Work를 진행하며 Host root부터 시작하여 상태가 변경된 컴포넌트까지 workInProgress 트리를 만들어 나갑니다.
이때 상태 변경 컴포넌트의 서브 트리를 제외한 Host root ~ 업데이트 컴포넌트까지는 React element의 기준으로 생각하면 변경된 부분은 없습니다. 그래서 굳이 fiber로 새로 만들 필요 없이 cururent를 재사용하여 workInProgress만 만들어 냅니다.

fiber는 컴포넌트 모델이 변경된 경우, 즉 React element가 변경된 경우에만 fiber로 다시 확장시킵니다. 그리고 컴포넌트 모델이 변경될 일은 props 밖에 없습니다(같은 type의 컴포넌트라고 가정). 내부 상태 값(useState())은 컴포넌트 모델과는 상관없습니다. 그래서 상태값이 바뀌었다고 해당 컴포넌트의 fiber를 새로 만들지는 않습니다.

지금까지 bailoutOnAlreadyFinishedWork()의 행동에 대한 이유를 구구절절이 이야기했지만, 위 내용의 코드는 비교적 간단합니다.

reconciler > ReactFiberBeginWork.js

  
function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime
): Fiber | null {
  var childExpirationTime = workInProgress.childExpirationTime

  if (childExpirationTime < renderExpirationTime) {
    return null
  } else {
    cloneChildFibers(current, workInProgress)
    return workInProgress.child
  }
}
  • workInProgress는 현재 처리해야 될 fiber가 아니므로 자손에서 업데이트가 발생했는지 확인9합니다. 만약 그렇다면 계속해서 서브 트리로 Work가 진행될 수 있도록 cloneChildFibers()을 통해 작업용 fiber를 만들어 주고 그렇지 않다면 해당 컴포넌트를 기준으로 Work를 진행할 필요가 없으므로 null을 반환10하여 더 이상 진행되지 않도록 끊어줍니다.

reconciler > ReactChildFiber.js

  
function cloneChildFibers(current: Fiber | null, workInProgress: Fiber): void {
  if (workInProgress.child === null) {
    return
  }
  // 첫번째 자식 복사
  let currentChild = workInProgress.child
  let newChild = createWorkInProgress(
    currentChild,
    currentChild.pendingProps,
    currentChild.expirationTime
  )
  workInProgress.child = newChild
  newChild.return = workInProgress
  // 형제들도 복사한다
  while (currentChild.sibling !== null) {
    currentChild = currentChild.sibling
    newChild = newChild.sibling = createWorkInProgress(
      currentChild,
      currentChild.pendingProps,
      currentChild.expirationTime
    )
    newChild.return = workInProgress  }
  newChild.sibling = null
}

createWorkInProgress()는 prepareFreshStack()에서 이미 다루었기 때문에 생략합니다.

Todo예제에서 Input 컴포넌트의 상태가 바뀌었다고 가정 하면 VDOM의 형태는 다음과 같습니다.

input update
bailoutOnAlreadyFinishedWork()를 통해 만들어지는 workInProgress 트리

위 이미지에서 보여지는 workInProgress들은 모두 bailoutOnAlreadyFinishedWork()를 통해 current를 재사용하여 만들어졌습니다. 그렇기 때문에 workInProgress Input의 child 포인터 또한 자동으로 input을 바라보고 있습니다. current를 복제한 후 변경되는 속성은 return밖에 없으며 부모 workInProgress를 가리키도록 하기 위함입니다.

Input까지는 컴포넌트 호출 없이 current를 재사용하여 fiber 트리를 구축하였지만, Input의 서브 트리부터는 변경된 상태를 반영한 fiber들로 트리를 구축해야 하므로 Input 컴포넌트 호출은 필수적입니다.

❗ 경로 최적화

이쯤에서 일전에 드린 질문에 대한 대답을 할 시간이 온 것 같습니다. 질문과 관련하여 우리가 알고 있는 정보는 다음과 같습니다.

  1. JSX로 선언된 컴포넌트는 createElement()로 변환되어 React element로 생성된다. 이때 props는 리터럴 객체를 사용한다.
  2. props가 변경되면 해당 컴포넌트를 호출한다.
  3. props가 변경되지 않았다면 자손의 업데이트 여부에 따라 workInProgress를 만든다.

만약 부모가 호출 된다면 하위 자식들은 모두 createElement()를 통해 React element로 반환될 것입니다. 그리고 1번에 의거하여 부모가 props를 넘겨주지 않아도 내부적으로 props는 매번 새로운 빈 객체로 생성됩니다.

props 변경 여부는 참조 값으로 판단하므로 2번에 의해 Foo 컴포넌트는 props를 부모로부터 전달받지 않아도 props 변경으로 판단되어 호출될 것입니다.

여기에 리액트 성능 최적화에 대한 중요한 힌트가 있습니다. 사실 위 질문은 이 부분을 설명드리기 위한 빌드업이었습니다. 😅

리액트의 흐름에서 개발자가 개입할 수 있는 부분이 딱 한 번 있습니다. 클래스 컴포넌트는 shouldComponentUpdate(), 함수형은 memo입니다. 이러한 것을 제공하는 이유는 리액트보다 개발자가 더 잘 판단할 것으로 생각하고 쓸데없는 연산을 하지 않도록 판단을 양보하는 것입니다.

경로 최적화의 핵심은 props입니다. 상태가 변경된 컴포넌트는 재호출되지만, 그 이외의 컴포넌트들은 props만 변경되지 않는다면 절대로 호출되는 일은 없습니다.

이를 이용할 수 있도록 함수형 컴포넌트에서는 memo를 제공하고 있습니다. 부모가 아무리 재호출 되어도 자식이 전달받은 props가 얕은 비교로 변경된 것이 없다면 bailoutOnAlreadyFinishedWork()를 통해 빠져나갈 수 있도록 해줍니다.

reconciler > ReactFiberBeginWork.js

  
function updateSimpleMemoComponent(current, nextProps,...){
  /*...*/
  const prevProps = current.memoizedProps;
  if (shallowEqual(prevProps, nextProps)) {
    didReceiveUpdate = false;
    if (updateExpirationTime < renderExpirationTime) {
      return bailoutOnAlreadyFinishedWork(...);
    }
  }
  /*...*/
}

memo를 이용한 최적화는 공식 홈페이지에서 보다 자세히 확인할 수 있으므로 생략합니다.

만약 memo가 없었다면 상태가 변경된 컴포넌트의 서브 트리는 예외 없이 모두 리-렌더링(컴포넌트 호출, 재조정 작업..) 될 것입니다. 매우 많은 노드가 존재할 경우 성능에 영향을 미치므로 경로 최적화를 고민해볼 시점이 됩니다.

여기서 memo를 제외하고 경로 최적화를 할 수 있는 방법이 한 가지 더 있습니다. 어떻게 하면 부모가 재호출되는데 자식의 props가 변경되지 않도록 할 수 있을까요?

바로 createElement() 호출을 회피하면 됩니다.

방법은 간단합니다. 경로 최적화 대상 컴포넌트만 부모 밖으로 끄집어낸다면 해당 컴포넌트는 부모의 호출에 더는 영향을 받지 않게 됩니다. Todo 예제에서 버튼을 밖으로 빼내어 Input의 change 이벤트로 인한 재호출로부터 더는 버튼이 영향을 받지 않도록 해봅시다.

경로 최적화가 매번 성능 향상으로 직결되지는 않습니다. 서브 트리의 크기가 작은 컴포넌트라면 굳이 경로 최적화를 생각하며 개발할 필요가 없다는 뜻이며 이번 실습은 보여주기 위한 진행임을 인지하시길 바랍니다.

function SubmitBtn() {
  log('Button 호출')
  return <button>submit</button>
}

function Input({ SubmitBtn }) { // 버튼 element를 밖에서 주입
  const [value, setValue] = useState('')
  return (
    <>
      <input value={value} onChange={e => setValue(e.target.value)} />
      {SubmitBtn} {/*또는 {this.props.children}*/}
    </>
  )
}

function Todo() {
  const [todos, push] = useState([])
  return (
    <>
      <Input SubmitBtn={<SubmitBtn />} /> {/*또는 <Input><SubmitBtn /></Input>*/}
      <OrderList list={todos} />
    </>
  )
}
변경 전
변경 후

SubmitBtn은 Input 컴포넌트 밖에서 element로 만들어진 상태로 전달됩니다. Input 내부에서 React.createElement()로 치환되는 게 아니므로 SubmitBtn는 항상 같은 props를 소유합니다. 다만 Todo 컴포넌트의 상태가 변경되어 리-렌더된다면 하위 서브트리인 Input, SubmitBtn, OrderList는 모두 재호출되며 재조정 작업 대상이 됩니다.

❗ 경로 최적화를 적용할 때 자주하는 실수

memo를 이용하여 경로 최적화를 할 때 다음의 두 가지 사항이 개발자를 실수로 이끕니다.

  1. 컴포넌트는 업데이트가 발생할 때마다 재호출 된다.
  2. memo는 얇은 비교로 props 변경을 판단한다.

만약 이벤트 핸들러나 컬렉션을 컴포넌트 안에서 선언하고 사용한다면 재호출 때마다 이들의 참조가 변하게 됩니다. 당연히 memo 컴포넌트는 전달받은 props에 대해 얕은 비교를 할 것이고 같지 않다는 결과를 도출할 것입니다.

const Button = React.memo(({ onClick }) =>...);
<Button onClick={() => alert("I'm Foo")} /> // onClick 핸들러는 매번 새로 생성된다.

이 문제는 여러가지 방법으로 해결할 수 있습니다.

memo는 두번째 인자로 비교 전략을 교체할 수 있도록 해줍니다. 그래서 객체의 경우 얕은 비교가 아닌 다른 전략을 통해 쉽게 해결할 수도 있습니다. 하지만 문제는 함수입니다.

쉽게 생각해서 이벤트 핸들러 선언을 컴포넌트 내부가 아닌 외부로 빼내어 딱 한번만 정의하면 될 것 같지만 이는 바로 난관에 봉착하게 됩니다. 핸들러에서 컴포넌트의 상태를 클로저로 의존하고 있다면 말이죠.

이때 사용하라고 만든 것이 useCallback()입니다. 이를 이용하여 이벤트 핸들러가 의존하고 있는 값들이 변할때에만 새로운 핸들러를 만들도록 할 수 있습니다.

6 - 5 재조정 전 사전 작업 공간인 update***()

재조정 작업을 풀어 설명하자면 컴포넌트 호출을 통해 반환된 React element를 current fiber와 비교하여 workInProgress를 새로 만들어야 하는지, 아니라면 어떤 부분들이 변경되었는지, current는 삭제되어야 하는지 등 두 컴포넌트를 비교하여 ‘재조정’ 하는 작업을 말합니다.

다시 beginWork()로 돌아와 이어서 진행하겠습니다.

reconciler > ReactFiberBeginWork.js

  
function beginWork(...) {
  /*...*/
  // workInProgress.expirationTime = NoWork;

  // fiber에 맞는 재조정 작업 라우팅
  switch (workInProgress.tag) {
    case IndeterminateComponent: {
      return mountIndeterminateComponent(...);
    }
    case FunctionComponent: {
      /*...*/
      return updateFunctionComponent(...);
    }
    case HostComponent:
      return updateHostComponent(...);
    case Fragment:
      return updateFragment(...);

    /* HostRoot, LazyComponent, Memo, ClassComponent... */
  }
}

여기까지 도달했다면 workInProgress는 업데이트 컴포넌트를 뜻합니다. 컴포넌트를 호출하여 변경된 상태를 반영한 자식을 반환받아 재조정 작업을 진행해야 합니다.

자주 접하게 되는 컴포넌트에 대해서만 진행하도록 하겠습니다.

1) IndeterminateComponent

먼저 IndeterminateComponentcreateFiberFromTypeAndProps()에서 fiber의 type이 함수일 때 다음과 같은 이유로 초기 tag 값을 IndeterminateComponent로 설정해주었습니다.

이제는 이 함수를 호출하여 Work tag를 결정지을 수 있습니다.

reconciler > ReactFiberBeginWork.js

  
function beginWork(...) {
  /*...*/
  // workInProgress.expirationTime = NoWork;

  switch (workInProgress.tag) {
    /*...*/
    case IndeterminateComponent: {
      return mountIndeterminateComponent(
        current,
        workInProgress,
        workInProgress.type,
        renderExpirationTime,
      );
    }
    /*...*/
  }
}

reconciler > ReactFiberBeginWork.js

  
function mountIndeterminateComponent(
  _current,
  workInProgress,
  Component,
  renderExpirationTime
) {
  const props = workInProgress.pendingProps
  let value = renderWithHooks(
    null,
    workInProgress,
    Component,
    props,
    context,
    renderExpirationTime
  )

  workInProgress.effectTag |= PerformedWork

  if (
    typeof value === 'object' &&
    value !== null &&
    typeof value.render === 'function' &&
    value.$$typeof === undefined
  ) {
    workInProgress.tag = ClassComponent;    /* 클래스 컴포넌트로 변환.. */
  } else {
    workInProgress.tag = FunctionComponent    reconcileChildren(null, workInProgress, value, renderExpirationTime)
    return workInProgress.child
  }
}

드디어 hooks를 분석할 때 보았던 renderWithHooks()가 나왔습니다.
함수를 실행해 반환한 value9의 상태에 따라서 잘못 사용한 클래스 컴포넌트21 ~ 24인지 아니면 올바른 함수형 컴포넌트28인지 판단합니다. 전자의 경우 클래스 컴포넌트로 변환하고 반환받은 value를 해당 함수의 인스턴스로 갈아치우며 코드27는 생략하였습니다. 함수형 컴포넌트의 value는 자식 React element이므로 해당 자식을 대상으로 재조정 작업을 진행30합니다.

reconcileChildren()30은 실질적으로 재조정 작업을 진행하는 담당자입니다. 사전작업 공간인 update***()는 최종적으로 reconcileChildren()을 사용하므로 맨 마지막으로 분석을 미루도록 하겠습니다.

2) FunctionComponent

함수형 컴포넌트는 호출하기에 앞서 default props를 먼저 처리합니다.

reconciler > ReactFiberBeginWork.js

  
function beginWork(...) {
  /*...*/
  // workInProgress.expirationTime = NoWork;

  switch (workInProgress.tag) {
    /*...*/
    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);

      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderExpirationTime,
      );
    }
    /*...*/
  }
}

reconciler > ReactLazyComponent.js

  
function resolveDefaultProps(Component, baseProps) {
  if (Component && Component.defaultProps) {
    const props = Object.assign({}, baseProps);
    const defaultProps = Component.defaultProps;
    for (let propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
    return props;
  }
  return baseProps;
}

함수를 실행해 업데이트를 반영한 자식을 만들어낼 차례입니다.

reconciler > ReractFiberBeginWork.js

  
function updateFunctionComponent(current, workInProgress, Component, nextProps, renderExpirationTime) {

  let nextChildren = renderWithHooks(
    current, 
    workInProgress, 
    Component, 
    nextProps, 
    context, 
    renderExpirationTime
  );

  if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderExpirationTime); // 훅과 관련된 부분들을 초기화
    return bailoutOnAlreadyFinishedWork(...);
  }

  workInProgress.effectTag |= PerformedWork;

  reconcileChildren(
    current,
    workInProgress,
    nextChildren,
    renderExpirationTime,
  );
  return workInProgress.child;
}
  • 13: 최적화를 위한 코드입니다.

    didReceiveUpdate13는 컴포넌트의 props 또는 state 변경 여부를 나타내는 변수이며 props는 beginWork()에서, state는 useState(), useReducer()의 업데이트 구현체인 updateReducer()에서 아래 코드를 통해 판단합니다.

    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate() // didReceiveUpdate = true;
    }

    if 문13을 정리하자면

    1. beginWork()에서 확인한 결과 props는 변경되지 않았다.
    2. 하지만 업데이트가 발생한 컴포넌트이므로 호출되어야 한다.
    3. 컴포넌트 호출 후에도 didReceiveUpdate는 여전히 false임을 미루어보아 컴포넌트 상태 또한 변경되지 않았다.
    4. props, state 모두 변경되지 않았다면 서브 트리 또한 변경될 부분이 없으므로 Work를 여기서 끊어주게 될 경우 불필요한 작업을 하지 않아도 된다.
    5. 문제는 컴포넌트가 한번은 호출되었기 때문에 라이프 사이클 훅(useEffect(), useLayoutEffect())의 잔여물이 fiber에 남아있다.
    6. 잔여물을 제거하고 bailoutOnAlreadyFinishedWork()를 진행한다. 만약 자손에서 업데이트 컴포넌트가 있다면 자식 workInProgress가 반환되어 계속해서 밑으로 Work가 진행될 것이지만, 없다면 여기서 Work는 종료된다.

    reconciler > ReactFiberHooks.js

     
    function bailoutHooks(current, workInProgress, expirationTime) {
      workInProgress.updateQueue = current.updateQueue // 라이프 사이클 초기화
      workInProgress.effectTag &= ~(PassiveEffect | UpdateEffect) // 라이프 사이클 tag 삭제
      if (current.expirationTime <= expirationTime) {
         current.expirationTime = NoWork
      }
    }

    updateQueue는 컴포넌트의 종류에 따라 저장되는 정보가 다릅니다. 함수형 컴포넌트는 라이프 사이클 hook을 저장하고 호스트 컴포넌트는 변경된 정보를 저장합니다. 이 부분은 추후에 나옵니다.

  • 18: 컴포넌트가 업데이트되어 Work를 진행했음을 나타내는 effect tag를 달아줍니다.

3) HostComponent

호스트 컴포넌트는 커스텀 컴포넌트와는 성격이 다릅니다. 컴포넌트가 업데이트되면 커스텀 컴포넌트는 상태값을 가지고 있기 때문에 이를 적용하기 위한 호출이 필요하지만, 호스트 컴포넌트는 뷰 그 자체이므로 변경된 정보를 바탕으로 호스트 환경에 의존적인 처리가 필요합니다.

reconciler > ReactFiberBeginWork.js

  
function updateHostComponent(current, workInProgress, renderExpirationTime) {
  const type = workInProgress.type
  const nextProps = workInProgress.pendingProps
  const prevProps = current !== null ? current.memoizedProps : null

  let nextChildren = nextProps.children
  const isDirectTextChild = shouldSetTextContent(type, nextProps)

  if (isDirectTextChild) {
    nextChildren = null 
  } else if (prevProps !== null && shouldSetTextContent(type, prevProps)) {
    // nextChildren  삽입전에 초기화 해주기 위해 tag를 설정함
    workInProgress.effectTag |= ContentReset
  }

  reconcileChildren(current, workInProgress, nextChildren, renderExpirationTime)
  return workInProgress.child
}

호스트 컴포넌트는 자식으로 문자열 하나만 가지고 있을 때에는 해당 문자열을 굳이 fiber로 만들지 않습니다.

  • 11: 문자열을 fiber로 만들지 않기 위해 nextChildren을 초기화합니다. nextChildren을 null로 넘겨받은 reconcileChildren()17은 아무런 작업도 하지 않습니다.
  • 14: nextChildren(문자열X)을 삽입하기 전에 만약 current가 문자열만을 가졌다면12 해당 문자열을 제거해주고 난 후에 삽입해야 합니다. 문제는 current가 가진 문자열은 fiber로 생성되지 않았기 때문에 재조정 작업 중에 삭제 로직을 타지 않게 됩니다. 여기서 tag를 달아주지 않는다면 자칫 nextChildren은 current 문자열의 형제로 삽입될 수 있으므로 ContentReset를 달아14줌으로써 재조정 담당자에게 초기화가 필요함을 알려주는 것입니다.

4) Fragment

Fragment는 이미 다루었지만, 매우 간단하고 자주 접하므로 코드만 보고 넘어가도록 하겠습니다.

reconciler > ReactFiberBeginWork.js

  
function updateFragment(current, workInProgress, renderExpirationTime) {
  const nextChildren = workInProgress.pendingProps // Fragment는 props 자체가 자식 저장소이다
  reconcileChildren(current, workInProgress, nextChildren, renderExpirationTime)
  return workInProgress.child
}

다음 포스트에서는 reconcileChildren()을 통해 본격적으로 재조정 작업을 확인할 것입니다. 여기에는 current와 workInProgress 사이의 변경점을 찾아내는 Diffing Algorithm, React element를 fiber로 확장하는 코드 등이 포함됩니다.


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