gh-pages
237
2021-06-20 07:26:36 작성 2021-06-22 00:34:04 수정됨
5
2446

JavaScript로 Web Components 구현하기


안녕하세요. 개인 프로젝트를 진행하며, 공부하다 알게 된 내용을 공유합니다.

MDN을 바탕으로 작성하였으며, 언급된 코드는 GitHub에 공유되어 있습니다.


타깃으로 하는 독자는 다음과 같아요.

- Vanilla JavaScript로 DOM Elements를 좀 더 재사용 가능하고, 모듈화하고 싶은 웹 프런트엔드 개발자

- Shadow DOM이 무엇인지 알고 싶은 웹 프런트엔드 개발자

- Web Components가 무엇인지 알고 싶은 웹 프런트엔드 개발자


아래의 내용을 알고 있으면 좋아요.

- JavaScript ES6 문법을 이해하고 있는 개발자

- DOM에 대한 이해가 있는 개발자


# 1. Using the Custom Elements

Web Components는 무엇이고, 또 언제 사용할 수 있을까요?

만약 아래와 같은 DOM Element를 하나 생성한다고 했을 때...


<div class="msg-box">
  <span class="msg-box__icon">info</span>
  <span>Hello!</span>
</div>


기존에는 아래와 같은 방법들로 구현이 가능했습니다.


// Method 1

function createMessageBox() {
  const div = document.createElement('div');
  div.classList.add('msg-box');
  
  const spanIcon = document.createElement('span');
  spanIcon.classList.add('msg-box__icon');
  spanIcon.textContent = 'info';
  div.appendChild(spanIcon);
  
  const spanMsg = document.createElement('span');
  div.appendChild(spanMsg);
  
  return div;
}


또는


// Method 2

function createMessageBox() {
  const div = document.createElement('div');
  div.classList.add('msg-box');
  
  div.innerHTML = `
    <span class="msg-box__icon">info</span>
    <span>Hello!</span>
  `;
  
  return div;
}


다만 이러한 방법은 다음과 같은 문제점이 있었어요.


- CSS class를 전역적으로 선언해 사용해야 했음

- 해당 Element를 사용할 때 마다 메서드 호출 및 DOM Tree에 Append 해줘야 하는 불편함

- Element Lifecycle 구성이 가능은 하나, 복잡하고 어려움


Web Components는 이러한 기존 DOM Elements 생성 방법에서 벗어나, 모듈화할 수 있고 재사용도 가능한 Elements를 생성하기 위해 나왔어요.


진행하기 전에, 위 코드를 Web Components를 이용해 구현해보자면 아래와 같아요.


const html = `
<div class="msg-box">
  <span class="msg-box__icon">info</span>
  <span>Hello!</span>
</div>

<style scoped>
  // 여기에 위의 Component에만 적용될 CSS를 정의할 수도 있어요
</style>
`;

class MessageBox extends HTMLElement {
  constructor() {
    super();
    
    this.attachShadow({ mode: 'closed' })
      .appendChild(html.content.cloneNode(true));
  }
}

window.customElements.define(
  'msg-box',
  MssageBox,
);


Web Component는 CustomElementsRegistry 인터페이스의 define 메서드로 정의가 가능하며, 이를 구현한 클래스는 window.customElements 로 접근이 가능해요.


아무튼 위와 같이 `msg-box`라는 이름으로 Custom Elements를 정의했다면, 아래와 같이 `<msg-box>` 태그를 HTML 코드 내에서 바로 사용할 수 있어요.


<msg-box></msg-box>


간단하죠? 이렇게 Web Components를 사용하게 되면 여러 이점이 있어요.


- Scoped CSS Style 사용 가능

- 명시적으로 DOM Element 정의 가능

- Shadow DOM을 이용해 외부에서는 정의한 Elements를 조작할 수 없도록 제어 가능

- HTML 태그를 이용해 명시적으로 사용 가능

- 쉽게 DOM Elements에 대한 Lifecycle 정의 가능


이를 이용해 MVVM Design Pattern을 갖는 Web Frontent Framework를 직접 만들 수도 있고...

아무튼 그럼 하나씩 보도록 할까요?


## 1.1. High-level View

Web Components를 정의하기 위해서는 언급했듯이 CustomElementRegister.define() 메서드를 이용하며, 아래 세 개의 Arguments를 받아 Custom Elements를 정의합니다.


- DOMString : Custom Elements의 이름 (kebob-case)

- class extends HTMLElement : Element의 행동을 정의한 Class

- { extends } : Inherits할 Node name (optional)


아래의 코드를 보며 좀 더 이해해보도록 할께요.


class WordCount extends HTMLParagraphElement {
  constructor() {
    super(); // always call
    
    // element functionality
  }
}

window.customElements.define(
  'word-count',
  WordCount,
  { extends: 'p' },
);


- DOMString : `word-count`

- class : `WordCount` (extends HTMLParagraphElement)

- extends : `p` (<p> 태그)


위의 `WordCount` 클래스는 `<p>` 태그인 HTMLParagraphElement 클래스를 Extends 했으며, 따라서 `<p>` 태그의 동작을 그대로 상속받고 있어요.


이렇게 `HTMLElement`가 아닌, 특정 태그의 동작을 상속받고자 한다면 Optional 값인 `{ extends }` 객체를 세 번째 Param으로 넘길 수 있습니다.


Constructor 내에 존재하는 `super()`는 기본적으로 모든 Custom Elements가 `HTMLElement` 클래스를 상속받고 있으니, 항상 이를 호출하도록 해줘야 해요.


아무튼 이렇게 정의한 `word-count` Element는 아래와 같이 HTML 코드로 바로 사용이 가능합니다.


<word-count></word-count>

<!-- or -->

<p is="word-count"></p>

(`is` Attribute가 궁금하다면 여기를 참고해주세요.)


물론 JavaScript로도 Programmatic하게 사용할 수 있어요.


document.createElement('word-count');

// or

document.createElement('p', { is: 'word-count' });



## 1.2. Working through some Simple examples

간단한 예제를 보며 좀 더 이해해보도록 하겠습니다.

아래의 DOM Element를 구현해보도록 할께요.


<popup-info img="/img/alt.png" data-text="text contents"></popup-info>

<!--
<span class="wrapper">
  <span class="icon" tabindex="0">
    <img src="...from parent attribute...">
  </span>
  
  <span class="info">...from parent attribute...</span>
</span>
-->


여기서 `...from parent attribute...`는 Custom Element를 사용할 Parent DOM Tree에서 정의할 값들을 의미하며, 구현한 코드는 아래와 같아요.


class PopupInfo extends HTMLElement {
  constructor() {
    super();
    
    // create shadow root
    this.attachShadow({ mode: 'closed' });
    
    // create nested elements
    const wrapper = document.createElement('span');
    wrapper.classList.add('wrapper');
    
    const icon = wrapper.appendChild(document.createElement('span'));
    icon.classList.add('icon');
    icon.setAttribute('tabindex', 0);

    const img = icon.appendChild(document.createElement('img'));
    img.src = this.hasAttribute('img') ? this.getAttribute('img') : '/img/default.png';

    const info = wrapper.appendChild(document.createElement('span'));
    info.classList.add('info');
    info.textContent = this.getAttribute('data-text');

    const style = document.createElement('style');
    style.textContent = /* css strings */;

    // attach ccreated elements to the Shadow DOM
    this.shadowRoot.append(style, wrapper);
  }
}

customElements.define('popup-info', PopupInfo);


Shadow Root는 아래에서 자세히 다뤄요. 지금은 그냥 또 하나의 Independent한 DOM을 생성한다고 이해하면 돼요.


아무튼 이렇게 정의를 해 주게 되면 아래와 같이 사용할 수 있어요.


<popup-info img="/img/alt.png" data-text="text contents"></popup-info>

전체 코드


`img`와 `data-text` Attributes는 어디서 사용할까요?

이들은 `this.hasAttribute()` 그리고 `this.getAttribute()`를 통해 참조가 가능해요. 따라서, 이를 이용해 Custom Elements를 사용하는 Parent DOM Tree에 정의된 Attributes를 가져와 사용할 수 있었어요.


## 1.3. Internal vs External styles

CSS Styles는 위의 `popup-info` 예제에서와 같이 CSS String을 직접 작성해도 되나, 아래와 같이 `<link>` 태그를 이용할 수도 있어요.


const linkElement = document.createElement('link');
linkElement.setAttribute('rel', 'stylesheet');
linkElement.setAttribute('href', 'style.css');

this.shadowRoot.append(linkElement, wrapper);


이 편이 조금 더 간단한 방법이긴 하지만, 아래에서 좀 더 쉬운 방법(Scoped Styles)을 다루고 있어요.


## 1.4. Using the Lifecycle callbacks

Custom Elements의 장점 중 하나는, 바로 별 다른 구현 없이 Lifecycle callbacks를 바로 구현할 수 있다는 것이에요.


- connectedCallback : Custom Elements가 DOM에 Connected 될 때 호출

  - 모든 DOM이 Parsing되기 전에도 호출될 수 있음

  - Node moved 시에도 호출

  - Disconnected 시에도 호출될 수 있으며, 이는 `isConnected` 프로퍼티를 이용해 판별이 가능

- disconnectedCallback : Custom Elements가 DOM에서 Disconnected 될 때 호출

- adoptedCallback : Node가 Moved 되었을 때 호출

- attributeChangedCallback : Custom Elements의 Attributes가 Added/Removed/Changed 되었을 때 호출

  - `static get observedAttributes()` Getter로 Observe할 Attributes를 알려줘야 함


실제 사용 예는 아래와 같아요.


class CustomSquare extends HTMLElement {
  constructor() {
    super();

    this.shadow = this.attachShadow({ mode: 'open' });

    const div = document.createElement('div');
    const style = document.createElement('style');

    this.shadow.appendChild(style);
    this.shadow.appendChild(div);
  }

  updateStyle() {
    this.querySelector('style').textContent = /* css string */;
  }

  /**
   * lifecycle 'connected'
   * */
  connectedCallback() {
    console.log('Custom square element added to page');
    this.updateStyle();
  }

  /**
   * lifecycle 'disconnected'
   * */
  disconnectedCallback() {
    console.log('Custom square element removed from page');
  }

  /**
   * lifecycle 'adopted'
   * */
  adoptedCallback() {
    console.log('Custom square element moved to new page');
  }

  /**
   * lifecycle 'attribute changed'
   * */
  attributeChangedCallback(name, oldValue, newValue) {
    console.log('Custom square element attributes changed');
    updateStyle();
  }

  static get observedAttributes() {
    // should return an array containing names of attributes
    return ['c', 'l']; // watch 'c' and 'l' attributes
  }
}

window.customElements.define('custom-square', CustomSquare);


이렇게 정의된 `<custom-square>`는 아래와 같이 사용할 수 있으며,


<custom-square l="100" c="red"></custom-square>


처음 DOM에 Connected 되었을 때, Attributes(`l`과 `c`)가 Updated 되었을 때, `<custom-square>`가 Moved 되었을 때, 그리고 DOM에서 Disconnected 되었을 때 각각의 Lifecycle callbacks가 실행돼요.


한 가지 유의해야 할 것은, 언급했듯이 Attributes를 Observing 할 때 반드시 해당 Attributes를 `static get observedAttributes()` Getter의 반환으로 명시해 줘야 해요.


# 2. Using Shadow DOM

Web Components에서 가장 중요한 개념은 바로... encapsulation 이에요.


복잡하게 구현된 Component 내부를 어떻게 해야 잘 유지할 수 있을 것이며, 또 다른 Components와는 어떻게 충돌을 회피할 수 있을까요? 그리고 CSS Scope는 어떻게 관리해야 하며, 이러한 것들을 어떻게 구현해야 깔끔하고 간결하게 코드를 유지할 수 있을까요?


Shadow DOM API는 이러한 것에 초점을 맞추었으며, 여기서는 이 Shadow DOM을 바탕으로 각각의 DOM을 서로 충돌 없이 분리가 가능한지 그 방법을 보도록 하겠습니다.


## 2.1. High-level view

다음과 같은 HTML 코드가 있다고 할 때,


<!DOCTYPE html>

<html>
  <head>
    <meta charste="utf-8">
    <title>Simple DOM</title>
  </head>

  <body>
    <section>
      <img src="dinosaur.png" alt="T-Rex">
      <p>Here we will add a link to the <a href="https://www.mozilla.org/">Mozilla</a></p>
    </section>
  </body>
</html>


이를 DOM에서는 다음과 같은 Tree 형태로 구성해요.




Shadow DOM 역시 이와 크게 다르지 않아요.




말 그대로 'Independent하게 DOM 자체를 분리하는 것' 일 뿐이에요.


- Shadow Host : 일반적인 DOM Node처럼 보이는, Shadow DOM의 연결 지점

- Shadow Tree : Shadow DOM 내부의 DOM Tree

- Shadow Boundary : Shadow DOM의 시작 Node부터 Shadow DOM의 끝 Node까지의 공간

- Shadow Root : Shadow Tree의 Root node


일반적인 DOM Structure와 비교해도 크게 다르지 않으며, API 또한 동일해요. 그저 Shadow DOM 내부의 모든 것들은 Shadow Boundary 외부에 영향을 끼칠 수 없다는 것 하나를 제외하고 말이죠.


## 2.2. Basic usage

앞서 Element.attachShadow() 메서드를 통해 Shadow DOM을 구성했었는데, 이 때 `mode` 값을 넘겼었어요.


const shadow = element.attachShadow({ mode: 'open' });
const shadow = element.attachShadow({ mode: 'closed' });


Mode는 두 가지가 있고, 차이는 아래와 같아요.


- open : Element.shadowRoot 프로퍼티를 이용해 Shadow DOM에 대한 참조를 얻을 수 있음

- closed : 참조가 불가능하게끔 Shadow DOM을 구성 (`Element.shadowRoot`에 `null` 값이 들어가요)


# 3. Using Templates and Slots

마지막으로, `<template>`과 `<slot>` 태그에 대해 보도록 하겠습니다.

(Vue를 아시는 개발자 분은 <slot> 태그가 익숙할텐데, 바로 Web Components에서 영감을 받아 나왔어요)


## 3.1. The truth about templates

재사용 가능한 Components를 만들기 위해서는... `<template>` 태그를 이용하는 것이 가장 쉽고 편리해요.


`<template>` 태그 내의 Nodes는 DOM에 렌더링되지는 않으나, Programmatic한 방법으로 참조가 가능해요.


<template id="my-paragraph">
  <p>My Paragraph</p>

  <style>
    p {
      font-size: 30px;
    }
  </style>
</template>


가령 위와 같은 템플릿이 있다면...


const template = document.getElementById('my-paragraph');
const templateContent = template.content;

document.body.appendChild(templateContent);


이런 방식으로 `template.content` 프로퍼티를 이용해 접근한 뒤, DOM에 추가할 수 있다는 말이죠.

다만, `<template>` 태그 내에서 `<style>`이 정의되었다 해도 이는 Shadow DOM을 의미하는 것이 아니기에 전역적으로 CSS Styles가 정의된다는 것을 유의해주세요(Scoped Styles를 정의하는 방법은 맨 아래에서 다뤄요).


## 3.2. Using templates with Web Components

바로 위의 `<template>` 코드를 가지고 `my-paragraph`라는 이름의 Custom Element를 정의해 보겠습니다.


customElements.define(
  'my-paragraph',
  class extends HTMLElement {
    constructor() {
      super();

      const template = document.getElementById('my-paragraph');

      this.attachShadow({ mode: 'closed' })
        .appendChild(template.content.cloneNode(true));
    }
  }
);


참고로, 여기서 굳이 `cloneNode()`를 해주고 있는데... 이는 `<template>`은 결국 하나만 존재하기 때문에, 여러 곳에서 `my-paragraph`를 사용하기 위해서는 이를 각각의 Element마다 `<template>`을 복제(Clone)해줘야 정상적으로 동작하기 때문이에요.


Clone하지 않으면 동일한 템플릿을 참조하게 되어버리고, 향후 Slot과 관련하여 Shadow DOM 조작 시 의도치 않은 결과가 초래될 수 있어요. (MDN)


좀 더 쉽게 사용하려면... 그냥 아래와 같이 `innerHTML` 프로퍼티를 이용하는 방법도 있어요.


customElements.define(
  'my-paragraph',
  class extends HTMLElement {
    constructor() {
      super();

      this.attachShadow({ mode: 'closed' })
        .innerHTML = document.getElementById('my-paragraph').innerHTML;
    }
  }
);



## 3.3. Adding flexibility with Slots

여기에 `<slot>` 태그를 붙여보도록 하겠습니다. 이를 이용하면 매우 Flexible한 Web Component 구성이 가능해져요.


<template>
  <p><slot></slot></p>
</template>


이렇게 템플릿을 구성했다면, 다음과 같이 `<slot>` 태그 자리에 Element를 정의할 수 있어요.


<my-paragraph>
  TEXT
</my-paragraph>


렌더링 시에는 아래와 동일하게 렌더링돼요.


<p>TEXT</p>


만약 여러 개의 `<slot>`이 필요하다면 어떻게 할 수 있을까요? 간단하게 이름을 지정해 사용할 수 있어요.


<template>
  <p class="title"><slot name="title"></slot></p>
  <p><slot></slot></p>
</template>


이렇게 정의된 템플릿은 아래와 같이 사용할 수 있어요.


<my-paragraph>
  <span slot="title">TITLE</span>
  Contents
</my-paragraph>


위 코드는 아래와 같이 렌더링돼요.


<p class="title"><span>TITLE</span></p>
<p>Contents</p>


참고로, Default Node를 정의하고자 한다면 아래와 같이 `<slot>` 태그 내에 정의할 수 있어요.


<template>
  <p><slot>default text</slot></p>
</template>


이 경우, 아래와 같은 코드는...


<my-paragraph></my-paragraph>


이렇게 렌더링돼요.


<p>default text</p>


## 3.4. Scoped styles

끝으로, Styles를 Component 내에만 정의하는 방법에 대해 보도록 하겠습니다.

사실 방법은 간단해요. 그냥 Shadow DOM 내에 `<style>` 태그가 오게끔 만들어주면 되죠.


const html = `
<div></div>

<style>
div {
  width: 100px;
  height: 100px;
  background-color: black;
}
</style>
`;

class extends HTMLElement {
  constructor() {
    super();
    
    this.attachShadow({ mode: 'closed' })
      .innerHTML = html;
  }
}


위에서 `<template>` 태그를 이용했을 때는 `<style>` 태그에 존재하는 CSS Styles가 전역적으로 선언되었지만, 위 코드는 Shadow DOM을 이용하기에 `<style>` 태그에 존재하는 CSS Styles는 Shadow Boundary 밖으로 영향을 끼칠 수 없어요.


이렇게 Web Component의 구성이 가능해요. 남은 건 이를 응용하는 것 뿐이고...

이를 이용하면 여러 가지를 할 수 있어요. 저도 하나 해봤고...


- Vanilla JavaScript로 Trello 구현하기 : https://git.io/JnaRi


아무튼 그렇습니다. 읽어주셔서 감사합니다 (ノ◕ヮ◕)ノ*:・゚✧


---


- Service Worker를 이용한 Assets Caching : https://okky.kr/article/977035

- JavaScript로 Web Components 구현하기 : https://okky.kr/article/977318


25
17
  • 댓글 5

  • 프로그램 탐험가
    269
    2021-06-20 08:32:39

    와.. 대단한 내공이 느껴지는 글 입니다

    바닐린 자바스크립트로 구현하는걸 연습하고 싶었는데

    마침 좋은 글을 발견 했네요!

    좋은 글 써주셔서 감사합니다 :)

  • gh-pages
    237
    2021-06-20 08:42:38

    @프로그램 탐험가 읽어주셔서 감사합니다 ^.^

  • dev_jian
    126
    2021-06-23 05:30:16

    어..어렵다..

  • gh-pages
    237
    2021-06-23 10:53:27

    @dev_jian 읽어주셔서 감사합니다 ^~^ 차근차근 따라해보시면 금방 이해되실 수 있을 거예요..

  • 경민민
    77
    2021-06-24 18:53:13
    새로운걸 알게돼서 너무 좋네요!!
  • 로그인을 하시면 댓글을 등록할 수 있습니다.