본문 바로가기
Projects/RxJS로 산성비 만들기

RxJS로 산성비 만들기(3) - 가상돔 업데이트를 위한 Diff 알고리즘

by 슥짱 2023. 2. 9.

Diff 알고리즘 구현

vDOM에서 무엇보다 중요한 것은 변경된 DOM 요소만을 업데이트 하는 Diff 알고리즘이다. 이번 글에서는 이전 가상돔 글에 이어, Diff 알고리즘을 완성해보도록 한다. Diff 알고리즘의 구현은 개발자 황준일님의 블로그를 참고해 내가 만든 웹 컴포넌트에 맞게 다시 작성했다.

 

updateElement 함수에서 prev는 이전 노드, next는 새로운 노드, $parentprev의 부모 엘리먼트, parentComponentprev의 부모 vDOM 컴포넌트, indexprevnext가 부모의 children 중 몇 번째 자식인지를 의미한다.

여기서의 노드는 vDOM 요소인 ComponentElement의 인스턴스, 그리고 TextNode를 의미한다. 또한 이후 설명에서 Element와 같이표기한 것은 Element 클래스의 인스턴스, "엘리먼트"라고 한글로 표기한 것은 실제 DOM의 엘리먼트를 의미한다.

 

이제 updateElement() 함수의 코드를 살펴보자.

function updateElement(prev, next, $parent, parentComponent, index = 0) {
  // 1. 이전 노드가 없던 자리에 새로운 노드가 추가된 경우.
  if ((prev === null || prev === undefined) && next) {
    if (typeof next === "object") {
      next.mount($parent);
    } else {
      $parent.innerText = next;
    }
    parentComponent.children.push(next);
    return;
  }

  // 2. 이전에 존재하던 노드가 제거된 경우
  if (next === null || next === undefined) {
    $parent.removeChild($parent.childNodes[index]);
    parentComponent.children.splice(index, 1);
    return;
  }

  // 3. 이전 노드, 새로운 노드가 모두 텍스트 노드(string | number)인 경우
  if (
    (typeof prev === "string" || typeof prev === "number") &&
    (typeof next === "string" || typeof prev === "number")
  ) {
    if (prev !== next) {
      $parent.innerText = next;
      parentComponent.children = [next];
    }
    return;
  }

  // 4. 이전 노드 또는 새로운 노드가 컴포넌트인 경우
  if (typeof prev.tag === "function" || typeof next.tag === "function") {
    if (typeof prev.tag === "function") {
      next.children = next.template();
    }

    updateElement(prev.children, next.children, $parent, prev, index);
    return;
  }

  // 5. 두 노드가 다른 태그 명을 가진 경우
  if (typeof prev.tag !== typeof next.tag) {
    return $parent.replaceChild(next.mount($parent), $parent.childNodes[index]);
  }

  // 6. 두 Element가 같은 태그 명을 가진 경우
  for (const [attr, value] of Object.entries(prev.attrs)) {
    if (prev[attr] !== next[attr]) {
      if (!next[attr]) {
        $parent.removeAttribute(attr, value);
      } else {
        $parent.setAttribute(attr, value);
      }
    }
  }

  const maxLength = Math.max(prev.children.length, next.children.length);

  for (let i = 0; i < maxLength; i++) {
    updateElement(
      prev.children[i],
      next.children[i],
      $parent.childNodes[index],
      prev,
      i
    );
  }
}

updateElement() 함수는 크게 6가지 경우로 분기된다.

1. 이전 노드가 없던 자리에 새로운 노드가 추가된 경우.

// 1. 이전 노드가 없던 자리에 새로운 노드가 추가된 경우.
  if ((prev === null || prev === undefined) && next) {
    if (typeof next === "object") {
      next.mount($parent);
    } else {
      $parent.innerText = next;
    }
    parentComponent.children.push(next);
    return;
  }

부모 컴포넌트에 next를 추가하기만 하면 된다. 단, next의 타입에 따라 그 과정이 조금 다르다. next가 텍스트 노드인 경우 단순히 부모 DOM 엘리먼트의 값을 수정해주면 된다. next가 텍스트 노드가 아닌 경우, 다시 말해 Element 또는 Component인 경우 mount() 메서드를 호출해 부모 DOM 엘리먼트에 마운트 해주어야 한다. 마운트가 끝나면, 부모 컴포넌트의 자식으로 next를 추가한다.

2. 이전에 존재하던 노드가 더이상 존재하지 않는 경우

  // 2. 이전에 존재하던 노드가 제거된 경우
  if (next === null || next === undefined) {
    $parent.removeChild($parent.childNodes[index]);
    parentComponent.children.splice(index, 1);
    return;
  }

이 경우는 훨씬 간단하다. 단순히 부모 엘리먼트인 $parent에서 그 값을 제거하고, $parent에 마운트 되어 있는 부모 컴포넌트에서도 동일 인덱스에 위치한 자식을 제거한다.

3. 이전 노드, 새로운 노드가 모두 텍스트 노드(string 또는 number)인 경우

// 3. 이전 노드, 새로운 노드가 모두 텍스트 노드(string | number)인 경우
  if (
    (typeof prev === "string" || typeof prev === "number") &&
    (typeof next === "string" || typeof prev === "number")
  ) {
    if (prev !== next) {
      $parent.innerText = next;
      parentComponent.children = [next];
    }
    return;
  }

prevnext 모두 텍스트 노드라면, 값이 변하지 않은 경우에만 새로운 값을 대입해준다.

4. 이전 노드 또는 새로운 노드가 컴포넌트인 경우

// 4. 이전 노드 또는 새로운 노드가 컴포넌트인 경우
  if (typeof prev.tag === "function" || typeof next.tag === "function") {
    if (typeof prev.tag === "function") {
      next.children = next.template();
    }

    updateElement(prev.children, next.children, $parent, prev, index);
    return;
  }

4번의 경우엔 next가 컴포넌트인지 여부에 따라 동작이 달라진다. next가 컴포넌트라면 자식들을 업데이트 하기에 앞서, next의 자식들, 부모 엘리먼트를 프로퍼티에 담아주는 작업이 필요하다. 컴포넌트가 아닌 Element라면, 인스턴스가 이미 children을 지니고 있으므로 상관하지 않아도 된다.

5. 두 노드가 다른 태그 명을 가진 Element인 경우

// 5. 두 노드가 다른 태그 명을 가진 경우
  if (typeof prev.tag !== typeof next.tag) {
    return $parent.replaceChild(next.mount($parent), $parent.childNodes[index]);
  }

서로 다른 태그 명을 가진 경우 볼 것도 없이 prevnext로 대체한다.

6. 두 노드가 같은 태그 명을 가진 Element인 경우

// 6. 두 Element가 같은 태그 명을 가진 경우
  for (const [attr, value] of Object.entries(prev.attrs)) {
    if (prev[attr] !== next[attr]) {
      if (!next[attr]) {
        $parent.removeAttribute(attr, value);
      } else {
        $parent.setAttribute(attr, value);
      }
    }
  }

  const maxLength = Math.max(prev.children.length, next.children.length);

  for (let i = 0; i < maxLength; i++) {
    updateElement(
      prev.children[i],
      next.children[i],
      $parent.childNodes[index],
      prev,
      i
    );
  }

Element가 같은 태그 명을 가진 경우라면, 먼저 엘리먼트의 속성을 옮겨주는 작업을 진행한다. 속성을 모두 옮겼다면, 자식들을 하나하나 순회하며 다시 updateElement() 함수를 호출한다.

 

위 함수를 사용해 카운터 예제를 만들어보았다.

createElement()로 추상화 하기

잘 동작하긴 하는데, template() 함수 내 코드가 너무 지저분하고 가독성이 떨어진다. 이를 조금이나마 가독성을 높이기 위해 ElementComponent의 인스턴스를 생성을 추상화 해보자.

 

createElement() 함수를 다음과 같이 작성한다.

/**
 * @param {Object} tag - 엘리먼트의 태그 명 또는 컴포넌트를 상속받은 클래스
 * @param {string} props - 일반 엘리먼트는 class 또는 id와 같은 속성, 컴포넌트는 초기 상태를 의미한다
 * @param {string | number | Element[] | Component[]} children - 자식 엘리먼트를 배열로 전달
 * @returns {Element | Component}
 * @example createElement('div', {class: 'greet'}, 'hello')
 * @example createElement(App, {id: 'app'}, [createElement(Header, {class: 'header'})])
 */
export function createElement(tag, props, children) {
  if (typeof tag === "function") {
    return new tag(props);
  }

  if (typeof tag === "string") {
    return new Element(tag, props, children);
  }
}

이게 끝이다. 자세한 설명은 코드의 주석을 확인하길 바란다.

 

마지막으로 방금 카운터 코드에 이를 적용해보면 template() 메서드는 다음과 같이 작성할 수 있다.

template() {
    const count = this.getState("count");

    return createElement("div", { id: "app" }, [
      createElement("span", { class: "result" }, `count: ${count}`),
      createElement("div", { class: "counter" }, [
        createElement("button", { class: "inc" }, "+"),
        createElement("button", { class: "dec" }, "-")
      ])
    ]);
  }

여전히 가독성이 떨어지지만... 전보다는 아주 조금이나마 나아보인다. 이벤트 등록으로 인해 onMount() 메서드 역시 코드가 너무 거창하고 번거로워지는 것 같긴 한데, 이는 프로젝트가 모두 끝나면 다시 다루어보도록 하겠다.

끝!!!

여기까지가 웹 컴포넌트 구현의 끝이다. 다 적고 보니 코드가 몇 줄 안되지만 꽤나 힘든 과정이었다. 이 프로젝트의 원래 목표였던 RxJS로 게임 로직을 작성하는데는 약 2, 3일 정도 걸린 반면, 가상돔 구현은 계속해서 버그를 고치느라 어림잡아 2주 정도의 시간이 소요된것 같다. 배꼽이 배보다 10배 정도 크다😅

 

오랜 시간에 걸쳐 가상돔 구현을 마치고 나니 가상돔에 대한 이해도 조금 높아진것 같고, 나름대로 뿌듯함도 느껴진다. 하지만 가장 크게 느낀 점은... 다음 부터는 그냥 리액트 쓰자^^ 한 번으로 만족할 만한 좋은 경험이었다.

 

지금까지는 클래스를 기반으로 코드를 짰는데, 게임 로직은 대부분 RxJS를 사용해 작성했으니 반응형으로 작성한 코드를 보길 원한다면 다음 포스팅을 기대해도 좋다(물론 코드 품질이 좋다는 뜻은 아니다!)

 


참고자료

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Virtual-DOM/#_4-diff-%E1%84%8B%E1%85%A1%E1%86%AF%E1%84%80%E1%85%A9%E1%84%85%E1%85%B5%E1%84%8C%E1%85%B3%E1%86%B7-%E1%84%8C%E1%85%A5%E1%86%A8%E1%84%8B%E1%85%AD%E1%86%BC

 

Vanilla Javascript로 가상돔(VirtualDOM) 만들기 | 개발자 황준일

Vanilla Javascript로 가상돔(VirtualDOM) 만들기 본 포스트는 React와 Vue에서 사용되고 있는 가상돔(VirtualDOM) 직접 만들어보는 내용이다. 그리고 이 포스트를 읽기 전에 Vanilla Javascript로 웹 컴포넌트 만들

junilhwang.github.io

 

댓글