IT일상

[Web Components] 컴포넌트 내부 요소를 격리하기. HTML Custom Elements + Shadow DOM

  • 프론트엔드
Profile picture

Written by solo5star

2023. 2. 27. 15:13

<my-alert severity="warn"></my-alert>

<my-article
  title="오늘 공부한 내용입니다~!!"
  content="GitHub Actions로 배포를 자동화하는 방법에 대해 공부해보았습니다."
></my-article>
customElements.define('my-alert', class MyAlert extends HTMLElement {
  constructor() {
    super();

    this.innerHTML = `
      <style>
        h1 { color: red; border: 1px solid black; }
      </style>
      
      <h1 data-severity="warn">문제가 발생하였습니다.</h1>
    `;
  }
});

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

    this.innerHTML = `
      <style>
        h1 { color: blue; }
        p { color: grey; }
      </style>

      <h1>${this.getAttribute('title')}</h1>
      <p>${this.getAttribute('content')}</p>
    `;
  }
});

Custom Element 두 개를 만들었습니다.

my-alert빨간 색 글자로 표시되어야 하고,

my-article파란 색 글자로 표시되어야 합니다.

그런데, 결과를 보면 이렇습니다.

1

my-article에서 적용한 스타일이 my-alert에 덮여쓰여 졌습니다.

.my-article__title {
  color: blue;
}

.my-article__title {
  color: red;
  border: 1px solid black;
}

물론 CSS class name을 사용하여 별도의 스타일로 지정하면 됩니다. 하지만! 오늘은 이런 문제를 해결할 수 있는 다른 방법을 소개해보고자 합니다.

바로, Shadow DOM 입니다.

Shadow DOM

2

Shadow DOM이란, 캡슐화된 HTML과 CSS 공간입니다. 격리되었다고도 합니다.

처음에 보여드렸던 코드에서는 Custom Element 내에서 사용한 스타일이 전역으로 사용되었는데, Shadow DOM을 사용하면 CSS가 내부 스코프에만 적용되며 의도치 않게 외부로 오염될 일이 없습니다.

customElements.define('my-alert', class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        h1 { color: red; border: 1px solid black; }
      </style>
      
      <h1 data-severity="warn">문제가 발생하였습니다.</h1>
    `;
  }
});

customElements.define('my-article', class MyArticle extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        h1 { color: blue; }
        p { color: grey; }
      </style>

      <h1>${this.getAttribute('title')}</h1>
      <p>${this.getAttribute('content')}</p>
    `;
  }
});
3
Chrome Devtools로 확인하면 shadow-root가 만들어진 것을 확인할 수 있습니다

Shadow DOM을 적용한 코드입니다. attachShadow() 를 호출하면 Shadow DOM 공간이 만들어집니다. 그리고 this.shadowRoot 를 통해 내부에 접근할 수 있습니다.

Shadow DOM을 만든 DOM, 즉 my-articlemy-alerthost라고 합니다.

4

CSS 스타일이 각 Custom Element에서만 적용된 모습을 볼 수 있습니다. 캡슐화가 잘 동작하죠? HTML 또한 캡슐화되기 때문에, id 속성 같은 유일해야 하는 값도 Shadow DOM 내부에서만 유일하면 됩니다.

:host, :host(), :host-context() 선택자

host는 Shadow DOM을 들고 있는 DOM입니다. Shadow DOM의 루트라고도 볼 수 있습니다.

CSS에서 host를 대상으로 스타일을 지정하려면 :host, :host(), :host-context() 와 같은 선택자를 사용하면 됩니다.

:host

customElements.define('my-alert', class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { font-weight: lighter; }

        h1 { color: red; font-weight: inherit; }
      </style>

      <h1 data-severity="warn">문제가 발생하였습니다.</h1>
      <p>자세한 내용은 관리자에게 문의해주세요.</p>
    `;
  }
});
5
Shadow DOM 자체에 font-weight: lighter가 적용되었다

:host는, host 자체를 선택합니다. Shadow DOM의 루트입니다. 내부의 엘리먼트들은 :host의 스타일을 상속을 받거나 상속 받도록 지정할 수도 있습니다.

:host()

괄호 안의 CSS 선택자 조건에 해당될 때 적용됩니다.

<my-alert severity="warn"></my-alert>
<my-alert severity="error"></my-alert>
customElements.define('my-alert', class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { font-weight: lighter; }

        :host([severity=warn]) > * { background: yellow; }
        :host([severity=error]) > * { background: pink; }

        h1 { color: red; font-weight: inherit; }
      </style>

      <h1 data-severity="warn">문제가 발생하였습니다.</h1>
    `;
  }
});
6

무조건적으로 적용되는 :host와는 달리, host에 조건을 확인합니다. my-alertseverity=warn이면 배경을 노란 색으로, severity=error이면 분홍색을 적용하도록 되어 있습니다.

:host-context()

:host() 는 host 자신의 조건만 확인하였다면, :host-context()는 상위 엘리먼트도 확인합니다.

<body>
  <article class="important">
    <my-alert severity="warn"></my-alert>
  </article>
  <my-alert severity="error"></my-alert>
</body>
customElements.define('my-alert', class MyAlert extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { font-weight: lighter; }

        :host([severity=warn]) > * { background: yellow; }
        :host([severity=error]) > * { background: pink; }

        :host-context(.important) > * { border: 2px solid red; }

        h1 { color: red; font-weight: inherit; }
      </style>

      <h1 data-severity="warn">문제가 발생하였습니다.</h1>
    `;
  }
});
7

상위 엘리먼트가 important CSS 클래스를 가지는 경우 :host-context(.important) 의 스타일이 표시됩니다.

정리하자면,

  • :host는 host 자신만 확인하며,
  • :host-context는 host 자신과 상위의 엘리먼트도 확인합니다.

:host, :host(), :host-context() 브라우저 호환성

2023년 2월 기준입니다. 글을 읽는 시점에 따라 브라우저 호환 여부가 달라질 수 있으니 https://caniuse.com/?search=%3Ahost 에서 필히 확인하시기 바랍니다.

8
:host 선택자 호환성
9
:host() 선택자 호환성
10
:host-context() 선택자 호환성

:host-context() 선택자의 경우 Safari, Safari on iOS, Firefox에서 지원되지 않으므로 이 점 유의하시면 됩니다.

Shadow DOM 옵션: mode

customElements.define('my-welcome', class MyWelcome extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<p>안녕하세요. 오늘도 힘찬 하루</p>`;
  }
});
const $myWelcome = document.querySelector('my-welcome');
$myWelcome.shadowRoot.innerHTML // shadowRoot 접근

mode는 외부에서 javascript로 Shadow DOM에 접근할 수 있는지 여부를 정합니다. 'open' 으로 되어있는 경우 접근 가능하며, 'close'로 되어있는 경우 접근이 불가합니다.

Shadow DOM 옵션: delegatesFocus

customElements.define('my-name', class MyArticle extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open', delegatesFocus: true });
    this.shadowRoot.innerHTML = `
      <input type="text" placeholder="이름을 입력하세요!" autofocus>
    `;
  }
});
11
문서가 로드된 후 Shadow DOM 내부의 input 엘리먼트에 autofocus가 위임된 모습

focus를 Shadow DOM 내부의 DOM에 위임할 때 사용합니다. 이는 autofocus 속성을 사용할 때 유용합니다.

외부의 스타일을 Shadow DOM에 적용하기

간혹 전역으로 사용하는 CSS 스타일들을 사용해야 할 때가 있습니다. 이러한 경우엔 CSSStyleSheet 객체를 생성하여 사용하여야 합니다.

const style = new CSSStyleSheet();
style.replace(`
  .box {
    border: 1px solid grey;
    border-radius: 4px;
    padding: 1rem;
  }
`);

customElements.define('my-name', class MyArticle extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open', delegatesFocus: true });
    this.shadowRoot.innerHTML = `
      <input class="box" type="text" placeholder="이름을 입력하세요!" autofocus>
    `;
    this.shadowRoot.adoptedStyleSheets = [style];
  }
});
12
box 클래스가 적용된 input 엘리먼트

user-agent Shadow DOM

13
Devtools 설정(Preferences)에서 활성화할 수 있습니다.
14
<input type="date"> 의 Shadow DOM

브라우저 자체에서도 Shadow DOM이 사용되고 있습니다. 예를 들어 <input type="date"> 에서는 위와 같은 Shadow DOM을 확인할 수 있습니다.

정리

Shadow DOM을 사용하면 요소들을 캡슐화 할 수 있어 정말 유용합니다. 특히 Custom Element와 함께 사용하면 컴포넌트로서 필수적인 기능들을 모두 수행할 수 있다고 볼 수 있습니다.

물론 React나 Vue같은 것들을 사용하면 그다치 필요 없는 것들이라 그런지 자료가 많이 없는 점은 정말 아쉬운 것 같습니다 ㅜㅜ 아무튼, 만약 바닐라 JS로 개발한다면 많은 도움이 될거라 생각합니다.

감사합니다.


Profile picture

Written by solo5star

안녕하세요 👋 개발과 IT에 관심이 많은 solo5star입니다

  • GitHub
  • Baekjoon
  • solved.ac
  • about
© 2023, Built with Gatsby