- Vanilla TS - 웹 컴포넌트 만들어보기2025년 03월 07일
- 밀밀 킴
- 작성자
- 2025.03.07.:28
반응형설정
es6-string-html을 설치하면 템플릿 리터럴의 html 코드 하이라이팅이 됩니다.
(prettier는 굳이 안 써도 사용하고 있겠죠?)
이렇게 템플릿 리터럴 앞에 /* HTML */ 주석을 달면
prettier에서 코드포맷팅을 해주고 es6-string-html 이 코드 하이라이팅을 해줍니다. (style 태그 안의 css도 포맷팅&하이라이팅 됨)
하나하나 createElement로 노드를 조작하면, 복잡해질수록 가독성과 유지보수가 너무 어렵기 때문에 템플릿 리터럴을 사용하도록 하겠습니다. 템플릿 리터럴을 사용했을 때의 단점이 어느정도 해소됩니다.
그리고 vite로 vanilla -> ts로 프로젝트 생성해줍니다.
시작하기
https://ko.vite.dev/guide/build.html#multi-page-app
(만일 MPA를 만들고자 하자면 진입점을 따로 설정해주면 됩니다!!!)
웹 컴포넌트 만들기
https://developer.mozilla.org/ko/docs/Web/API/Web_components
Vite
Vite, 프런트엔드 개발의 새로운 기준
ko.vite.dev
api를 다 알 필요 없고 mdn에 매우 잘 정리되어있습니다.
이 페이지의 튜토리얼 파트를 봅시다. 템플릿과 슬롯은 vue를 사용했다면 익숙한 개념입니다.
바로 생명주기 봅시다.
class TestElement extends HTMLElement { constructor() { super(); } // 커스텀 엘리먼트가 추가될 때 connectedCallback() {} // 커스텀 엘리먼트가 연결 해제되었을 때 disconnectedCallback() {} // 커스텀 엘리먼트가 새로운 document로 이동되었을 때 adoptedCallback() {} // 어트리부트가 추가/제거/변경 attributeChangedCallback(name, oldValue, newValue) {} // 어떤 어트리부트가 변경될 때 감지할지 명시 static get observedAttributes() { return ["attr"]; } }
이와 같은 형태로 작성하면 됩니다.
일단 빠르게 컴포넌트 만들어봅니다. 그냥 html 캡슐화 하려면 생성자만 있어도 됩니다.
this는 HTMLElement를 상속받은 커스텀 엘리먼트, 내가 만든 웹 컴포넌트가 됩니다.
그냥 innterHTML에 템플릿 리터럴로 html을 때려박아요.
그리고 define해주면 끝.
class TestElement extends HTMLElement { constructor() { super(); this.innerHTML = /*HTML */ `<h1>hello world</h1>`; } } customElements.define("test-element", TestElement);
main.ts 에서는 그냥 import 만 해줬어요.
import './components/test';
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vite + TS</title> </head> <body> <div id="app"> <test-element/> </div> <script type="module" src="/src/main.ts"></script> </body> </html>
index.html은 이렇게 되겠죠.
잘 됩니다.
여기서 render 메소드를 분리해서, 렌더링하는 부분은 따로 빼줄게요.
class TestElement extends HTMLElement { constructor() { super(); } connectedCallback() { this.render(); } render() { this.innerHTML = /*HTML */ `<h1>hello world</h1>`; } } customElements.define("test-element", TestElement);
그다음 shadow dom 이 적용되도록 변경하겠습니다.
shadow dom 을 사용하면 스타일을 캡슐화 할 수 있는 장점이 있어요.
멤버변수를 추가해줍시다.
class TestElement extends HTMLElement { private shadow: ShadowRoot; // 추가 constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.render(); } render() { this.shadow.innerHTML = /*HTML */ `<h1>hello world</h1>`; } } customElements.define("test-element", TestElement);
이제 attribute로 color를 받고 slot으로 내부 컨텐츠를 받도록 해보겠습니다.
그리고 attribute가 바뀌면 감지할 수 있게 observedAttributes의 return 배열에 추가, attributeChangedCallback도 추가
class TestElement extends HTMLElement { private shadow: ShadowRoot; private state = { color: "pink", }; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } get template() { const template = document.createElement("template"); template.innerHTML = /* HTML */ ` <style> h1 { color: ${this.state.color}; } </style> <h1><slot name="content">DEFAULT TEXT<slot></h1> `; return template; } connectedCallback() { this.render(); } attributeChangedCallback(attrName: string, _oldVal: string, newVal: string) { if (attrName === "color") { this.state.color = newVal; this.render(); } } static get observedAttributes() { return ["color"]; } render() { this.shadow.replaceChildren(this.template.content.cloneNode(true)); } } customElements.define("test-element", TestElement);
<div id="app"> <test-element color="orange"> <span slot="content">슬롯 테스트</span> </test-element> <button id="color-btn">색 바꾸기</button> </div> <script type="module" src="/src/main.ts"></script>
const colorBtn = document.querySelector('#color-btn'); colorBtn?.addEventListener('click', () => { const testElement = document.querySelector('test-element'); if (testElement) { testElement.setAttribute('color', 'blue'); } })
간단하게 메인 함수에서 테스트.
잘 됩니다.
스타일 캡슐화
@font-face { font-family: 'Ownglyph_ParkDaHyun'; src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2411-3@1.0/Ownglyph_ParkDaHyun.woff2') format('woff2'); font-weight: normal; font-style: normal; } body { font-family: 'Ownglyph_ParkDaHyun'; }
전역 css에 폰트를 추가하고 body 폰트를 지정해줍니다.
컴포넌트 안의 h1 스타일은 컴포넌트 안에서만 적용됩니다.
폰트 패밀리는 전역 css에서 상속됐습니다. 상속 가능한 css는 상속됩니다.
ul, li { list-style: none; }
하지만 ul, li 같은 것은 상속되지 않습니다.
전역 css 가 잘 적용되어야 한다면 shadow dom 을 쓰지 않거나...
adoptedStyleSheets 을 사용해서 공통화를 해주면 될 것 같아요.
BaseComponent 추상 클래스
abstract class BaseComponent extends HTMLElement { protected shadow: ShadowRoot; protected state: Record<string, any> = {}; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } protected abstract get template(): string; protected render() { const template = document.createElement("template"); template.innerHTML = this.template; this.shadow.replaceChildren(template.content.cloneNode(true)); } connectedCallback() { this.render(); } attributeChangedCallback(attrName: string, _oldVal: string, newVal: string) { this.state[attrName] = newVal; this.render(); } } export default BaseComponent;
추상클래스를 만들고 템플릿을 꼭 자식 클래스에서 정의하도록 만들었어요.
import BaseComponent from "./BaseComponent"; class TestHeader extends BaseComponent { protected state = { color: "Blue", }; protected get template() { return /* HTML */ ` <style> h1 { color: ${this.state.color}; } </style> <h1><slot name="content">DEFAULT TEXT</slot></h1> `; } static get observedAttributes() { return ["color"]; } } customElements.define("test-header", TestHeader);
BaseComponent를 상속받아 만든 TestHeader 가 동일하게 작동됩니다.
근데 생각해보니 이벤트 리스너를 안 넣어줬어요.
super.render() 호출하고 이후에 이벤트 리스너를 넣어주면 되긴할텐데 그렇게 하면 이벤트 리스너가 렌더링 될 때마다 추가되니까 이렇게 만들면 안 되겠죠.
그리고 replaceChildren 으로 자식을 교체했기 때문에 추가했던 이벤트 리스너도 없어저요.
렌더에서 매번 새로 만드는 것도 문제입니다.
attribute가 변경되면 render()가 실행될 게 아니라 자식 클래스에서 업데이트 컴포넌트를 수동으로 구현하게 할게요.
abstract class BaseComponent extends HTMLElement { protected shadow: ShadowRoot; protected state: Record<string, any> = {}; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } protected abstract get template(): string; protected onMounted() {} protected onUnmounted() {} protected render() { const template = document.createElement("template"); template.innerHTML = this.template; this.shadow.appendChild(template.content.cloneNode(true)); } protected updateComponent() {} connectedCallback() { this.render(); this.onMounted() } disconnectedCallback() { this.onUnmounted(); } attributeChangedCallback(attrName: string, _oldVal: string, newVal: string) { this.state[attrName] = newVal; this.updateComponent(); } } export default BaseComponent;
import BaseComponent from "./BaseComponent"; class TestHeader extends BaseComponent { constructor() { super(); } protected state = { color: "Blue", }; protected get template() { return /* HTML */ ` <style> h1 { color: ${this.state.color}; } </style> <h1><slot name="content">DEFAULT TEXT</slot></h1> `; } static get observedAttributes() { return ["color"]; } updateComponent(): void { const h1 = this.shadowRoot?.querySelector("h1"); if (h1) { h1.style.color = this.state.color; } } handleClickTitle = () => { console.log("Title clicked"); }; onMounted() { const title = this.shadowRoot?.querySelector("h1"); if (title) { title.addEventListener("click", this.handleClickTitle); } } onUnmounted() { const title = this.shadowRoot?.querySelector("h1"); if (title) { title.removeEventListener("click", this.handleClickTitle); } } } customElements.define("test-header", TestHeader);
이렇게 하면 의도대로 동작합니다.
그런데 컬러 바꾸는 부분을 좀 더 개선하면..
h1 { color: var(--title-color, ${this.state.color}); // 변경 } host.style.setProperty("--title-color", this.state.color); // 변경
import BaseComponent from "./BaseComponent"; class TestHeader extends BaseComponent { constructor() { super(); } protected state = { color: "blue", }; protected get template() { return /* HTML */ ` <style> h1 { color: var(--title-color, ${this.state.color}); // 변경 } </style> <h1><slot name="content">DEFAULT TEXT</slot></h1> `; } static get observedAttributes() { return ["color"]; } updateComponent(): void { const h1 = this.shadowRoot?.querySelector("h1"); const host: HTMLElement | null = this.shadowRoot ?.host as HTMLElement | null; if (h1 && host) { host.style.setProperty("--title-color", this.state.color); // 변경 } } handleClickTitle = () => { console.log("Title clicked"); }; onMounted() { const title = this.shadowRoot?.querySelector("h1"); if (title) { title.addEventListener("click", this.handleClickTitle); } } onUnmounted() { const title = this.shadowRoot?.querySelector("h1"); if (title) { title.removeEventListener("click", this.handleClickTitle); } } } customElements.define("test-header", TestHeader);
css 변수를 사용하면 style이 변경되어 더욱 깔끔하네요.
지금까지 간단하게 웹 컴포넌트를 만드는 방법을 알아보았습니다.
사실 직접 이렇게 구현하는 건 과하고 실무에서는 lit.js를 쓰는 게 낫습니다. 하지만 레거시에 부분적으로 캡슐화 된 컴포넌트를 조금 추가할 때는 이 방법도 나쁘지 않을 수 있어요. 라이브러리를 아무것도 추가 안 해도 되니까!
저는 JSP 로 된 레거시 프로젝트 디자인 개선을 하면서 본문 내용보다는 더 단순하게 스타일만 적용된 래퍼를 웹 컴포넌트로 만들어서 사용했습니다. 이런 웹 컴포넌트를 사용하지 않으면 전체적 디자인 여백 수정을 할 때마다 리스트마다의 jsp 파일 1n 개를 수정해야 하지만, 한번 컴포넌트화를 하면 파일 하나만 수정할 수 있는 장점이 있죠.
반응형다음글이전글이전 글이 없습니다.댓글