# Virtual DOM

우선 virtual DOM의 등장 배경부터 알아보자.

웹 어플리케이션이 거대해질수록, DOM의 조작은 너무나 큰 비용이며 매우 느렸다.

예를 들어, 부모 엘리먼트 일부분을 변경하면, 변경될 필요도 없는 나머지 children들도 모두 다시 그려진다던지.. 굉장히 비효율적이다.

그래서 DOM의 변경을 최소화시켜야한다. 이게 virtual DOM의 역할이다.

react.js에서는 성능 및 브라우저 간 호환성을 위해 virtual DOM을 선택했다.

가상돔은 HTML 돔의 추상화 개념이다. 가벼우며, 브라우저 스펙의 구현체와는 분리되어있다.

react 가상돔과 실제 돔은 거의 유사하다. checked, dangerouslySetInnerHTML, key, ref, htmlFor, className 등의 차이가 있을뿐이다.

htmlFor, className 같은 경우는, for와 class가 자바스크립트의 예약어이기때문에 조금 바꿔서 사용하도록했다.

aria-와 data-, 웹컴포넌트를 제외하고는 모든 DOM Properties, attributes는 카멜케이스로 작성한다.

나머지 차이점은 여기서 확인하도록 하자.

작성한 react 컴포넌트는 React Element로 변환된다.

그러면 ReactElement은 빠르고 쉽게 비교, 업데이트 작업을 한 후 가상 돔에 삽입된다.

# 재조정 (Reconciliation)

state, props가 변경되면, render함수는 새로운 React 엘리먼트 트리를 반환합니다.

여기서 생성된 트리에 맞게 가장 효율적인 UI 갱신방법은 react의 비교 알고리즘 (Diffing Algorithm)을 사용

복잡도 O(n^3)을 O(n)으로 낮추기위해 2가지 조건을 두고 DOM과 똑같은 모델을 만들어서 변경된 부분을 계산해낸다.

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

# react의 비교 알고리즘 (Diffing Algorithm)

두 개의 트리를 비교할 때, React는 두 엘리먼트의 루트(root) 엘리먼트부터 비교합니다.

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

이전 DOM 노드가 파괴되고, 루트 엘리먼트 하위 컴포넌트도 모두 언마운트됩니다.

두 루트 엘리먼트의 타입이 같으면, 두 엘리먼트의 속성을 비교하여 변경된 속성만 갱신합니다.

DOM 노드의 처리가 끝나면, React는 이어서 해당 노드의 자식들을 재귀적으로 처리합니다.

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

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

이렇게 마지막에 추가하는경우는 문제가없지만, 다른곳을 변경한다면 종속트리를 유지하지않고 모든 자식을 변경하기때문에 매우 비효율적입니다.

이러한 문제를 해결하기위해 key라는 속성을 사용합니다.

# Key

그래서 key는 유일한 값을 가져야하지만, 전역적으로 유일 할 필요는 없고, 형제 사이에서만 유일하면 됩니다.

Key는 React가 어떤 항목을 변경, 추가 또는 삭제할지 식별하는 것을 돕습니다. key는 엘리먼트에 안정적인 고유성을 부여하기 위해 배열 내부의 엘리먼트에 지정해야 합니다.

key는 반드시 변하지 않고, 예상 가능하며, 유일해야 합니다. 변하는 key(Math.random()으로 생성된 값 등)를 사용하면 많은 컴포넌트 인스턴스와 DOM 노드를 불필요하게 재생성하여 성능이 나빠지거나 자식 컴포넌트의 state가 유실될 수 있습니다.

key에는 리스트가 변경되지않는곳이 아니라면 index를 사용하지 않는 것이 좋습니다.

index를 key로 사용했을때 발생하는 문제의 예시코드

index를 key로 사용하면 배열 엘리먼트의 순서가 바뀌었을 때 key 또한 바뀝니다. 그렇게되면 컴포넌트 state가 의도하지않는 방식으로 동작하게 될 수 있습니다.


# virtual DOM의 구현

구현에 앞서..

<ul className="list">
  <li>item 1</li>
  <li>item 2</li>
</ul>

위 jsx 코드는 babel에 의해 아래 코드와 같이 트랜스파일링됩니다.

React.createElement(
  "ul",
  { className: "list" },
  React.createElement("li", {}, "item 1"),
  React.createElement("li", {}, "item 2")
);

리액트의 경우에는 이런식으로 엘리먼트를 생성합니다.

function h(type, props, …children) {
  return { type, props, children };
}

h('ul', { 'class': 'list' },
  h('li', {}, 'item 1'),
  h('li', {}, 'item 2'),
);

이런식으로 React.createElement를 대체할 수 있는 함수를 생성할 수 있습니다.

/** @jsx h */
const a = (
  <ul className="list">
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

이렇게 상단에 주석을 넣어주면 babel이 React.createElement대신 h를 사용합니다.

h함수가 실행되면 우리의 가상돔은 이렇게 표현됩니다.

const a = {
  type: "ul",
  props: { className: "list" },
  children: [
    { type: "li", props: {}, children: ["item 1"] },
    { type: "li", props: {}, children: ["item 2"] },
  ],
};

이렇게 생성된 가상돔을 실제 돔에 추가할 수 없으므로,

가상돔 노드를 파라미터로 받아서, 실제 돔 노드를 리턴하는 createElement 함수를 작성합니다. (props제외)

변수 앞의 $은 실제 돔 표현을 구분하기위해 붙였습니다.

function createElement(node) {
  if (typeof node === "string") {
    //노드가 string이면
    return document.createTextNode(node); //텍스트노드반환
  }
  const $el = document.createElement(node.type); //해당 노드 타입으로 엘리먼트 생성
  node.children
    .map(createElement) //children 재귀처리
    .forEach($el.appendChild.bind($el)); //children들을 생성된 엘리먼트의 자식으로 append
  return $el; //해당 element 반환
}

document.createTextNode(data) document.createElement(tagName[, options])

이렇게 가상돔을 실제돔으로 변경했습니다!

이제 가상돔트리의 변화에 대한 감지 혹은 비교 알고리즘을 만들어야합니다.

여기서는 old, new 두 개의 가상돔 트리를 비교하여 실제 돔에 필요한 변경만 수행합니다.

$parent는 가상 노드의 실제 DOM요소입니다. index는 부모 엘리먼트에 있는 노드의 위치 입니다.

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    // 이전 노드가 없는 경우(노드가 새로 추가된 경우)
    $parent.appendChild(createElement(newNode));
  } else if (!newNode) {
    // 새로운 노드가 없는 경우(노드를 삭제해야 하는 경우)
    $parent.removeChild($parent.childNodes[index]);
  }
}

두 노드를 비교하고 노드가 실제로 변경되었는지 알려주는 함수입니다.

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
         typeof node1 === ‘string’ && node1 !== node2 ||
         node1.type !== node2.type
}

작성된 changed함수를 활용하여 노드의 변경을 적용합니다.

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(createElement(newNode));
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
  }
}

마지막으로 두 노드의 children을 비교합니다. updateElement함수를 재귀적으로 호출합니다.

고려해야할상황

텍스트 노드는 자식(children)을 가질 수 없기 때문에, 엘리먼트 노드만 비교를 해야합니다.
이제 현재 노드에 대한 참조를 부모로 전달해야합니다.
모든 자식(children)을 하나씩 비교해야 합니다. (어떤 시점에 ‘undefined’를 가질 수도 있습니다만 우리 함수는 그것을 처리 할 수 있습니다.)
인덱스, 자식(children) 배열의 child 노드의 인덱스입니다.
function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(createElement(newNode));
  } else if (!newNode) {
    $parent.removeChild($parent.childNodes[index]);
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(createElement(newNode), $parent.childNodes[index]);
  } else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
        i
      );
    }
  }
}

이렇게 가상돔의 구현을 완료했습니다.

# 요약

h함수와 createElement 함수 작성
가상돔트리의 변화에 대한 감지 혹은 비교 알고리즘 작성
실제 돔에 변경된 부분만 적용


ref

https://ko.reactjs.org/docs/

https://ko.reactjs.org/docs/reconciliation.html

https://github.com/FEDevelopers/tech.description/wiki/가상-돔과-돔의-차이점

https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060