본문 바로가기

FrontEnd

리액트를 직접 만들며 알아보는 렌더/커밋/조정 알고리즘

반응형

원문 보기 :  https://pomb.us/build-your-own-react/

 

Rodrigo Pombo

An overengineer building tools for better code reading comprehension

pomb.us

0단계 : 리뷰

리액트 코드를 바닐라 js로 바꿔봅시다.

리액트

const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)

바닐라 JS

const element = {
  type: "h1",
  props: {
    title: "foo",
    children: "Hello",
  },
}

const container = document.getElementById("root")

const node = document.createElement(element.type)
node["title"] = element.props.title

const text = document.createTextNode("")
text["nodeValue"] = element.props.children

node.appendChild(text)
container.appendChild(node)


1단계 : createElement 함수

리액트를 나만의 리액트로 바꿔봅니다.

JSX를 JS로 바꿉니다.

React는 children이 없을 때 기본 값을 래핑하거나 빈 배열을 만들지 않지만
간단한 코드를 위해 이렇게 합니다.

텍스트 노드는 "TEXT_ELEMENT"로 따로 처리합니다.

function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      children: children.map(child =>
        typeof child === "object"
          ? child
          : createTextElement(child)
      ),
    },
  }
}

function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

JSX는 컴파일러 지정이 필요합니다.

아래와 같이 하면 바벨은 리액트 대신 우리가 정의한 함수를 이용해 JSX를 변환합니다.

const Didact = {
  createElement,
}

/** @jsx Didact.createElement */
const element = (
  <div id="foo">
    <a>bar</a>
    <b />
  </div>
)


2단계 : render 함수

지금은 DOM에 항목을 추가하는 것에만 관심이 있습니다. 업데이트 및 삭제는 나중에 처리하겠습니다.
텍스트 노드는 "TEXT_ELEMENT"로 따로 처리합니다.
마지막으로 노드에 props 요소를 할당합니다
function render(element, container) {
  const dom =
    element.type == "TEXT_ELEMENT"
      ? document.createTextNode("")
      : document.createElement(element.type);
  const isProperty = key => key !== "children";
  Object.keys(element.props)
    .filter(isProperty)
    .forEach(name => {
      dom[name] = element.props[name];
    });
  element.props.children.forEach(child => render(child, dom));
  container.appendChild(dom);
}

const Didact = {
  createElement,
  render
};
// .....
const container = document.getElementById("root");
Didact.render(element, container);

여기까지 실행해보기

3단계 : concurrent 모드 (동시성 UI 패턴)

렌더링을 시작하면 완전한 요소 트리를 렌더링할 때까지 멈추지 않을 것입니다.
요소 트리가 크면 메인 스레드를 너무 오래 차단할 수 있습니다.
그리고 브라우저가 사용자 입력을 처리하거나 애니메이션을 부드럽게 유지하는 것과 같이
우선 순위가 높은 작업을 수행해야 하는 경우
렌더링이 완료될 때까지 기다려야 합니다.
(주 : 커밋은 무조건 한번에 처리함)
  • 각 children 파이버 자체를 스택 프레임처럼 처리하여 한번에 단 하나의 프레임만 처리하며, 프레임은 쌓이지 않음.
  • 링크드 리스트를 이용해 중간에 작업을 yield 할 수 있음.
따라서 작업을 작은 단위로 나누고 각 작업 단위를 마친 후 수행해야 할 다른 작업이 있으면 브라우저가 렌더링을 중단하도록 합니다.
루프를 만들기 위해 requestIdleCallback을 사용합니다.
requestIdleCallback을 setTimeout으로 생각할 수 있습니다.
다른 점은 실행 시점을 알려주는 대신, 브라우저는 메인 스레드가 유휴 상태일 때 콜백을 실행합니다.
React는 더 이상 requestIdleCallback을 사용하지 않습니다.
이제 스케줄러 패키지를 사용합니다. 그러나 개념적으로 동일합니다.

requestIdleCallback은 또한 데드라인 파라미터 제공합니다.
브라우저가 스레드를 다시 제어해야 할 때까지 남은 시간을 확인하는 데 사용할 수 있습니다.
루프 사용을 시작하려면 첫 번째 작업 단위를 설정한 다음 작업을 수행할 뿐만 아니라
다음 작업 단위를 반환하는 performUnitOfWork 함수를 작성해야 합니다.
let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
  	// Todo : performUnitOfWork
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)
 

4단계 : Fiber (리액트 내부 UI 표현 자료구조)

파이버는 작업 단위를 의미하는 자료구조 입니다.

작업 단위를 구성하려면 데이터 구조인 파이버 트리가 필요합니다.

  • 엘리먼트에 대해 하나의 파이버가 존재합니다.
  • 파이버는 작업 단위입니다.

 

파이버===엘리먼트===작업단위

다음과 같은 엘리먼트 트리를 렌더링한다고 가정합니다.
Didact.render(
  <div>
    <h1>
      <p />
      <a />
    </h1>
    <h2 />
  </div>,
  container
)
 

render 함수에서 루트 파이버를 만들고 nextUnitOfWork로 설정합니다.
나머지 작업은 performUnitOfWork 함수에서 발생하며
각 파이버에 대해 세 가지 작업을 수행합니다.
  1. DOM에 엘리먼트 추가
  2. children을 위한 파이버 만들기
  3. 다음 작업 단위 선택 (select the next unit of work- 첫번째 자식 > 형제 > 부모의 형제(삼촌))

파이버의 목표중 하나는 다음 작업 단위를 쉽게 찾는 것입니다.

따라서 각 파이버는 첫번째 자식, 다음 형제, 부모에 대한 링크를 갖고 있습니다.

한 파이버에 대한 작업을 완료하면 첫번째 자식 > 형제 > 부모의 형제(삼촌) 우선순위로 이동합니다.
첫번째 자식이 항상 우선하므로 트리의 가장 아래가 작업이 끝나면 부모의 형제(삼촌)가 다음 작업의 대상이 됩니다.
작업할 삼촌이 없으면 삼촌을 찾을 때까지 위로 올라갑니다.
다시 루트에 도착하면 모든 작업이 끝납니다.
 
이를 코드로 옮겨봅시다.
function performUnitOfWork(fiber) {
  // DOM에 엘리먼트 추가 : 마운트
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }
  // 부모에 연결
  if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }
  // 자식을 위한 파이버 만들기
  const elements = fiber.props.children
  let index = 0
  // 형제끼리 연결하기 위해
  let prevSibling = null
  // 자식 파이버로 만들기
  while (index < elements.length) {
    const element = elements[index]

    const newFiber = {
      type: element.type,
      props: element.props,
      parent: fiber,
      dom: null,
    }

    if (index === 0) {
      fiber.child = newFiber
    } else {
      // 형제끼리 >>>로 연결. 첫번째 자식만 연결 갖고 있음
      prevSibling.sibling = newFiber
    }

    prevSibling = newFiber
    index++
  }
   // 다음 작업 단위 선택 (select the next unit of work- 자식 > 형제 > 부모의 형제(삼촌))
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber

  while (nextFiber) {
    if (nextFiber.sibling) { // 자식
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent // 부모로 이동
  }
}


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

let nextUnitOfWork = null

5단계 : 렌더와 커밋 페이즈

이전 코드는 엘리먼트 작업 시마다 DOM에 새 노드를 추가하고 있습니다 

이 경우 브라우저가 작업을 중단하면 불완전한 UI를 보게 됩니다.

커밋은 한번에 이루어저야 하는 이유입니다.

 

따라서 돔 조작 코드를 제거하고

 // 이전의 이부분 제거
 if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }

파이버의 루트를 추적합니다.

이를 wipRoot라 부릅시다.

 

그리고 모든 작업을 마치면(다음 unit of work-작업 단위가 없으면)

전체 파이버 트리를 DOM에 한꺼번에 커밋합니다.

AS-IS

let nextUnitOfWork = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
  	// Todo : performUnitOfWork
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

TO-BE

function commitRoot() {
  // TODO add nodes to dom
}

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

let nextUnitOfWork = null
let wipRoot = null

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  // 다음 작업 단위가 없고,아직 커밋 안헀으면
  if (!nextUnitOfWork && wipRoot) {
    commitRoot()
  }

  requestIdleCallback(workLoop)
}

 

6단계 : 조정(Reconciliation)

지금까지는 DOM에 항목만 추가했지만 노드를 업데이트하거나 삭제하는 것은 어떻게 할까요?
렌더링 함수에서 수신한 엘리먼트를 DOM에 커밋한 마지막 파이버 트리와 비교해야 합니다.

 

따라서 커밋을 마친 후 "DOM에 커밋한 마지막 파이버 트리"에 대한 참조를 저장해야 합니다.
우리는 그것을 currentRoot라고 부릅니다.
또한 모든 파이버에 alternate 속성을 추가합니다.
이 속성은 이전 커밋 단계에서 DOM에 커밋한 파이버인 이전(old) 파이버에 대한 링크입니다.
 
 
새로운 파이버를 생성하는 performUnitOfWork에서 코드를 추출하여...
function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }

  const elements = fiber.props.children
  // new! 자식들을 위한 파이버를 만듭니다.
  reconcileChildren(fiber, elements)

  // 다음 작업 단위 선택 : 첫번째 자식
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  // 다음 작업 단위 선택 : 형제 > 삼촌
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}
reconcileChildren함수를 만듭니다.
여기서 이전 파이버와 새로운 파이버를 조정합니다.
(루트부터 alternate로 이전 파이버 참조 자식한테 전달)
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

    const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type
   // 이전 파이버와 새 엘리먼트의 타입이 같으면 DOM 노드를 유지하고 새 props로 업데이트하면 됩니다.
    if (sameType) {
      // TODO update the node
    }
    // 타입이 다르고 엘리먼트가 있으면 새 돔 노드를 만듭니다.
    if (element && !sameType) {
      // TODO add this node
    }
    // 타입이 다르고 이전 노드가 있으면 노드를 삭제해야 합니다.
    if (oldFiber && !sameType) {
      // 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++
  }
}
​
이전 파이버(wipFiber.alternate)의 자식과 조정하려는 엘리먼트 배열을 동시에 반복합니다.
배열과 링크드리스트을 동시에 반복하는 데 필요한 모든 상용구를 무시하면
이 while 내부에서 가장 중요한 oldFiber와 엘리먼트만 남게 됩니다.
 
엘리먼트는 DOM에 렌더링하려는 것이고 oldFiber는 마지막으로 렌더링한 것입니다.
DOM에 적용해야 하는 변경 사항이 있는지 확인하기 위해 비교해야 합니다.
그것들을 비교하기 위해 우리는 타입을 사용합니다:
  • 이전 파이버와 새 엘리먼트의 타입이 같으면 DOM 노드를 유지하고 새 props로 업데이트하면 됩니다.
  • 타입이 다르고 새 엘리먼트가 있으면 새 DOM 노드를 만들어야 함을 의미합니다.
  • 타입이 다르고 이전 파이버가 있는 경우 이전 노드를 제거해야 합니다.
여기서 React는 또한 더 나은 조정을 위해 key를 사용합니다.
예를 들어, 자식이 엘리먼트 배열에서 위치를 변경할 때 감지합니다.
 
 

이전 Fiber와 엘리먼트 타입이 같으면 이전 Fiber의 DOM 노드와 엘리먼트의 props를 유지하는 새 Fiber를 만듭니다.

또한 파이버에 새로운 속성인 effectTag를 추가합니다. 나중에 커밋 단계에서 이 속성을 사용할 것입니다.

    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",
      }
    }​

타입이 다르고 새 엘리먼트가 있으면 새 DOM 노드를 만듭니다.

effectTage는 PLACEMENT 입니다.

    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: "PLACEMENT",
      }
    }

노드를 삭제하는 경우는 이전 파이버에 이펙트 태그를 추가합니다.

새로운 파이버가 없기 때문입니다.

   if (oldFiber && !sameType) {
      oldFiber.effectTag = "DELETION"
      deletions.push(oldFiber)
    }

삭제는 DOM에 파이버 트리를 커밋할 때, wipRoot에서 수행합니다. (commitRoot 함수)

해당 wipRoot에는 삭제에 대한 fiber 정보가 없으므로 배열을 이용해 해당 정보를 수집합니다.

fiber에는 부모의 참조가 있고 부모의 돔에 접근할 수 있습니다.

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

루트에 커밋할 때, 삭제를 먼저 수행합니다.

function commitRoot() {
  deletions.forEach(commitWork)
  commitWork(wipRoot.child)
  currentRoot = wipRoot
  wipRoot = null
}

구현은 그냥 아래에서 보시는게 편할것 같습니다.

업데이트의 경우 이전 prop의 업데이트, 추가, 삭제를 해야해서 좀 복잡합니다만, 나머지는 쉽습니다.

 

스텝 7 : 함수 컴포넌트

이전에 엘리먼트 하나 당 파이버 하나라고 했는데요,

함수형 컴포넌트도 엘리먼트지만 약간 다릅니다.

function App(props) {
  return Didact.createElement(
    "h1",
    null,
    "Hi ",
    props.name
  )
}
// 타입이 함수?
const element = Didact.createElement(App, {
  name: "foo",
})
  • 돔 노드를 갖고 있지 않습니다.
  • children이 prop이 아니라 함수 실행을 통해 얻어집니다.
파이버 타입이 함수인지 확인하고 이에 따라 다른 업데이트 함수로 이동합니다.
function performUnitOfWork(fiber) {
  const isFunctionComponent =
    fiber.type instanceof Function
  if (isFunctionComponent) {
    updateFunctionComponent(fiber)
  } else {
    updateHostComponent(fiber)
  }
  if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}
​

updateHostComponent는 이전 그 함수입니다.

함수형 컴포넌트는 children을 얻기 위해 함수를 호출합니다.

function updateFunctionComponent(fiber) {
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

그리고 commitWirk 함수를 변경해줘야 하는데요

파이버에 돔 노드가 없기 떄문입니다.

DOM 노드의 부모를 찾으려면 DOM 노드가 있는 섬유를 찾을 때까지 파이버 트리 위로 올라가야 합니다.

  let domParentFiber = fiber.parent
  while (!domParentFiber.dom) {
    domParentFiber = domParentFiber.parent
  }
  const domParent = domParentFiber.dom

 노드를 제거할 때 DOM 노드가 있는 자식을 찾을 때까지 계속 진행해야 합니다.
function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}
​

스텝 8 : 훅 

마지막 단계입니다.

함수 컴포넌트에 상태를 추가합니다.

// 현재 작업중인 파이버 전역상태
let wipFiber = null
// 작업중 파이버의 훅 인덱스 전역상태
let hookIndex = null

// 함수 컴포넌트 업대이트 시 전역 상태 초기화
function updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}


function useState(initial) {
  const oldHook =
    wipFiber.alternate &&
    wipFiber.alternate.hooks &&
    wipFiber.alternate.hooks[hookIndex]
   // 이전 상태가 있으면 복사 else 초기화
  const hook = {
    state: oldHook ? oldHook.state : initial,
    queue: []
  }
  
  // 액션은 다음 렌더링 시에 호출하여 반영함.....
  const actions = oldHook ? oldHook.queue : []
  actions.forEach(action => {
    hook.state = action(hook.state)
  })

   const setState = action => {
   // setState 호출 시 액션을 푸시함
    hook.queue.push(action)
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,
    }
    // 다음 업데이트 시 액션 실행하도록!
    nextUnitOfWork = wipRoot
    deletions = []
  }

  wipFiber.hooks.push(hook)
  // 다음 훅을 위해 인덱스 증가
  hookIndex++
  return [hook.state, setState]
}

이게 끝입니다.


실제 리액트와의 차이점 요약

  • 리액트는 렌더링을 건너뛰기 위한 휴리스틱과 힌트를 사용합니다.
  • 리액트는 커밋 단계에서 링크드 리스트를 이용해 이펙트가 있는 파이버들만 방문합니다.
  • 리액트는 이전 트리의 파이버를 재활용합니다.
  • 리액트는 expiration timestamp를 이용해 여러 업데이트틀 수신해도 가장 높은 우선순위의 업데이트를 결정할 수 있습니다.
    • 즉, 항상 현재 진행 중인 작업 트리를 전부 버리지 않습니다.
  • 그 외에도 많지만 이정도면 충분합니다.

참고 : 버츄얼 돔

공식문서에서 버츄얼 돔이라는 용어는 단 두 페이지에서만 사용됨.

https://reactjs.org/docs/faq-internals.html

 

Virtual DOM and Internals – React

A JavaScript library for building user interfaces

reactjs.org

https://reactjs.org/docs/implementation-notes.html

 

Implementation Notes – React

A JavaScript library for building user interfaces

reactjs.org

VDOM

리뷰 : 전역 변수

// 다음 처리할 작업 단위
let nextUnitOfWork = null;
// 현재 돔에 반영된 파이버 트리
let currentRoot = null; 
// work in progress - 반영 위해 작업 중인 트리의 루트
let wipRoot = null;
// 삭제 대상 파이버 리스트 
let deletions = null;


// 훅 처리를 위한 작업 중 파이버 (함수형 컴포넌트)
let wipFiber = null;
// 훅 인덱스
let hookIndex = null;

 

좀 더 알아보기

https://indepth.dev/posts/1007/the-how-and-why-on-reacts-usage-of-linked-list-in-fiber-to-walk-the-components-tree

 

The how and why on React’s usage of linked list in Fiber to walk the component’s tree - React inDepth

This article explores the main the work loop in React’s new reconciler implementation called Fiber. It compares and explains the differences between browser's call stack and the implementation of the stack in React's Fiber architecture.

indepth.dev

https://indepth.dev/posts/1501/exploring-how-virtual-dom-is-implemented-in-react

https://indepth.dev/posts/1009/in-depth-explanation-of-state-and-props-update-in-react

https://indepth.dev/posts/1008/inside-fiber-in-depth-overview-of-the-new-reconciliation-algorithm-in-react

 

반응형