웹 컴포넌트(2) - 커스텀 엘리먼트


오늘은 이전에 썼던 글 WebComponents: Keep calm and #UseThePlatform에 이어지는 것으로, Web Components의 주요 표준 중 하나인 Custom Elements에 대한 글이다.

tl;dr

  • <div> 대신 <current-time>처럼 적절한 이름의 태그를 사용할 수 있다
  • HTML Element와 Javascript Class를 한 몸으로 만들어 준다
  • IE11 이상만 지원, Polyfill 필요할 수도 있다
  • 긴 MutationObserver 코드는 굿바이

사용법은 아래의 코드를 보는 것이 빠르겠다. 아래의 코드를 크롬이나 사파리에 올려보자. 이외의 브라우저에서는 아래의 브라우저 지원과 Polyfill 섹션을 참조하자.

<!DOCTYPE html>
<html>
  <head>
    <script src="../src/CurrentTime.js"></script>
  </head>
  <body>
    <current-time>
      <!-- fallback value -->
      6/11/2017, 11:55:49
    </current-time>
  </body>
</html>
class CurrentTime extends HTMLElement {
  constructor() {
    // 클래스 초기화. 속성이나 하위 노드는 접근할 수는 없다.
    super();
  }

  static get observedAttributes() {
    // 모니터링 할 속성 이름
    return ["locale"];
  }

  connectedCallback() {
    // DOM에 추가되었다. 렌더링 등의 처리를 하자.
    this.start();
  }

  disconnectedCallback() {
    // DOM에서 제거되었다. 엘리먼트를 정리하는 일을 하자.
    this.stop();
  }

  attributeChangedCallback(attrName, oldVal, newVal) {
    // 속성이 추가/제거/변경되었다.
    this[attrName] = newVal;
  }

  adoptedCallback(oldDoc, newDoc) {
    // 다른 Document에서 옮겨져 왔음
    // 자주 쓸 일은 없을 것.
  }

  start() {
    // 필요에 따라 메서드를 추가할 수 있다.
    // 이 클래스 인스턴스는 HTMLElement이다.
    // 따라서 `document.querySelector('current-time').start()`로 호출할 수 있다.
    this.stop();
    this._timer = window.setInterval(() => {
      this.innerText = new Date().toLocaleString(this.locale);
    }, 1000);
  }

  stop() {
    // 이 메서드 역시 CurrentTime클래스의 필요에 의해 추가했다.
    if (this._timer) {
      window.clearInterval(this._timer);
      this._timer = null;
    }
  }
}

// <current-time> 태그가 CurrentTime 클래스를 사용하도록 한다.
customElements.define("current-time", CurrentTime);

아래에는 참고로 비슷한 역할을 수행하도록 Custom Elements를 사용하지 않고 작성한 코드이다.

<!DOCTYPE html>
<html>
  <head>
    <script src="../src/CurrentTime.js"></script>
  </head>
  <body>
    <div class="current-time">
      <!-- fallback value -->
      6/11/2017, 11:55:49
    </div>
  </body>
</html>
class CurrentTime {
  constructor(el) {
    this._el = el;

    this._init();
    this.start();
  }

  _init() {
    // 속성 변경을 모니터
    this._localeChangedObserver = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (
          mutation.type === "attributes" &&
          mutation.attributeName === "locale"
        ) {
          this.locale = this._el.getAttribute("locale");
        }
      });
    });
    this._localeChangedObserver.observe(this._el, {
      attributes: true,
      attributeFilter: ["locale"]
    });

    // 엘리먼트가 DOM에서 제거되었는지 모니터
    this._disconnectedObserver = new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        if (
          mutation.type === "childList" &&
          Array.prototype.slice.call(mutation.removedNodes).indexOf(this._el) >=
            0
        ) {
          this.dispose();
        }
      });
    });
    this._disconnectedObserver.observe(this._el.parentNode, {
      childList: true
    });
  }

  start() {
    this.stop();

    this._timer = window.setInterval(() => {
      this._el.innerText = new Date().toLocaleString(this.locale);
    }, 1000);
  }

  stop() {
    if (this._timer) {
      window.clearInterval(this._timer);
      this._timer = null;
    }
  }

  dispose() {
    this.stop();
    this._localeChangedObserver.disconnect();
    this._disconnectedObserver.disconnect();
  }

  static create(el) {
    return new CurrentTime(el);
  }
}

document.addEventListener(
  "DOMContentLoaded",
  () => {
    document.querySelectorAll(".current-time").forEach(el => {
      CurrentTime.create(el);
    });
  },
  false
);

Modernizing HTML: 빠진 조각

천천히 글을 읽어보기로 했다면 아래의 W3C에서 발췌한 서문부터 시작해 본다.

This specification describes the method for enabling the author to define and use new types of DOM elements in a document. - W3C: Custom Elements

W3C의 Custom Elements 표준 서문은 이를 새로운 DOM Elements를 정의하고 사용하는 방법이라 말하고 있다. 필자에 따라서는 Missing Piece of HTML(HTML에 빠진 조각), Association of a tag with a class(태그와 클래스의 연동) 이라고도 한다.

HTML은 우리 모두 잘 알다시피 웹 페이지 문서의 구조를 설명하기 위해 태어난 언어로, 현대의 웹 애플리케이션을 만들기 위해 컴포넌트로써 사용하는 것은 어색하다. HTML 표준은 우리가 애플리케이션을 만들 때 필요한 컴포넌트들의 정의는 포함하지 않기 때문이다.

Div 중독증

우리가 웹 애플리케이션을 작성할 때 가장 많이 사용하는 태그는 무엇인가? 기억을 돌이킬 필요도 없이 div태그이다. 아래는 gmail의 마크업이지만, 우리 모두 이렇게 사용하고 있음을 부정할 사람은 없을 것이다. 때에 따라서 figure, code, nav등의 태그들을 조금 더 섞어 줄 수 있겠으나, 그 의미적인 태그들이 우리가 div를 쓰고 있는 이유를 없애 주지는 않는다.

image 'div' soup: 묻지도 따지지도 말고 몽땅 div에 넣고 저어라!

한번은 왜 div인가? 하는 물음을 가지고 다른 방법을 찾아보기도 했지만, HTML 표준에 따르자면 다른 태그들도 마땅치 해결책이 되지는 않았다. 비슷한 용도로 쓰이는 span으로 만들라 한들 문제가 될 리 없으나, 인라인 태그 안에 블록 태그를 넣는 것은 HTML 스펙을 무시하는 일이니 결국 어떤 컴포넌트던지 블록 태그인 div에 담는 것으로 돌아오고 만다.

Authors are strongly encouraged to view the div element as an element of last resort, for when no other element is suitable. - W3C HTML5.1: The div element > 아무런 엘리먼트도 어울리지 않으니 div를 사용한다

할 수만 있다면, 의미 없는 div를 남발하는 것보다 의미 있는 태그 이름을 주는 쪽이 좋지 않을까? 기존 HTML 표준은 div이외에 마땅한 해결책을 주지 않았지만, 아래는 Custom Elements를 사용하여 적절한 이름을 가지게 한 마크업의 예시이다. 이쁘다!

<div class="user-profile">
  <div class="layout card small">...</div>
</div>

<user-profile>
  <card-layout type="small">...</card-layout>
</user-profile>

자바스크립트 - HTML: 마리오네트 인형극

image

div 인형들을 실로 묶어 자바스크립트가 연출하는 꼭두각시 인형극 - [Polymer 2.0: Under the Hood - Rob Dodson](https://www.youtube.com/watch?v=9vYJ8K6AKc)_

위에서부터 얘기했지만 사실 div 엘리먼트는 자바스크립트와 연관이 없다. "음? 무슨 말이지? 자바스크립트로 웹 애플리케이션 잘 만들고 있는데!" 물론 그렇다. 다시 고쳐 말하자면 엘리먼트들은 자바스크립트를 알지 못하며, 자바스크립트는 엘리먼트들을 조정해 꼭두각시 인형처럼 연출한다. 아래의 익숙한 코드들을 살펴보자.

// 자바스크립트 컨트롤을 묶을 엘리먼트를 받아와야 한다.
constructor(el) {
    this._el = el;
}
...
// 이 클래스 인스턴스가 `유지하는 엘리먼트`의 innerText
this._el.innerText = 'text';
...
// 클래스 인스턴스와 엘리먼트의 라이프사이클은 다르다.
// bootstrap
document.addEventListener('DOMContentLoaded', () => {
    CurrentTime.create(document.querySelector('.current-time'));
}, false);
// finalize
this._disconnectedObserver = new MutationObserver(mutations => { ... this.dispose() ... });
this._disconnectedObserver.observe(this._el.parentNode, {
    childList: true
});

우리에게 너무나도 익숙하기에 당연하게 느껴지겠지만 아래의 Custom Elements를 정의하는 코드와 비교하며 어떠한 점이 다른지 찾아보자. 아래의 자세한 로직은 곧 설명할 테니 걱정 말자!

위의 코드에서는 자바스크립트에게 엘리먼트를 패스해주기 전까지 서로 어떠한 관계도 없다. 자바스크립트에게 패스해 준 이후에도 엘리먼트는 프로퍼티에 저장해 두고 조작해야 할 3인칭 대상이다. 또한 클래스 인스턴스와 엘리먼트는 라이프 사이클을 달리하기에 항상 부트스트랩 코드가 필요하며 경우에 따라서는 길고 고통스러운 MutationObserver 코드를 작성해야 하기도 한다. (심지어 대상 엘리먼트가 DOM에서 제거되었는지 알기 위해서는 대상 엘리먼트의 상위 엘리먼트까지 살펴야 한다!)

class CurrentTime extends HTMLElement {...}
...
this.innerText = 'text';
...
// connect
connectedCallback() { ... }
// disconnect
disconnectedCallback() { ... }

Custom Elements의 코드에서는 클래스의 인스턴스 this가 엘리먼트 인스턴스 자체이다. 엘리먼트와 클래스 인스턴스는 동일한 라이프 사이클을 가지기에, 엘리먼트의 라이프 사이클에 맞추기 위한 어떠한 부트스트랩 코드나 MutationObserver가 필요하지 않다. 첫번째 코드는 우리에게 익숙하지만 두번째 코드는 생소하지만 무척이나 간결하고도 자연스럽다.

Custom Elements는 웹 애플리케이션을 작성하는데 필요한 HTML의 빠진 조각을 자연스러우면서도 직관적인 방법으로 제공해준다.

구현: 한 걸음씩

엘리먼트와 클래스 묶어주기

무엇을 하고 싶은지 알아봤으니, 이제 Custom Elements를 등록하는 방법부터 차근차근 알아보겠다. 아래는 가장 간단한 코드로 Custom Elements를 등록하는 방법이다. 이는 windowCustomElementRegistrycustom-time태그와 주어진 클래스를 묶는 역할을 한다.

window.customElements.define("current-time", class extends HTMLElement {});
<current-time></current-time>

이것으로 HTML에서 <current-time>태그를 사용할 수 있게 되었다. 아무런 로직을 주지 않았으므로 별 의미 없는 일이 되었지만 엄연히 current-time은 HTML에서 사용될 수 있는 Custom Element가 되었다.

이름 규칙: -를 포함하자

주의할 점은 태그의 이름인데, Custom Elements는 특별한 이름 규칙을 필요로 한다. 쉽게 설명하면 글자 가운데 -를 하나 이상 포함해야 한다는 것이다. W3C Custom Elements: 올바른 이름 규칙

올바른 이름 예

<tui-editor></tui-editor>
<my-element></my-element>
<super-awsome-carousel></super-awsome-carousel>

잘못된 이름 예

<tuieditor></tuieditor> /* `-` 없음 */ <font-face></font-face> /* 예약된 태그
이름 SVG */ <missing-glyph></missing-glyph> /* 예약된 태그 이름 SVG */

이러한 제약을 가지는 이유는 HTML파서가 자바스크립트에서 선언된 Custom Elements를 모르는 상황에서도 Custom Elements가 될지도 모르는 태그들을 구분하기 위해서이다. HTML표준에 정의되어 있지 않으면서도 Custom Elements 이름 규칙에 맞지 않는 태그들은 HTMLUnknownElement인터페이스가 할당된다. 그러나 Custom Elements들은 HTMLElement로부터 직접 상속되어야 하므로 Custom Elements 이름 규칙에 맞는 것들의 HTMLUnknownElement 상속을 막기 위해서이다. WHATWG: 태그 이름에 따라 HTMLElement 인터페이스가 할당되는 방법

HTMLElement의 상속과 constructor

여태까지의 예제에서도 그러했지만, Custom Elements 스펙은 기본적으로 es6 클래스를 등록하도록 정의 되어있다(es5 prototype 형태를 사용하는 방법은 뒤에서 다룬다). 그리고 당연하지만 Custom Elements 클래스 constructor는 여타 es6 클래스의 constructor와 다를 바 없이 동작한다. 아래의 'yey!'메시지는 예상할 수 있는 대로 new CurrentTime()을 통해 새로운 인스턴스를 생성할 때마다 확인할 수 있다. 또한 DOM에 <current-time>엘리먼트가 추가될 때마다도 메시지를 보여줄 것이다.

<current-time></current-time>
class CurrentTime extends HTMLElement {
  constructor() {
    super(); // 항상 맨 앞에!

    console.log("yey!");
  }
}
window.customElements.define("current-time", CurrentTime);

다만 사용법에 있어서 주의할 점이 있는데, 전통적인 방법에서 우리는 아래의 코드처럼 constructor에서 DOM을 조작하는 데 익숙하다. 이것이 가능한 이유는 우리가 Document의 'DOMContentLoaded'이벤트를 받아 DOM이 로드되고 나서 class를 초기화하기에 constructor가 실행되는 시점에서 엘리먼트는 DOM에 붙어있는 상태이다. 따라서 constructor에서 어떠한 DOM 조작을 해도 무방하다.

class CurrentTime {
  constructor(el) {
    super();

    this._initDOM(el); // DOM 조작들
  }
}

그러나 HTMLElement를 상속받은 Custom Elements의 constructor의 실행 시점은 아직 DOM에 추가지되지 않은 상태이다. 그렇기에 아래처럼 constructor에서는 어떠한 DOM 조작도 할 수 없다. 그러므로, 이곳에서는 DOM과 무관한 클래스 인스턴스 자체의 준비만 해야 한다.

constructor() {
    super(); // 항상 맨 앞에!

    console.log(this.parentNode); // null
    console.log(this.firstChild); // null
    console.log(this.innerHTML); // ""
    console.log(this.getAttribute('locale')); // null
    this.setAttribute('locale', 'ko-KR'); // 에러: Uncaught DOMException: Failed to construct 'CustomElement': The result must not have attributes
    this.innerText = 'Arr'; // 에러: Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
}

connectedCallback & disconnectedCallback

connectedCallbackdisconnectedCallbackHTMLElement를 상속받은 경우, 이 Custom Element가 DOM에 추가되거나/제거될 때마다 실행된다. 이때마다 인스턴스 객체가 생성/파괴되는 것은 아니므로, DOM을 수정함에 따라 얼마든지 여러 번 실행될 수 있다. 또한 한번 생성된 객체는 자동으로 파괴되지 않으므로 disconnectedCallback에서 적절히 인스턴스 객체를 정리하는 작업을 해 줄 필요도 있다. connectedCallback의 실행 시점은 이 인스턴스가 DOM에 추가된 후이기 때문에 DOM을 조작하는 작업을 이곳에서 하는 것이 알맞다. 밑의 코드를 실행해보면 예상하는 대로 속성/부모/자식의 값을 수정할 수 있다.

이 콜백들을 사용할 때에도 주의할 점이 있는데, connectedCallback이 실행되는 시점에 이 엘리먼트가 DOM에 추가되어 있기는 하지만, 자식 엘리먼트들은 아직 DOM에 추가되지 않았다. 따라서 자식 엘리먼트를 수정할 수는 있더라도, HTML에서 삽입한 자식 엘리먼트들에 접근할 수는 없다는 점에 유의하자.

별도로 자식 엘리먼트들에 접근하는 방법은 뒤에서 알아보겠다.

<current-time locale="ko-KR">
  자식!
</current-time>
class CurrentTime extends HTMLElement {
    ...
    connectedCallback() {
        // 이 엘리먼트는 DOM에 추가되었다.
        console.log(this.parentNode); // ok "<body></body>"
        console.log(this.firstChild); // null <--- 아직 자식 엘리먼트에 접근할 수는 없다.
        console.log(this.innerHTML); // "" <--- 아직 자식 엘리먼트에 접근할 수는 없다.
        console.log(this.getAttribute('locale')); // ok "ko=KR"
        this.setAttribute('locale', 'en-US'); // ok
        this.innerText = 'Arr'; // ok
    }
    ...
    disconnectedCallback() {
        // 이 엘리먼트가 DOM에서 제거되었다.
        // connectedElement에서 수행한 셋업을 청소하는 일을 하자
    }
}
window.customElements.define('current-time', CurrentTime);

이 콜백 메서드들은 MutationObserverchlidList를 통해 처리하는 방식보다 간결하고 직관적인 방법을 제공해준다. 아래의 MutationObserver를 통한 방식과 비교해보자.

...
this._disconnectedObserver = new MutationObserver(mutations => {
    mutations.forEach(mutation => {
        if (mutation.type === 'childList' &&
            Array.prototype.slice.call(mutation.removedNodes).indexOf(this._el) >= 0) {
            ...
        }
    });
});
this._disconnectedObserver.observe(this._el.parentNode, {
    childList: true
});
...

MutationObserver가 IE11부터 지원하고, 비슷한 기능을 제공하는 MutationEvents는 deprecated 되었다. 이러한 점들과 Custom Elements 역시 IE11부터 지원하는 점을 고려해 볼 때, MutationObserver를 사용해야 할 프로젝트에서는 Custom Elements 도입을 적극 고려해 보아도 좋겠다는 생각이다.

(Mutation Events는 성능에도 문제가 있다 - Mutation Events Replacement: The story so far / existring points of consensus)

attributeChangedCallback & observedAttributes

attributeChangedCallback은 위의 콜백들과 마찬가지로 MutationObserver에서 attributes를 모니터링하는 기능을 한다. 주어진 이름으로 메서드를 등록해 놓으면 속성이 변경될 때마다 콜백 메서드가 실행된다. 한가지 잊지 말아야 할 것은 observedAttributes에서 관심 있는 속성들의 이름을 나열해 주어야 한다는 것이다.

...
static get observedAttributes() {
    // 모니터링 할 속성 이름
    return ['locale'];
}

attributeChangedCallback(attrName, oldVal, newVal) {
    // 속성이 추가/제거/변경되었다.
    this[attrName] = newVal;
}
...

adoptedCallback

이 콜백 메서드는 해당 엘리먼트가 다른 Document에서 옮겨져 올 때 수행된다. document.adoptNode()가 이 엘리먼트를 대상으로 실행되었을 때 호출된다고 설명되어 있다. 아직 특별한 쓰임새는 보이지 않으므로 알고만 넘어가자.

adoptedCallback(oldDoc, newDoc) {
    // 다른 Document에서 옮겨져 왔음
}

이로써 Custom Elements의 스펙을 기준으로 하나씩 구현에 필요한 내용들을 살펴보았다. Custom Elements가 새로운 개념을 도입하기에 독자에 생소하고 어려워 보일 수 있다고 본다. 하지만 위의 내용들을 정리해 보면 결코 많은 내용을 담고 있지 않다. 더불어 엘리먼트와 인스턴스 콜백들의 주의점 들은 엘리먼트의 라이프 사이클을 고려해보면 당연하다고도 볼 수 있는 내용들이니 겁내지 말자.

  • window.customElements.define: 태그 이름과 클래스를 연결해 준다.
  • 태그 이름에는 반드시 하나 이상의 -를 포함해야 한다.
  • constructor: 인스턴스의 생성이며 DOM 조작은 할 수 없다.
  • connectedCallback / disconnectedCallback: 엘리먼트가 DOM에 추가/제거되었다. DOM 조작을 할 수 있다.
  • attributeChangedCallback / observedAttributes: 관심 있는 속성들을 모니터링 할 수 있다.
  • adoptedCallback: 잊자.

브라우저 지원과 Polyfill

브라우저 지원

Polyfill

모바일을 포함 IE11 이상의 최신 브라우저에서 Polyfill을 사용하면 Custom Elements를 사용할 수 있다. 쉬운 접근은 webcomponents-loader.js를 사용하는 것이며, 만약 프로젝트 소스들을 babel로 트랜스파일 하고있다면 custom-elements-es5-adapter.js를 추가해준다. 구글 스타일 답다고 해야할지 모르겠으나 Polyfill 사용에 다양한 옵션을 제공해 주는데 크게 아래의 3가지 방법으로 해결 가능하다.

  • Custom Elements polyfill Custom Elements Polyfill만 포함
  • webcomponentjs concatenated의 Polyfill은 약간 복잡한 모양새를 가지고 있는데, webcomponents-hi-ce.js, webcomponents-hi-sd-ce.js등의 Custom Elements의 약자인 ce가 포함된 파일을 사용하거나, 모든 폴리필을 포함한 webcomponents-lite.js를 사용하면 된다.
  • webcomponentsjs loader 다이나믹으로 필요한 것만 로드하는 polyfill loader

우리는 종종 브라우저 지원을 넓히기 위해 es5로 자바스크립트를 트랜스파일 하는데, Custom Elements 표준은 customElements.define에 es6 class를 요구한다. 이러한 경우에는 custom-elements-es5-adapter.js를 추가적으로 로드할 필요가 있다. 주의할 점은 custom-elements-es5-adapter.js는 네이티브로 브라우저에서 Custom Elements를 지원하는데, 자바스크립트 파일을 es5로 트랜스파일 한 경우에 필요한 것이지, IE11에 필요한 것은 아니라는 것이다.

조금 더 자세한 얘기들

위의 내용은 주요한 개념에 따라 움직이는 것들이기에 확실히 이해해두면 좋다. 이제부터는 위에서 알아본 주요한 스펙 이외에 실제 Custom Elements를 구현할 때 만날 수 있는 내용들을 조금 더 자세히 알아볼 것인데, 바로 이해하지 않더라도 추후 참고할 수 있을 정도로 읽어두자.

업그레이드

만일 Custom Elements를 customElements.define을 통해 정의하였으나 DOM에서는 사용되지 않는 경우가 있을 수 있다. 그러나 한번 정의되고 나면 나중에 DOM에 해당 이름의 엘리먼트가 추가되는 경우라도 곧바로 주어진 엘리먼트를 묶어주며, 알맞은 콜백 메서드들을 정상적으로 호출한다.

반대로 DOM에 엘리먼트가 존재하지만 customElements.define메서드를 통해 선언되지 않았다면, DOM에 있는 엘리먼트는 span과 마찬가지로 동작하며, 추후 customeElements.define을 통해 선언되면 주어진 클래스를 바로 묶어준다. 이러한 과정을 특별히 upgrade라고 한다.

이 upgrade 과정은 특별히 중요한데 우리가 'DOMContentLoaded'이벤트를 보고 인스턴스를 초기화하는 이유와 마찬가지로 DOM이 생성된 이후에 자바스크립트가 수행될 경우 DOM에 있는 Custom Elements는 스타일도, 동작도 못 가진 채로 화면에 노출될 수 있기 때문이다.

이러한 경우 :defined pseudo 클래스를 사용해 아래처럼 customeElements.define을 통해 정의되기 이전까지 해당 엘리먼트를 가려둘 수도 있고, 필요에 따른 스타일을 적용해 볼 수 있겠다.

current-time:not(:defined) {
  display: hidden;
}

자식 엘리먼트들

connectedCallback메서드에서 자식 엘리먼트들에 접근할 수 없다는 사실은 위에서 알아본 바 있다. 이 콜백 메서드에서 자식 엘리먼트들이 DOM에 추가되었으며, define되었는지 알기 위해서는 조금 다른 전략이 필요하다. MutationObserver, Promise 등 여러 방법들이 있겠으나 가장 간단하고 확실한 방법은 자식 Custom Elements들의 connectedCallback에서 부모 Custom Element의 메서드를 수행시켜 주거나 Custom Event를 발생시키는 것이다. DOM을 수정할 connectedCallback이 이러한 특성을 가지므로 Introducing Custom Elements의 Asynchronously Defining Custom Elements 부분에서 얘기하는 전략을 더 읽어보는 것도 도움이 된다.

class CurrentTimeText extends HTMLElement {
    ...
    connectedCallback() {
        // call parents callback
        this.parentNode.childReady(this);
        // or
        this.parentNode.dispatchEvent(new Event('childReady'));
    }
    ...
}

위의 어느 위의 방법이 마음에 들지 않을 수 있는 독자도 있을 것이다. 잠시 참아보자. 다음 글에서 소개할 Shadow DOM과 함께 보다 아름다운 모습으로 해결하는 방법을 얘기하겠다.

window.customElements & CustomElementRegistry

window가 생성될 때, 브라우저는 CustomElementRegistry 인스턴스를 초기화하여, window.customElements에 할당한다. 이 객체의 3가지 인스턴스 메서드들은 다음과 같다. W3C: CustomElementRegistry

  • window.customElements.define: 태그 이름에 자바스크립트 클래스를 Custom Elements로 등록한다.

    window.customElements.define("current-time", CurrentTime);
  • window.customElements.get: 태그 이름으로 정의된 클래스를 가져온다.

    const CurrentTime = window.customElements.get("current-time");
    const anotherTime = new CurrentTime();
  • window.customElements.whenDefined: 태그 이름의 Custom Elements가 등록될 때 이벤트를 받는다.

    customElements.whenDefined("current-time").then(() => {
    console.log("current-time defined!");
    });

Fallback strategy

<current-time>
  /* fallback html */ 13:00
</current-time>

Custom Elements를 이해하지 못하는 IE8~10 레거시 브라우저에서 <current-time> 커스텀 태그이름을 가진 엘리먼트는 HTMLUnknownElement인터페이스를 가지게 되며, innerHTML을 그대로 렌더링 하게 된다. 또한 HTMLUnknownElement역시 스타일을 입힐 수 있으므로, 레거시 브라우저를 고려해야 하는 환경에서는 span이라 생각하고 fallback html을 자식으로 가지고 있는 것이 좋다.

이는 Github에서 사용하고 있는 Custom Elements의 실패 전략인데, 동적으로 Custom Elements의 콘텐츠가 변화하지 않더라도, 페이지가 서버를 통해 리프레시 될 때 서버에서 랜더링한 적절한 콘텐츠를 보여줄 수 있도록 하는 방법이다.

Autonomous custom elements vs Customized built-in elements

Autonomouse custom elements는 여태까지 우리가 알아보아온 HTMLElement를 직접 상속받는 Custom Elements의 형태이고, Customized built-in elements는 HTML 표준에 정의된 div, input table등의 빌트인 엘리먼트들을 상속하는 형태이다.

빌트인 엘리먼트를 상속받는 이유는 해당 엘리먼트의 동작을 그대로 상속받으면서도 이외의 기능이나 스타일을 추가하기 위함이다. 이는 다만 조금(많이) 번거로운 문법을 요구한다.

<button is="current-time">6/11/2017, 11:55:49</button>
class CurrentTime extends HTMLButtonElement {
    ...
}
customElements.define('current-time', CurrentTime, {extends: 'button'});

악! HTML에서는 is라는 녀석이 붙고, 클래스 extends에서 분명 HTMLButtonElement를 상속받음을 알려주었음에도 불구하고, 또다시 customElements.define에서 버튼을 상속받고 있음을 또 알려준다. 이것이 정말 최선입니까

표준에 대한 논의 글을 따라가보면 HTML과 자바스크립트 생태계를 고려한 어쩔 수 없는 문법이라는 것인데, HTML 파서, 자바스크립트 파서는 브라우저만 존재하는 것도 아니고, button이 각 파서들에게 button으로 인식되기 위해서는 이러한 문법이 필요하다는 것이다. Autonomouse custom elements가 -를 반드시 포함하면서 HTMLElement를 바로 상속하는 명시적인 방법임에 반해, HTML 파서들에게(브라우저가 아닌) Customized built-in elements는 button 혹은 table 등 상속받은 엘레먼트로 인식되어야만 한다는 이유이다.

이유는 어쩔 수 없다 하겠으나, 이 사태에 대해 우리 또한 느낄 수 있듯이 각 브라우저 진영에서도 이 난잡한 문법에 대한 논의들이 있었다. 논의 끝에 어쩔 수 없는 문법마지못해 동의한 것으로 보이지만, 아직 제대로 구현된 브라우저가 없는 만큼 이러한 표준이 있다는 것만 알고 넘어가자.

+보너스: HTMLElement & HTMLUnknownElement

사실 HTML 표준에서 정한 이름 이외의 태그를 사용하는 일은 이 문서에서 사용한 Custom Elements 표준이 아니라 하더라도 이전부터(이름만 바꾸는 일이라면 IE8부터도!) 사용 가능했다. 궁금하다면 지금 에디터를 열어 HTML문서를 하나 작성해서 띄워봐도 좋겠다.

HTML 표준에 따르면 태그 이름에 따라 적절한 HTMLElement하위 인터페이스를 연결하며, 표준 이름의 어느것 에도 맞지 않는 태그에는 HTMLUnknownElement인터페이스를 할당한다. HTMLUnknownElement이름이 무언가 오류처럼 느껴질지 모르겠으나, HTMLSpanElement와 다를 바 없다.

HTMLSpanElementHTMLElement를 상속하며, display속성으로 inline값을 가지는 인터페이스인데, HTMLElementdisplay기본 속성값이 inline이므로, 동일하게 HTMLElement를 상속하는 HTMLUnknownElement역시 HTMLSpanElement와 다를 바 없다. 즉 <lol>태그는 <span>태그와 다를 바 없다. 즉 특별한 이름의 <random>, <lol>등을 CustomElements 표준 이전에도 적절한 스타일을 할당하여 얼마든지 사용 가능했다.

Determine HTML5 Element Support in Javascript: HTMLUnknownElementHTMLUnknownElement에 대한 재미있는 시점을 보여주기도 한다.

마치며

필자는 Custom Elements의 튜토리얼들을 보며 "아! 이런 나이스 한!"이라며 이걸 어느 프로젝트에 적용해볼까 전전긍긍해왔던 기억이 있다. 하지만 이 표준을 지원하는 브라우저는 Chrome뿐이었고, 표준도 v0에서 v1으로 바뀌며 계속 제대로 써볼 기회는 손에 잡히지 않았었다. 물론 그사이 Chrome 베이스의 Electron 프로젝트에서 무작정 시도해 보기도 했지만! 이제는 IE11이상의 프로젝트라면 충분히 도전해 볼 환경이 되었다고 생각된다. 특히나 MutationObserver의 사용이 빈번한 프로젝트라면 과감히 Custom Elements를 시도해 볼만 하다.

다음 글로는 Web Components의 두 번째 표준 Shadow DOM에 대해서 써보려 한다. Web Components의 표준들은 각각 쓰임새도 있지만, 같이 쓰이면 더욱 시너지가 있는 만큼 Custom Elements에 Shadow DOM이 결합되며 어떻게 더 힘을 발휘하는지 기대해주면 좋겠다.

참조

최규우2017.06.09
Back to list