개발/Frontend

[번역] Virtual DOM 이 뭔데? (한번 만들어보기!)

Junghyun Kim 2021. 6. 6. 13:47
반응형

Virtual DOM 에 대해서 잘 정리되어 있는 글을 발견하여 공유하고자 번역을 하였습니다.
원본은 시리즈가 2편으로 되어있으나, 번역본에서는 한 게시글에 모두 담았습니다.

번역을 하면서 글을 자연스럽게 만들기 위해 의역을 많이 첨가하였습니다. (따라서, 오역이 많을 수 있습니다... 댓글로 알려주시면 수정하겠습니다. 훈수 좋아합니다...)

원본(시리즈 1) What is the Virtual DOM? (Let's build it!)
원본(시리즈2) Let's build a VDOM

 


 

프론트엔드를 개발하다 보면 아마 가상 돔 혹은 쉐도우 돔에 대해서 들어보고 사용해보셨을 겁니다. 아마 여러분들이 많이 사용하시는 JSX 또한 사실은 가상 돔의 문법적 설탕입니다. 이번 튜토리얼에서는 Virtual DOM 이 어떻게 생겼는지 알아볼 것이고, 이어서 어떻게 구현할 수 있는지 보여드리도록 하겠습니다!

Virtual DOM?

DOM 조작은 상당히 무거운 작업입니다. 자바스크립트 객체에 프로퍼티를 할당하는 것과의 속도 차이는 약 0.4ms 정도가 차이가 납니다. 한 번으로는 그 차이가 크게 느껴지지 않습니다. 그러나 횟수가 누적될수록 그 차이가 점점 커집니다.

// Assigning a property to an object 1000 times
let obj = {};
console.time("obj");
for (let i = 0; i < 1000; i++) {
  obj[i] = i;
}
console.timeEnd("obj");

// Manipulating dom 1000 times
console.time("dom");
for (let i = 0; i < 1000; i++) {
  document.querySelector(".some-element").innerHTML += i;
}
console.timeEnd("dom");

위 코드를 실행시켰을 때, 첫번째 반복문은 약 ~3ms이 시간이 걸렸던 반면에 두 번째 반복문은 약 ~41ms의 시간이 소요되었습니다.

이제 실제 예시를 한번 살펴봅시다.

function generateList(list) {
    let ul = document.createElement('ul');
    document.getElementByClassName('.fruits').appendChild(ul);

    fruits.forEach(function (item) {
        let li = document.createElement('li');
        ul.appendChild(li);
        li.innerHTML += item;
    });

    return ul;
}

document.querySelector("ul.some-selector").innerHTML = generateList(["Banana", "Apple", "Orange"])

만약 여기서 배열의 값들이 변경된다면 DOM을 조작해서 요소들을 수정해야하만 합니다. 아래와 같이 말입니다.

document.querySelector("ul.some-selector").innerHTML = generateList(["Banana", "Apple", "Mango"])

여기서 혹시 어디가 잘못되었는지 아시겠나요?

배열에서 Orange가 빠지고 Mango가 추가되었습니다. 즉, 배열에서는 단 하나의 변경만 일어났는데 우리는 전체를 다 수정해버렸습니다. 변경분만 DOM에 반영되도록 코드를 작성해야겠지만, 우리 개발자의 천성은 게으릅니다.

그래서 Virtual DOM이라는 것이 만들어진 것입니다!
이제 우리는 Virtual DOM이 왜 필요한지를 알았으니 바로 Virtual DOM 핵심으로 들어가보겠습니다.

Virtual DOM?!

Virtual DOM이란 DOM을 객체로써 표현을 한 것입니다.

<div class="contents">
    <p>Text here</p>
    <p>Some other <b>Bold</b> content</p>
</div>

그래서 위의 HTML 코드는 아래와 같은 VDOM 객체로 작성될 수 있습니다.

let vdom = {
    tag: "div",
    props: { class: 'contents' },
    children: [
        {
            tag: "p",
            children: "Text here"
        },
        {
            tag: "p",
            children: ["Some other ", { tag: "b", children: "Bold" }, " content"]
        }

    ]
}
// 실제로는 더 많은 프로퍼티들이 존재할 수 있습니다. 위 VDOM 객체는 간략화된 버전입니다.

위 VDOM을 보시면 어느 정도 이해가 가실 거라고 생각합니다. 특히나 React를 사용해보신 분이라면 더욱 친숙하게 느껴지실 수도 있습니다.

VDOM은 기본적으로 아래의 프로퍼티를 갖는 객체입니다

  • tag: 태그의 이름을 나타냅니다. (가끔 type이라고 불리기도 합니다.)
  • props: 모든 props을 소유하고 있습니다.
  • children: (1) 콘텐츠가 text인 경우 string 값을 가집니다. (2) 콘텐츠가 엘리먼트들이면 VDOM 배열 값을 가집니다.

VDOM 사용법

  • DOM을 변경하는 대신에 VDOM을 변경합니다.
  • 어느 함수가 DOM과 VDOM의 차이점을 체크하여 실제로 변경된 부분만 DOM에 반영합니다.
  • 변경 사항으로 사용된 VDOM은 최신의 DOM 내용과 같기 때문에, 다음 번에 변경될 VDOM과 비교를 할 때에 최신의 VDOM을 재활용할 수 있습니다.

VDOM을 사용하므로 얻을 수 있는 이점

아까 사용했던 generateList 함수를 더 발전시켜 실전적인 예제를 만들어봅시다.

function generateList(list) {
    // VDOM 생성하는 부분. 
    // 추후 설명하겠습니다...
}

patch(oldUL, generateList(["Banana", "Apple", "Orange"]));

patch 함수가 무엇인지 신경 쓰지 않으셔도 됩니다. 단순히 DOM에 변경분을 반영한다고 생각하시면 됩니다.

자, 이제 다시 배열의 원소가 변경되었다고 생각해봅시다!

patch(oldUL, generateList(["Banana", "Apple", "Mango"]));

patch 함수는 3번째 li만 변경되었음을 찾아내고 3번째 li만 DOM에서 바꿀 겁니다. 더 이상 모든 요소를 바꿀 필요가 없습니다!
이것이 VDOM의 전부입니다. 다음에는 어떻게 VDOM을 구현하는지 알아보도록 하겠습니다.


VDOM은 기능 상으로는 아래 4가지를 할 수 있어야 합니다.

  1. Virtual Node를 만듭니다. (편의상 vnode라고 부르겠습니다.)
  2. VDOM을 마운트(혹은 로드)합니다.
  3. VDOM을 언마운트합니다.
  4. Patch 합니다. (두 vnode를 비교하고 변경분을 파악하여 마운트 합니다.)

vnode 만들기

이 함수는 간단한 유틸리티 함수입니다.

function createVNode(tag, props = {}, children = []) {
    return { tag, props, children}
}
// Vue 혹은 다른곳에서는 이 함수를 h 라고 부릅니다. (hyperscript)
// hypertext를 javascript 객체로 바꾸어줍니다.

vnode 마운팅

마운팅이란 #app과 같은 컨테이너에 vnode를 추가하는 것을 의미합니다.
아래 함수는 모든 노드의 자식을 반복적으로 살펴보고 각 컨테이너에 마운트 합니다.

function mount(vnode, container) { ... }
// 이 후 나오는 코드들은 모드 이 mount안에 쓰여진 코드들입니다.

1. DOM 엘리먼트 생성

const element = (vnode.element = document.createElement(vnode, tag))

vnode.element이 무엇인지 궁금해하실 수 있습니다. vnode.element는 어떤 element가 vnode의 부모인 줄 알 수 있도록 내부적으로 설정하는 프로퍼티입니다.

2. props 객체로부터 attributes 설정

Object.entries(vnode.props || {}).forEach([key, value] => {
  element.setAttribute(key, value)
})

3. children 마운트

2가지의 경우의 수가 있습니다.

  • children이 단순 text인 겨우
  • children이 vnode 배열인 경우
if (typeof vnode.children === 'string') { 
  element.textContent = vnode.children 
} else { 
  vnode.children.forEach(child => { 
    mount(child, elemment) // 재귀적으로 children을 마운트합니다 
  }) 
}

4. DOM에 추가하기

container.appendChild(element)

최종 코드

function mount(vnode, container) {
  const element = (vnode.element = document.createElement(vnode.tag))

  Object.entries(vnode.props || {}).forEach([key, value]) => {
    element.setAttribute(key, value)
  })

  if (typeof vnode.children === 'string') {
    element.textContent = vnode.children
  } else {
    vnode.children.forEach(child => {
      mount(child, element)
    })
  }

  container.appendChild(element)
}

vnode 언마운팅

언마우팅은 DOM에서 간단히 element를 제거하는 것입니다.

function unmount(vnode) {
  vnode.element.parentNode.removeChild(vnode.element)
}

vnode 패칭

이 부분은 상대적으로 우리가 작성해야 하는 코드들 중 가장 복잡한 부분입니다. 기본적으로 두 vnodes을 비교하여 변경분을 찾아내어 반영해야 합니다.
이번에는 아래 코드의 이해를 돕기 위해 주석을 달아놓았습니다.

function patch(VNode1, VNode2) {
  // 부모 DOM 엘리먼트를 할당합니다.
  const element = (VNode2.element = VNode1.element)

  // 이제 두 vnode의 차이점을 체크합니다.

  // 만약 노드들의 태그가 다르다면, 전체 내용이 변경되었다고 가정합니다.
  if (VNode1.tag !== VNode2.tag) {
    // 기존 노드를 언마운트하고 새로운 노드를 마운트 합니다.
    mount(VNode2. element.parentNode)
    unmount(VNode1)
  } else {
      // 노드가 같은 태그일 때, 두 가지 비교가 남아있습니다.
    // - Props
    // - Children

    // props 체크는 현재 하지않겠습니다. 핵심에서 벗어난 내용이라 다음에 글을 쓴다면 해당 내용을 포함시키도록 하겠습니다.

    // Checking the children
    // 새로운 노드의 children이 string인 경우
    if (typeof VNode2.children == "string") {
      // children이 **엄격히** 다른 경우
      if (VNode2.children !== VNode1.children) {
        element.textContent = VNode2.children;
      } else {
          // 만일 새로운 노드가 children 배열인 경우
        // - children 배열의 크기가 같은 경우
        // - 기존의 노드가 새로운 노드보다 children 배열의 크기가 더 큰 경우
        // - 새로운 노드가 기존의 노드보다 children 배열의 크기가 더 큰 경우

        // 배열의 크기 찾기
        const children1 = VNode1.children;
        const children2 = VNode2.children;
        const commonLen = Math.min(children1.length, children2.length);

        // 재귀적으로 공통 children 패칭하기
        for (let i = 0; i < commonLen; i++) {
            patch(children1[i], children2[i])
        }

        // 새로운 노드가 더 적은 children을 가질 때,
        if (chidren1.length > children2.length) {
            children1.slice(children.length).forEach(child => {
                unmount(child)
            })
        }

        // 새로운 노드가 더 많은 children을 가질 때,
        if(children2.length > children1.length) {
          children2.slice(children1.length).forEach(child => {
            mount(child, element)
          })
      }
    }
  }
}

완성되었습니다. vdom의 기본적인 부분을 구현했습니다. props 체킹과 최적화와 같은 부분들이 더 남아있지만 이로써 vdom의 기본적인 개념을 이해할 수 있을거라 생각합니다.

다시, generateList 예제로 돌아가봅시다. vdom 구현을 마쳤으니, 아래와 같이 수정할 수 있습니다.

function generateList(list) {
  let children = list.map(child => createVNode("li", null, child));

  return createVNode("ul", { class: 'fruits-ul' }, children)
}

mount(generateList(["apple", "banana", "oragne"], document.querySelector("#app"))
반응형