[JS] Vanilla JS와 Serverless로 SSR 사이트 만들기 - 컴포넌트 심화

thru·2024년 2월 26일
0

혼자 JS, Serverless 갖고 놀기 - 컴포넌트 심화편


소개

지난 포스팅에선 모든 컴포넌트가 상속받는 공통 컴포넌트의 메서드에 대해서 소개했었다. 이번 포스팅은 일부 메서드에 추가 기능을 더해서 컴포넌트 시스템을 강화하고자 한다.


매개변수

일단 자식 컴포넌트에 매개변수를 전달할 수 있도록 생성자를 수정한다.

export default class Component {
  $target;
  state = {};
  refs = {};
  children = [];
  props = {}; /**@new */
  constructor(selector, props) {
    this.$target = document.querySelector(selector);
    if (!this.$target) return;
    
    this.props = { ...props } /**@new */

    this.setup();
    this.hydrate();
  }

props 필드를 하나 만들고 생성할 때 전달받도록 한다.

매개변수는 혹시 모를 변경이 부모 컴포넌트에 영향을 미치는 것을 방지하기 위해 복사본을 저장한다.

싱글톤

위 코드대로라면 매개변수를 갱신하기 위해 전달해서 리렌더링을 시키려고 해도 새로운 인스턴트를 만들어낸다.

mounted에서 자식 컴포넌트를 마운트할 때, 생성자를 그대로 쓰더라도 기존에 인스턴스가 만들어진 적이 있다면 해당 인스턴스의 render를 촉발시키는 게 목표이다. 싱글톤 패턴이 해결법이 될 수 있다.

싱글톤은 클래스 외부에 존재하는 변수에 클래스의 인스턴스를 저장하고, 두 번째 생성자 호출부터는 그 인스턴스를 반환해서 한 번만 생성이 이루어지도록 하는 패턴이다.

let singleton;

class OnlyOne {
  constructor() {
    if (singleton) return singleton;
    
    singleton = this;
  }
}

첫 호출 시에 this, 즉 인스턴스 자체를 singleton에 저장한다.

이를 응용해서 리렌더링 로직을 구현한다.

const singleton = {}; /**@new */

export default class Component {
  $target;
  state = {};
  refs = {};
  children = [];
  props = {};
  constructor(selector, props) {
    if (!selector.startsWith('#')) { /**@new */
      console.error('selector is not Id');
      return;
    }
    
    if (singleton[selector]) { /**@new */
      singleton[selector].props = { ...props };
      singleton[selector].render();

      return singleton[selector];
    }

    this.$target = document.querySelector(selector);
    if (!this.$target) return;

    singleton[selector] = this; /**@new */
    this.props = { ...props };

    this.setup();
    this.hydrate();
  }

싱글톤을 쓴다고 해서 컴포넌트 당 무조건 하나의 인스턴스만 갖게 만드는 건 컴포넌트의 재사용성을 약화시킨다. 때문에 싱글톤에 저장하는 기준을 선택자로 구분했다.

생성자의 selector는 HTML 요소의 id 속성값을 사용한다는 규칙을 자체적으로 세웠다. id 속성은 document 내에서 유일한 값을 가져야 한다는 원칙을 기본적으로 가지고 있다. 이 id 속성값을 key로 해서 singleton 객체에 인스턴스를 저장한다.

사실 id의 유일성이 강제되는 것은 아니다. 이는 약간 불안한 감이 있긴 하다. 거슬린다면 컴포넌트 생성시 uuid 같은 걸 만들어서 사용하는 방법도 가능하리라 생각한다.

싱글톤에 인스턴스가 저장된 클래스를 재 호출되면 매개변수인 props를 업데이트하고 render로 흐름이 이동한다.

사용

mounted() {
  const slideBarProp = {
    curSlideIndex: this.state.slideIndex,
    moveSlide: this.moveSlide.bind(this),
  };
  this.addChild(SlideBar, ID.SLIDE_BAR, slideBarProp);

  const scrollIndicatorProp = {
    curPathname: this.props.curPathname,
    curSlideIndex: this.state.slideIndex,
    loadPageData: this.moveSlide.bind(this),
  };
  this.addChild(
    ScrollIndicator,
    ID.SCROLL_INDICATOR_HORIZON,
    scrollIndicatorProp,
  );

  super.mounted();
}
addChild(Child, selector, props /**@new */) {
  const child = new Child(selector, props /**@new */);

  if (!this.children.includes(child)) {
    this.children.push(child);
  }

  return child;
}

mounted에 자식 컴포넌트 생성 로직이 추상화되어있는 addChild를 사용하면 생성 및 리렌더링을 구현할 수 있다.


이벤트 위임

현재는 이벤트 버블링을 이용하긴 하지만 이벤트 핸들러를 이벤트마다 추가하고 있어 메모리 낭비가 있다. 핸들러를 루트 컴포넌트에만 달아 이벤트 위임을 구현한다.

먼저 루트 컴포넌트 지정이 가능하도록 생성자를 수정한다.

const singleton = {};
const eventCallbacks = {}; /**@new */

export default class Component {
  $target;
  root; /**@new */
  state = {};
  refs = {};
  props = {};
  children = [];
  events = []; /**@new */
  constructor(selector, props, root) {
    
    /**@생략 */
    
    singleton[selector] = this;
    this.props = { ...props };
    this.root = root || this.id; /**@new */ 

    this.setup();
    this.hydrate();
  }

  /**@생략 */

  get id() { /**@new */
    return this.$target.id;
  }
  get idSelector() {
    return `#${this.id}`;
  }
  get isRoot() {
    return this.root === this.id;
  }
}

생성자 호출시 root를 지정하지 않으면 자신의 id를 저장해 루트 컴포넌트로서 작동하도록 한다. id와 root를 쉽게 다루기 위한 get 메서드들도 추가한다.

이벤트 콜백을 저장할 변수인 eventCallbacksevents도 추가한다.

addChild에서는 root의 기본값을 현재 컴포넌트의 root로 지정해 모든 자식 컴포넌트가 하나의 루트 컴포넌트를 가리킬 수 있도록 한다.

addChild(
  Child, 
  selector, 
  props, 
  root = this.root /**@new */
) {
  const child = new Child(selector, props, root);

  if (!this.children.includes(child)) {
    this.children.push(child);
  }

  return child;
}

addEvent는 이벤트 핸들러를 등록하지 않고 콜백을 변수에 저장하는 역할로 변경한다.

/**@all new */
addEvent(eventType, selector, callback) {
  if (!eventCallbacks[this.root]) return;

  const callbackInfo = {
    selector,
    callback,
  };
  eventCallbacks[this.root][eventType]?.push(callbackInfo);

  this.events.push({
    eventType,
    callbackInfo,
  });
}

eventCallbacks는 루트 컴포넌트의 이벤트 타입별 콜백을 저장하는 기능을 한다.

events는 컴포넌트가 추가한 이벤트의 기록을 저장한다.

setup() {
  if (this.isRoot) { /**@new */
    eventCallbacks[this.id] = {
      click: [],
      scroll: [],
      mousemove: [],
      wheel: [],
      touchmove: [],
      popstate: [],
    };
  }
}
hydrate() {
  if (this.isRoot) this.setEventDeligation(); /**@new */
  
  this.render();
}

setup에서 현재 컴포넌트가 루트라면 eventCallbacks를 초기화한다. 현재는 프로젝트에서 사용하는 이벤트 타입만 명시되어 있다.

hydrate에는 루트 컴포넌트에 이벤트 핸들러를 등록하는 기능을 추가할 것이다.


setEventDeligation에선 배열에 저장한 콜백을 핸들러에서 실행하기 위해 등록한다.

setEventDeligation() {
  if (!this.isRoot) return;

  for (const [eventType, targetList] of Object.entries(
    eventCallbacks[this.id],
  )) {
    this.$target.addEventListener(eventType, (event) => {
      targetList.forEach(({ selector, callback }) => {
        if (!event.target.closest(selector)) return false;

        callback(event);
      });
    });
  }
}

addEventListener에서 등록한 targetList는 참조값으로 접근하는 배열이기 때문에 등록 이후에 콜백이 추가되더라도 핸들러에서 실행할 수 있다. 물론 이를 위해선 eventCallbacks의 배열을 업데이트할 때 참조값을 변경해선 안된다.

이제 컴포넌트 트리의 이벤트들을 이벤트 타입 당 핸들러 하나로 관리해서 메모리를 절약할 수 있다.


상태 관리

현재 컴포넌트 설계대로 개발을 진행하면서 컴포넌트 하위에 자식 컴포넌트를 조금씩 추가하다보니 컴포넌트 간 상태가 꼬이면서 디버깅이 어려워지는 걸 느낄 수 있었다.

state 변경을 모아서 처리하는 batching을 통해 문제를 해결해보고자 한다.

문제점

현재 setState는 컴포넌트의 state에 바로 반영하고 리렌더링을 바로 촉발시키는 방식으로 구현되어있다.

setState(nextState) {
  this.state = { ...this.state, ...nextState };
  this.render(); // 즉시 렌더링
}

이 방식은 컴포넌트 구조가 조금만 복잡해져도 렌더링 흐름이 꼬인다.

부모 컴포넌트가 자식 컴포넌트에 상태를 변경시키는 함수를 전달하는 상황을 고려해본다.

class Parent extends Component {
  setup() {
    this.state = {
      count: 0,
    };
  }
  
  mounted() {
    const myChildProp = {
      increaseCount: this.setState({
        count: this.state.count + 1,
      }),
    };
    this.addChild(MyChild, ID.MY_CHILD, myChildProp);
  }
}
class MyChild extends Component {
  hydrate() {
    this.props.increaseCount();
    
    super.hydrate();
  }
}

이러면 자식 컴포넌트의 렌더링이 진행되던 중에 부모의 렌더링 흐름이 끼어든다.

🔽 MyChild 컴포넌트의 hydrate 주기 그래프, 첫 렌더링이 일어나기도 전에 increaseCount로 인한 리렌더링이 일어나고 있다.

해결하려면 즉시 렌더링을 촉발하진 말아야 한다. 이 때 유용한 것이 requestAnimationFrame이다. requestAnimationFrame은 등록한 콜백을 디스플레이 프레임 변화가 시작되는 시점에 실행시켜주는 기능을 한다. 렌더링은 화면 요소의 변화를 촉발하니 프레임 구간에 맞춰 실행시키는 게 합리적이다.

배칭

이왕 미루는 김에 모아서 한번에 하면 효율적일 것 같다. 일정 기간 내의 상태 변화를 병합해서 한번에 처리한다는 게 배칭의 핵심 개념이다.

const stateStore = {};

export default class Component {
  raf = null;
  /**@ 생략 */

  setState(nextState) {
    stateStore[this.id] = { ...stateStore[this.id], ...nextState };

    cancelAnimationFrame(this.raf);

    this.raf = requestAnimationFrame(() => {
      this.state = stateStore[this.id];

      this.render();
    });
  }

  /**@ 생략 */
}

먼저 상태 변화 내역을 축적시키기 위해 전역에 stateStore라는 상태 저장소를 만들어 저장한다.

setState에서는 상태 변화 내역을 컴포넌트의 state에 반영하고 리렌더링을 촉발하는 콜백을 requestAnimationFrame에 등록한다. 등록 전에는 이미 대기중인 requestAnimationFrame을 취소하도록 cancelAnimationFrame을 실행해서 배칭 및 디바운싱이 이루어질 수 있도록 한다.

이젠 기존 렌더링이 모두 끝난 뒤에 다음 렌더링이 수행되므로 흐름이 중간에 꼬이지 않는다.

렌더링 스킵

상태 변화 전후가 동일하다면 리렌더링을 다시 할 필요가 없다. state를 비교하는 로직을 구현한다.

setState(nextState) {
  stateStore[this.id] = { ...stateStore[this.id], ...nextState };

  cancelAnimationFrame(this.raf);

  if (this.shallowEqualState()) return; /**@new */

  this.raf = requestAnimationFrame(() => {
    this.state = stateStore[this.id];

    this.render();
  });
}

shallowEqualState() {
  const keys = Object.keys(this.state);
  const nextKeys = Object.keys(stateStore[this.id]);

  if (keys.length !== nextKeys.length) return false;

  for (const key of keys) {
    if (this.state[key] !== stateStore[this.id][key]) return false;
  }

  return true;
}

현재 state는 객체인데 깊은 비교는 계산 코스트가 과하기에 얕은 비교만 한다. 배칭 결과로 남은 상태 변화 내역이 기존과 동일하면 setState를 취소한다.


언마운트

이제 언마운트할 때 싱글톤과 상태 저장소, 이벤트들을 삭제하고 requestAnimationFrame을 취소해줘야 오류가 발생하지 않는다.

unmount() {
  this.children.forEach((child) => {
    child.unmount();
  });

  singleton[this.idSelector] = null; /**@new */
  
  stateStore[this.id] = {}; /**@new */
  cancelAnimationFrame(this.raf); /**@new */

  /**@new */
  this.events.forEach(({ eventType, callbackInfo }) => {
    const targetIndice = [];
    eventCallbacks[this.root][eventType].forEach(
      (prevCallbackInfo, index) => {
        if (prevCallbackInfo === callbackInfo) {
          targetIndice.push(index);
        }
      },
    );
    targetIndice.reverse().forEach((target) => {
      eventCallbacks[this.root][eventType].splice(target, 1);
    });
  });

  this.$target.remove();
  this.$target = null;
}
profile
프론트 공부 중

0개의 댓글