HTML을 사용하다보면 <div>나 <button> 같은 기본 HTML태그가 아닌
커스텀 태그를 사용할 수 없는가에 대해 의문이 생긴다.
Custom Elements를 활용하면 아래와 같은 커스텀 태그를 만들 수 있다.
<user-card></user-card>
<my-modal></my-modal>
<app-header></app-header>
이를 통해 의미 있는 이름으로 태그를 사용 할 수 있으며 재사용이 가능해진다.
즉, 프레임워크 없이도 컴포넌트처럼 사용이 가능해진다.
class UserCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `<p>User</p>`;
}
}
customElements.define("user-card", UserCard);
user-card라는 태그 이름을 키로 저장<user-card></user-card>
<user-card>를 읽는다.<user-card>
<p>User</p>
</user-card>
Custom Element의 생명주기란, 한 요소가 다음 과정을 거치는 흐름이다.
이 과정마다 브라우저가 자동으로 호출해주는 함수가 있고,
그 함수들을 “약속된 이름”으로 구현하면 된다.
class MyElement extends HTMLElement {
constructor() {}
connectedCallback() {}
disconnectedCallback() {}
attributeChangedCallback(name, oldValue, newValue) {}
static get observedAttributes() {}
}
이 다섯 개가 Custom Elements의 전 생명주기다.
<user-card></user-card>
브라우저가 HTML을 파싱하다가 이 태그를 발견하고 customElements.define을 통해 정의했다면
브라우저는 내부적으로 인스턴스를 생성하는데 이때 constructor()가 호출된다.
constructor의 특징
즉, 아직 "화면에 나타났다"고 볼 수 없다.
→ DOM 연결 여부가 불확실하므로, innerHTML과 같은 DOM 조작은 constructor 내부에서 실행하지 않아야한다.
<body>
<user-card></user-card>
</body>
요소가 실제로 DOM 트리에 들어갔을 때 실행된다. 사실상 렌더링 단계라고 할 수 있다.
connectedCallback() {
this.innerHTML = `<p>User</p>`;
}
<user-card> 요소 내부의 자식 DOM을 구성한다 그 결과 DOM은 아래와 같아진다.
<user-card>
<p>User</p>
</user-card>
<user-card name="민준" age="27"></user-card>
Custom Element에 attribute가 부여되더라도,
브라우저는 이 값이 화면에 쓰일 데이터인지, 내부 상태인지,
혹은 단순한 정보인지를 알 수 없다.
그래서 기본적으로 브라우저는
attribute가 추가되거나 변경되더라도 아무 반응도 하지 않는다.
만약 모든 커스텀 요소의 모든 attribute 변화를
자동으로 감시하고 자동으로 반응하도록 만들었다면,
불필요한 연산이 늘어나 성능 문제가 발생하게 된다.
이 때문에 Custom Elements에서는
어떤 attribute가 중요한지 개발자가 직접 명시하도록 되어 있다.
static get observedAttributes() {
return ['name', 'age'];
}
이 코드는 브라우저에게 다음과 같이 전달된다.
이 요소에서는
name과ageattribute의 변화만 감시하겠다
배열에 포함된 attribute 중 하나라도 변화가 발생하면,
브라우저는 해당 변화를 감지하게 된다.
하지만 이 시점까지는
아직 실제 동작은 정의되지 않은 상태다.
단지 “어떤 변화를 감시할지”만 정해졌을 뿐이다.
observedAttributes에 의해 감시가 설정된 attribute가 변경되면,
브라우저는 아래 메서드를 자동으로 호출한다.
attributeChangedCallback(name, oldValue, newValue) {
// ...
}
이 메서드는 우리가 직접 호출하지 않는다.
브라우저가 정확한 시점에 호출해준다.
이 메서드는 아래 모든 경우에 호출된다.
1) HTML에서 처음 등장할 때
<user-card name="민준" age="27"></user-card>
요소가 생성되면서 attribute가 처음 설정되는 순간,
브라우저는 이를 “변화”로 인식한다.
name null 민준
age null 27
2) JavaScript로 attribute 값을 변경할 때
el.setAttribute('name', '우혁');
name 민준 우혁
3) attribute를 제거할 때
el.removeAttribute('age');
age 27 null
attributeChangedCallback(name, oldValue, newValue)
각 인자는 다음 의미를 가진다.
name
→ 변화가 발생한 attribute의 이름
oldValue
→ 변경 이전의 값
→ 이전에 존재하지 않았다면 null
newValue
→ 변경 이후의 값
→ attribute가 제거된 경우 null
중요한 점은,
attributeChangedCallback이 화면을 자동으로 바꾸지 않는다는 것이다.
브라우저의 역할은 여기까지다.
"이 attribute가 이렇게 바뀌었다."
그 이후 동작은 개발자의 책임이다.
보통 이 메서드에서는 다음 흐름을 따른다.
예시:
class UserCard extends HTMLElement {
static get observedAttributes() {
return ['name'];
}
attributeChangedCallback(attr, oldVal, newVal) {
if (attr === 'name') {
this.render();
}
}
render() {
this.innerHTML = `<p>${this.getAttribute('name')}</p>`;
}
}
즉, attributeChangedCallback은 트리거일 뿐이며,
실제 화면 변경 로직은 직접 작성해야 한다.
요소가 DOM에서 빠질 때 호출된다.
element.remove();
또는 부모가 제거될 때도 호출된다.
다음과 같은 일을 맡는다.
Custom Elemtns 태그 이름에는 반드시 하이픈이 필요하다.
→ 미래의 HTML 표준 태그와 충돌하지 않게 하기 위한 안전장치
HTML 표준 태그는 계속해서 생겨나고 있는데, 미래에 HTML 표준으로 추가된 태그인지 Custom Elements인지 브라우저가 구분 할 수 없기에 하이픈을 사용해야한다.
하이픈을 사용하지 않고 customElements.define 을 사용하면 에러가 발생한다.
Custom Elements는 태그 수준의 컴포넌트화를 가능하게 하지만,
DOM과 스타일이 전역에 그대로 노출된다는 구조적 한계를 가진다.
이 문제는 "재사용 가능한 단위"로 사용하려 할수록 명확해진다.
Custom Element 내부에서 작성한 스타일은
기본적으로 전역 CSS 규칙의 영향을 그대로 받는다.
p {
color: red;
}
이 스타일은 다음 요소에 모두 영향을 미친다.
<user-card>
<p>User</p>
</user-card>
<article>
<p>Post</p>
</article>
이로 인해 외부 스타일에 의해 스타일이 깨지는 문제가 발생한다.
Custom Element는 단순한 HTML 요소이기 때문에
그 내부 DOM 구조가 그대로 외부에 노출된다.
<user-card>
<p class="name">민준</p>
</user-card>
이 상태에서 외부에서는 다음과 같은 접근이 가능하다.
user-card .name {
color: blue;
}
또는
document.querySelector('user-card .name')
이는 곧 다음을 의미한다.
내부 스타일은 외부 CSS에 의해 언제든지 바뀔 수 있고
내부 DOM 구조는 외부 코드에서 직접 접근 할 수 있다.
즉, Custom Elements는 "이 요소를 어떻게 쓰는가"는 분리해주지만,
"이 요소가 어떻게 만들어졌는가"까지 숨겨주지는 못한다.
이러한 문제를 해결하기 위해 DOM 구조와 스타일 자체를 전역 환경으로부터 분리하려는 시도가 등장했고,
그 역할을 맡는 것이 바로 Shadow DOM이다.