451 lines
9.1 KiB
Markdown
451 lines
9.1 KiB
Markdown
---
|
|
id: frontend-web-components
|
|
title: Web Components — Custom Element / Shadow DOM
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [frontend, web-components, vibe-coding]
|
|
tech_stack: { language: "TS / HTML", applicable_to: ["Frontend"] }
|
|
applied_in: []
|
|
aliases: [Web Components, Custom Element, Shadow DOM, slot, Lit, declarative shadow DOM]
|
|
---
|
|
|
|
# Web Components
|
|
|
|
> Browser native component. **Custom Element + Shadow DOM + Template + Slot**. Framework agnostic. Lit / Stencil 가 friendly.
|
|
|
|
## 📖 핵심 개념
|
|
- Custom Element: `<my-component>` 정의.
|
|
- Shadow DOM: scoped CSS / DOM.
|
|
- Template: 재사용 HTML.
|
|
- Slot: child 삽입 point.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### Vanilla custom element
|
|
```ts
|
|
class MyCard extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.shadowRoot!.innerHTML = `
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
border: 1px solid #ccc;
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}
|
|
h2 { margin: 0 0 8px; }
|
|
</style>
|
|
<h2>${this.getAttribute('title') ?? ''}</h2>
|
|
<slot></slot>
|
|
`;
|
|
}
|
|
|
|
static observedAttributes = ['title'];
|
|
|
|
attributeChangedCallback(name: string, old: string | null, value: string | null) {
|
|
if (name === 'title' && this.shadowRoot) {
|
|
const h2 = this.shadowRoot.querySelector('h2');
|
|
if (h2) h2.textContent = value ?? '';
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('my-card', MyCard);
|
|
```
|
|
|
|
```html
|
|
<my-card title="Hello">
|
|
<p>Card content</p>
|
|
</my-card>
|
|
```
|
|
|
|
### Lifecycle
|
|
```
|
|
connectedCallback: DOM 에 추가
|
|
disconnectedCallback: 제거
|
|
attributeChangedCallback: attribute 변경
|
|
adoptedCallback: 다른 document 로 이동
|
|
```
|
|
|
|
### Shadow DOM (scoped CSS)
|
|
```ts
|
|
this.attachShadow({ mode: 'open' }); // 외부 access OK
|
|
this.attachShadow({ mode: 'closed' }); // 외부 access X (드물게)
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
/* 이 component 만 — 다른 곳 영향 X */
|
|
p { color: red; }
|
|
</style>
|
|
<p>Scoped paragraph</p>
|
|
`;
|
|
```
|
|
|
|
### CSS 변수 (theme)
|
|
```html
|
|
<style>
|
|
my-button {
|
|
--button-color: blue;
|
|
--button-bg: white;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
```ts
|
|
class MyButton extends HTMLElement {
|
|
connectedCallback() {
|
|
this.shadowRoot!.innerHTML = `
|
|
<style>
|
|
button {
|
|
color: var(--button-color, black);
|
|
background: var(--button-bg, white);
|
|
}
|
|
</style>
|
|
<button><slot></slot></button>
|
|
`;
|
|
}
|
|
}
|
|
```
|
|
|
|
→ CSS variable 가 Shadow boundary 통과.
|
|
|
|
### Slot
|
|
```ts
|
|
this.shadowRoot.innerHTML = `
|
|
<header>
|
|
<slot name="title"></slot>
|
|
</header>
|
|
<main>
|
|
<slot></slot> <!-- default -->
|
|
</main>
|
|
<footer>
|
|
<slot name="actions"></slot>
|
|
</footer>
|
|
`;
|
|
```
|
|
|
|
```html
|
|
<my-card>
|
|
<h2 slot="title">Title</h2>
|
|
<p>Body content</p>
|
|
<button slot="actions">OK</button>
|
|
</my-card>
|
|
```
|
|
|
|
### Lit (modern WC framework)
|
|
```bash
|
|
yarn add lit
|
|
```
|
|
|
|
```ts
|
|
import { LitElement, html, css } from 'lit';
|
|
import { customElement, property } from 'lit/decorators.js';
|
|
|
|
@customElement('my-card')
|
|
export class MyCard extends LitElement {
|
|
static styles = css`
|
|
:host { display: block; padding: 16px; }
|
|
h2 { margin: 0; }
|
|
`;
|
|
|
|
@property({ type: String })
|
|
cardTitle = '';
|
|
|
|
@property({ type: Number })
|
|
count = 0;
|
|
|
|
render() {
|
|
return html`
|
|
<h2>${this.cardTitle}</h2>
|
|
<p>Count: ${this.count}</p>
|
|
<button @click=${() => this.count++}>+</button>
|
|
<slot></slot>
|
|
`;
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<my-card card-title="Hello">
|
|
<p>Slotted content</p>
|
|
</my-card>
|
|
```
|
|
|
|
→ React 같은 declarative + reactive.
|
|
|
|
### Lit + signals (modern)
|
|
```ts
|
|
import { LitElement, html } from 'lit';
|
|
import { signal, SignalWatcher } from '@lit-labs/signals';
|
|
|
|
const count = signal(0);
|
|
|
|
@customElement('my-counter')
|
|
class MyCounter extends SignalWatcher(LitElement) {
|
|
render() {
|
|
return html`<button @click=${() => count.set(count.get() + 1)}>${count}</button>`;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Stencil (큰 design system)
|
|
```bash
|
|
npm init stencil
|
|
```
|
|
|
|
```ts
|
|
import { Component, Prop, State, h } from '@stencil/core';
|
|
|
|
@Component({ tag: 'my-card', styleUrl: 'my-card.css', shadow: true })
|
|
export class MyCard {
|
|
@Prop() title: string;
|
|
@State() count = 0;
|
|
|
|
render() {
|
|
return (
|
|
<div>
|
|
<h2>{this.title}</h2>
|
|
<button onClick={() => this.count++}>{this.count}</button>
|
|
<slot />
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
→ Compile-time. 작은 bundle.
|
|
|
|
### Declarative Shadow DOM (SSR)
|
|
```html
|
|
<my-card>
|
|
<template shadowrootmode="open">
|
|
<style>
|
|
:host { display: block; padding: 16px; }
|
|
</style>
|
|
<h2>Server-rendered</h2>
|
|
<slot></slot>
|
|
</template>
|
|
<p>Slotted</p>
|
|
</my-card>
|
|
```
|
|
|
|
→ Server 가 shadow DOM 직접 render. JS 없어도 styled.
|
|
|
|
### Form-associated (FACE)
|
|
```ts
|
|
class MyInput extends HTMLElement {
|
|
static formAssociated = true;
|
|
internals_: ElementInternals;
|
|
|
|
constructor() {
|
|
super();
|
|
this.internals_ = this.attachInternals();
|
|
}
|
|
|
|
set value(v: string) {
|
|
this.internals_.setFormValue(v);
|
|
}
|
|
}
|
|
```
|
|
|
|
→ `<form>` 안 native form value.
|
|
|
|
### React 안 사용
|
|
```tsx
|
|
import 'my-card.js';
|
|
|
|
function App() {
|
|
return (
|
|
<my-card card-title="Hello" onClick={() => console.log('clicked')}>
|
|
<p>Content</p>
|
|
</my-card>
|
|
);
|
|
}
|
|
```
|
|
|
|
→ React 가 unknown element 그대로 render. 단 event 가 camelCase 충돌 — wrapper.
|
|
|
|
```tsx
|
|
// React wrapper
|
|
import { createComponent } from '@lit/react';
|
|
import { MyCard } from './my-card';
|
|
|
|
const MyCardReact = createComponent({
|
|
tagName: 'my-card',
|
|
elementClass: MyCard,
|
|
react: React,
|
|
events: { onChange: 'change' },
|
|
});
|
|
```
|
|
|
|
### Vue / Svelte / Solid 안 사용
|
|
```vue
|
|
<template>
|
|
<my-card :card-title="title" @change="handle">
|
|
<p>Content</p>
|
|
</my-card>
|
|
</template>
|
|
```
|
|
|
|
→ 거의 다 native 호환.
|
|
|
|
### Bundle / size
|
|
```
|
|
Lit: ~5 KB
|
|
Stencil: 컴파일 시 작음
|
|
Vanilla: 0 dependency
|
|
|
|
→ 작은 widget = vanilla / Lit.
|
|
```
|
|
|
|
### Distribution
|
|
```ts
|
|
// npm package
|
|
"main": "dist/my-card.js",
|
|
"types": "dist/my-card.d.ts",
|
|
"customElements": "dist/custom-elements.json"
|
|
|
|
// CDN
|
|
<script type="module" src="https://unpkg.com/my-card@1.0.0/dist/my-card.js"></script>
|
|
```
|
|
|
|
→ 어디서나 사용.
|
|
|
|
### Use cases
|
|
```
|
|
✅ Design system (cross-team / cross-framework)
|
|
✅ Embeddable widget (3rd party)
|
|
✅ Shadow DOM 의 isolation 필요
|
|
✅ Long-lived component (framework migration 안전)
|
|
|
|
❌ Heavy app component (React/Solid 가 빠름)
|
|
❌ Frequent re-render
|
|
❌ Complex state (better with framework)
|
|
```
|
|
|
|
### 함정
|
|
```
|
|
1. Style 격리 — 외부 CSS 안 영향 X (의도).
|
|
2. SSR 지원 약함 (Declarative Shadow DOM 가 해결 중).
|
|
3. Form integration 어려움 (FACE 가 해결).
|
|
4. A11y — 직접 ARIA 추가.
|
|
5. Bundle 더 큼 (한 component 의 표준 < 큰 framework).
|
|
```
|
|
|
|
### Polyfill
|
|
```
|
|
Modern browser = native 지원.
|
|
옛 IE11 = 큰 polyfill.
|
|
|
|
→ 무시.
|
|
```
|
|
|
|
### vs React component
|
|
```
|
|
Web Component:
|
|
+ Framework agnostic
|
|
+ Browser native
|
|
+ Long-lived
|
|
- Less ecosystem
|
|
|
|
React component:
|
|
+ Familiar
|
|
+ 큰 ecosystem
|
|
+ Server / streaming
|
|
- React 만
|
|
```
|
|
|
|
### Atomic / Compound
|
|
```html
|
|
<my-tabs>
|
|
<my-tab name="home">Home</my-tab>
|
|
<my-tab name="about">About</my-tab>
|
|
</my-tabs>
|
|
```
|
|
|
|
```ts
|
|
class MyTabs extends LitElement {
|
|
@state() activeTab = '';
|
|
|
|
firstUpdated() {
|
|
const tabs = this.querySelectorAll('my-tab');
|
|
this.activeTab = tabs[0]?.getAttribute('name') ?? '';
|
|
}
|
|
|
|
// ...
|
|
}
|
|
```
|
|
|
|
### Custom event
|
|
```ts
|
|
this.dispatchEvent(new CustomEvent('select', {
|
|
detail: { id: '...' },
|
|
bubbles: true,
|
|
composed: true, // shadow DOM 통과
|
|
}));
|
|
```
|
|
|
|
```ts
|
|
document.querySelector('my-card').addEventListener('select', (e) => {
|
|
console.log(e.detail.id);
|
|
});
|
|
```
|
|
|
|
### shadow vs light DOM
|
|
```
|
|
Shadow: scoped, encapsulated.
|
|
Light: 일반 child — 외부 CSS 영향.
|
|
|
|
→ Slot 가 light 의 일부.
|
|
::slotted(p) { color: red; } — slotted content 일부 styling.
|
|
```
|
|
|
|
### Adoption
|
|
```
|
|
Apple Music web app: Web Components
|
|
GitHub: 많은 web component (custom element)
|
|
Microsoft FAST: UI library
|
|
Salesforce LWC: Large-scale web components
|
|
Google Material: web component 형태
|
|
```
|
|
|
|
→ 큰 회사 가 design system 으로 사용.
|
|
|
|
## 🤔 의사결정 기준
|
|
| 상황 | 추천 |
|
|
|---|---|
|
|
| Cross-framework design system | Web Components (Lit) |
|
|
| Embeddable widget | Web Components |
|
|
| Single framework app | React / Solid 등 native |
|
|
| Shadow DOM 격리 critical | Web Components |
|
|
| 작은 widget | Lit |
|
|
| 큰 design system | Stencil |
|
|
|
|
## ❌ 안티패턴
|
|
- **모든 거 web component 강제**: 작은 app 가 의미 없음.
|
|
- **A11y 안 신경**: native 보다 더 많은 일.
|
|
- **CSS 가 외부 못 customize**: design token / part API.
|
|
- **Lifecycle 잘못**: connectedCallback 가 매 attach.
|
|
- **No SSR**: 큰 site = 빈 page first paint.
|
|
- **Bundle 큰 framework + 작은 widget**: vanilla 또는 Lit.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Lit = modern + light.
|
|
- Cross-framework design system 의 답.
|
|
- Declarative Shadow DOM = SSR.
|
|
- Custom event + composed: true.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Frontend_Tailwind_Architecture]]
|
|
- [[Frontend_Design_Tokens]]
|
|
- [[React_Headless_UI_Patterns]]
|