티스토리 뷰

자바스크립트

Custom-element 와 shadow-dom를 사용해보며 배운 것을 정리하는 글

YG - 96년생 , 강아지 있음, 개발자 희망 2023. 4. 3. 21:10

 

글이 전하려고 하는 정보

커스텀 엘리먼트와 쉐도우 돔을 이용하여 메서드 형식으로 사용하는 방법, CSS 속성을 주는 방법, 타입스크립트에서 타입 지정하는 방법 등등 사용하며 배운 지식을 전하려고 합니다.

 


Custom-element를 사용하는 방법 

 

https://developer.mozilla.org/ko/docs/Web/Web_Components/Using_custom_elements

 

사용자 정의 요소 사용하기 - 웹 컴포넌트 | MDN

웹 컴포넌트 표준의 주요 기능 중 하나는 사용자 정의 페이지 기능을 제공하는 길고 중첩된 요소들의 묶음으로 만족하는 것보다는, HTML 페이지에서 기능을 캡슐화하는 사용자 정의 요소를 생성

developer.mozilla.org


Shadow Dom을 사용하는 방법

 

https://htmlwithsuperpowers.netlify.app/get-started/shadow-dom.html

 

Shadow DOM | HTML with Superpowers

 

htmlwithsuperpowers.netlify.app

https://ui.toast.com/posts/ko_20170721

 

웹 컴포넌트(3) - 쉐도우 돔(#Shadow DOM)

이 글은 웹 컴포넌트 소개 연재로 그중 세 번째인 쉐도우 돔에 대한 글이다. 아마도 이전 글의 커스텀 엘리먼트 글을 읽고 온 분은 여러 스펙, API, 기억해 두어야 할 것들로 질렸을지도 모르겠다.

ui.toast.com

 


읽기 전 

모든 정보는 웹팩 환경에서 이루어져 있습니다. 웹팩을 사용하지 않았을 경우 다르게 작동할 수 있습니다.


custom-elements 선언하는 방법

customElements.define( 선언할 html 태그 이름, 해당하는 Javascript 코드)

 

주의할 점으로는 선언할 html 태그 이름은 무조건 'dash(-) ' 가 들어가야 합니다.  html의 이름 규칙을 따라야 하기 때문입니다.

 

 

HTML Standard

 

html.spec.whatwg.org

import Header from './Header';
import SearchInput from './SearchInput';
import MoviesContainer from './MoviesContainer';
import MovieListItem from './MovieListItem';
import Modal from './Modal';
import MovieScore from './MovieScore';
import Image from './Image';
import Button from './Button';
import Skeleton from './Skeleton';

customElements.define('movie-header', Header);
customElements.define('search-input', SearchInput);
customElements.define('movies-container', MoviesContainer);
customElements.define('movie-item', MovieListItem);
customElements.define('movie-modal', Modal);
customElements.define('movie-score', MovieScore);
customElements.define('movie-image', Image);
customElements.define('common-button', Button);
customElements.define('skeleton-item', Skeleton);

shadowDom 없는 custom-elements CSS 적용 방법

 

커스텀 엘리먼트 파일에서 CSS 파일을 불러오면 사용이 가능합니다. 

 

웹팩의 css-loader가 없다면 안될 수 있습니다

import './Button.css';

class Button extends HTMLElement {
  connectedCallback(): void {
    this.render();
  }

  render(): void {
    const text = this.getAttribute('text');
    const color = this.getAttribute('color');

    this.innerHTML = /*html*/ `
      <button class="btn ${color}">${text}</button>
    `;
  }
}

export default Button;

Button.css

.btn {
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: 8px;
  color: var(--white);
}

.primary {
  background-color: var(--red);
}

.darken {
  background-color: var(--black);
}

shadowDom과 custom-elements를 같이 사용했을 때 CSS 적용 방법

 

쉐도우 돔에서는 CSS를 적용시킬 때 내부에서 적용을 시켜줘야 잘 작동이 되었습니다. 먼저 HTML 코드를 렌더 시키고 그 이후에 CSS 파일을 쉐도우 돔 내부에 적용시키는 방법을 사용했습니다.

 

Button.ts

class Button extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.render();
    this.setComponentStyle();
  }

  static get observedAttributes() {
    return ['name', 'color', 'id'];
  }

  render() {
    const name = this.getAttribute('name');
    const id = this.getAttribute('id');
    const color = this.getAttribute('color');

    this.shadowRoot!.innerHTML = `
    <button type="button" id="${id}" alt="${id}" class="button--${color} text-caption">${name}</button>
    `;
  }

  setComponentStyle() {
    const componentStyle = document.createElement('style');
    componentStyle.textContent = `
      .text-caption {
        font-size: 14px;
        line-height: 20px;
        font-weight: 400;
      }
      button {
        width: 171px;
        height: 44px;
        border: none;
        border-radius: 8px;
        font-weight: 600;
        cursor: pointer;
        transition: background-color 0.3s;
      }
      
      .button--white {
        border: 1px solid var(--grey-300);
        background: transparent;
        color: var(--grey-300);
      }
      .button--white:hover{
        background: var(--grey-400);
        color: var(--grey-100);
      }
      
      .button--orange {
        background: var(--primary-color);
        color: var(--grey-100);
      }
      
      .button--orange:hover{
        background: var(--yellow-color);
        color: var(--grey-400);
      }
      @media (max-width: 500px) {
        button {
          width: 150px;
          height: 44px;
          border: none;
          border-radius: 8px;
          font-weight: 600;
          cursor: pointer;
        }
      }`;

    this.shadowRoot!.append(componentStyle);
  }
}

export default Button;

custom-elements 메서드 사용하는 방법

 

querySelector를 이용하여 원하는 커스텀엘리먼트를 선택한다. 그리고 메서드를 실행한다.

export const $ = <E extends Element>(selector: string): E | null => document.querySelector(selector);

index.ts

 

movies-container를 dom에서 찾은 후 reset 메서드를 실행시키는 코드입니다.

const setDisconnectedError = (): void => {
  const movieContainer = $('movies-container') as HTMLMovieContainerElement;
  const modal = $('#modal') as HTMLDialogElement;
  movieContainer.setSearchWord('OFFLINE_ERROR');

  movieContainer.reset();
  $('body')?.classList.remove(SCROLL_HIDDEN_CLASSNAME);
  modal.close();

  $('#more-button')?.classList.add('hide');
  $('#more-button')?.classList.add('hide-button');
  $('#skeleton-container')?.classList.add('skeleton-hide');

  movieContainer.setErrorMessage('인터넷 연결이 끊겼습니다.');
};

MoviesContainer.ts

class MoviesContainer extends HTMLElement {
  reset(): void {
    const movieListWrapper = $('#movie-list-wrapper') as HTMLElement;

    movieListWrapper.innerHTML = '';

    if ($('#no-result-message')) {
      $('#no-result-message')?.remove();
    }

    this.#movies.resetPageIndex();
  }

}

export default MoviesContainer;

 

안 좋은 예

 

외부와 격리시켜서 캡슐화하기 위해 사용한 shadowDom을 무력화시키는 코드를 사용하기.

이런 코드를 사용하지 않고 외부에 노출되어 있는 <movie-header></movie-header>와 같은 커스텀 엘리먼트를 찾아 메서드를 실행시키는 것이 좋습니다.

 

const $$$ = (rootNode, selector) => {
  const arr = [];

  const traverser = (node) => {
    if (node.nodeType !== Node.ELEMENT_NODE) return;

    if (node.matches(selector)) {
      arr.push(node);
    }

    const children = node.children;
    if (children.length) {
      for (const child of children) {
        traverser(child);
      }
    }

    const shadowRoot = node.shadowRoot;
    if (shadowRoot) {
      const shadowChildren = shadowRoot.children;
      for (const shadowChild of shadowChildren) {
        traverser(shadowChild);
      }
    }
  };

  traverser($(rootNode));

  return arr[0];
};

 

 

 

다음과 같이 직접 값을 조정하지 않고 add-restaurant-modal에서 reset 메서드를 만들어서 사용할 수 있을 것입니다.

$$$('add-restaurant-modal', '#categoryList').value = '';
$$$('add-restaurant-modal', '#nameInput').value = '';
$$$('add-restaurant-modal', '#distanceList').value = '';
$$$('add-restaurant-modal', '#descriptionInput').value = '';
$$$('add-restaurant-modal', '#linkInput').value = '';

shadow Dom, custom-elements 타입 지정하기

? 혹은! 붙이기 

    $('#skeleton-container')?.classList.add('skeleton-hide');

 

  getSelectValue(): string {
    const id = this.getAttribute('id');
    const select = this.shadowRoot!.querySelector(
      `#${id}`
    ) as HTMLSelectElement;

    return select.value;
  }

custom-elements interface 만들기

타입을 내보낼 때 클래스를 기반으로 타입을 내보내면 타입뿐만 아니라 메서드 코드, 클래스 그 자체를 내보낸다고 봐서 따로 인터페이스를 만들어주었습니다.

 

네이밍은 HTML Element와 관련 있는 코드라고 알려주기 위해 HTML ~ Element로 짓는 컨벤션을 만들어보았습니다.

 

export interface HTMLMovieContainerElement extends HTMLElement {
  reset: () => void;
  setSearchWord: (searchWord: string) => void;
  setErrorMessage: (errorMessage: string) => void;
}
export interface HTMLMovieListItemElement extends HTMLElement {
  updateReviewedElement: () => void;
}

this를 사용하여 여러 개의 컴포넌트 관리하기

 

setLoadingEvent 메서드에서 this.querySelector을 사용하여 커스텀 엘리먼트 내부에서 img 태그를 찾아서 기능을 만들었습니다. this를 사용하지 않았더라면 id를 따로 부여해서 id를 찾은 후 동작시켰어야 했을 텐데 this를 이용하면 쉽게 만들 수 있습니다.

import './Image.css';
import DEFAULT_IMAGE from '../image/default-movie-image.png';

class Image extends HTMLElement {
  connectedCallback(): void {
    this.render();
    this.setLoadingEvent();
  }

  render(): void {
    const EMPTY = 'null';

    const imgUrl = this.getAttribute('imgUrl');
    const title = this.getAttribute('title');
    const width = this.getAttribute('width');

    const URL = imgUrl !== EMPTY ? `https://image.tmdb.org/t/p/w${width}${imgUrl}` : DEFAULT_IMAGE;

    this.innerHTML = /*html*/ `
      <img
        class="movie-image skeleton-image"
        src="${URL}"
        loading="lazy"
        alt="${title}"
        title="${title}"
      />`;
  }

  setLoadingEvent(): void {
    this.querySelector('img')?.addEventListener('load', () => {
      this.querySelector('img')?.classList.remove('skeleton-image');
    });
  }
}

export default Image;

custom-elements에서 attribute 사용하기

observedAttributes을 사용하지 않아도 attribute를 사용할 수 있습니다.

 

  static get observedAttributes() {
    return ['name', 'color', 'id'];
  }

 

 <movie-image imgUrl="${imgUrl}" title="${title}" width="200" class="movie-list-image-wrapper"></movie-image>

Shadow Dom에서 Jest, Cypress 테스트 편하게 하기 위한 방법

 

 

shadow-dom-testing-library

An extension of DOM-testing-library to provide hooks into the shadow dom. Latest version: 1.10.0, last published: 2 months ago. Start using shadow-dom-testing-library in your project by running `npm i shadow-dom-testing-library`. There are no other project

www.npmjs.com

 

 

 

cypress-shadow-dom

Extend Cypress commands with shadow DOM support. Latest version: 1.4.1, last published: 3 years ago. Start using cypress-shadow-dom in your project by running `npm i cypress-shadow-dom`. There are no other projects in the npm registry using cypress-shadow-

www.npmjs.com

 

 

 

shadow | Cypress Documentation

Traverse into the shadow DOM of an element.

docs.cypress.io


Shadow Dom안에 Shadow Dom을 넣어서 Form, input 이벤트가  안될 때 

검색 키워드 : Form Associated 

 

 

More capable form controls

New web platform features make it easier to build custom elements that work like built-in form controls.

web.dev

 

HTMLElement.attachInternals() - Web APIs | MDN

The HTMLElement.attachInternals() method returns an ElementInternals object. This method allows a custom element to participate in HTML forms. The ElementInternals interface provides utilities for working with these elements in the same way you would work

developer.mozilla.org

 

ElementInternals and Form-Associated Custom Elements

In Safari Technology Preview 162 we enabled the support for ElementInternals and the form-associated custom elements by default.

webkit.org


사용하며 느낀 점

리액트가 아닌 자바스크립트로 컴포넌트를 처음 만들었을 때 custom-elements를 사용했습니다. 원하는 기능을 동작하려고 할 때 querySelector로 해당하는 커스텀 엘리먼트를 찾아서 메서드를  사용할 수 있다는 점이 가장 큰 장점으로 다가왔습니다.

 

또한 HTMLElement의 메서드를 사용할 수 있어서 유용했습니다.

 

shadow Dom을 이용했을 때엔 HTML, CSS, Javascript 모두 캡슐화되어 있는 하나의 돔처럼 이용할 수 있는 것이 좋았고, 진정한 컴포넌트처럼 생각했었습니다. 그렇지만 Shadow Dom을 사용하는 순간부터 골치 아픈 일들이 많이 생깁니다. 예를 들어 CSS를 Javascript에서 작성해서 적용시켜야 하고, 테스트를 해야 한다면 따로 라이브러리를 또 설치해서 사용해야 하기에 외부 코드에 의존성이 높아지게 됩니다. 그리고 Form을 이용해서 submit 이벤트를 이용해야 할 때가 있었는 데 form으로 이루어진 Shadow Dom 안에 다른 input Shadow Dom이 있을 경우 required 혹은 form 이벤트가 작동하지 않았었습니다. 이를 해결하기 위해 Form Associated를 이용해야 해결할 수 있었습니다. 저는 이 방법 말고 일일이 input값, form에 관한 것을 javascript 를 통해 관리하는 방법으로 해결했었습니다.

 

 


결론

custom-elements는 유용했습니다. Shadow Dom은 커스텀 엘리먼트가 너무 많아서 CSS에서 사용하는 클래스를 중복되지 않게 관리해야 한다 했을 때 유용할 것 같고, 그 외에 장점은 못 느꼈습니다. 따라서 custom-elements만 사용하는 것을 추천합니다.

 

하지만 저는 Shadow Dom과 custom-elements를 같이 사용할 때 더욱 빛을 발하는 slot, host와 같은 기능은 사용하지 않았습니다. 제가 모르는 다른 장점이 있을 수 있습니다.

 

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함