맞춤 요소

웹 기능: 자율 맞춤 요소

커스텀 요소는 customElements.define()를 통해 태그 이름에 등록된 클래스입니다. 등록 및 기본 수명 주기는 잘 이해되어 있습니다. 아래 규칙은 일상적으로 잘못 구현되는 부분을 대상으로 합니다.

수명 주기: 잘못 구현된 부분

콜백 자체는 잘 문서화되어 있습니다. 다음은 버그를 일으키는 오해입니다.

  • connectedCallback는 두 번 이상 실행될 수 있습니다. 삽입할 때마다 실행되므로 요소를 이동하면 다시 실행됩니다. 설정을 멱등성으로 만듭니다. 첫 번째 실행이 한 번만 있다고 가정하지 마세요.
  • connectedMoveCallback는 이동 시 연결 해제/재연결 쌍을 건너뜁니다. Node.moveBefore()를 사용하여 요소가 재배치되면 disconnectedCallback → connectedCallback 대신 이 콜백이 실행되므로 상태, 포커스, iframe/미디어 재생이 이동 후에도 유지됩니다. 설정 또는 해제에 비용이 많이 들거나 눈에 띄게 다시 실행되는 경우 정의합니다.
  • disconnectedCallback에서 해체 전역 리스너, 타이머, 옵저버를 삭제하지 않으면 누수가 발생합니다. 하지만 요소가 다시 삽입될 수 있으므로 해체를 영구적인 것으로 취급하지 마세요.
  • 생성자에서 super()를 먼저 호출하고 전체 DOM이 있는지에 의존하지 마세요. this를 터치한 후 super()를 호출하면 ReferenceError이 발생합니다. 속성은 일반적으로 존재하지만 (항상 그런 것은 아님) 긴 하위 요소 목록은 파싱이 완료되지 않았을 수 있습니다. connectedCallback는 일회성 초기화에, slotchange는 직접 하위 요소 모니터링에, attributeChangedCallback는 속성 모니터링에 사용합니다. 모든 하위 요소가 파싱되기 전에 connectedCallback가 실행될 수 있으므로 후속 slotchange/attributeChangedCallback 호출을 무시하지 마세요.
  • attributeChangedCallback는 observedAttributes에만 실행되며 출력에 영향을 미치지 않는 속성에 대해서는 저렴하게 유지되어야 합니다.
  • 수명 주기 후크 및 observedAttributes는 등록 시 스냅샷이 생성됩니다. customElements.define()는 둘 다 한 번 읽습니다. 호출 후 클래스에 후크를 추가하거나 observedAttributes를 확장하는 것은 자동으로 무시됩니다. 특히 attributeChangedCallback는 observedAttributes가 define() 시간에 이미 존재하지 않는 한 실행되지 않습니다.

업그레이드 시기 및 :defined

클래스가 등록되기 전에 DOM에 요소가 존재할 수 있습니다 (서버에서 렌더링된 HTML 또는 스크립트가 늦게 로드되는 경우). 정의되기 전까지는 동작이 없는 비활성 HTMLElement입니다. customElements.define()가 실행되면 브라우저는 이미 문서에 있는 일치하는 모든 요소를 업그레이드하여 생성자를 실행한 다음 connectedCallback를 실행합니다.

모델이 일상적으로 놓치는 두 가지 결과:

  1. 업그레이드 시 생성자/connectedCallback가 실행될 때 속성과 하위 요소가 이미 있을 수 있습니다. connectedCallback의 기존 속성을 읽어 초기 상태를 동기화하고, 요소가 비어 있는 상태로 시작되었다고 가정하지 않습니다.
  2. connectedCallback에서도 하위 요소가 누락될 수 있습니다. HTML 파서는 긴 하위 요소 목록을 파싱하는 도중에 결과를 생성할 수 있으므로 모든 하위 요소가 연결되기 전에 콜백이 실행되는 경우가 있습니다. 패턴 (slotchange + attributeChangedCallback)은 위의 수명 주기에서 생성자 글머리 기호를 참고하세요.

정의되지 않은 요소를 숨겨 스타일이 지정되지 않은 콘텐츠가 깜박이지 않도록 합니다. 하지만 장애 시 개방되므로 스크립트가 로드되지 않아도 콘텐츠에 계속 액세스할 수 있습니다. visibility: hidden를 사용하여 강제로 숨기는 대신 지연을 사용하여 opacity에 애니메이션을 적용합니다. 그러면 요소가 업그레이드되지 않더라도 제한 시간이 지나면 요소가 표시됩니다.

my-element:not(:defined) {
  opacity: 0;
  /* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
  animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }

이러한 가드의 범위를 전체 body가 아닌 필요한 요소 또는 :has(:not(:defined))를 통한 특정 하위 트리로 지정합니다. 느리고 중요하지 않은 위젯 (예: 공유 버튼)이 전체 페이지의 페인트를 지연시켜서는 안 됩니다.

속성 ⇄ 속성 반영

HTML 속성과 JS 속성을 동기화하여 마크업과 스크립트 모두에서 요소를 사용할 수 있도록 합니다. 속성 값은 항상 문자열입니다. 강제 변환하세요.

class MyToggle extends HTMLElement {
  static observedAttributes = ['label', 'count', 'disabled'];

  // String attribute reflected via getter/setter.
  get label() { return this.getAttribute('label') ?? ''; }
  set label(v) {
    v ? this.setAttribute('label', v) : this.removeAttribute('label');
  }

  // Numeric attribute: coerce from string on read, serialize on write.
  get count() { return Number(this.getAttribute('count')) || 0; }
  set count(v) { this.setAttribute('count', String(v)); }

  // Boolean attribute: present = true, absent = false. Never set it to "false".
  get disabled() { return this.hasAttribute('disabled'); }
  set disabled(on) { this.toggleAttribute('disabled', !!on); }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this.render();
  }
}

값이 있는 위치

위의 예에서는 DOM을 정보 소스로 유지합니다. 각 getter는 getAttribute를 읽습니다. 이는 몇 가지 간단한 문자열 속성에 적합한 기본값입니다. 상태가 존재할 수 있는 위치가 하나뿐이므로 동기화가 해제될 수 없습니다.

더 풍부한 구성요소의 경우 정보 소스를 JS 필드에 유지하고 CSS 타겟팅 또는 이를 읽는 소비자와 같이 다른 항목이 이를 확인해야 하는 경우에만 외부 속성에 반영합니다. 필드를 읽는 것은 액세스할 때마다 속성을 파싱하고 강제 변환하는 것보다 훨씬 저렴하며 문자열이 아닌 값은 직렬화를 통해 왕복하지 않습니다. 이것이 Lit과 같은 라이브러리가 JS에서 상태를 유지하고 속성을 저장소로 취급하는 대신 선택적으로 반영하는 이유입니다. 즉, 모든 속성이 아닌 외부에서 관찰해야 하는 것만 반영합니다.

문제:

  • observedAttributes 규칙: 변경 시 부작용을 다시 렌더링하거나 트리거해야 하는 속성만 나열합니다. 모든 것을 관찰하면 아무런 이점 없이 모든 변형에 콜백이 추가됩니다.
  • 반사 루프: 속성을 작성하는 setter가 attributeChangedCallback를 다시 입력합니다. 위와 같이 oldVal !== newVal 검사로 보호합니다.
  • 스타일 전용 상태: 스타일 지정 (로드 중, 활성, 펼침)을 위해서만 존재하는 내부 상태의 경우 합성 속성을 반영하는 것보다 맞춤 상태를 사용하는 것이 좋습니다. 아래의 맞춤 상태를 참고하세요. 소비자가 마크업에서 상태를 설정할 수 있어야 하는 경우에만 속성에 반영합니다.

맞춤 상태

맞춤 상태는 CSS가 타겟팅할 수 있도록 요소가 자체에 대해 노출하는 불리언 플래그로, ElementInternals를 통해 설정되고 :state() 가상 클래스와 일치합니다. 합성 속성을 반영하는 대신 내부 런타임 UI 상태(busy, expanded, invalid)에 사용하세요. 소비자의 속성과 충돌할 수 없고 DOM에 표시되지 않으며 외부에서 읽기 전용입니다(소비자는 :state()에 스타일을 지정할 수 있지만 위조할 수는 없음).

class AsyncButton extends HTMLElement {
  #internals;
  #action = async () => {};

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  set action(fn) { this.#action = fn; } // host supplies the work to run

  async run() {
    if (this.#internals.states.has('busy')) return; // guard re-entry
    this.#internals.states.add('busy');
    this.#internals.states.delete('success');
    try {
      await this.#action();
      this.#internals.states.add('success');
    } catch {
      this.#internals.states.add('error');
    } finally {
      this.#internals.states.delete('busy');
    }
  }
}

CustomStateSet는 집합이므로 독립 상태는 결합된 data-status enum을 만들지 않고 구성됩니다 (busy 더하기 error).

커스텀 상태는 런타임 전용입니다. DOM으로 직렬화되지 않으므로 새로고침, 요소 재생성 또는 서버 렌더링 시 유지되지 않습니다. 상태를 마크업에서 선언적으로 설정해야 하거나 로드 간에 유지해야 하는 경우 속성의 작업입니다(값이 있는 위치 참고). 정보 소스에서 connectedCallback로 복원하세요. 경험 법칙: 구성요소가 런타임에 파생하고 소유하며 스타일만 지정하면 되는 항목의 맞춤 상태, 마크업에서 설정하거나 직렬화해야 하는 항목의 속성입니다.

구성요소 내부와 소비자 스타일시트에서 이러한 상태의 스타일을 지정하려면 웹 구성요소 스타일 지정을 참고하세요.

이름 지정 규칙

  • 진정한 비공개 상태: 하드 캡슐화를 위한 네이티브 # 필드 (#count), 서브클래스가 여전히 도달해야 하는 경우에만 _ 프리픽스 # 필드는 상속되지 않습니다. 서브클래스는 기본 클래스의 비공개 필드를 볼 수 없습니다.
  • 기호 키 속성은 중간 지점입니다. 일반 열거에서는 숨겨지지만 기호를 보유한 모든 코드에서 연결할 수 있습니다 (내보내거나 클래스에서 static 필드로 노출). 각 기호는 고유하므로 설명을 공유하는 두 기호는 충돌하지 않습니다. 이는 서브클래스 또는 동위 요소에 # 필드가 제공할 수 없는 제어된 액세스가 필요할 때 유용합니다.
  • 자체 속성으로 전역 속성 (style, class, id, slot, part, title, lang, dir)을 섀도 처리하지 마세요. 그러면 기본 제공 동작이 중단됩니다. disabled는 양식 컨트롤에서만 전역적입니다. 다른 요소에서는 효과를 직접 구현해야 합니다.

새 API 표시 경로의 이름을 지정할 때는 플랫폼의 자체 규칙을 따르세요. 맞춤 요소 태그 이름에는 하이픈이 하나 이상 포함된 소문자 케밥 케이스 (예: my-toggle)를 사용하고, 속성과 메서드에는 lowerCamelCase를 사용하고, 해당 속성과 동기화된 소문자 속성 이름을 사용합니다.