9.1 KiB
9.1 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| frontend-web-components | Web Components — Custom Element / Shadow DOM | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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
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);
<my-card title="Hello">
<p>Card content</p>
</my-card>
Lifecycle
connectedCallback: DOM 에 추가
disconnectedCallback: 제거
attributeChangedCallback: attribute 변경
adoptedCallback: 다른 document 로 이동
Shadow DOM (scoped CSS)
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)
<style>
my-button {
--button-color: blue;
--button-bg: white;
}
</style>
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
this.shadowRoot.innerHTML = `
<header>
<slot name="title"></slot>
</header>
<main>
<slot></slot> <!-- default -->
</main>
<footer>
<slot name="actions"></slot>
</footer>
`;
<my-card>
<h2 slot="title">Title</h2>
<p>Body content</p>
<button slot="actions">OK</button>
</my-card>
Lit (modern WC framework)
yarn add lit
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>
`;
}
}
<my-card card-title="Hello">
<p>Slotted content</p>
</my-card>
→ React 같은 declarative + reactive.
Lit + signals (modern)
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)
npm init stencil
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)
<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)
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 안 사용
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.
// 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 안 사용
<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
// 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
<my-tabs>
<my-tab name="home">Home</my-tab>
<my-tab name="about">About</my-tab>
</my-tabs>
class MyTabs extends LitElement {
@state() activeTab = '';
firstUpdated() {
const tabs = this.querySelectorAll('my-tab');
this.activeTab = tabs[0]?.getAttribute('name') ?? '';
}
// ...
}
Custom event
this.dispatchEvent(new CustomEvent('select', {
detail: { id: '...' },
bubbles: true,
composed: true, // shadow DOM 통과
}));
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.