기본적인 웹 컴포넌트 정리

2024년 2월 6일

Web Component란 바닐라 자바스크립트에서 재사용 가능한 컴포넌트를 만들수 있도록 해주는 웹 표준 기술 모음이다.

보통 리액트, 뷰 같은 프론트엔드 라이브러리, 프레임워크에서 컴포넌트 기반으로 화면을 그려나가는데, 웹 컴포넌트를 활용하면 바닐라 자바스크립트 내에서도 이러한 구현이 가능하다.

class MyWebComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });

    // 템플릿 정의
    this.template = document.createElement("template");
    // 템플릿 내용을 설정
    this.template.innerHTML = `
          <style>
            ::slotted(span){
                font-weight: bold;
                font-size : 2rem;
            }
          </style>

          <slot name="slot"></slot>
        `;
    // 템플릿을 섀도우 DOM에 추가
    this.shadowRoot.appendChild(this.template.content.cloneNode(true));
  }
}

customElements.define("my-web-component", MyWebComponent);

- html 에서 불러오기

<my-web-component></my-web-component>

기본적으로 이러한 형태로 정의하고 HTML 파일 내에서 정의한 컴포넌트 명으로 불러오면 된다.

class MyWebComponent extends HTMLElement {

이는 컴포넌트 명을 정의하는 부분이다.

웹 컴포넌트로 사용하기 위해서 모든 HTML요소의 기본 클래스인 HTMLElement로 확장한다.

  constructor() {
    super();
    this.attachShadow({ mode: "open" });

웹 컴포넌트를 초기화하기 위해서 super() 메서드를 통해 HTMLElement 의 생성자를 호출한다.

또한 this.attatchShadow를 통해서 섀도우 DOM을 컴포넌트에 부착한다.

- Shadow DOM 이란?

Shadow DOM은 말그대로 숨겨진 DOM이라는 뜻이다.

Shadow DOM은 웹 컴포넌트의 핵심적인 기능중 하나이다.

이는 기존의 DOM 트리에서 독립적인 DOM 트리를 생성할수 있도록 해준다.

Shadow DOM을 통해서 크게 3가지의 이점을 얻을수 있는데

  1. 독립적인 스타일링 보장

  2. 독립적인 DOM 트리 생성으로 인한 캡슐화

  3. 재사용성 극대화

이다.

Shadow DOM을 통해서 내부에 정의된 스타일은 해당 컴포넌트에만 적용이 되고, 외부 스타일은 컴포넌트에 영향을 끼치지 않는다.

또한 독립적인 DOM 트리 생성으로 인하여 id나 클래스명 충돌을 방지한다.

또한 해당 컴포넌트를 다른곳에서도 재사용할수 있도록 해준다.

위 코드에서 mode를 open 값으로 설정했는데, 이는 외부 자바스크립트로부터 접근을 가능하도록 하여, 컴포넌트가 캡슐화 되면서도 외부에서 조작할수 있도록 하는 것이다.

this.template = document.createElement("template");
// 템플릿 내용을 설정
this.template.innerHTML = `
          <style>
            /* 여기에 스타일 정의 */
          </style>
          <div>여기에 컨텐츠</div>
        `;

이는 컴포넌트의 템플릿을 정의한다.

템플릿이란 해당 컴포넌트의 내용물들을 정의한다고 생각하면 된다.

html에서는 my-web-component를 통해 컴포넌트를 불러오지만, 해당 컴포넌트 내에서는 저 템플릿의 내용을 사용해서 실제 DOM요소가 생성 되는 것이다.

하지만 이 템플릿 태그는 페이지 로드시에는 렌더링 되지 않는다. 그렇기때문에 다른곳에서 동적으로 재사용할때 활용할 수 있다.

하지만 지금 해당 컨텐츠가 브라우저에 렌더링 되어있는 모습을 확인할수 있는데,

이는

this.shadowRoot.appendChild(this.template.content.cloneNode(true));

이 코드로 인하여 가능한것이다.

위 코드는 템플릿을 섀도우돔에 깊은 복사를 통해 삽입해주어서 실제 DOM에 띄워주는 역할을 한다.


- slot

slot은 동적으로 템플릿내에 내용을 넣어줄수 있도록 한다.

this.template.innerHTML = `
          <style>
            ::slotted(span){
                font-weight: bold;
                font-size : 2rem;
            }
          </style>

          <slot name="slot"></slot>  // 여기에 외부에서 삽입한 컨텐츠가 들어간다.
        `;
<my-web-component>
  <span slot="slot">이 텍스트는 볼드체로 표시됩니다.</span>
</my-web-component>

slot 태그는 해당 Shadow DOM 내부에 배치되고, 외부 컨텐츠가 내부로 삽입될수 있도록 해주는 입구역할을 한다.

위 코드처럼 Shadow DOM내의 slot name과 외부 컨텐츠의 slot 명을 일치시켜준후, ::slotted 선택자를 통해서 스타일링을 하면 외부 요소가 내부 Shadow DOM으로 삽입된다.


웹 컴포넌트는 리액트와 마찬가지로 라이프사이클이 존재한다.

크게 4가지로 나누어져있는데

connectedCallback : 커스텀 컴포넌트가 DOM에 추가 될때 호출

disconnectedCallback: 커스텀 컴포넌트가 DOM에서 해제될때 호출

adoptedCallback : 커스텀 컴포넌트가 새로운 DOM으로 이동할때 호출

attributeChangedCallback : 커스텀 컴포넌트 속성에 변경이 생겼을때 호출

로 나누어볼수 있다.

<my-input-component
  type="text"
  placeholder="값을 입력하세요"
></my-input-component>

이런식으로 공통 input 컴포넌트를 만들고, 해당 속성 값들을 외부에서 주입 받는다고 가정해보자.

 // my-input-componet

  attributeChangedCallback(name, oldValue, newValue) {
    console.log("속성이 변경되었어요!");
    if (name === "type") {
      this.inputElement.type = newValue;
    } else if (name === "placeholder") {
      this.inputElement.placeholder = newValue;
    }
  }

  connectedCallback() {
    console.log("DOM에 컴포넌트가 추가 되었어요!");
  }

  disconnectedCallback() {
    console.log("컴포넌트가 해제되었어요!");
  }

  adoptedCallback() {
    console.log("Element가 다른 page로 이동 하였습니다.");
  }

attributeChangeCallback함수가 2번 실행된걸 볼수 있는데, 이는 해당 속성값을 type과 placeholder 총 2개를 변경해주었기 때문에, 순차적으로 변경함수가 2번 일어난것이다.

또한 connectedCallback은 말그대로 브라우저에 컴포넌트가 등장했을때 실행되는 함수이다.

disconnectedCallback은 해당 컴포넌트가 DOM 내에서 사라질때 실행된다.

document.body.removeChild(document.querySelector("my-input-component"));

이런식으로 해당 컴포넌트를 삭제한다고 가정해본다면, 저 컴포넌트가 삭제된후에 실행되는 함수라고 생각하면 된다.

마지막으로 adoptedCallback은 해당 컴포넌트를 새로운 페이지로 이동시킬때 발생하는 함수이다.


- 타입스크립트를 활용한 근간이 되는 Basic Component 생성

class BasicComponent extends HTMLElement {
  shadowRoot!: ShadowRoot | null;
  styleSheet: CSSStyleSheet;

  constructor(template: string) {
    super();
    this.attachShadow({ mode: "open" });
    const temp: HTMLTemplateElement = document.createElement("template");
    temp.innerHTML = template;
    if (this.shadowRoot) {
      this.shadowRoot.appendChild(temp.content.cloneNode(true));
    }
    this.styleSheet = new CSSStyleSheet();
    this.shadowRoot!.adoptedStyleSheets = [this.styleSheet];
  }

  addCSSRules(cssText: string): void {
    this.styleSheet.replaceSync(cssText);
  }

  addEventListenerToElement(
    selector: string,
    event: string,
    handler: EventListener
  ): void {
    if (this.shadowRoot) {
      const element = this.shadowRoot.querySelector(selector);
      if (element) {
        element.addEventListener(event, handler.bind(this));
      }
    }
  }

  dispatchCustomEvent<T>(
    eventName: string,
    detail: T,
    options: { bubbles: boolean; composed: boolean } = {
      bubbles: true,
      composed: true,
    }
  ): void {
    this.dispatchEvent(
      new CustomEvent<T>(eventName, {
        detail,
        bubbles: options.bubbles,
        composed: options.composed,
      })
    );
  }
}

export default BasicComponent;
  shadowRoot!: ShadowRoot | null;

기본적으로 shadowRoot : ShadowRoot | null 으로 타입을 할당하면 에러가 난다.

타입스크립트 내에선 모든 속성 값이, 생성자 내에서 초기화가 되길 원하므로 저러한 에러가 난다.

하지만 !라는 Definite Assignment Assertion 를 사용하여 저 해당 프로퍼티가 생성자에서 초기화가 되지 않더라도 클래스 내의 다른 부분에서 무조건 초기화가 될 것임을 보장해 줄수 있다.

addEventListenerToElement 같은 경우, 만들어진 태그요소에 이벤트를 바인딩 해주는 역할을 한다.

예를 들어 Button 컴포넌트를 하나 만들었다고 가정한다면,

- Button.ts

  constructor() {
    super(template);
    this.buttonElement = null;
    if (this.shadowRoot) {
      this.buttonElement = this.shadowRoot.getElementById(
        "modalButton"
      ) as HTMLElement;
      this.addEventListenerToElement("button", "click", this.handler);
    }
  }

사용하려는 태그, 이벤트, 핸들러 이런식으로 지정을 해주면 해당 컴포넌트 템플릿에 대한 이벤트 바인딩이 진행된다.

또한 dispatchCustomEvent 같은 경우, CustomEvent 객체를 이용하여 사용자가 정의하는 이벤트를 만들어줄 수 있다.

bubbles 같은 경우, 하위 요소에서 상위요소까지 이벤트가 전달될수 있도록해준다.

composed 같은 경우, Shadow DOM에서 실제 DOM으로 이벤트가 전달될수 있도록 한다.

또한 스타일링 정의는 CSSStyleSheet를 사용하였는데, 이는 스타일에 관한 객체를 만들어 줌으로써

해당 컴포넌트에 관한 스타일을 지정해줄수 있다.

이는 shadow DOM에서 효과적으로 스타일링을 적용시킬수 있고 하나 이상의 shadow DOM에 스타일링 적용이 가능하다. (동일한 시트는 동일한 문서의 여러 섀도우 하위 트리와 공유될 수 있다.)

하지만 이는 아직 지원하지 않는 브라우저가 존재하기 때문에,

CSSStyleeSheet 에 관한 설명

여기선 adoptedStyleSheets 함수와 replaceSync 함수를 사용하여 스타일을 적용시켜주었다.