이 포스트는 Rodrigo PomboBuild your own React을 번역한 것입니다. 의역이 많으며, 오역이 있을 경우 댓글에 제보 부탁드립니다. 원본 게시물은 codesurfer를 이용하여 코드의 변화를 보기 쉽게 되어 있으므로, 가능하면 원문을 보시는 것을 추천드립니다.

우리는 리액트를 처음부터 직접 만들어 볼 것입니다. 최적화나 필수적이지 않은 기능들은 제외하고, 실제 리액트 코드 구조를 기반으로 한 단계씩 따라가 봅시다.

이전에 올린 "build your own React" 포스트들과는 달리 이번 포스트에서는 리액트 16.8 버전을 기반으로 하고 있습니다. 이제 훅을 사용할 수 있으며, 클래스와 관련된 코드를 제거할 수 있습니다.

이전의 오래된 블로그 포스트와 코드의 히스토리는 Didact repo에서 확인할 수 있습니다. 또, 동일한 내용을 다루는 콘텐츠도 있지만, 이는 그와 독립적인 포스트입니다.

우리가 새롭게 만들 버전의 리액트에 들어갈 내용들을 하나씩 소개합니다:

  • Step I: createElement 함수
  • Step II: render 함수
  • Step III: 동시성 모드(Concurrent Mode)
  • Step IV: Fibers
  • Step V: 렌더와 커밋 단계 (Render and Commit Phases)
  • Step VI: 재조정(Reconciliation)
  • Step VII: 함수형 컴포넌트(Function Components)
  • Step VIII: 훅(Hooks)

Step Zero: Review

먼저 기본 개념을 복습해 보겠습니다. React, JSX, DOM 엘리먼트가 동작하는 방식을 이미 잘 알고 있다면 이 단계는 건너 뛰어도 됩니다.

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

이 3줄 짜리 코드로 된 리액트 앱을 사용할 것입니다. 첫 번째 줄은 리액트 엘리먼트를 정의합니다. 그 다음 DOM으로부터 노드를 얻습니다. 마지막으로, 컨테이너 안에 리액트 엘리먼트를 생성합니다.

이제 리액트 특유의 코드를 모두 제거하고 이를 순수한 바닐라 자바스크립트로 교체해 봅시다.

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

맨 첫 줄에, JSX로 정의된 엘리먼트가 있습니다. 이는 자바스크립트에서 유효한 문법이 아니므로 바닐라 JS로 교체하기 위해서는 유효한 JS 코드가 필요합니다.

JSX는 바벨과 같은 빌드 툴에 의해 JS 코드로 변환됩니다. 변환은 대체로 간단합니다. 태그 이름, props, children를 매개변수로 넘기는 createElement 함수를 호출하여 태그 내부의 코드를 바꾸면 됩니다.

const element = React.createElement(
  "h1",
  { title: "foo" },
  "Hello"
)

const container = document.getElementById("root")
ReactDOM.render(element, container)

React.createElement 는 인자값들로 객체를 생성합니다. 몇 가지 유효성 검사를 제외하고는 이게 전부입니다. 따라서 안전하게 함수 호출 부분을 그 결과물로 바꿀 수 있습니다.

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

const container = document.getElementById("root")
ReactDOM.render(element, container)

그리고 바로 이 element가 type과 props를 객체 속성 값으로 가지는 객체입니다. (사실 실제로는 더 많은 속성이 있지만, 여기서는 두 가지만 신경쓰도록 합니다)

type은 우리가 생성하려는 돔 노드의 타입을 지정하는 문자열입니다. tagName은 HTML 엘리먼트를 생성할 때 document.createElement 에 전달하는 값입니다. 이 부분은 7단계에서 보도록 하겠습니다.

props는 JSX 속성의 key와 value를 포함하고 있는 또 하나의 객체입니다. 이 역시 특별한 children 이라는 특별한 속성을 가집니다.

이 예제에서 children은 문자열입니다. 하지만 일반적으로 더 많은 엘리먼트의 배열의 형태입니다. 이것이 엘리먼트들이 트리 형태인 이유입니다.

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

const container = document.getElementById("root")
ReactDOM.render(element, container)

교체해야 할 리액트 코드의 다른 부분은 ReactDOM.render 라고 부르는 것입니다. render는 리액트가 돔을 변경하는 지점으로, 이제 우리가 직접 업데이트를 할 수 있게 해 봅시다.

ReactDOM.render(element, container)

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)

먼저 엘리먼트의 type을 이용해 노드를 생성합니다. 이 경우 타입은 h1입니다. 그리고 모든 엘리먼트 props들을 노드에 할당합니다. 지금은 title 뿐입니다.

  • 여기서 엘리먼트는 리액트 엘리먼트를, 노드는 DOM 엘리먼트를 의미합니다.
    ...const text = document.createTextNode("")
    text["nodeValue"] = element.props.children
    ​
    ...
    다음으로, 자식 노드들을 생성합니다. 현재 자식노드는 문자열 하나 뿐이므로, 텍스트 노드 하나를 생성합니다.

이때 innterText를 설정하는 대신 textNode를 사용하면 모든 엘리먼트들을 이후에 동일한 방식으로 다룰 수 있습니다. h1에 title을 할당한 것을 참고하여 nodeValue의 값을 설정합니다. 이는 문자열이 마치 props: {nodeValue: "hello"} 값을 가지는 것과 비슷합니다.

...
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
​
node.appendChild(text)
container.appendChild(node)

마지막으로 textNode를 h1에 추가하고, 이 h1을 컨테이너에 추가합니다.

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)

자, 이제 리액트를 사용하지 않고 그것과 동일한 앱을 완성했습니다.


Step I: createElement 함수

const element = (
  <div id="foo">
  <a>bar</a>
  <b />
  </div>
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

다시 다른 앱으로 시작해 봅시다. 여기서는 리액트 코드를 우리가 직접 만든 버전으로 교체해 볼 것입니다.

우리가 만든 createElement 를 입력하는 것 부터 시작해 봅시다.

JSX를 JS로 변환하면 createElement 를 호출하는것을 볼 수 있습니다.

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)
const container = document.getElementById("root")
ReactDOM.render(element, container)

이전 단계에서 보았던 type과 props를 가진 객체 엘리먼트입니다. 우리가 만들 함수가 하는 일은 객체를 생성하는 것 뿐입니다.

우리는 props에 스프레드 연산자(spread operator)를 사용하고, children에 나머지 파라미터 구문(rest parameter syntax)을 적용하면, children 이 항상 배열 형태가 됩니다.

예를 들어, createElement("div") 은 다음을 반환합니다

{
  "type": "div",
    "props": { "children": [] }
}

createElement("div", null, a) 의 결과는 다음과 같습니다.

{
  "type": "div",
    "props": { "children": [a] }
}

그리고 createElement("div", null, a, b) 의 결과는 다음과 같습니다.

{
  "type": "div",
    "props": { "children": [a, b] }
}

또한 children 배열은 string이나 number과 같은 기본 타입의 값들을 포함할 수 있습니다. 따라서 우리는 객체가 아닌 모든 것들을 감싸서 자체 엘리먼트 안에 넣고, 이를 TEXT_ELEMENT라는 특별한 타입으로 생성할 수 있습니다.

type,
  props: {
    ...props,
      children: children.map(child => typeof child === "object"
                             ? child: createTextElement(child)),
          },
  }
}function createTextElement(text) {
  return {
    type: "TEXT_ELEMENT",
    props: {
      nodeValue: text,
      children: [],
    },
  }
}

실제 리액트에서는 children이 아닐 경우엔 기본 타입의 값들을 래핑하거나 빈 배열을 생성하지 않습니다. 하지만 코드를 간결하게 만들고, 우리의 라이브러리의 목적은 성능이 개선된 코드보다는 간단한 코드를 만드는 데 있으므로 그냥 진행하도록 합니다.

const element = React.createElement(
  "div",
  { id: "foo" },
  React.createElement("a", null, "bar"),
  React.createElement("b")
)

아직까지는 계속 리액트의 createElement 를 사용하고 있는 상태입니다.

이제 이를 교체하기 위해, 우리의 라이브러리에 이름을 부여합니다. 리액트같이 들리지만 교육적인(Didact) 목적이 드러나는 이름이 필요합니다.

const Didact = {
  createElement,
}const element = Didact.createElement(
  "div",
  { id: "foo" },
  Didact.createElement("a", null, "bar"),
  Didact.createElement("b")
)

이제 이를 "디액트"라고 부르도록 합시다

하지만 여기서도 JSX는 계속 사용하고 싶습니다. 어떻게 바벨에게 리액트 대신 우리가 만든 디액트의 createElement 를 사용하도록 할 수 있을까요?

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

코멘트를 위와 같이 추가하면 바벨이 JSX를 트랜스파일 할 때 우리가 정의한 함수를 사용할 수 있게 됩니다.


Step II: render 함수

다음으로 ReactDOM.render 함수를 우리 버전으로 바꿔 봅시다.

function render(element, container) {
  // TODO create dom nodes
}const Didact = {
  createElement,
  render,
}

/** @jsx Didact.createElement */
const element = (
  <div id="foo">
  <a>bar</a>
  <b />
  </div>
)
const container = document.getElementById("root")
Didact.render(element, container)

지금까지는 DOM에 어떤 것들을 추가하는 것에만 집중했다면, 이제 갱신과 삭제를 다루어보겠습니다.

function render(element, container) {
  const dom = document.createElement(element.type)
  ​
  container.appendChild(dom)
}

엘리먼트 타입을 이용하여 돔 노드를 생성하는 것부터 시작하겠습니다. 그 다음 새롭게 만들어진 노드를 컨테이너에 추가합니다.

function render(element, container) {
  const dom = document.createElement(element.type)
  ​
  element.props.children.forEach(child =>
                                 render(child, dom)
                                )
  ​
  container.appendChild(dom)
}

이 과정을 각각의 자식들 모두에게 재귀적으로 수행합니다.

function render(element, container) {
  const dom =
        element.type == "TEXT_ELEMENT"
  ? document.createTextNode("")
  : document.createElement(element.type)
  ​
  element.props.children.forEach(child =>
                                 render(child, dom)
                                )
  ​
  container.appendChild(dom)
}

또한 텍스트 엘리먼트도 처리해야 합니다. 만약 타입이 TEXT_ELEMENT인 경우, 일반적인 노드 대신 텍스트 노드를 생성하도록 합니다.

function render(element, container) {
  const dom = ...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)
}

마지막으로 해야 할 것은 노드에 엘리먼트 속성들을 부여하는 것입니다.

끝입니다. 이제 JSX를 DOM으로 렌더링할 수 있는 라이브러리를 만들었습니다. codesandbox 에서 테스트 해 볼 수 있습니다.

Step III: 동시성 모드(Concurrent Mode)

다른 코드를 추가하기 전에, 약간 리팩토링이 필요합니다.

재귀 호출이 문제입니다. 우리가 렌더링을 시작하면, 모든 엘리먼트 트리를 렌더링하는 것을 마치기 전까지는 이를 멈출 수 없습니다. 만약 엘리먼트 트리가 크다면 메인 스레드의 동작이 너무 오랫동안 멈출 것입니다. 그리고 브라우저가 유저의 입력이나 애니메이션을 부드럽게 하는 것에 높은 우선순위를 두고 있다면, 이 작업들은 렌더링이 끝나기 전까지 대기해야 합니다.

따라서 작업을 더 작은 단위로 나눈 다음, 각각의 단위마다 브라우저가 어떤 작업이 필요한 경우 렌더링 도중에 끼어들 수 있도록 할것입니다.

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

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

반복문을 만들기 위해 requestIdleCallback 함수를 사용합니다 . requestIdleCallbacksetTimeout 같은 것으로 생각하면 됩니다. 하지만 언제 실행해야 할지를 알려주는 대신, 메인 스레드가 대기 상태일 때 브라우저가 콜백을 실행할 것입니다.

리액트는 requestIdleCallback 을 더이상 사용하지 않고 대신, scheduler package 를 사용합니다. 개념적으로는 동일합니다.

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

requestIdleCallback 에는 데드라인이 있는 패러미터가 있습니다. 이를 이용하여 다시 브라우저에서 제어를 가져갈 때 까지 얼마나 걸리는지를 체크할 수 있습니다.

2019년 11월 현재, 동시성 모드는 아직 리액트의 안정적인 버전에 포함되지 않았습니다. 안정 버전에서의 반복문은 다음과 같습니다:

while (nextUnitOfWork) {    
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork) 
}

function performUnitOfWork(nextUnitOfWork) {
  // TODO
}

반복문을 시작하려면 첫 번째 작업 단위를 설정해야 합니다. 그리고 그 작업을 수행하는 것 뿐만 아니라 다음 작업 단위를 반환하기 위해 performUnitOfWork 함수를 작성합니다.


Step IV: Fibers

작업 단위들을 구조화하기 위해서는 fiber tree 라는 자료구조가 필요합니다. 엘리먼트마다 하나의 fiber를 가지며, 각각의 fiber는 하나의 작업 단위가 됩니다.

다음의 예시를 보겠습니다.

image.png

다음과 같이 생긴 트리를 렌더링하고 싶다고 합시다.

Didact.render(
  <div>
  <h1>
  <p />
  <a />
  </h1>
  <h2 />
  </div>,
  container
)

render 함수 내부에 루트 fiber 를 생성하고, 이를 nextUnitOfWork 로 설정합니다. 남은 작업들은 performUnitOfWork 함수에서 일어나는데, 각각의 fiber에서는 다음 3가지 작업을 합니다.

  1. 돔에 엘리먼트를 추가하기
  2. 각 엘리먼트의 children 에 대해 fiber를 생성하기
  3. 다음 작업 단위를 선택하기

image.png

이 자료구조의 목적 중 하나는 다음에 필요한 작업 단위를 찾기 쉽도록 하는 것입니다. 그래서 각각의 fiber는 첫 번째 자식과 형제자매, 부모의 링크를 가지는 것입니다.

만약 어떤 fiber에서 작업 수행을 끝마쳤을 때, fiber에게 자식이 있다면 그곳이 다음 작업 단위가 됩니다.

우리 예시에서는, div fiber에서의 작업이 끝난 후 다음 작업 단위는 h1 fiber가 되는 것입니다.

만약 fiber에 자식이 없다면 형제자매가 다음 작업의 대상이 됩니다. 가령, p fiber는 자식이 없으므로 이를 끝마치고 나면 a fiber로 옮겨가게 됩니다.

image.png

만약 어떤 fiber가 자식도, 형제자매도 없다면 부모의 형제자매 fiber로 이동합니다. 예를 들면 ah2 처럼요.

또한 만약 부모에게 형제자매가 없다면, 형제자매가 있는 조상을 찾거나 루트에 도착할 때 까지 계속 거슬러 올라갑니다. 만약 루트에 도달했다면 이는 렌더링 작업 수행이 모두 끝났음을 의미합니다.

이제 이를 코드에 채워봅시다.

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)
}let nextUnitOfWork = null

먼저, render 함수에서 이 코드를 제거합니다.

function createDom(fiber) {
  const dom =
        fiber.type == "TEXT_ELEMENT"
  ? document.createTextNode("") : document.createElement(fiber.type)

  const isProperty = key => key !== "children"
  Object.keys(fiber.props)
    .filter(isProperty)
    .forEach(name => {
    dom[name] = fiber.props[name]
  })return dom
}

function render(element, container) {
  // TODO set next unit of work
}let nextUnitOfWork = null

자체 함수 내부에서 돔 노드를 생성하는 부분은 그대로 유지할 것입니다. 이는 나중에 사용합니다.

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

render 함수에서 fiber 트리의 루트에 nextUnitOfWork 함수를 설정합니다.

function workLoop(deadline) {
  let shouldYield = false
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(
      nextUnitOfWork
    )
    shouldYield = deadline.timeRemaining() < 1
  }
  requestIdleCallback(workLoop)
}requestIdleCallback(workLoop)function performUnitOfWork(fiber) {
  // TODO add dom node
  // TODO create new fibers
  // TODO return next unit of work
}

그 다음, 준비를 마친 브라우저가 우리가 만든 workLoop를 호출하면, 루트에서부터 작업을 시작합니다.

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }// TODO create new fibers
  // TODO return next unit of work
}

먼저 새로운 노드를 생성하고 돔에 이를 추가합니다. 계속해서 fiber.dom 속성 내부의 돔 노드를 추적합니다.

// ...
if (fiber.parent) {
  fiber.parent.dom.appendChild(fiber.dom)
}const elements = fiber.props.children
let index = 0
let prevSibling = nullwhile (index < elements.length) {
  const element = elements[index]const newFiber = {
    type: element.type,
    props: element.props,
    parent: fiber,
    dom: null,
  }
  }// TODO return next unit of work

각각의 자식들마다 새로운 fiber를 생성합니다.

while (index < elements.length) {
  const element = elements[index]const newFiber = { ... }if (index === 0) {
                      fiber.child = newFiber
                    } else {
                      prevSibling.sibling = newFiber
                    }
  ​
  prevSibling = newFiber
  index++
}// TODO return next unit of work

그리고 이들을 첫 번째 자식인지 아닌지에 따라 자식 혹은 형제자매로서 fiber 트리에 추가합니다.

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

마지막으로 다음 작업은 탐색입니다. 탐색은 먼저 자식, 형제 자매, 부모의 형제 자매 순서로 진행됩니다.

이제 performUnitOfWork 작업이 끝났습니다.

Step V: 렌더와 커밋 단계(Render and Commit Phases)

아직 문제 하나가 더 남아있습니다.

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }if (fiber.parent) {
    fiber.parent.dom.appendChild(fiber.dom)
  }const elements = fiber.props.children
  ...

엘리먼트에서 작업을 수행할 때 마다 각각의 돔에 새로운 노드를 추가하고 있습니다. 그리고 브라우저가 렌더링이 진행되고 있는 중간에 난입할 수 있다는 것을 기억해야 합니다. 이 경우 유저는 미완성 된 UI를 보게 됩니다. 물론 이렇게 되지 않도록 해야합니다.

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

  // remove​dconst elements = fiber.props.children
  ...

이를 위해 여기서 돔을 변형시키는 부분을 제거합니다.

function render(element, container) {
  wipRoot = {
    ...
    nextUnitOfWork = wipRoot
  }let nextUnitOfWork = null
  let wipRoot = null

그 대신, fiber 트리의 루트를 추적할 것입니다. 이를 작업중인(work in progress) 루트 라는 뜻으로 wipRoot라고 하겠습니다.

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

function render() {
  ...

  function workLoop(deadline) {
    ...

    if (!nextUnitOfWork && wipRoot) {
      commitRoot()
    }
    requestIdleCallback(workLoop)
  }
    ...

일단 모든 작업이 끝나고 나면 (더 이상 다음 작업이 없는 경우), 전체 fiber 트리를 돔에 커밋합니다.

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

이 과정은 commitRoot 함수에서 이루어집니다. 여기서 모든 노드를 재귀적으로 dom에 추가합니다.

Step VI: 재조정(Reconciliation)

이제까지 우리는 돔에 요소들을 넣는 작업을 했습니다. 그럼 노드를 갱신과 삭제하는 것은 어떻게 된걸까요?

다음에 할 것이 바로 그 부분입니다. 우리가 render 함수로 얻은 엘리먼트들을 마지막으로 커밋한 fiber 트리와 비교해야 합니다.

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

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

let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null

따라서 커밋이 끝난 다음에는 "마지막으로 돔에 커밋된 fiber 트리"를 저장할 필요가 있습니다. 이를 currentRoot라고 합시다.

또한 모든 fiber에 alternate 라는 속성을 추가해야 합니다. 이 속성은 이전의 커밋 단계에서 돔에 추가했던 오래된 fiber에 대한 링크입니다.

이제 새로운 fiber를 생성하는 코드를 performUnitOfWork에서 추출해서,

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }const elements = fiber.props.children
  reconcileChildren(fiber, elements)if (fiber.child) {
    return fiber.child
  }
  let nextFiber = fiber
  while (nextFiber) {
    if (nextFiber.sibling) {
      return nextFiber.sibling
    }
    nextFiber = nextFiber.parent
  }
}

function reconcileChildren(wipFiber, elements) {
  ...
}

새로 reconcileChildren 함수를 만듭니다.

function reconcileChildren(wipFiber, elements) {
  let index = 0
  let prevSibling = nullwhile (index < elements.length) {
    const element = elements[index]const newFiber = {
      type: element.type,
      props: element.props,
      parent: wipFiber,
      dom: null,
    }if (index === 0) {
      wipFiber.child = newFiber
    } else {
      prevSibling.sibling = newFiber
    }
    ​
    prevSibling = newFiber
    index++
  }
}

이제 이곳에서 오래된 fiber를 새로운 엘리먼트로 재조정(reconcile)할 것입니다.

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

오래된 fiber(wipFiber.alternate)의 자식들과 재조정하기를 원하는 엘리먼트의 배열을 동시에 순회합니다.

만약 배열과 링크드 리스트를 동시에 반복하는 데 필요한 이 모든 보일러플레이트를 신경쓰지 않는다면, while문 안에는 oldFiberelement라는 가장 중요한 부분만 남습니다. element는 우리가 돔 안에 렌더링하고 싶은 것이며, oldFiber는 가장 마지막으로 렌더링 했던 것입니다.

이를 돔에 적용하기 위해서는 둘 사이에 어떤 차이가 생겼는지 비교해야 합니다.

const sameType =
      oldFiber &&
      element &&
      element.type == oldFiber.type
​
if (sameType) {
  // TODO update the node
}
if (element && !sameType) {
  // TODO add this node
}
if (oldFiber && !sameType) {
  // TODO delete the oldFiber's node
}

이러한 비교를 위해서 타입을 사용합니다:

  • 만약 오래된 fiber와 새로운 엘리먼트가 같은 타입이라면, 돔 노드를 유지하고 새로운 props만 업데이트 합니다.
  • 만약 서로 타입이 다르고 새로운 엘리먼트가 존재한다면, 이는 새로운 돔 노드 생성이 필요하다는 뜻입니다.
  • 그리고 만약 타입이 다르고 오래된 fiber가 존재한다면, 오래된 노드를 제거해야 합니다.

여기서 리액트는 key 역시 사용해서 더 나은 재조정을 하도록 합니다. 예를 들면, 엘리먼트 배열의 자식이 변하는 지점을 감지하는 것 입니다.

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

오래된 fiber와 엘리먼트가 같은 타입을 가질 때, 오래된 fiber와 엘리먼트의 props에서 새로운 fiber를 생성하고 돔 노드를 유지합니다.

또한 fiber에 effectTag 라는 새로운 속성을 추가합니다. 이 속성은 나중에 커밋 단계에서 사용하게 됩니다.

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

그리고 새로운 돔 노드가 필요한 경우, 새로운 fiber에 PLACEMENT라는 effect tag를 붙입니다.

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

노드를 삭제해야 하는 경우에는 새로운 fiber가 필요하지 않으며, 오래된 fiber에 태그를 추가합니다.(DELETION)

하지만 fiber 트리를 돔에 커밋할 때, 우리는 오래된 fiber가 없는 작업 중인 루트 (work in progress root)에서 이 작업을 합니다.

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

따라서 제거하고 싶은 노드를 추적하기 위한 배열(deletions)이 필요합니다.

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

그러면 돔에 변경사항을 커밋할 때 이 배열에 있는 fiber를 사용할 수 있습니다.

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

이제 새로운 effectTags를 처리하기 위해 commitWork 함수를 변경해 보겠습니다.

const domParent = fiber.parent.dom

if ( fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
  domParent.appendChild(fiber.dom)
}commitWork(fiber.child)
commitWork(fiber.sibling)

만약 fiber가 PLACEMENT 태그를 가진다면 이전에 했던 것과 동일하게 부모 fiber 노드에 자식 돔 노드를 추가합니다.

if ( fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
  domParent.appendChild(fiber.dom)
} 
else if (fiber.effectTag === "DELETION") {
  domParent.removeChild(fiber.dom)
}

DELETION 태그는 반대로 자식을 부모 돔에서 제거합니다.

if ( fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
  domParent.appendChild(fiber.dom)
} 
else if ( fiber.effectTag === "UPDATE" && fiber.dom != null) {
  updateDom(
    fiber.dom,
    fiber.alternate.props,
    fiber.props
  )
} else if (fiber.effectTag === "DELETION") {
  domParent.removeChild(fiber.dom)
}

갱신의 경우, 이미 존재하는 돔 노드를 변경된 props를 이용하여 갱신합니다.

function updateDom(dom, prevProps, nextProps) {
  // TODO
}

이 작업은 updateDom 이라는 함수에서 수행할 것입니다.

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 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]
  })
}

오래된 fiber의 props들을 새로운 fiber의 props와 비교하여, 사라진 props는 제거하고, 새롭거나 변경된 props를 설정합니다.

const isEvent = key => key.startsWith("on")
const isProperty = key =>
key !== "children" && !isEvent(key)
const isNew = (prev, next) => key => ...

갱신을 위해 사용하는 특별한 종류의 속성이 있는데 바로 이벤트 리스너입니다. 따라서 만약 속성의 이름이 "on" 이라는 접두사로 시작한다면 이를 다르게 처리해줘야 합니다.

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]
    )
  })
    ...

만약 이벤트 핸들러가 바뀐다면, 이를 노트에서 제거합니다.

    ...

    // 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]
      )
    })
}

그다음 새로운 핸들러를 추가합니다.

재조정이 적용된 버전의 코드를 여기서 시험해볼 수 있습니다.

Step VII: 함수형 컴포넌트(Function Components)

그 다음 해야 할 것은 함수형 컴포넌트 지원을 추가하는 것입니다.

먼저 예제를 h1 엘리먼트를 반환하는 간단한 함수형 컴포넌트를 사용하는 것으로 바꿔보겠습니다.

/** @jsx Didact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
  const container = document.getElementById("root")
Didact.render(element, container)

만약 JSX를 JS로 변환하게 된다면 다음과 같을 것입니다

function App(props) {
  return Didact.createElement(
    "h1",
    null,
    "Hi ",
    props.name
  )
}
const element = Didact.createElement(App, {
  name: "foo",
})

function performUnitOfWork(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber)
  }const elements = fiber.props.children
  reconcileChildren(fiber, elements)

함수형 컴포넌트는 다음 두 가지 면에서 차이가 있습니다.

  • 함수형 컴포넌트에서 만들어진 fiber는 돔 노드가 없습니다.
  • 자식들을 props에서 직접 가져오는 대신 함수를 실행하여 얻습니다.
      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
        }
      }function updateFunctionComponent(fiber) {
        // TODO
      }function updateHostComponent(fiber) {
        if (!fiber.dom) {
          fiber.dom = createDom(fiber)
        }
        reconcileChildren(fiber, fiber.props.children)
      }

fiber 타입이 함수인지 체크한 다음 그 결과에 따라 따라 다양한 갱신 함수로 이동합니다.

updateHostComponent 에서는 이전과 동일한 일을 합니다.

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

그리고 updateFunctionComponent 에서는 자식 요소를 얻는 함수를 실행합니다.
가령, 여기서 fiber.typeApp 함수이고, 이를 실행하면 h1 엘리먼트를 반환합니다.
그 다음 자식을 얻게 되면, 재조정 작업은 같은 방법으로 수행되므로 더 이상 코드를 바꿀 필요는 없습니다.

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

그 다음 변경이 필요한 것은 commitWork 함수입니다.
지금 우리는 돔 노드가 없는 fiber를 가지고 있기 때문에 두 가지를 바꿔야 합니다.

let domParentFiber = fiber.parent
while (!domParentFiber.dom) {
  domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
      ​
if ( fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
  domParent.appendChild(fiber.dom)
} else if ...

먼저, 돔 노드의 부모를 찾으려면 돔 노드를 가진 fiber를 찾을 때 까지 fiber 트리의 상단으로 올라갑니다.

...
else if (fiber.effectTag === "DELETION") {
  commitDeletion(fiber, domParent)
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom)
  } else {
    commitDeletion(fiber.child, domParent)
  }
}

그리고 노드를 제거할 때도 동일하게, 돔 노드를 가진 자식을 찾을 때 까지 탐색을 수행합니다.

Step VIII: 훅(Hooks)

const Didact = {
  createElement,
  render,
}

/** @jsx Didact.createElement */
function App(props) {
  return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />
  const container = document.getElementById("root")
Didact.render(element, container)

마지막 단계입니다. 이제 함수형 컴포넌트에 상태를 추가해 봅시다.

const Didact = {
  createElement,
  render,
  useState,
}/** @jsx Didact.createElement */
function Counter() {
  const [state, setState] = Didact.useState(1)
  return (
    <h1 onClick={() => setState(c => c + 1)}>
    Count: {state}
    </h1>
    )
}
const element = <Counter />
const container = document.getElementById("root")
Didact.render(element, container)

예제를 고전적인 카운터 컴포넌트로 바꿔 보겠습니다. 우리가 카운터를 클릭할 때마다, state를 1씩 추가하게 됩니다.

여기서 우리는 Didact.useState를 사용해서 카운터의 값을 얻거나 갱신합니다.

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

이곳이 예제에서 카운터 함수를 호출하는 부분입니다. 그리고 그 함수 내부에서 useState를 호출합니다.

let wipFiber = null
let hookIndex = nullfunction updateFunctionComponent(fiber) {
  wipFiber = fiber
  hookIndex = 0
  wipFiber.hooks = []
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

함수형 컴포넌트를 호출하기 전에 useState 함수의 내부에서 사용하기 위한 몇몇 전역 변수들을 초기화해야 합니다.

먼저 작업중인 fiber를 설정합니다. 또한 그 fiber에 hook 배열을 추가함으로서 동일한 컴포넌트에서 여러 번 useState 함수를 호출 할 수 있도록 합니다.

function useState(initial) {
  const oldHook =
        wipFiber.alternate &&
        wipFiber.alternate.hooks &&
        wipFiber.alternate.hooks[hookIndex]
  const hook = {
    state: oldHook ? oldHook.state : initial,
  }
  ​
  wipFiber.hooks.push(hook)
  hookIndex++
  return [hook.state]
}

함수형 컴포넌트가 useState 를 호출할 때 이것이 오래된 hook인지를 체크하는데, 이때 훅 인덱스를 사용하여 fiber의 alternate를 체크합니다.

만약 우리가 가지고 있는 것이 오래된 hook이라면 상태를 초기화하지 않았을 경우 이 훅의 상태를 새로운 훅으로 복사합니다.

그리고 새로운 훅을 fiber에 추가한 뒤 훅 인덱스 값을 증가시킨 다음 state를 반환합니다.

...
const hook = {
  state: oldHook ? oldHook.state : initial,
  queue: [],
}const setState = action => {
      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]
}

또한 useState는 상태를 갱신하는 함수 역시 리턴해야 하므로, 액션을 받는 setState 함수를 정의합니다. (카운터 예제의 경우, 액션은 상태를 1 증가시키는 동작입니다) 이 액션을 우리가 훅에 추가한 큐에 넣습니다.

그리고 렌더 함수에서 했던 것과 비슷한 작업을 하는데, 새로운 작업중(wip)인 루트를 다음 작업할 단위로 설정하여 반복문에서 새로운 렌더 단계를 시작할 수 있도록 합니다.

const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
  hook.state = action(hook.state)
})

아직 액션을 실행하지는 않았습니다. 이는 컴포넌트 렌더링 다음에 수행하는데, 오래된 훅의 큐에서 모든 액션을 가져온 다음 이를 새로운 훅 state에 하나씩 적용하면 갱신된 state를 얻을 수 있게 됩니다.

자 이제 끝났습니다. 우리는 직접 나만의 버전으로 리액트를 만들어보았습니다. 이를 codesandbox 혹은 github에서 실행해볼 수 있습니다.

에필로그

이 포스트의 목적은 리액트가 어떻게 동작하는지 알기 쉽도록 돕는 것 외에도, 리액트 코드베이스를 깊이 탐구하기 용이하게 하려는 의도도 있습니다. 그렇기에 거의 대부분 동일한 변수와 함수명을 사용한 것입니다.

가령, 실제 리액트 앱에서 함수형 컴포넌트에 중단점을 추가한다고 했을 때, 콜 스택은 다음을 표시합니다:

  • workLoop
  • performUnitOfWork
  • updateFunctionComponent

또한 리액트의 모든 기능들과 최적화 요소들이 포함되지는 않았습니다. 가령, 다음은 리액트와는 다르게 동작하는 것들입니다

  • 디액트에서는 렌더 단계의 모든 트리를 순회하지만 실제 리액트는 대신 특정한 힌트만을 따라가며 변하지 않은 서브 트리는 휴리스틱하게 뛰어넘습니다.
  • 디액트는 커밋 단계에서도 모든 트리를 순회합니다. 하지만 리액트는 영향이 가는 fiber들의 연결 리스트를 유지하여 해당 fiber만 방문합니다.
  • 작업중(wip)인 트리를 생성할 때마다 우리는 각 fiber에 새로운 객체를 생성했지만, 리액트는 이전의 트리에서 가져온 fiber를 재사용합니다.
  • 디액트는 렌더 단계에서 새로운 갱신을 얻을 때 작업중(wip)인 트리를 버리고 루트에서부터 새로 시작하지만, 리액트는 각 갱신의 만료 타임스탬프를 표시해두고, 갱신시 이를 높은 우선순위로 참고하여 결정합니다.
  • 그 외에도 많은 것들이 다릅니다.