React 톺아보기 - 05. Reconciler_3

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

render phase flow

6 - 6 reconcileChildren()

재조정 작업의 목적은 VDOM 트리에서 변경이 발생한 부분을 효과적으로 비교하여 새로운 트리를 만들어내는 것입니다. 이때 효과적이란 최소한의 연산을 뜻하는데 리액트는 이 부분을 어떻게 처리하였는지 공식홈페이지에 자세히 기술하고 있습니다. 한번 읽어 보시고 오세요. 진행하는 데 많은 도움이 됩니다.

아래는 그 일부분을 발췌한 내용입니다.

두 가지 가정에 기반을 두어 O(n) 복잡도의 휴리스틱 알고리즘을 구현했습니다.

  1. 서로 다른 type의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
  2. 개발자는 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

핵심은 React element를 fiber로 확장할 때 current를 재사용할 수 있는지 또는 새로운 fiber를 만들어 내어야 하는지 이 두 가지의 선택지에서 어떠한 근거를 가지고 선택하느냐입니다. 만약 새로운 fiber로 생성한다면 하위 서브트리는 자연스레 완전히 새로운 서브트리로 재구축됩니다.

이번 포스트의 목적은 이것을 어떻게 코드로 작성하였는지 분석하는 것입니다.

1) ChildReconciler()

재조정 작업과 관련된 일련의(추가, 삭제..) 함수들이 존재합니다. 이 함수들은 fiber의 마운트 여부에 따라 로직의 경로가 조금씩 다르며 매우 빈번하게 호출되므로 리액트는 컴파일러의 최적화에 도움을 주기 위해 경로가 다른 함수들을 다음과 같이 미리 분리하여 사용되도록 하였습니다.

reconciler > ReactChildFiber.js

function ChildReconciler(shouldTrackSideEffects) {
  // 삭제
  function deleteChild(){
    if(!shouldTrackSideEffects) {
      // 마운트
    }
    /*...*/
  }

  // 추가, 위치 이동
  function placeChild(){
    if(!shouldTrackSideEffects) {
      // 마운트
    }
    /*...*/
  }

  /*...*/

  // 재조정 작업 진행
  function reconcileChildFibers(...) {...}
  return reconcileChildFibers;}

// flag를 통해 마운트, 업데이트용 함수를 미리 나눠둠.
const reconcileChildFibers = ChildReconciler(true);const mountChildFibers = ChildReconciler(false);
  • 마운트의 경우 shouldTrackSideEffects는 false이며 업데이트는 true입니다.

위 함수들은 reconcileChildren()에서 아래와 같이 사용됩니다.

reconciler > ReactFiberBeginWork.js

function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderExpirationTime: ExpirationTime
) {
  if (current === null) {
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderExpirationTime
    )
  } else {
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderExpirationTime
    )
  }
}

2) 재조정 작업의 시작

ChildReconciler()가 반환하는 reconcileChildFibers()는 자식 React element의 형식에 맞는 재조정 함수로 라우팅하는 역할을 합니다. 형식이란 단일 React element, Text, 복수의 자식을 담은 배열을 말합니다.

reconciler > ReactChildFiber.js

function reconcileChildFibers(
  returnFiber,
  currentFirstChild,
  newChild,
  expirationTime
) {
  // key가 없는 Fragment
  const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null
  if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children
  }

  const isObject = typeof newChild === 'object' && newChild !== null

  // React element
  if (isObject) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            expirationTime
          )
        )
      /*...*/
    }
  }

  // Text
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        '' + newChild,
        expirationTime
      )
    )
  }

  // 복수개의 자식을 담고 있는 배열
  if (isArray(newChild)) {
    return reconcileChildrenArray(
      returnFiber,
      currentFirstChild,
      newChild,
      expirationTime
    )
  }

  // Remaining cases are all treated as empty.
  return deleteRemainingChildren(returnFiber, currentFirstChild)
}
  • 5 ~ 12: key가 없는 Fragment는 하위 컴포넌트를 묶어주는 용도로만 사용되므로 굳이 fiber로 만들 필요가 없습니다. 자식을 props에서 꺼내11 재조정을 진행하므로 Fragment는 VDOM에 존재하지 않게 됩니다.
  • 33: 컴포넌트 호출로 반환된 Text는 호스트 컴포넌트 하위에 있는 Text와는 다르게 fiber로 만들어 줍니다.
  • 20, 34: 재조정을 통해 반환된 fiber를 placeSingleChild()가 받아 Placement side-effect tag를 달아 주어야 하는지 판단합니다.

side-effect

VDOM 입장에서는 트리에 변경점을 불러오는 모든 행위가 side-effect입니다. 그러므로 재조정 작업은 side-effect를 낳는다고 생각할 수 있습니다. 커스텀 컴포넌트 호출 또한 side-effect에 해당하므로 다음과 같이 tag를 설정해주었습니다.

let nextChildren = renderWithHooks(...);
workInProgress.effectTag |= PerformedWork;

fiber에 새겨진 effectTag는 Commit phase에서 fiber에 어떤 작업들이 행해졌는지 확인하는데 쓰이며 해당 tag를 기반으로 최종 마무리(DOM 조작, 라이프 사이클)를 합니다.

placeSingleChild()

단일 자식에 대한 재조정이 진행되고 난 후에 DOM 삽입 여부를 판단합니다.

reconciler > ReactChildFiber.js

function placeSingleChild(newFiber: Fiber): Fiber {
  if (shouldTrackSideEffects && newFiber.alternate === null) {
    newFiber.effectTag = Placement
  }
  return newFiber
}
  • current가 존재하는 상황4에서 newFiber의 alternate가 null5이라는 것은 호출로 반환된 컴포넌트가 이전 컴포넌트와 type 또는 key가 달라 fiber를 새로 생성하였음을 뜻합니다. 그래서 새로 만들어진 newFiber를 삽입하기 위해 Placement tag를 달아줍니다.

본격적으로 React element, Text, array가 어떻게 재조정을 거치는지 확인해봅시다.

3) reconcileSingleElement()

fiber를 만들어낼 때 가장 먼저 해야 할 일은 기존 fiber를 재사용할 수 있느냐를 판단하는 겁니다. 이는 매우 중요합니다.

reconciler > ReactChildFiber.js

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  expirationTime: ExpirationTime
): Fiber {
  const key = element.key
  let child = currentFirstChild

  // newChild와 매칭되는 current를 찾는다.
  while (child !== null) {
    if (child.key === key) {
      if (
        child.tag === Fragment
          ? element.type === REACT_FRAGMENT_TYPE
          : child.elementType === element.type
      ) {
        deleteRemainingChildren(returnFiber, child.sibling)
        // current 재사용
        const existing = useFiber(
          child,
          element.type === REACT_FRAGMENT_TYPE
            ? element.props.children
            : element.props,
          expirationTime
        )
        existing.return = returnFiber
        return existing
      } else {
        deleteRemainingChildren(returnFiber, child)
        break
      }
    } else {
      deleteChild(returnFiber, child)
    }
    child = child.sibling
  }

  // 새 fiber 생성..
}
  • 12, 37: current를 재사용하기 위해서는 현재 재조정 대상인 React element와 매칭되는 current가 필요합니다. while 문을 보면 형제37를 순회하고 있습니다. 그 이유는 현재 단일 자식에 대한 재조정 작업을 진행하고 있기는 하지만, 같은 위치의 current 트리에는 여러 형제가 존재하고 있을 수도 있습니다. 그러므로 이 복수의 current 중에서 현재 React element와 매칭되는 fiber를 찾고 있는 것입니다.
  • 13, 35: key가 다르다면 우리가 찾는 current가 아니며, 해당 current는 삭제되어야 함을 나타내므로 제거35해줍니다.

    다시 말씀드리지만 reconcileSingleElement()는 해당 깊이에 단일 자식만 위치할 것임을 나타냅니다. 그러므로 지금 처리되고 있는 element와 매칭되지 않는 current들은 삭제된 것입니다.

  • 16 ~ 17, 31: key가 같아도 type이 다르다면 current를 버리고 다시 만들어야 합니다. 이때 해당 current만 제거하면 안 되고 형제들도 다 같이 제거31해야 합니다. 해당 깊이에는 단일 fiber만 위치할 것이고 type이 달라 재사용하지는 못하지만 이미 매칭되는 current를 찾았으므로13 더 이상 while 문을 진행할 필요가 없습니다32.
  • 19: 우리가 원하던 current를 찾았다면 해당 current를 제외한 나머지 형제들은 모두 제거해줍니다.

    deleteRemainingChildren()19의 두번 째 인자로 ‘current의 형제’를 넘겨주고 31 라인에서는 ‘current’임을 인지하시길 바랍니다.

  • 21 ~ 27: key, type이 모두 같다면 해당 current를 재사용할 수 있습니다. 이때 딱 한 가지 current의 데이터가 아닌 반드시 반환된 element의 데이터를 사용해서 workInProgress를 만들어야 하는 것이 있습니다. 바로 부모로부터 전달받은 props입니다. 자식이 재조정 작업 중이라는 건 부모가 업데이트되어 재호출되었다는 뜻으로 부모가 넘겨주는 props 또한 변경될 수도 있음을 나타냅니다.
  • 28: current를 재사용 했기 때문에 현재 return은 current의 부모를 가리키고 있습니다. workInProgress tree의 부모를 가리키도록 수정해줍니다.

다음은 current의 삭제입니다.

deleteRemainingChildren(), deleteChild()

reconciler > ReactChildFiber.js

function deleteRemainingChildren(returnFiber, currentFirstChild) {
  if (!shouldTrackSideEffects) {
    // 컴포넌트 마운트라면 current가 없으므로 애초에 지울 것도 없다.
    return null
  }

  let childToDelete = currentFirstChild
  while (childToDelete !== null) {
    deleteChild(returnFiber, childToDelete)
    childToDelete = childToDelete.sibling
  }
  return null
}

넘겨받은 currentFirstChild 부터 시작하여 모든 형제를 삭제해줍니다.

reconciler > ReactChildFiber.js

function deleteChild(returnFiber, childToDelete) {
  if (!shouldTrackSideEffects) {
    // deleteRemainingChildren()와 동일
    return
  }

  const last = returnFiber.lastEffect
  if (last !== null) {
    last.nextEffect = childToDelete
    returnFiber.lastEffect = childToDelete
  } else {
    returnFiber.firstEffect = returnFiber.lastEffect = childToDelete
  }

  childToDelete.nextEffect = null
  childToDelete.effectTag = Deletion
}

fiber의 타입

deleteChild()의 로직을 설명하기에 앞서 fiber가 어떤 타입으로 취급될 수 있는지 먼고 짚고 넘어가야 할 것 같습니다. 이를 위해서 Duck typing의 개념이 필요합니다.
fiber를 Duck typing에 빗대어 설명하자면 fiber는 VDOM 노드이면서 Effect입니다. VDOM 노드가 가지고 있어야 할 속성(return, sibling, child, index..)과 side-effect의속성(effectTag, stateNode, updateQueue)들을 모두 가지고 있기 때문입니다. 또한, Effect collection(nextEffect, firstEffect, lastEffect)입니다.

Effect는 후반에 Work를 마무리하면서 부모로 전달되는데 이 부분은 그때가서 자세히 다루므로 알고만 계세요.

삭제는 예외적으로 Effect를 위로 올리는 시기가 조금 다릅니다. 부모로 전달하기 위해서는 completeUnitOfWork()까지 Effect가 살아 있어야 하는데 문제는 삭제되는 fiber는 지금 말고는 접근할 수 있는 시점이 없습니다. 그렇기 때문에 여기서 부모로 Deletion tag를 가진17 fiber를 연결8 ~ 14하는 겁니다.

useFiber()

current를 재사용하는 코드는 간단합니다.

reconciler > ReactChildFiber.js

function useFiber(
  fiber: Fiber,
  pendingProps: mixed,
  expirationTime: ExpirationTime
): Fiber {
  const clone = createWorkInProgress(fiber, pendingProps, expirationTime)
  clone.index = 0
  clone.sibling = null
  return clone
}

createWorkInProgress()의 설명은 reconciler_1에서 확인하세요.

  • props를 제외한 current의 속성들을 복사한 workInProgress를 만들어 냅니다. 현재 위치에 존재하는 노드는 자신 하나이므로 위치를 나타내는 index는 0, sibling은 null입니다. index 속성은 복수의 자식이 담겨있는 배열을 재조정할 때 이용됩니다.

4) reconcileSingleTextNode()

reconciler > ReactChildFiber.js

function reconcileSingleTextNode(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  textContent: string,
  expirationTime: ExpirationTime
): Fiber {
  if (currentFirstChild !== null && currentFirstChild.tag === HostText) {
    deleteRemainingChildren(returnFiber, currentFirstChild.sibling)
    const existing = useFiber(currentFirstChild, textContent, expirationTime)
    existing.return = returnFiber
    return existing
  }
  deleteRemainingChildren(returnFiber, currentFirstChild)
  const created = createFiberFromText(
    textContent,
    returnFiber.mode,
    expirationTime
  )
  created.return = returnFiber
  return created
}

Text는 key를 가지고 있지 않습니다. 그러므로 첫 번째 current 자식이 Text fiber라면8 재사용10하고 그렇지 않다면 모두 지워14준 후 fiber를 새롭게 만들어 반환합니다.

5) reconcileChildrenArray()

배열에서 발생할 수 있는 변경에는 원소의 추가, 이동, 삭제가 있습니다. reconcileChildrenArray()는 이런 변경을 최소한의 연산으로 탐색하기 위한 노력을 담고있습니다. 이번 섹션의 목적은 코드에서 이 의도와 의미를 알아내는 것입니다.

reconcileChildrenArray()는 두 개의 자료구조를 넘겨받습니다.

if (isArray(newChild)) {
  return reconcileChildrenArray(
    returnFiber,
    currentFirstChild,    newChild,    expirationTime
  )
}

current의 연결 리스트currentFirstChild, 컴포넌트가 반환한 React element를 담고 있는 배열newChild.

이제 이 두 자료구조를 비교하여 원소들의 추가, 이동, 삭제를 판별하는 로직을 짠다고 합시다. 어떻게 짜면 효율적일까요? 가장 간단하게는 2 중첩 형태의 완전 탐색을 이용할 수도 있을 것이고 또는 맵으로 변경하여 key와 원소의 위치를 나타내는 index로 판별할 수도 있습니다.

이번 섹션에서는 current와 React element를 타겟으로 통칭하여 설명하도록 하겠습니다.

리액트는 O(n)의 시간복잡도로 판별을 완수합니다. 연결리스트든 배열이든 타겟을 가리키는 커서는 해당 위치를 딱 한 번만 지나가게 된다는 뜻이며 이때 사용되는 알고리즘의 핵심은 key와 type 그리고 원소의 위치입니다.

양쪽의 타겟들을 비교할 때 최적의 케이스는 컴포넌트에서 반환된 React element 배열에서 current들이 움직이지 않고 그 자리에 계속 위치해 있는 것입니다. 해당 케이스에서 current가 이동하지 않았으니 이동을 제외한 타겟의 추가, 삭제는 시간 복잡도에 별다른 영향을 주지 않습니다. current가 이동하지 않았다고 가정만 할 수 있다면 커서의 전진만으로 타겟의 key와 type을 이용하여 추가, 삭제를 손쉽게 판단할 수 있습니다.

하지만 current가 원래 위치에 없을 때는 이야기가 다릅니다. 이때는 element 배열을 모두 탐색해보지 않는 한 current의 이동, 삭제를 판단할 수 없습니다. 해당 케이스에서는 판별을 위해 완전 탐색을 사용할 수도 있지만 그렇게 하지는 않고 공간 복잡도를 올리고 시간 복잡도를 내리는 선택을 합니다. current 연결 리스트를 맵으로 변환하여 key 또는 index로 current를 찾아내는 방식으로 말이죠.

여기서 언급되는 key는 fiber를 식별하는데 사용되므로 매우 중요합니다.
예를 들어 누가 봐도 고양이 컴포넌트이지만 개발자가 사자라고 주장한다면 리액트는 군말 없이 고양이를 사자로 취급합니다. 문제는 key를 설정하지 않아 값이 null일 때 발생합니다. 이 부분은 후반에 코드와 함께 알아봅니다.

비교 알고리즘의 대략적인 내용은 위와 같고 이제부터 좀 더 세부적인 내용을 확인해 보겠습니다.

1. current가 이동하지 않은 최적의 케이스로 탐색

먼저 주석과 함께 다음의 코드를 한번 쭉 훑어보세요. current가 이동하지 않았다고 가정을 하고 진행되는 로직이며 블록별 의미를 본인 나름대로 해석해 보시길 바랍니다.

reconciler > ReactChildFiber.js

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<*>,
  expirationTime: ExpirationTime
): Fiber | null {
  let resultingFirstChild: Fiber | null = null
  let previousNewFiber: Fiber | null = null

  let oldFiber = currentFirstChild // current 커서
  let lastPlacedIndex = 0
  let newIdx = 0 // new child 커서
  let nextOldFiber = null
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {      nextOldFiber = oldFiber      oldFiber = null    } else {      nextOldFiber = oldFiber.sibling    }    // key가 다르다면 null을 같다면 type을 비교하여 생성 혹은 current를 재사용하여 fiber를 반환
    const newFiber = updateSlot(      returnFiber,      oldFiber,      newChildren[newIdx],      expirationTime    )    if (newFiber === null) {      if (oldFiber === null) {        oldFiber = nextOldFiber      }      break    }    if (shouldTrackSideEffects) {      if (oldFiber && newFiber.alternate === null) {        deleteChild(returnFiber, oldFiber)      }    }    // fiber에 위치 index를 새기며 이동, 추가의 경우 placement effect tag도 달아줌.
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)    if (previousNewFiber === null) {      resultingFirstChild = newFiber    } else {      previousNewFiber.sibling = newFiber    }    previousNewFiber = newFiber
    oldFiber = nextOldFiber
  }
  /*...*/
}
  • 16 ~ 21: 위 코드에서는 안 나와 있지만, element가 null일 경우 fiber로 만들지 않고 생략합니다. 그래서 아래 코드의 경우 oldFiber의 index가 newIdx보다 클 수16 있습니다.
[null, null, a] => a{ index:2 }, [b] => b{ index:0 }

해당 위치가 null이었다는 걸 VDOM에서는 확인할 수 없으므로 해당 지식을 알고 있는 여기서 추가로 판별하고 처리18하고 있는 것입니다.

  • 23 ~ 39: current의 이동을 판별하고 그에 대한 처리를 합니다.

    updateSlot()은 current를 재사용할 수 있으면 재사용을 하고 type이 다르면 fiber을 새로 만들어 줍니다.
    fiber 말고도 null을 반환할 수도 있는데, 이는 key가 다르거나 newChild26가 null일 때 그렇습니다.
    만약 null이 반환된다면29 이번 섹션에서 다루는 케이스에 해당하지 않으므로 더이상 로직을 진행하지 않고 중단33 합니다.

    null일 경우 최적의 케이스에 대한 로직을 중단하는데 아래의 경우에는 어떨까요?

    [null, <div key="1"/>] => [null, <div key="1"/>]

    current는 이동하지 않았습니다. 하지만 첫 번째 element가 null이기 때문에 위 로직은 중단될 것입니다.
    null은 아무런 기준이 되는 정보를 가지고 있지 않으므로 이는 어쩔 수 없는 부분입니다.

    다음의 경우에 대해서

    [<div key="1"/>, <div key="2"/>] => [null, <div key="2"/>]

    개발자는 1번을 지우기 위해 null를 반환했습니다. 하지만 리액트 입장에서는 첫 번째 element가 null이라고 1번이 삭제되었다는 판단을 내릴 수 있을까요? 이것은 배열을 모두 탐색해보기 전까지는 알 수 없습니다. key가 변경되었든 newChild가 null이든 updateSlot()이 null를 반환하면 로직을 중단시킬 수밖에 없는 것이 이 이유 때문입니다.

  • 35 ~ 39: fiber가 새로 생성되었다면36 current는 삭제된 것이므로 제거해준다37.
  • 41 ~ 46: 마무리 단계입니다. placeChild()41는 fiber에 현재 위치를 index에 새기고 필요에 따라 Placement tag를 달아줍니다. 그리고 이전 형제와 엮어45줍니다.

fiber 재사용과 생성을 담당하는 updateSlot() 를 먼저 확인하고 placeChild()로 넘어가도록하겠습니다.

2. updateSlot()

reconciler > ReactChildFiber.js

function updateSlot(
  returnFiber: Fiber,
  oldFiber: Fiber | null,
  newChild: any,
  expirationTime: ExpirationTime
): Fiber | null {
  const key = oldFiber !== null ? oldFiber.key : null

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    if (key !== null) {
      return null
    }
    return updateTextNode(returnFiber, oldFiber, '' + newChild, expirationTime)
  }

  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        if (newChild.key === key) {          if (newChild.type === REACT_FRAGMENT_TYPE) {
            return updateFragment(
              returnFiber,
              oldFiber,
              newChild.props.children,
              expirationTime,
              key
            )
          }
          return updateElement(returnFiber, oldFiber, newChild, expirationTime)
        } else {
          return null
        }
      }
      /*...*/
    }

    if (isArray(newChild)) {
      if (key !== null) {
        return null
      }

      return updateFragment(
        returnFiber,
        oldFiber,
        newChild,
        expirationTime,
        null
      )
    }
  }

  return null
}
  • element의 종류에 맞춰서 적절히 라우팅 해주고 있습니다. 여기서 사용되는 update***()는 SQL의 UPSERT와 같이 ‘current가 재사용 할 수 있으면 하고 없으면 새롭게 만들어 반환한다.’ 의 의미를 담고 있는 함수입니다.
  • null을 반환하는 경우는 key가 다르거나32 newChild가 fiber로 확장될 수 없을 때 53입니다.
  • React element에는 key를 설정할 수 있지만 Text, 배열에는 key를 설정할 수 없습니다. key를 설정할 수 없는 newChild10, 38이지만 current의 key가 존재11, 39한다면 이는 current가 이동, 삭제되었다고 볼 수 있으므로 null을 반환합니다.

update***()는 로직의 구조가 비슷하므로 일반적인 updateElement() 하나만 확인하고 넘어가겠습니다.

reconciler > ReactChildFiber.js

function updateElement(
  returnFiber: Fiber,
  current: Fiber | null,
  element: ReactElement,
  expirationTime: ExpirationTime
): Fiber {
  if (current !== null && current.elementType === element.type) {    const existing = useFiber(current, element.props, expirationTime)
    existing.return = returnFiber
    return existing
  } else {
    const created = createFiberFromElement(
      element,
      returnFiber.mode,
      expirationTime
    )
    created.return = returnFiber
    return created
  }
}
  • current를 재사용하기 위해서는 key와 type이 같아8야 하며 이때 props만은 재사용하지 않는9다고 일전에 설명드렸습니다.

복수의 자식을 재조정하는 지금까지의 내용으로 배열에서 key를 설정하지 않으면 발생하는 문제점에 대해 유추해 볼 수 있습니다.

❗️ key의 중요성

문제점을 설명하기에 앞서 이것과 관련된 우리가 알고 있는 정보들은 다음과 같습니다.

  • key와 type이 같으면 props만 변경된다.
  • key를 설정하지 않으면 key는 null이다.

문제는 배열에서 key를 설정하지 않은(또는 배열의 index를 사용), 같은 type의 fiber가 이동되었을 때 발생합니다.
다음의 Counter 예제를 통해 어떠한 문제가 발생하는지 눈으로 확인해봅시다.

Shift 버튼을 통해 Counter 컴포넌트를 이동시키길 원합니다. 하지만 바람과 달리 이름만 이동 되고 숫자는 그대로입니다. 이유는 key와 type이 같으니 props만 변경되어 fiber의 hook 객체에 담겨있는 숫자는 이동하지 않아서 입니다. 이처럼 복수의 같은 type의 컴포넌트를 사용할 때는 필수적으로 유니크한 key를 설정해주어야 합니다.

위 내용을 바탕으로 공식홈페이지의 리스트와 key를 다시 한번 읽어 보세요. 아마 내용을 받아들이는 깊이가 이전과는 다름을 느끼실겁니다. 암기가 아닌 이해가 중요합니다.

3. placeChild()

updateSlot()을 통해 fiber를 반환받으면 fiber의 index와 삽입(이동, 추가) 여부를 나타내는 Placement tag를 달아주어야 합니다. 이동 + 추가는 한 묶음으로 HTML 내장 함수인 appendChild()를 이용하여 한번에 처리할 수 있습니다.

우리는 실질적으로 이동시킬 element 선택에도 조금의 생각이 필요합니다.
다음의 경우에 어떤 element에 Placement tag를 달아주어야 할까요?

[<div key="1"/>, <div key="2"/>, <div key="3"/>] => [<div key="2"/>, <div key="3"/>, <div key="1"/>]

1이 맨뒤로 이동하면서 2와 3의 위치가 한칸씩 앞으로 이동하게 되었습니다.
이때 placement tag를 모든 컴포넌트에 달아 주어 각각 DOM 조작을 해야 할까요?

일반적으로 배열에서의 이동이란 ‘현재 위치’를 기준으로 앞과 뒤로 움직이는걸 뜻하지만 리액트는 이동을 상대적인 것으로 ‘인접한 원소’를 기준으로 그보다 앞 혹은 뒤로 움직이는걸 이동이라고 정의했습니다.

이렇게 정의한 덕분에 위 배열 예제에서 한가지에 대한 처리로 문제를 해결 할 수 있게 되었습니다.

  1. 1이 뒤로 이동했다고 판단할 경우 2, 3을 그대로 두고 1만 조작하던지
  2. 1은 가만히 있었는데 2, 3이 앞으로 이동했다고 판단하여 2, 3을 조작하던지

이동의 정의를 인접한 원소를 기준으로 뒤로 이동한 경우 1, 앞으로 이동한 경우를 나타낸 것이 2입니다. 그리고 전자로 로직을 작성한 함수가 placeChild()입니다.

reconciler > ReactChildFiber.js

function placeChild(
  newFiber: Fiber,
  lastPlacedIndex: number,
  newIndex: number
): number {
  newFiber.index = newIndex
  if (!shouldTrackSideEffects) {
    // 마운트의 경우 index 설정만 필요함.
    return lastPlacedIndex
  }
  const current = newFiber.alternate
  if (current !== null) {
    const oldIndex = current.index
    if (oldIndex < lastPlacedIndex) {
      // 이동
      newFiber.effectTag = Placement
      return lastPlacedIndex
    } else {
      // 이동하지 않음
      return oldIndex
    }
  } else {
    // 추가
    newFiber.effectTag = Placement
    return lastPlacedIndex
  }
}
  • lastPlacedIndex는 0입니다. 그리고 cucrnet가 없다면13 이는 새롭게 만들어진 fiber임으로 DOM에 삽입하기 위해 tag를 달아25줍니다.

이제 원소의 이동을 판단할 차례인데 이동의 기준은 현재 삽입할 위치의 이전 원소가 되겠습니다. 이전 원소의 index보다 current의 index가 작다는 것은 current가 오른쪽으로 이동했음을 뜻합니다.

placeChild

위 내용을 토대로 가장 최악의 시나리오는 무엇일까요? 바로 맨 뒤 원소가 맨 앞으로 이동했을 경우 입니다. 이때는 첫 번째 원소를 제외한 나머지 fiber들에 placement tag가 달릴 것 입니다.

이 부분을 확인하기 위해 Todo 예제를 다음과 같이 수정하겠습니다.

function Todo() {
  const [todos, push] = useState([])
  return (
    <>
      <button onClick={() => push([todos.pop(), ...todos])}>pull</button>      <Input submit={todo => push(todos => [...todos, todo])} />
      <OrderList list={todos} />
    </>
  )
}
function OrderList({ list }) {
  return (
    <ol>
      {list.map(item => (
        <li key={item}>{item}</li>      ))}
    </ol>
  )
}

// placement tag를 처리하는 Commit phase 함수.
function commitPlacement(finishedWork) {
  log(`key: ${finishedWork.key} tag: ${finishedWork.effectTag}`)
  /*...*/
}

맨 뒤에 있는 todo item을 앞으로 당겨오는 pull 버튼을 추가했습니다. item의 key는 입력 값 그대로 사용합니다.

placement when pull tail

tag: 2는 placement tag를 나타냅니다.

4. 삭제할 current만 있거나 추가할 newChild만 있거나

최적의 케이스에서 current 리스트와 newChildren의 길이가 같다면 current 삭제와 element 추가를 모두 쉽게 판별할 수 있습니다. 하지만 길이가 다를 경우도 많겠죠. current 리스트가 더 길거나 그 반대로 newChildren이 긴 경우처럼요.

다행히도 이때의 삭제, 추가 판별은 오히려 쉽습니다.

reconciler > ReactChildFiber.js

function reconcileChildrenArray(...) {
  /*...*/
  // for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) { ... }

  // current 리스트가 더 긴 경우  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }
  /*...*/
}
  • current 리스트가 더 길다는 건 나머지들은 모두 삭제되었다는 뜻으로 삭제만 하면 끝입니다.

reconciler > ReactChildFiber.js

function reconcileChildrenArray(...) {
  /*...*/
  // if (newIdx === newChildren.length) {...}

  // newChildren 배열이 더 긴 경우  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(
        returnFiber,
        newChildren[newIdx],
        expirationTime,
      );
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }
  /*...*/
}
  • 반대로 newChildren이 길 경우는 추가되었다는 뜻이겠죠. 코드의 의미는 명확합니다.

createChild()는 이제까지 많이 봐왔던 형식으로 React element type에 맞는 fiber로 확장하는 코드만 존재하므로 생략합니다.

이 이후에는 current가 이동했다고 판단하고 코드를 작성하였습니다. current가 이동하게 되면 더 이상 커서의 전진만으로 판별할 수 없으므로 current 리스트를 맵으로 변환하여 진행합니다.

5. current가 이동한 경우

로직의 흐름은

  1. 먼저 current 리스트를 맵으로 변환하고
  2. 아직 커서가 지나가지 않은 newChildren 배열의 원소들을 마저 지나가며 맵에서 current를 찾아옵니다.
  3. 찾았다면 current가 이동한 것이고 못찾았다면 삭제된 것이다.

reconciler > ReactChildFiber.js

function reconcileChildrenArray(...) {
  /*...*/
  // if (oldFiber === null) {...}

  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      expirationTime,
    );

    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // 재활용된 fiber의 경우 맵에서 제거해준다
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key,
          );
        }
      }

      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  if (shouldTrackSideEffects) {
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}
  • 6: current 연결 리스트를 맵으로 변환합니다.
  • 9: updateSlot()과 동일합니다. 다만 current를 참조하는 방식이 map을 통한 참조라는 것만 다릅니다.
  • 39: newChildren을 모두 탐색한 후에도 currrent가 맵에 남아 있다면 모두 제거된 것이므로 삭제해주면 됩니다.
  • 42: 부모는 child 속성으로 첫 번째 자식만 참조하므로 resultingFirstChild만 반환합니다.

mapRemainingChildren()에서는 맵의 key를 설정하는 부분 정도만 주의깊게 보시면 될 것 같습니다.

reconciler > ReactChildFiber.js

function mapRemainingChildren(returnFiber, currentFirstChild) {
  const existingChildren = new Map()
  let existingChild = currentFirstChild

  while (existingChild !== null) {
    if (existingChild.key !== null) {
      existingChildren.set(existingChild.key, existingChild)    } else {
      existingChildren.set(existingChild.index, existingChild)    }
    existingChild = existingChild.sibling
  }

  return existingChildren
}

이전에 Todo 예제에서 Input컴포넌트의 상태가 업데이트 된 후의 VDOM 형태를 표현한 이미지를 기억하시나요? Input 컴포넌트는 상태가 업데이트 되었으니 5.update***에서 컴포넌트가 호출될 것입니다. 그리고 반환하는 자식 React element는 6.reconcileChildren에서 재조정 작업을 거친 후 fiber로 확장되어 VDOM에 반영됩니다.
이 과정을 모두 거친 후의 VDOM 형태는 다음과 같습니다.

reconcile children

다음 포스트에서는 지금까지 진행했던 Work의 작업물을 마무리하는 방법을 알아 볼 것입니다.


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