티스토리 뷰

웹 개발/웹

Custom DOM Library 만들기

곤이씨 2021. 4. 5. 01:22

Custom DOM Library

 

 DOM API를 통해 요소 노드를 불러오는 것은 꽤나 번거로운 일이다. 이를 해결하기 위해 Lv.1-1 자동차 경주 미션에서 jQuery 스타일로 코드를 작성하는 방법을 배웠다. 그리고 강의 시간에 준이 이보다 더 개선된 형태의 custom DOM library를 만들어봐도 좋을 것 같다는 이야기를 해주었다. 페어였던 카일이 주도하여 custom DOM library를 만들었고 이후 좀 더 코드를 개선하였다. (Thanks to 카일)

 

export const $ = (() => {
  const constructor = function (selector) {
    if (!selector) {
      return;
    }
    this.targets = document.querySelectorAll(selector);
    this.target = this.targets.length === 1 && this.targets[0];
  };

  constructor.prototype.each = function (callBack) {
    if (!callBack || typeof callBack !== 'function') {
      throw new Error('Custom DOM Library Error: this method needs callback function!');
    }

    this.targets.forEach((target, idx) => callBack(target, idx));

    return this;
  };

  constructor.prototype.map = function (callBack) {
    if (!callBack || typeof callBack !== 'function') {
      throw new Error('Custom DOM Library Error: this method needs callback function!');
    }

    return [...this.targets].map((target, idx) => callBack(target, idx));
  };

  constructor.prototype.filter = function (callBack) {
    if (!callBack || typeof callBack !== 'function') {
      throw new Error('Custom DOM Library Error: this method needs callback function!');
    }

    return [...this.targets].filter((target, idx) => callBack(target, idx));
  };

  constructor.prototype.addClass = function (className) {
    if (className === undefined) {
      throw new Error('Custom DOM Library Error: this method needs classname!');
    }

    this.each(target => target.classList.add(className));

    return this;
  };

  constructor.prototype.removeClass = function (className) {
    if (className === undefined) {
      throw new Error('Custom DOM Library Error: this method needs classname!');
    }

    this.each(target => target.classList.remove(className));

    return this;
  };

  constructor.prototype.toggleClass = function (className) {
    if (className === undefined) {
      throw new Error('Custom DOM Library Error: this method needs classname!');
    }

    this.each(target => target.classList.toggle(className));

    return this;
  };

  constructor.prototype.setEvent = function (type, eventHandler) {
    if (type === undefined || eventHandler === undefined) {
      throw new Error('Custom DOM Library Error: this method needs event type and event handler!');
    }

    this.each(target => target.addEventListener(type, eventHandler));

    return this;
  };

  constructor.prototype.getValue = function () {
    return this.target.value;
  };

  constructor.prototype.setValue = function (value) {
    if (value === undefined) {
      throw new Error('Custom DOM Library Error: this method needs value');
    }

    this.each(target => {
      target.value = value;
    });
  };

  constructor.prototype.enable = function () {
    this.each(target => {
      target.disabled = false;
    });
  };

  constructor.prototype.disable = function () {
    this.each(target => {
      target.disabled = true;
    });
  };

  constructor.prototype.show = function () {
    this.each(target => {
      target.style.display = 'block';
    });
  };

  constructor.prototype.hide = function () {
    this.each(target => {
      target.style.display = 'none';
    });
  };

  constructor.prototype.innerText = function (text) {
    this.each(target => {
      target.innerText = text;
    });
  };

  constructor.prototype.innerHTML = function (html) {
    this.each(target => {
      target.innerHTML = html;
    });
  };

  constructor.prototype.isCheckedInput = function () {
    return this.target.checked;
  };

  const instantiate = selector => {
    return new constructor(selector);
  };

  return instantiate;
})();

 

 코드가 상당히 길다. 그만큼 편리한 기능을 많이 제공해준다. 먼저 $ 하나로 노드를 한개부터 여러개까지 불러올 수 있다. each라는 프로토타입 메서드를 생성하여 노드가 여러개인 경우에도 굳이 forEach문을 사용할 필요 없이 메서드 체이닝으로 바로 원하는 기능을 수행할 수 있도록 하였다. 이 코드를 이해하기 위해서는 자바스크립트의 프로토타입, 클로저, 즉시실행 함수, this를 알아야한다. 이 중 즉시 실행 함수가 익숙하지 않아 내용을 정리해봤다.

 

 

1. 즉시 실행 함수 (+ 함수 내부에서 함수 반환)

const hiFunction = ((name1) => {
  console.log("hi " + name1);
  return function helloFunction(name2) {
    console.log("hello " + name2);
  };
})("gonnie"); // hi gonnie

hiFunction("jun"); // hello jun
hiFunction("poco"); // hello poco
helloFunction("kail"); // Uncaught ReferenceError: helloFunction is not defined

 

 위 코드의 실행결과를 확인하면 'hi gonnie', 'hello jun', 'hello poco'가 차례대로 나온다. 그 이유는 hiFunction이 함수 표현식으로 함수가 선언됨과 동시에 실행되기 때문이다. hiFunction을 선언함과 동시에 'gonnie'를 인자로 주면서 실행시키기 때문에 name1의 값은 'gonnie'가 되어 "hi gonnie"를 출력한다. 그러나 이후 hiFunction을 호출하면 출력이 "hi"가 아닌 "hello"로 시작하는 것을 확인할 수 있다. 그 이유는 hiFunction이 최초에 실행될 때, 익명함수를 리턴하기 때문이다. 따라서 hiFunction은 이제 helloFunction으로 대체됐다. 그러나 helloFunction은 hiFunction 스코프 내부에서 선언 됐기 때문에 함수 밖에서 helloFunction을 직접 호출하는 것은 불가능하다. 

 

 

 위처럼 코드를 작성하면서 얻을 수 있는 이점은 무엇이 있을까?

 

 

 함수는 재사용을 위해 만드는 것이기 때문에 많이 사용될수록 좋다. 그러나 함수 안에 있는 코드 중 딱 한번 실행되기만 하면 되는 코드가 있다. 예를 들어, 초기화를 하는 코드라던가 프로토타입에 메소드를 추가 혹은 삭제하는 코드는 중복 실행될 필요가 없다. 따라서 함수가 여러번 사용되지만 특정 코드는 딱 한번만 실행되게 하면 되는 경우에 위와 같은 방식으로 코드를 작성하면 불필요한 리소스 낭비를 줄일 수 있다. 아래의 코드를 보며 좀 더 자세히 이해해보자

 

const $ = (() => {
  const constructor = function (selector) {
    this.targets = document.querySelectorAll(selector);
    this.target = this.targets.length === 1 && this.targets[0];

    constructor.prototype.each = function (callBack) {
      if (!callBack || typeof callBack !== "function") {
        return;
      }

      this.targets.forEach((target, idx) => callBack(target, idx));
      return this;
    };

    constructor.prototype.setValue = function (value) {
      this.each((target) => {
        target.value = value;
      });
    };

    const instantiate = (selector) => {
      return new constructor(selector);
    };

    return instantiate;
  };
})();

 

 함수 선언과 동시에 실행을 하여 프로토타입에 메소드를 생성하는 코드를 딱 한번만 실행하게 한다. 이후 다른 곳에서 함수가 호출될 때는 instantiate 함수를 통해 새로운 인스턴스를 생성하게 한다. 만약 위와 같이 코드를 짜지 않는다면 함수가 실행될 때마다 불필요하게 프로토타입에 메소드를 생성해야할 것이고 이는 바람직한 방법이 아니다. 따라서 위와 같은 방법을 숙지해놓으면 코드를 좀 더 효율적으로 짤 수 있다.

 

 

 그러나 위와 같은 방식으로 코드를 작성하는 경우, 동일한 DOM을 조회할 때마다 매번 새로운 인스턴스를 생성하게 된다. 이에 대해 리뷰어에게 여쭤보니 lodash의 memoize 함수를 참고해보라는 말씀을 해주셨다.

 

 

2. memoize

export function memoize(callback) {
  const cache = new Map();

  return function (arg) {
    if (!cache.get(arg)) {
      cache.set(arg, new callback(arg));
    }

    return cache.get(arg);
  };
}

 

 memoize 함수를 이용하면 캐싱을 통해 중복된 계산을 피하고 메모리를 절약할 수 있다. 앞의 custom DOM library 코드는 동일한 DOM을 조회할 때 마다, 새로운 인스턴스를 생성하는 문제가 있다고 언급했다. memoize 함수를 적용하면 이러한 문제를 해결할 수 있다. 그러나, custom DOM library는 memoize 함수를 도입하면 안된다. 그 이유는 이전에 호출했던 DOM이 non-live 객체인 NodeList인 경우, DOM의 상태 변화를 반영하지 않기 때문이다. 따라서 DOM의 상태가 변경되었다면 새롭게 $(selector)로 조회를 해줘야 하는데 memoize를 사용하면 이미 selector가 캐쉬에 저장되어 있어 새롭게 인스턴스를 생성하는 것이 아닌 기존 요소를 리턴한다. 따라서, 사용자는 memoize가 적용된 custom DOM library를 사용하는 한 갱신된 값을 얻을 수 없다. 

 

 

 위와 같은 이유로 memoize를 공부하고 적용했지만 마지막에는 원래의 코드로 돌아왔다. 그리고 본래의 코드에서 우려되었던 새 인스턴스를 매번 생성하여 불필요한 메모리를 차지한다는 문제는 자바스크립트의 가비지 컬렉터가 해결해줌을 깨달았다. 자바스크립트의 가비지 콜렉터는 참조되지 않는 메모리 주소에 대하여 메모리를 해제한다. 따라서 새로운 인스턴스를 생성하고 변수로 지정한 다음에 동일한 이름의 인스턴스를 새로 생성하여 변수에 다시 저장하면 이전 인스턴스는 더 이상 참조되지 않아 가비지 컬렉터에 의해 메모리 상에서 사라진다. 따라서 우려했던 문제는 해결되는 것이다.

 

 

3. 커스텀 라이브러리 사용 시 주의사항

 

문서화를 잘 해야한다.
다른 팀원이 인터페이스를 익히는데 시간이 필요함을 인지한다.
지속적인 유지보수가 필요하다.
사용자가 라이브러리 제작자의 의도대로 라이브러리를 사용하지 않는 경우 undefined가 아닌 throw new Error를 통해 명시적으로 에러를 알려준다.

 

 

4. 실사용 예시

// 기본 방법
document
  .querySelectorAll('.container')
  .forEach($container => ($container.innerHTML = '<span>Hello everyone</span>'));

// Custom DOM Library
$('.container').innerHTML('<span>Hello everyone</span>');

 

 위와 같이 기본 자바스크립트 내장 객체 메서드를 사용하는 경우에는 코드가 길어진다. 그러나 Custom DOM Library를 사용하면 같은 기능을 수행함에도 훨씬 더 코드를 짧고 간결하게 쓸 수 있다.

 

 이 외에도 기존 Node 객체나 HTML Collection, Node List에서 제공하지 않는 메서드들을 자신의 필요에 맞게 추가할 수 있다.

 

 

5. 마무리

 커스텀 라이브러리는 확실한 트레이드 오프가 있다. 커스텀 라이브러리를 잘만 짜면 생산성을 올리고 코드의 간결함을 얻을 수 있다. 그러나 커스텀 라이브러리를 관리하는 데 투자해야하는 시간과 노력도 분명히 존재한다. 또한, 요즘 같이  코드를 오픈 소스로 공개하는 경우에는 커스텀 라이브러리를 쓰면 오히려 사람들이 코드를 이해하는 데 방해가 될 수도 있다. 따라서 커스텀 라이브러리 사용의 장단점을 명확하게 이해하고 쓰는 것이 중요하다 😄