본문 바로가기
카테고리 없음

Template literals 구현 (2) - Web Component

by memeseo 2023. 2. 14.

Web Component

: 웹 컴포넌트는 JavaScript, CSS, HTML들을 컴포넌트화하기 위해 필요한 4개의 표준을 묶어서 부르는 이름이다.

 

웹 컴포넌트가 등장한 배경이 흥미로운데, 수 많은 자바스크립트 프레임워크들이 쏟아져 나왔을 때 구글은 프레임워크의 단점들을 보완하기 위해서는 브라우저 기능과 적절히 섞어 사용해야 가볍고 성능 좋은 APP을 만들 수 있다며 권유했다. 아무런 의심 없이 프레임워크를 사용하고 있던 나에게 적잖은 깨우침을 주었다랄까.. (!) 구글과 페이스북을 포함한 프레임워크 제작사들은 React vs Web Component가 아닌 Web Component With React, Angular, Ember의 뉘앙스로 글을 쓰고 있다고 한다.

 

프레임워크 Component와 비교해보는 Web Component의 장점

 

가장 큰 장점을 꼽으라면 상호운용성(Interoperability)이다.

프레임워크 컴포넌트는 사용하고 있는 프레임워크 안에서만 훌륭하다. Angular 컴포넌트 안에서 React를 쉽게 동작시킬 수 없으며 그 반대도 마찬가지다. 반면에 웹 컴포넌트는 웹 표준 이외에는 어떤 것도 관여하지 않기 때문에 어떤 생태계에서도 동작한다.

 

1. 상호운용성 (Interoperability) : 다른 기술 스택의 프로젝트에서도 동작 한다.
2. 수명 (Lifespan) : 상호운용이 가능하기 때문에 더 긴 수명을 가지게 되고, 새 기술 스택에 맞춰 리팩터링 할 필요가 줄어든다.
3. 가용성 (Portability) : 컴포넌트가 특정 라이브러리나 프레임워크에 크게 의존하지 않는다면 어떤 생태계에서도 동작하기 때문에 도입에 대한 장벽이 낮아진다.

 

Web Component의 세 가지 주요 기술

1. Custom Elements
2. Shadow DOM
3. HTML template

내가 구현하면서 사용한 기능에 대해서만 포스팅 할거기 때문에 custom elementsshadow dom만 다룰 예정이다.

 

1. Custom Elements

custom element는 web custom의 주요 표준 중 하나이다.

 

장점

  • <div></div> 대신 <custom-div></custom-div>처럼 적절한 이름 태그를 사용할 수 있다.
  • HTML Element와 JavaScript Class를 한 몸으로 만들어 준다.
  • 긴 MutationObserver코드를 사용하지 않아도 된다. (MutationObserver는 DOM의 변화를 감지하는 JavaScript 장치이다.)

 

Custom Element 사용법

 

1. class 구문을 사용하여 웹 구성 요소 기능을 지정하는 클래스를 생성한다.

class customDiv extends HTMLElement {
	constructor(){
    	//...
    }
	// ...
}

 

2. 메서드를 사용하여 엘리먼트와 클래스를 묶어 준다.

window.customElements.define('custom-div', class extends HTMLElement {});

<custom-div></custom-div>

 

❗ 이름 규칙
* Custom Element는 특별한 이름 규칙을 필요로 한다. 글자 가운데 - 를 하나 이상 포함해야 한다.

올바른 이름
<test-editor></test-editor>
<my-element></my-element>
<super-awsome-element></super-awsome-element>


잘못된 이름

<testEditor></testEditor> // - 없음
<font-face></font-face>	// 예약된 태그 이름 SVG

 

웹 컴포넌트 라이프 사이클

자주 사용하는 라이프 사이클 위주로 알아보자.

 

connectedCallback & disconnectedCallback

HTMLElement를 상속받은 경우 이 Custom Element가 DOM에 추가되거나 제거될 때마다 실행된다. (이 때문에 세 번째 장점이 가능한 것.) 

 

(1) connectedCallback

  • custom element가 문서의 DOM에 처음 연결될 때 호출된다.
connectedCallback() : void{
	console.log('beforeCreate');
    super.connectedCallback();
    console.log('created');
}
❗ 주의

전통적인 방법에서는 constructor에서 DOM을 조작할 수 있었다. 이게 가능했던 이유는 Document의 DOMContentLoaded이벤트를 받아 DOM이 로드되고 나서 class를 초기화하기에 construtor가 실행되는 시점에서 엘리먼트는 DOM에 붙어 있는 상태이다. 따라서 construtor에서 어떠한 DOM 조작을 해도 무방한 것이다.

그러나 HTMLElement를 상속받은 Custom Element의 constructor의 실행시점은 아직 DOM에 아직 추가되지 않은 상태이다. 그렇기 때문에 어떠한 DOM도 조작할 수 없으며, DOM과 무관한 클래스 인스턴스 자체의 준비만 해야 한다.

constructor(){
	super();
    console.log(this.parentNode); // null
    console.log(this.firstChild); // null
    console.log(this.innerHTML); // ""
    console.log(this.getAttribute('locale')); // null
    this.setAttribute('locale', 'ko-KR'); // 에러
    this.innerText = 'Ah' // 에러
}​

 

(2) disconnectedCallback

  • custom element가 문서의 DOM에서 연결 해제 될 때 호출된다.
disconnectCallback():void{
	super.disconnectCallback();
}

 

(3) update

  • Property 값들을 attributes에 반영한다. (vue update 훅과 같은..)
  • lit-html의 render를 호출한다.
update(changedProperty):void{
    super.update(changedProperty);
}

 

(4) firstUpdated

  • DOM이 처음으로 업데이트되었을 때 호출된다. element의 템플릿이 처음 만들어졌을 때 한 번만 실행하기 위해 사용된다. (vue mounted, created 훅과 같은..)
firstUpdated():void{
	//...
}

 

(5) updated

  • DOM이 업데이트되어 렌더링 된 후에 호출된다.
update(changedProperty: PropertyValues): void{
	console.log(changedProperty);
}

 

전체적인 구현 예시

// vue component

<div>
	<custom-div><custom-div>	
</div>

<script>
import {CustomDiv} from '../../CustomDiv';
component : {
	CustomDiv
}
</script>
class CustomDiv extends HTMLElement {
    constructor() {
        // 클래스 초기화. 속성이나 하위 노드는 접근할 수는 없다.
        super();
    }
    static get observedAttributes() {
        // 모니터링 할 속성 이름
        return ['locale'];
    }
    connectedCallback() {
        // DOM에 추가되었다. 렌더링 등의 처리를 하자.
        this.start();
    }
    disconnectedCallback() {
        // DOM에서 제거되었다. 엘리먼트를 정리하는 일을 하자.
        this.stop();
    }
    attributeChangedCallback(attrName, oldVal, newVal) {
        // 속성이 추가/제거/변경되었다.
        this[attrName] = newVal;
    }
    adoptedCallback(oldDoc, newDoc) {
        // 다른 Document에서 옮겨져 왔음
        // 자주 쓸 일은 없을 것.
    }
    start() {
        // 이 클래스 인스턴스는 HTMLElement이다.
        // 따라서 `document.querySelector('custom-div').start()`로 호출할 수 있다.
    }
    stop() {
        // ...
    }
}
// <custom-div> 태그가 CustomDiv 클래스를 사용하도록 한다.
customElements.define('custom-div', CustomDiv);

 


 

2. Shadow DOM

 

html과 css는 모두 하나의 페이에 모든 엘리먼트, 모든 css 룰이 쓰이고 있다. 즉, html과 css는 모두 public이고 global이다. 예외로 shadow dom은 html과 css에 스코프를 줄 수 있다.

 

예시)

네이버 개발자 도구를 열어 div태그 안에 span을 추가하였고, style에 div태그 배경색을 설정했다. 아래 이미지를 보면 알 수 있듯이 style태그는 어디에 붙어 있던 글로벌이기 때문에 페이지에 존재하는 모든 div를 파란색으로 덮었다. 

 

쉐도우 돔에 비슷한 일을 해보자. 아래 코드가 위의 코드와 달라진 것은 attachShadow({mode:'open'})함수 실행이 하나 더 추가된 것뿐이다. 이 함수는 쉐도우 루트를 생성하는데 이게 DOM 스코프 경계선 역할을 한다.

 

개발자 도구를 보면 #shadow-root (open)이라는 게 생겼고, 그 밑에 있는 style은 밖으로 새나가지 않는다는 걸 확인할 수 있다. 반대로 글로벌에 존재하는 스타일 역시 #shadow-root (open) 안에 있는 엘리먼트에는 영향을 미치지 못한다. 이 처럼 쉐도우 돔은 돔 자체의 분리 역할을 한다. 즉 쉐도우 루트를 기준으로 id를 중복해서 써도 되고, 루트 안팎의 동일한 이름의 class 역시 전혀 다른 클래스의 역할을 수행한다. 쉐도우 루트 밖에서 쉐도우 돔의 엘리먼트를 셀렉트할 수도 없다.

 

컴포넌트 : Custom Element + Shadow DOM = DOM OOP

커스텀 엘리먼트는 HTML 엘리먼트를 확장해 오브젝트로 만들어 주고, 쉐도우 돔은 그 오브젝트에 스코프를 제공해 준다. 즉, 커스텀 엘리먼트와 쉐도우 돔은 DOM을 OOP의 대상으로 바라볼 수 있게 해준다.

❗ iframe도 shadow dom과 비슷한데요?
iframe을 사용한 DOM의 분리는 다음과 같은 단점이 있다.

1. http 요청이 한 차례 더 일어난다.
2. 별도의 페이지이기 때문에 소비되는 리소스가 높고 느리다.
3. iframe의 주소가 같은 도메인이 아닌 경우 접근이 불가능하다.

 

쉐도우 분해하기 : 쉐도우 트리

  • 쉐도우 돔 : shadow root에 붙어있는 DOM
  • 쉐도우 루트 : #shadow-root
  • 쉐도우 호스트 : 쉐도우 루트의 부모
  • 라이트 돔 : 도큐먼트 쉐도우 호스트에 붙어있는 노드들.

아래 코드 동작을 위의 정의로 풀이해 보면 '쉐도우 돔의 슬롯이 가진 이름에 맞는 라이트 돔의 노드가 각 슬롯에 삽입된다.'라고 할 수 있다.

<body>
	<div id="slot-test">
    <!--light DOM-->
    	<span slot="title">Hello</span>
        <span slot="desc">Word</span>
    </div>
    //...
</body>
// shadow DOM

document.querySelector('#slot-test')
	.attachShadow({mode: 'open'})
    .innerText = `
    	<h1>
        	<slot name="title"></slot>
        <h1>
        
        <p>
        	<slot name="desc"></slot>
        </p>
    `

 

슬롯의 이름에 맞는 라이트 돔이 자리를 찾아갔다.

 

 

주의 사항 

외부에서 어떤 오브젝트의 private 속성을 변경하고 싶을 때는 오브젝트의 정체성에 맞게 메서드를 새로 추가하는 게 맞다. shadow DOM도 마찬가지로, 내부 돔을 직접 수정하려는 시도는 잘 못된 시도이며, 웹 컴포넌트의 정체성에 맞게 필요하다면 메서드를 추가해서 내부 돔에 접근해야 한다. 

 

// Good
<my-element lang="ko"></my-element>
// Bad
document.querySelector('my-element')
	.shadowRoot
   	.querySelector('div')
    .innerText = '만세';
// Good
const myElement = document.querySelector('my-element');
myElement.testfunc();
//Bad
const badIdea = document.querySelector('my-element')
				.shadowRoot
                .querySelector('div')
                .innerText;
 
 alert(badIdea);

 

 

 

 

참고

https://developer.mozilla.org/en-US/docs/Web/Web_Components

https://meetup.nhncloud.com/posts/120