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

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
frontend
web-components
vibe-coding
language applicable_to
TS / HTML
Frontend
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

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.

🔗 관련 문서