Files
2nd/10_Wiki/Topics/Coding/Frontend_Web_Components.md
T
2026-05-09 22:47:42 +09:00

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]]