javascript - 나만의 HTML tag 만들어서 배포하기?!

정현우·2024년 3월 3일
6
post-thumbnail

[ 글의 목적: web component 를 활용해 custom html, cdn으로 배포하기 실습 ]

HTML Customization

HTML tag 커스텀은 Web Component 로 가능하며, 해당 기술은 이미 10년도 더 전부터 사용이 가능했던 기술이다. 2000년대 초 부터 상호작용이 계속 추가가 되면서 애플리케이션의 복잡성이 증가되었고, 이러한 복잡성을 관리하기 위해 웹 엔지니어들은 UI를 작고 재사용 가능한 컴포넌트 단위로 나누어 개발하는 접근법을 논의하기 시작했다. 그리고 2011년 웹 컴포넌트가 등장했다. 2013년 W3C에 의해 표준 웹 API가 만들어지면서 모던 브라우저들은 이 API들을 제공하게 되었다.

1. Web Component

  • 해당 명세에 대한 내용은 MDN - 웹 컴포넌트 에서 아주 상세하게 살펴볼 수 있다. 해당 글의 목적은 이 Web Component를 "활용" 해 cdn 서버 배포까지 해보는 것이다.

  • 사실 이 "웹 컴포넌트" 를 쉽게 표현하자면, FE 개발을 하다보면 특정 tag들이 style 또는 기능까지 계속 겹치는게 눈에 보인다. 예를 들자면 아래와 같은 세트를 생각해보자!

<div>
  <label for="nameInput">이름 입력하세요!</label>
  <input type="text" id="nameInput" />
</div>

  • 좀 더 거시적으로 보자면, div 안에 lableinput 이 존재하며, 이 세트를 계속해서 만들고 싶은 것이다. HTML tag로 반복하자면 너무 귀찮다. 이를 Class 와 같이, 틀에 찍어내듯이 찍어내고 싶은 욕구가 들 것이고, 이를 만족해주는 기술이 Web Component 이다.

1) Web Component 기본 사용법

  • 아주 간단하다! 이미 HTMLElement 라는 class 를 제공하기 때문에 아래와 같이 작성하면 끝이다!
class MyCustomButton extends HTMLElement {
  constructor() {
    super(); // 상위 클래스의 생성자 호출
}

// 사용자 정의 엘리먼트를 "custom-button" 이라는 이름으로 등록
customElements.define("custom-button", MyCustomButton);
  • 이제 html 에서 <custom-button></custom-button> 이라고 사용하면 된다. 위 label & input tag 예시를 들어 만들어 보자면 아래와 같다. (가장 쉽고 빠르게 만드는 방법)
class LabelInput extends HTMLElement {
  constructor() {
    super(); // 상위 클래스의 생성자 호출
    this.innerHTML = `
      <div>
        <label for="nameInput">이름 입력하세요!</label>
        <input type="text" id="nameInput" />
      </div>
    `;
  }
}

customElements.define("label-input", LabelInput);
  • 이제 html에 추가하면 아래와 같다.

2) Web Component, HTMLElement 가 가지는 "생명 주기"

  • 사실 위 방법은 좀 야매에 가깝고, innerHTML 대신 document.createElement 와 더 나아가 this.attachShadow 를 쓰는게 좋다. 그 전에 HTMLElement 요놈이 가지는 attribute 들을 좀 더 체크해보자!

  • MDN - 웹 컴포넌트, 사용자 정의 요소 사용하기 에서 "생명 주기 콜백 사용하기" 를 살펴보면, 이 HTML tag 자체에 대해 특정 이벤트 따라 코딩을 할 수 있다!

(1) connectedCallback

  • 사용자 정의 요소가 문서에 연결된 요소에 추가될 때마다 호출된다. 이 callback은 노드가 이동될 때마다 발생하며 요소의 내용이 완전히 해석되기 전에 발생할 지도 모른다고 한다.

  • 요소가 더 이상 연결되지 않았을 때 호출될 수도 있으므로, 확실하게 하기 위해선 Node.isConnected (en-US)를 사용하세요 라고 한다.

  • 간단하게 보면 커스텀 컴포넌트가 "DOM에 추가될 때" 호출 된다고 볼 수 있다.

(2) disconnectedCallback

  • 사용자 정의 요소가 document의 DOM에서 "연결 해제" 되었을 때마다 호출된다.

(3) adoptedCallback

  • 사용자 정의 요소가 "새로운 document로 이동되었을 때" 마다 호출됩니다.

(4) attributeChangedCallback

  • 사용자 정의 요소의 특성들 중 하나가 추가되거나, 제거되거나, 변경될 때마다 호출된다. 어떤 특성이 변경에 대해 알릴지는 static get observedAttributes 메서드에서 명시됩니다.

  • 간단하게 보면 커스텀 "컴포넌트 속성에 변경이 생겼을 때 호출" 된다고 볼 수 있다.

그에 따라 LabelInput 업그레이드 하기

<label-input input-name="이름" input-id="nameInput"></label-input>
<label-input input-name="나이" input-id="ageInput"></label-input>
<label-input input-name="닉네임" input-id="nicknameInput"></label-input>
class LabelInput extends HTMLElement {
  constructor() {
    super(); // 상위 클래스의 생성자 호출
  }

  connectedCallback() {
    const $label = document.createElement("label");
    $label.innerText = `${this.name}을 입력하세요!`;
    $label.setAttribute("for", this.id);

    const $input = document.createElement("input");
    $input.setAttribute("type", "text");
    $input.setAttribute("id", this.id);

    this.appendChild($label);
    this.appendChild($input);
  }

  get id() {
    return this.getAttribute("input-id");
  }

  get name() {
    return this.getAttribute("input-name");
  }
}
  • 생성자 대신에 "DOM에 추가될 때" tag에 주어진 attribute를 가지고 dynamic하게 rendering할 수 있도록 세팅할 수 있다. 이제 <label-input input-name="이름" input-id="nameInput"></label-input> 한 줄 만으로 labelinput tag 세트를 바로 만들 수 있게 되었다!

  • 참고로 get id()this.id 와 같이 바로 instance 변수에 접근해서 가져올 수 있게 하는, java로 따지면 getter 를 만드는 것이다!

3) 옵저버 패턴도 가능하다?!

  • 옵저버 패턴(Observer Pattern) 은 옵저버(관찰자)들이 관찰하고 있는 대상자의 상태가 변화가 있을 때마다 대상자는 직접 목록의 각 관찰자들에게 통지 하고, 관찰자들은 알림을 받아 조치를 취하는 "행동/행위 패턴" 을 의미한다.

  • observedAttributes 이라는 method 를 통해 안에 감시할 attribute들을 array로 적으면 변경되는 순간 attributeChangedCallback() 함수를 실행 해준다.

class LabelInput extends HTMLElement {

  ...생략...
  
  static get observedAttributes() {
    return ["input-name"];
  }

  attributeChangedCallback() {
    if (this.querySelector("label")) {
      this.querySelector("label").innerText = `${this.name}을 입력하세요!`;
    }
  }

  ...생략...
}
  
// 다른 버튼 만들고 아래와 같이 테스트해보자!
document.getElementById("changeBtn").addEventListener("click", () => {
  document
    .getElementById("targetTestDom")
    .setAttribute("input-name", "변경테스트");
});

  • input-name 을 바꾸자마자 innerText 이 바로 바꿔진다! 참고로 위 코드에서 observedAttributes2번 호출된다!

  • (1) <label-input input-name="이름" input-id="nameInput"></label-input> 과 같이 tag 가 최초로 만들어지면서 attribute가 쥐어졌을 때, (2) button 에 의해 해당 속성을 바꾸었을때

  • 이렇게 좀 더 다이나믹하게 tag들을 컨트롤 할 수 있다!!


2. shadow DOM

  • 왜 갑자기 shadow DOM 이 나오느냐! 위 일반적인 Web Component 의 방식의 "빡침 포인트" 는 완전하게 독립적으로 tag들을 세팅할 수 없다. 즉 DOM 요소를 독립적으로 만들어서 적용할 수 없다.

  • 이는 쉽게 말해 여러분들이 Web Component 내부에서 어떤 태그를 만들던, 어떤 style을 dynamic 하게 세팅하던, 외부로 부터 접근이 가능하며, 외부로 부터 계속해서 참고 당한다!

  • 다른 비유지만, FE 가 익숙한 사람에게 react 를 비유를 하자면, styled component 를 떠올려 보자. 이 친구를 사용하면 css 를 그 component 에게만 기가막히게 독립적으로 먹일 수 있다! 이런 역할을 shadow DOM 을 통해 할 수 있다!

1) 실제로 흔하게 볼 수 있는 shadow dom

  • shadow dom을 그림으로 표현하자면 아래 2가지 그림과 같다. 단순하게 실제 host dom이 있고 여기에 shadow dom tree가 구성된다!

  • <input type="range"> 를 사용해 본 적이 있는가?! 일단 크롬 개발자 도구 > 환경 설정 > 요소 > 사용자 에이전트 그림자 DOM 표시 세팅을 하고 보자!

  • 이 쥐똥만한 친구에게 위와 같은 노력의 div tag 들이 희생되고 있다! 뿐만 아니다! 아래 태그는 어떨까? MDN 에서 사용되는 video tag 예제를 그대로 가져와서 보면
<video controls width="250">
  <source
    src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm"
    type="video/webm"
  />
  <source
    src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
    type="video/mp4"
  />
</video>

  • 음층나게 많은 tag 들이 숨어있다. 이게 shadow dom 의 핵심이다. Shadow DOM의 핵심은 '캡슐화' 이다.

  • 즉 Shadow DOM을 사용하면 웹 요소의 내부 구조와 스타일을 외부 환경으로부터 격리 할 수 있고, 그렇기 때문에 동일한 웹 페이지 내의 다른 요소와 스타일이나 스크립트가 충돌하는 것을 방지 할 수 있다. 더 자세한 사항은 MDN - shadow DOM 사용하기 로 대체하겠다.

  • Does shadow DOM improve style performance? 와 같은 글도 같이 읽어보면 좋다.

2) shadow dom 활용하기

  • MDN에서는 "어떠한 요소에든 shadow root을 부착할 수 있다" 고 한다. shadow root 는 곧 안에 만들어질 다른 shadow dom tree의 host 인 node를 의미 한다.
let shadow = elementRef.attachShadow({ mode: "open" });
let shadow = elementRef.attachShadow({ mode: "closed" });
  • open 은 바깥에서 작성된 javascript를 사용하여 shadow dom에 접근을 허용하며 closed 는 그렇지 않다. open 인 dom은 let myShadowDom = myCustomElem.shadowRoot; 와 같이 js 선택자로 접근이 가능하다. 하지만 위에서 살펴본 videoclosed 며 일반적인 방법으로 접근이 불가능하다!

  • 우린 이런 shadow dom 으로 위 label + input 조합을 자체 style tag를 가지게 함 으로써 css custom 까지 해보자!

class LabelInput extends HTMLElement {
  constructor() {
    super(); // 상위 클래스의 생성자 호출
    const shadow = this.attachShadow({ mode: "closed" });
    this.shadow = shadow;
  }

  connectedCallback() {
    const style = document.createElement("style");
    style.textContent = `
      label {
        margin-right: 10px;
        font-size: 1.1rem;
      }
      input {
        border: 1px solid;
        border-radius: 4px;
        height: 18px;
      }
    `;
    this.shadow.appendChild(style);
  }

  static get observedAttributes() {
    return ["input-name"];
  }

  attributeChangedCallback() {
    this._render();
  }

  get id() {
    return this.getAttribute("input-id");
  }

  get name() {
    return this.getAttribute("input-name");
  }

  _render() {
    if (this.shadow.querySelector("label")) {
      this.shadow.removeChild(this.shadow.querySelector("label"));
    }
    if (this.shadow.querySelector("input")) {
      this.shadow.removeChild(this.shadow.querySelector("input"));
    }
    const $label = document.createElement("label");
    $label.innerText = `${this.name}을 입력하세요!`;
    $label.setAttribute("for", this.id);

    const $input = document.createElement("input");
    $input.setAttribute("type", "text");
    $input.setAttribute("id", this.id);

    this.shadow.appendChild($label);
    this.shadow.appendChild($input);
  }
}
  • connectedCallback 에서만 style tag를 만들어서 밀어 넣고, _render 라는 private 전용 method를 만들어서 실제 dom을 create 하고 append 하는 작업은 따로 만들었다.

  • attributeChangedCallback 에만 _render(); 를 호출하게 해서 (1) 실제 attribute를 부여 받았을 때, (2) 외부 컨트롤에 의해 그 attribute 를 변경했을때 에만 랜더링하도록 세팅했다.

  • 제법 그럴싸해졌다. 이렇게 해두면 우리가 만든 style 는 철저하게 shadow host 에 속하는 shadow dom tree 들만 적용된다!

조금만 더 고도화를 해보자!

class LabelInput extends HTMLElement {
  constructor() {
    super(); // 상위 클래스의 생성자 호출
    const shadow = this.attachShadow({ mode: "closed" });
    this.shadow = shadow;
  }

  connectedCallback() {
    const style = document.createElement("style");
    style.textContent = `
      div {
        width: 300px;
        display: flex;
        gap: 5px;
        flex-direction: column;
        align-items: flex-start;
        margin: 6px;
      }
      label {
        margin-right: 10px;
        font-size: 1.1rem;
      }
      input {
        border: 1px solid;
        border-radius: 4px;
        height: 18px;
        width: 100%;
      }
    `;
    this.shadow.appendChild(style);
  }

  static get observedAttributes() {
    return ["input-name"];
  }

  attributeChangedCallback() {
    this._render();
  }

  get id() {
    return this.getAttribute("input-id");
  }

  get name() {
    return this.getAttribute("input-name");
  }

  _render() {
    if (this.shadow.querySelector("div")) {
      this.shadow.removeChild(this.shadow.querySelector("div"));
    }
    const $div = document.createElement("div");
    const $label = document.createElement("label");
    $label.innerText = `${this.name}을 입력하세요!`;
    $label.setAttribute("for", this.id);

    const $input = document.createElement("input");
    $input.setAttribute("type", "text");
    $input.setAttribute("id", this.id);

    $div.appendChild($label);
    $div.appendChild($input);
    this.shadow.appendChild($div);
  }
}
  • 아에 div 를 만들어서 label & inputdiv 에 넣고, dynamic rendering 할 때 기존 존재 여부랑 삭제는 div 만 컨트롤하게 했다. 그리고 전체적인 css 를 flex 로 잡아줬다.
<label-input input-name="이름" input-id="nameInput"></label-input>
<label-input input-name="나이" input-id="ageInput"></label-input>
<label-input input-name="닉네임" input-id="nicknameInput"></label-input>
<label-input input-name="이것" input-id="thisInput"></label-input>
<label-input input-name="저것" input-id="thatInput"></label-input>

  • 이런 형태로 완전 나만의 full customized html tag 를 구성할 수 있다. 조금 더 생각해보자. 이 js file을 "CDN 배포" 하면 어떤가?

  • 그니까 npm 도 필요 없이, install 도 필요없이, 그냥 내가 필요할때 html tag 에다가 <script src="https://cdn링크"></script> 넣고, 내가 기억하는 <label-input> 와 같은 html tag 짜면 기가 막힐 것 이다!

3) CDN 배포하기 & 사용하기

가장 쉽고 빠른길이 있다. 바로 jsDelivr 를 사용하는 것! https://www.jsdelivr.com 공식 페이지를 체크해보자!

  • jsDelivr는 전세계에서 무료로 이용할 수 있는 CDN(Content Delivery Network) 서비스다. 전 세계 곳곳에 캐시 서버를 두고, 접속한 지역에서 가장 가까운 서버로부터 파일을 전송받게 되어 빠른 속도를 유지할 수 있다는 장점이 있다.

  • jsDelivr는 "무료"이며 "상업적으로도 이용할 수" 있다. 하지만 이미지 CDN으로 사용하거나 대용량 파일 전송용으로 사용하는 것은 금지되어 있다!

  • 아주 간단하게 github 에 올라간 file을 CDN 배포할 수 있다. jsDelivr doc 를 보면 아래 흐름과 같다.

  1. 일단 최초로 요청온 것의 파일을 찾고
  2. 올바르게 잘 찾았다면
  3. 멀티 CDN 서버에 caching 을 한다.

즉, 그냥 github에 올리기만 하면 된다는 것이다!

  • 그래서 일단 github 에 올려야 한다. 그런 다음 아래 url pattern에 따라 요청 url을 만들어 보자!

  • https://cdn.jsdelivr.net/gh/깃헙아이디/깃헙Repo이름@브랜치이름/파일이름

  • 나의 경우는 다음과 같았다. https://cdn.jsdelivr.net/gh/Nuung/all-about-javascript@main/WebComponents/index.js

  • 최초 요청에 시간이 좀 걸리지만 아주 금방 cahing 되며 아주 잘 불러온다!! 테스트할 겸 button 도 하나 더 추가해봤다.

  • 어떻게 활용하면 되는가? 이제 html<script src="https://cdn.jsdelivr.net/gh/Nuung/all-about-javascript@main/WebComponents/index.js"></script> 한 줄 추가하고 여러분들이 커스텀한 그 HTML TAG 바로 쓰면 된다. 그러면 어떤 써드파티없이 바로 여러분들의 custom tag를 활용할 수 있다. (아 사실 네트워크 통신 자체가 써드파티인격이다ㅎ)

  • 뿐만 아니다, 지금 읽고 있는 여러분들도 저 import tag 하나로 custom한 tag를 자유롭게 활용할 수 있다.

  • 한 가지 주의할 점은 jsDelivr는 12시간마다 캐시 데이터를 갱신하므로, 파일에 변동사항이 있는 경우 실시간 반영이 되지 않는다. 따라서, 실시간으로 즉시 반영해야 하는 경우에는 별도의 브랜치나 Repo를 파서 다른 주소로 파일을 올리고 다시 url 링크 만들어서 캐싱하는 방식이 필요하다.

  • 여러분들의 다음 사이드/토이 프로젝트의 FE는 npm 을 포함해 third-party 하나없이 web component 만 구성해서 사용해보는 것은 어떨까?!


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글