[React] Reconciliation - 재조정

Juno·2021년 9월 7일
2
post-thumbnail

👋 들어가기

  안녕하세요:) 이번 포스팅은 지난 build your own react 3~5장에 이어서 공부하는 AUSG 스터디를 위해 작성된 글입니다. 부족한 점 있으시면 언제든 피드백 주시면 감사하겠습니다 🙏

  해당 챕터에서는 React에서 Reconciliation 과정(DOM에 노드를 삭제하고 수정하는 과정)이 어떻게 이루어지는지 다루고 있어서 사전지식으로 React 문서의 Reconciliation에 대해 먼저 소개해 드린 이후에 코드를 살펴보도록 하겠습니다 :)

Reconciliation(재조정)

 React의 특징은, 가상돔을 그려서 실제 렌더링 하고 있는 DOM과 비교하여 차이점만 갱신시켜주는 방식으로 성능상의 최적화를 이뤄내고 있다는 점 입니다.

React 엘리먼트 트리를 만드는 render()함수는 새로운 React 엘리먼트 트리를 반환하는데, 일반적인 알고리즘은 n개의 엘리먼트가 있는 트리에 대해 O(n^3)의 복잡도를 가지기 때문에 만들어진 트리에 맞게 가장 효과적으로 UI를 갱신하는 휴리스틱 알고리즘을 구현했습니다.

단, 다음과 같은 두 가지 가정을 기반으로 두고 있습니다.
1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
2. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.

1️⃣ Dffing Algorithm

 두 개의 트리를 비교할 때, React는 두 엘리먼트의 루트(root) 엘리먼트부터 비교합니다. 이후의 동작은 루트 엘리먼트의 타입에 따라 달라집니다.

엘리먼트의 타입이 다른 경우

  두 루트 엘리먼트의 타입이 다르면 React는 이전의 트리를 버리고 완전히 새로운 트리를 구축합니다.

// 바뀌기 이전의 트리
<div>
  <Counter />
</div>

// 바뀐 이후의 트리 
<span>
  <Counter />
</span>

바뀐 이후의 트리의 루트 엘리먼트는 div 에서 span태그로 바뀌었습니다. 이 경우에는 새로운 트리를 구축하게 되는 것이죠.

 트리를 버릴 때 이전 DOM 노드들은 모두 삭제됩니다. 새로운 트리가 만들어 질 때 새로운 DOM 노드들이 DOM에 삽입됩니다. (이전의 state들은 모두 사라집니다.)

DOM 엘리먼트의 타입이 같은 경우

  다음과 같이 React DOM 엘리먼트를 비교할 때, React는 두 엘리먼트의 속성을 확인하여, 동일한 내역은 유지하고 변경된 속성들만 갱신합니다.

// 바뀌기 이전의 엘리먼트
<div className="before" title="stuff" />

// 바뀐 이후의 엘리먼트
<div className="after" title="stuff" />

다음과 같이 두 앨리먼트를 비교했을 떄 className만 변경되었으므로 동일한 속성은 유지하고 className만 변경해 줍니다.

2️⃣ 자식에 대한 재귀적인 처리(key prop)

  DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경합니다.

// 바뀌기 이전의 트리
<ul>
  <li>first</li>
  <li>second</li>
</ul>

// 바뀐 이후의 트리
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

만약 다음과 같은 트리 구조의 DOM이 바뀐다고 할 떄, firstsecond를 비교하여 일치하는 것을 확인하고 마지막 요소에 third를 추가한다고 해봅시다. 간단하게 구현할 수 있겠지만, 만약 첫번째 요소에 엘리먼트를 추가 할 경우 성능이 좋지 않을 것 입니다.

keys

따라서 React에서는 key 속성을 지원하여 다음과 같이 구현하고 있습니다.

// 바뀌기 이전의 트리
<ul>
  <li key="first">first</li>
  <li key="second">second</li>
</ul>

// 바뀐 이후의 트리
<ul>
  <li key="zero">zero</li>
  <li key="first">first</li>
  <li key="second">second</li>
</ul>

자식들이 key를 가지고 있다면, React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인합니다.

firstsecond라는 key를 가진 li요소는 이미 있으므로 변경하지 않고, zero라는 key를 가진 li 엘리먼트만 맨 위에 추가하는 것으로 해결할 수 있습니다.

컴포넌트 인스턴스는 key를 기반으로 갱신되고 재사용되므로 요소를 유일하게 식별가능한 요소로 두는 것이 바람직합니다. 인덱스를 사용했을 경우 항목의 순서가 바뀌었을 때 컴포넌트의 순서가 엉망이 될 수 있기 때문입니다. (key는 형제 내에서만 유일하면 됩니다.)

Step 6: 재조정(Reconciliation)

function commitRoot() {
  commitWork(wipRoot.child) 
  currentRoot = wipRoot // 마지막으로 DOM에 커밋된 fiber 트리를 currentRoot에 저장합니다.
  wipRoot = null
}

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  const domParent = fiber.parent.dom
  domParent.appendChild(fiber.dom)
  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

 앞서 언급드렸듯, 이번 Step 에서는 노드들을 갱신하고 삭제하는 기능을 추가합니다. render 함수로 얻은 엘리먼트들을 마지막으로 커밋한 fiber 트리와 비교해야 합니다. 마지막으로 커밋한 fiber 트리를 알아야 하므로, 커밋이 끝난 후엔 이를 저장할 필요가 있겠습니다. 이를 currnetRoot 라고 합시다.

function render(element, container) {
  wipRoot = {
    dom: container,
    props: {
      children: [element],
    },
    alternate: currentRoot,
  }
  nextUnitOfWork = wipRoot
}

 또한, 모든 fiber에 alternate 라는 속성을 추가할 것인데, 이 속성은 이전 커밋 단계에서의 DOM에 추가했던 fiber의 정보를 담고 있습니다.

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  const elements = fiber.props.children
  // 새로운 fiber를 생성하는 코드를 reconcileChildren 내부로 옮깁니다.
  reconcileChildren(fiber, elements)

  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

 그리고, performUnitOfWork() 함수 안에서 자식 elements에 대한 fiber를 생성하는 로직을 reconcileChildren() 함수 안으로 이동시킵니다.

 즉, 해당 함수에서는 직전에 commit된 fiber와 새로 렌더링 할 fiber를 비교하여 변경사항에 따라 fiber를 생성하는 역할을 담당하게 됩니다.

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let oldFiber =
    wipFiber.alternate && wipFiber.alternate.child
  let prevSibling = null

  while (
    index < elements.length ||
    oldFiber != null
  ) {
    const element = elements[index]
    let newFiber = null

    // TODO compare oldFiber to element
    const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type

    if (sameType) {
       newFiber = {
         type: oldFiber.type,
         props: element.props,
         dom: oldFiber.dom,
         parent: wipFiber,
         alternate: oldFiber,
         effectTag: "UPDATE",
      }
      // TODO update the node
    }
    if (element && !sameType) {
      newFiber = {
         type: element.type,
         props: element.props,
         dom: null,
         parent: wipFiber,
         alternate: null,
         effectTag: "PLACEMENT",
      }
      // TODO add this node
    }
    if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
      // TODO delete the oldFiber's node
    }

    if (oldFiber) {
      oldFiber = oldFiber.sibling
    }

    if (index === 0) {
      wipFiber.child = newFiber
    } else if (element) {
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
}

이를 DOM에 반영하기 위해서는 오래된 fiber와 새로 만들 fiber 사이에 어떠한 차이가 생겼는지에 따라 비교하기 위해서 타입을 사용합니다.

🔖 다음과 같은 규칙으로 추가, 수정, 삭제 작업이 이루어 집니다.
1. 'UPDATE' : oldFiber와 새로운 엘리먼트의 타입이 같다면, DOM 노드를 유지하고 새로운 props만 업데이트 합니다.
2. 'PLACEMENT': 타입이 다르면서 새로운 엘리먼트가 존재한다면, 새로운 DOM 노드를 생성합니다.
3. 'DELETION' : 타입이 다르면서 oldFiber가 존재한다면, 해당 DOM 노드를 삭제합니다.

이렇게 타입에 따라 fiber를 reconciliation 해주는 함수, reconcileChildren()를 만들었으므로, 이를 통해 DOM 노드를 조정해주면 된다.

function render(element, container) {
  // wipRoot
  deletions = [] // 삭제할 fiber를 담아놓을 배열
}

let deletions = null

삭제할 때를 위해서,wipRootoldFiber를 담고 있지 않으므로 이를 추적하기 위한 deletions가 필요합니다.

function commitRoot() {
  deletions.forEach(commitWork)
  // ...
}

 deletions 배열에 담아두고 있다면, 다음과 같이 commit phase에 다음과 같이 삭제할 fiber들을 참조할 수 있습니다!

  이제 위에서 비교하기 위해 지정했던 태그들 (effectTags)를 이용해서 commitWork() 함수를 변경하겠습니다.

function commitWork(fiber) {
  if (!fiber) {
    return
  }
  
  const domParent = fiber.parent.dom
  if (fiber.effectTag === "PLACEMENT") {
    domParent.appendChild(fiber.dom)
  }
  
  if (fiber.effectTag === "UPDATE") {
    updateDom(fiber.dom, fiber.alternate.props,
      ,fiber.props)
  }
  
  if (fiber.effectTag === "DELETION") {
   	domParent.removeChild(fiber.dom)
  }

  commitWork(fiber.child)
  commitWork(fiber.sibling)
}

 이때 'UPDATE' 의 경우 이미 존재하는 DOM 노드를 변견된 props를 이용하여 갱신하는데, 이는 updateDom 함수를 통해 로직을 구현하겠습니다.

// for update EventListener
const isEvent = key => key.startsWith("on")
const isProperty = key =>
  key !== "children" && !isEvent(key)

// for update props
const isProperty = key => key !== "children"
const isNew = (prev, next) => key =>
  prev[key] !== next[key]
const isGone = (prev, next) => key => !(key in next)

function updateDom(dom, prevProps, nextProps) {
  // Remove old or changed event listeners
  Object.keys(prevProps)
    .filter(isEvent)
    .filter(
      key =>
        !(key in nextProps) ||
        isNew(prevProps, nextProps)(key)
    )
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.removeEventListener(
        eventType,
        prevProps[name]
      )
    })
  
  // Remove old properties
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach(name => {
      dom[name] = ""
    })

  // Set new or changed properties
  Object.keys(nextProps)
    .filter(isProperty)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      dom[name] = nextProps[name]
    })
  
  // Add event listeners
  Object.keys(nextProps)
    .filter(isEvent)
    .filter(isNew(prevProps, nextProps))
    .forEach(name => {
      const eventType = name
        .toLowerCase()
        .substring(2)
      dom.addEventListener(
        eventType,
        nextProps[name]
      )
    })
}

 먼저, oldFiber의 props들을 newFiber의 props와 비교하여 업데이트 해줍니다.

 그 props 중 on 이라는 접두사를 가진 이벤트 핸들러는 다른 처리가 필요하여 다음과 같이 처리하여 마무리 하였습니다.

Reference

profile
사실은 내가 보려고 기록한 것 😆

0개의 댓글