[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user