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

RxJS로 산성비 만들기(2) - 웹 컴포넌트(가상돔) 구현하기

by 슥짱 2023. 2. 6.

이미지 출처: GeeksForGeeks

혹시 조회수가 높아질까 싶어 썸네일용 이미지도 넣어본다😂

 

웹 컴포넌트 특히, 가상돔을 만드는게 여간 어려운 일이 아니었다. 굳이 굳이 가상돔을 구현한 데에는 두 가지 이유가 있다.(이제부터 가상돔은 vDOM, 실제돔은 rDOM이라고 부르도록 하겠다.)

 

첫째로 컴포넌트의 상태가 변함에 따라 다시 렌더링 되었을 때, 기존 게임 함수가 의존하고 있는 엘리먼트가 항상 그대로 유지되어야만 했다. 예를 들어, 게임 함수가 <input> 엘리먼트에 의존하고 있을 때, 외부 상태의 변화로 인해 해당 <input> 엘리먼트도 함께 리렌더링 된다면, 게임은 더 이상 rDOM에 존재하지 않는 엘리먼트를 참조하게 된다. 아래 코드에서 render 버튼을 클릭하면 이후로 버튼들이 작동하지 않게 되는 것과 같다.

둘째로, RxJS를 사용함에 따라 컴포넌트가 unmount될 때 Observer의 구독을 해제할 수 있는 라이프 사이클이 필요했기 때문이다. Observer의 구독이 unmount된 컴포넌트에서도 계속 남아있으면 메모리 누수가 발생하므로 vDOM을 통해 unmount 시점을 포착해 구독을 해제할 수 있도록 했다.

Element 클래스

먼저 컴포넌트를 구성할 가상 엘리먼트를 만들기 위한 클래스가 필요하다.

class Element {
  element;
  constructor(tag, attrs, children = []) {
    this.tag = tag;
    this.attrs = attrs || {};
    this.children = Array.isArray(children)
      ? children.filter((x) => x)
      : [children].filter((x) => x);
  }

  mount($parent) {
    this.element = document.createElement(this.tag);
    Object.keys(this.attrs).forEach((key) => {
      this.element.setAttribute(key, this.attrs[key]);
    });

    $parent.append(this.element);
    this.children.forEach((child) => {
      if (!child) return;
      if (typeof child === "string" || typeof child === "number") {
        this.element.innerText = child;
      } else {
        child.mount(this.element);
      }
    });

    return this.element;
  }
}

Element는 태그 이름과 속성, 자신의 자식노드를 생성자의 매개변수로 받는다. children에는 동일한 Element의 인스턴스 또는 string이나 number와 같은 TextNode가 올 수 있다.

 

Element는 가상 DOM의 요소이므로 mount() 메소드가 호출되기 전까지 메모리 상에만 존재한다. mount()가 호출되면 Element는 rDOM에 반영된다. 먼저 태그명과 속성으로 실제 DOM 요소를 만든 뒤, 부모 엘리먼트에 부착한다. 그리고 children을 순회하며 자기 자신에 mount 시킨다. children은 또 재귀적으로 mount()를 호출하며 모든 엘리먼트가 rDOM에 반영된다.

Component 클래스

Component 클래스의 전체적인 코드는 다음과 같다.

class Component {
  tag;
  $parent;
  children;
  #state;

  /**
   * @param {{[key]: any}} props - {key: value} 형식의 객체만 전달 가능합니다. 여기서의 key값을 getState(), setState() 메서드에 전달해 상태를 조회, 수정할 수 있습니다.
   */
  constructor(props) {
    if (this.constructor === Component) {
      throw new Error("추상 클래스를 인스턴스화 할 수 없습니다.");
    }
    this.tag = this.constructor;
    this.#state = new BehaviorSubject(props ?? {});
    this.#state.pipe(skip(1)).subscribe(() => {
      this.update();
      this.onUpdate();
    });
  }

  mount($parent) {
    this.$parent = $parent;
    this.children = this.template();
    if (!(this.children instanceof Element)) {
      throw new Error("하나 이상의 Element로 컴포넌트를 감싸야 합니다.");
    }

    const mounted = this.children?.mount($parent);

    const beforeUnmount = this.onMount();
    this.#unmount(beforeUnmount);

    return mounted;
  }

  template() {}

  /**
   * @description unmount전에 실행할 함수를 onMount의 리턴으로 넘겨주어야 한다.
   */
  onMount() {}

  update() {
    const next = this.template();
    updateElement(this.children, next, this.$parent, this);
    this.onUpdate();
  }

  onUpdate() {}

  #unmount(callback) {
    // Mutation Observer를 이용한 방식으로 인해 반드시 최상위 Wrapper Element가 필요함.
    // 그렇지 않으면 원치 않는 순간에 unmout가 발생
    const onObserve = (_, observer) => {
      if (!this.$parent.contains(this.children.element)) {
        this.#state.complete();
        callback && callback();
        observer.disconnect();
      }
    };
    const observer = new MutationObserver(onObserve);
    observer.observe(this.$parent, { childList: true });
  }

  getState(key) {
    return this.#state.getValue()[key];
  }

  getStates() {
    return this.#state.getValue();
  }

  setState(key, value) {
    if (value === this.getState(key)) return;
    this.#state.next({ ...this.#state.getValue(), [key]: value });
  }
}

 

먼저 생성자 부분을 살펴보자.

class Component {
  constructor(props) {
    if (this.constructor === Component) {
      throw new Error("추상 클래스를 인스턴스화 할 수 없습니다.");
    }
    this.tag = this.constructor;
    this.#state = new BehaviorSubject(props ?? {});
    this.#state.pipe(skip(1)).subscribe(() => {
      this.update();
    });
  }
// 생략 ...
}

Component는 직접 인스턴스를 만들어서는 안 되는 추상 클래스이다. 자바스크립트에는 abstract 키워드를 사용해 추상 클래스를 만들 수 없기 때문에 이 부분을 먼저 구현해 주었다. 호출한 생성자 함수 명을 this.constructor로 확인하고, 만약 Component를 직접 인스턴스화하려 한 경우 에러를 던진다.

 

정상적으로 Component를 상속받은 컴포넌트는 생성자 명을 Element 클래스와 같이 tag란 이름의 프로퍼티로 담고, 매개변수로 전달받은 props는 컴포넌트의 초기 상태 값으로 담는다. 상태는 초기 값을 담을 수 있도록 BehaviorSubject의 인스턴스로 만들어 관리한다. 구독을 통해 상태의 변화가 감지되면, update() 메소드를 호출해 업데이트한다. 단, BehaviorSubject는 초기 값이 전달된 순간에도 발행되므로, skip(1)을 사용해 첫 번째 업데이트를 방지한다.

 

다음으로 template()mount(), onMount() 메소드를 보자.

class Component {
  // 생략...
  template() {}

  mount($parent) {
    this.$parent = $parent;
    this.children = this.template();
    if (!(this.children instanceof Component)) {
      throw new Error("하나 이상의 Element로 컴포넌트를 감싸야 합니다.");
    }

    const mounted = this.children?.mount($parent);

    const beforeUnmount = this.onMount();
    this.#unmount(beforeUnmount);

    return mounted;
  }

   /**
   * @description unmount전에 실행할 함수를 onMount의 리턴으로 넘겨주어야 한다.
   */
  onMount() {}
  // 생략...
}

template()은 추상 메서드이므로 구체 클래스에서 다음과 같이 직접 구현해야 한다. template()의 리턴 값은 리액트와 같이 반드시 하나의 가장 큰 엘리먼트로 다른 엘리먼트들을 감싼 뒤 반환해야 한다.

 

template()는 자식 클래스에서 아래와 같이 구현한다.

class App extends Component {
   template() {
    return new Element("div", { id: "app" }, [
      new Element("button", { class: "btn" }, "click me")
    ]);
  }
}

mount()Element 클래스의 그것과 같이, 실제 DOM에 컴포넌트를 부착하는 역할을 담당한다. tempalate()의 반환을 children 프로퍼티에 할당하고(children은 업데이트시 자식의 변화를 탐지하기 위해 담아둔다) 이를 mount 한다. children의 자리에 Element의 인스턴스가 오면 의도치 않은 동작을 할 수 있으므로 에러를 던진다. 

 

그리고 컴포넌트가 마운트 된 시점에 실행되는 메서드 onMount()를 실행한다. onMount() 역시 구체 클래스에서 구현하는 추상 메소드이다. 리액트의 useEffect()와 같이, onMount() 내에서 반환한 함수는 unmount()에 넘겨져, 컴포넌트가 unmount되기 직전에 호출된다. 주로 아래 예시와 같이 클린업을 위해 사용할 수 있다.

class App extends Component {
    template() {
      return new Element("div", { id: "app" }, [
        new Element("button", { class: "btn" }, "click me")
      ]);
    }

    onMount() {
        const clickButton$ = fromEvent(document.querySelector('.btn'), 'click');
        clickButton$.subscribe(()=> alert('hello'));

        return () => clickButton$.complete();
    }
}

 

상태의 변화로 update()가 호출되면, 다시 updateElement()를 통해 바뀌기 이전 children과 이후 children을 비교하고 변경된 부분만 rDOM에 업데이트 하는 작업을 수행한다. 그리고 업데이트 끝나면 onUpdate()를 호출한다. onUpdate()는 이후 의존성을 추가할 수 있도록 수정할 예정이다.

class Component {
  // 생략...
  update() {
    const next = this.template();
    updateElement(this.children, next, this.$parent);
    this.onUpdate();
  }

  onUpdate() {}
  // 생략... 
}

 

다음은 unmount() 메소드이다.

#unmount(callback) {
    const onObserve = (_, observer) => {
      if (!this.$parent.contains(this.children.element)) {
        observer.disconnect();
        callback();
        this.#state.complete();
      }
    };
    const observer = new MutationObserver(onObserve);
    observer.observe(this.$parent, { childList: true });
  }

unmount()MutationObserver를 사용해 구현했다. mount() 메서드를 설명할 때 $parent를 프로퍼티에 담아둔 이유가 바로 MutationObserver를 통해 부모 엘리먼트를 구독하기 위함이었다.

 

observer는 부모 엘리먼트를 구독하는데, childList 옵션을 주었으므로 자식에게 변화가 일어났을 때에만 발행받는다. 자식에게 변화가 일어났을 때, 만약 부모가 더 이상 children의 엘리먼트를 가지고 있지 않다면, 이는 컴포넌트가 unmount 된 것이라 볼 수 있다.

 

컴포넌트가 unmount 되면 옵저버를 disconnect()한다. 그리고 mount()의 리턴으로 받았던 콜백함수를 호출한 뒤, 마지막으로 컴포넌트의 상태를 관리하던 옵저버블도 complete한다. state을 구독하던 옵저버들도 자동으로 unsubscribe 될 것이다.

 

MutationObserver를 사용해 unmount를 구현함으로써 생긴 한계도 있다. 다음 글에서 다루겠지만, 컴포넌트를 업데이트할 때, children 바뀐 경우 이를 제거하고 새로운 노드를 삽입한다. 이 과정에서 $parentchildren이 자신에게서 삭제된 것을 알게되고 unmount() 메소드를 실행해버린다. 안타깝게도 위 문제를 해결하는 방법은 떠올리지 못해, 애초에 컴포넌트를 생성할 때 $parent 반드시 Element의 인스턴스만 위치할 수 있도록 에러로 강제했다.

 

마지막으로 컴포넌트 내부에서 상태를 조회, 수정하는 메서드들이다.

 getState(key) {
    return this.#state.getValue()[key];
  }

  getStates() {
    return this.#state.getValue();
  }

  setState(key, value) {
    if (value === this.getState(key)) return;
    this.#state.next({ ...this.#state.getValue(), [key]: value });
  }
  • getState()는 키 값을 받아, 동일한 키르 저장된 상태의 값을 반환한다.
  • getStates()는 모든 상태를 반환한다.
  • setState()는 값의 변경이 있는 경우에만 수행된다. next()로 새로운 값을 발행하면, 이를 구독 중이던 옵저버를 통해 update()가 호출될 것이다.

몇 줄 안되는 unmount() 메서드를 구현하기 위해 많은 고민이 있었다. 사실상 가상돔을 구현하게된 가장 큰 이유인데, 사실 기존에 프로토타입으로 작성한 innerHTML을 덮어쓰는 렌더 방식의 컴포넌트에서도 첫 번째 이유는 어느 정도 해결이 가능했고, 클린업 하지 않아도 게임의 동작에 문제는 발견되지 않았지만... 그냥 오기로 해봤다. 지금까지는 의도한 대로 잘 동작하는 것 같은데, 논리적 허점이 있다면 누구든 피드백을 주길 바란다.

 

마지막으로 가상돔에서 가장 중요한 변화를 확인하고 업데이트 하는 updateElement() 함수에 대한 설명이 남았는데, 포스팅이 너무 길어져서 다음 글에서 다루도록 하겠다.

 

지금까지의 코드는 아래 코드샌드박스에서 확인할 수 있다. 

댓글