Elementos personalizados

Um elemento personalizado é uma classe registrada em um nome de tag usando customElements.define(). O registro e o ciclo de vida básico são bem compreendidos. As regras abaixo se concentram nas partes que costumam ser implementadas incorretamente.

Ciclo de vida: as partes que são implementadas de forma errada

Os callbacks em si estão bem documentados. Estes são os equívocos que causam bugs:

  • connectedCallback pode ser executado mais de uma vez. Ele é acionado em cada inserção, então mover o elemento o executa novamente. Torne a configuração idempotente: não presuma uma única primeira execução.
  • connectedMoveCallback pula o par de desconexão/reconexão em uma movimentação. Quando um elemento é realocado com Node.moveBefore(), esse callback é acionado em vez de disconnectedCallback → connectedCallback. Assim, o estado, o foco e a reprodução de iframe/mídia sobrevivem à mudança. Defina-o quando a configuração ou a desmontagem for cara ou for executada novamente de forma observável.
  • Eliminação em disconnectedCallback. Remova listeners, timers e observadores globais para evitar vazamentos. No entanto, o elemento pode ser reinserido, então não trate a remoção como permanente.
  • Chame super() primeiro no construtor e não dependa da presença do DOM completo. Chamar super() depois de tocar em this gera um ReferenceError. Os atributos geralmente estão presentes, mas nem sempre. Listas longas de filhos podem não ter terminado a análise. Use connectedCallback para inicialização única, slotchange para monitorar filhos diretos e attributeChangedCallback para monitorar atributos. connectedCallback ainda pode ser disparado antes que todos os filhos sejam analisados. Portanto, não ignore as chamadas slotchange / attributeChangedCallback subsequentes.
  • attributeChangedCallback é acionado apenas para observedAttributes e precisa permanecer barato para atributos que não afetam a saída.
  • Os hooks de ciclo de vida e observedAttributes são capturados no momento do registro. customElements.define() lê ambos uma vez. Adicionar um hook ou estender observedAttributes na classe depois da chamada é ignorado silenciosamente. Em particular, attributeChangedCallback nunca é acionado, a menos que observedAttributes já estivesse presente no momento de define().

Cronograma do upgrade e :defined

Um elemento pode existir no DOM antes que a classe dele seja registrada (em HTML renderizado pelo servidor ou quando o script é carregado com atraso). Até que seja definido, ele é um HTMLElement inerte sem nenhum comportamento. Quando customElements.define() é executado, o navegador faz upgrade de todos os elementos correspondentes que já estão no documento, executando o construtor e, em seguida, connectedCallback.

Duas consequências que os modelos costumam ignorar:

  1. Os atributos e filhos já podem estar presentes quando o construtor/connectedCallback é executado no upgrade. Leia os atributos atuais em connectedCallback para sincronizar o estado inicial e nunca presuma que o elemento começou vazio.
  2. Os filhos ainda podem estar ausentes, mesmo em connectedCallback. O analisador de HTML pode gerar uma análise intermediária de listas de filhos longas, então o callback às vezes é executado antes que todos os filhos sejam anexados. Consulte o marcador do construtor em Ciclo de vida acima para ver o padrão (slotchange + attributeChangedCallback).

Oculte elementos indefinidos para evitar que o conteúdo apareça sem estilo, mas não bloqueie o acesso para que o conteúdo continue acessível se o script não for carregado. Anime opacity com um atraso em vez de ocultar com visibility: hidden, para que o elemento se revele após um tempo limite, mesmo que nunca seja atualizado:

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; } }

Defina o escopo dessas proteções para os elementos que precisam delas ou para uma subárvore específica usando :has(:not(:defined)), em vez de todo o body. Um widget lento e não crítico (um botão de compartilhamento, por exemplo) não deve atrasar a pintura de toda a página.

Reflexão de atributo ⇄ propriedade

Mantenha o atributo HTML e a propriedade JS sincronizados para que o elemento possa ser usado tanto na marcação quanto no script. Os valores de atributo são sempre strings. Faça a coerção deles.

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();
  }
}

Onde o valor fica

O exemplo acima mantém o DOM como a fonte de verdade: cada getter lê getAttribute. Esse é o padrão correto para alguns atributos simples do tipo string: há apenas um lugar em que o estado pode residir, então ele não pode ficar dessincronizado.

Para componentes mais avançados, mantenha a fonte de verdade em um campo JS e reflita para fora em um atributo somente quando algo mais precisar vê-lo, como a segmentação de CSS ou um consumidor que o lê. Ler um campo é muito mais barato do que analisar e forçar um atributo em cada acesso, e os valores que não são strings nunca passam por ida e volta na serialização. É por isso que bibliotecas como o Lit mantêm o estado em JS e refletem seletivamente, em vez de tratar os atributos como armazenamento: refletem apenas o que o mundo externo precisa observar, não todas as propriedades.

Dificuldades:

  • Disciplina observedAttributes: liste apenas os atributos cuja mudança precisa renderizar novamente ou acionar um efeito colateral. Observar tudo adiciona um callback a cada mutação sem nenhum benefício.
  • Loops de reflexão: um setter que grava um atributo vai inserir novamente attributeChangedCallback. Proteja com uma verificação oldVal !== newVal, como acima.
  • Estado somente de estilo: para o estado interno que existe apenas para direcionar o estilo (carregamento, ativo, expandido), prefira estados personalizados em vez de refletir um atributo sintético. Consulte Estados personalizados abaixo. Reflita em um atributo somente quando os consumidores precisarem definir o estado usando a marcação.

Estados personalizados

Os estados personalizados são flags booleanas que um elemento expõe sobre si mesmo para o CSS segmentar, definidas por ElementInternals e correspondentes à pseudoclasse :state(). Use-os para o estado interno da interface do ambiente de execução (busy, expanded, invalid) em vez de refletir um atributo sintético: eles não podem entrar em conflito com os atributos de um consumidor, nunca aparecem no DOM e são somente leitura de fora (um consumidor pode estilizar :state(), mas não pode falsificá-lo).

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 é um conjunto, então os estados independentes são compostos (busy mais error) sem inventar um tipo enumerado data-status combinado.

O estado personalizado é somente para o ambiente de execução. Ele não é serializado para o DOM, então não sobrevive a uma recarga, à recriação do elemento ou à renderização do servidor. Se o estado precisar ser definido de forma declarativa na marcação ou persistir entre os carregamentos, use um atributo (consulte Onde o valor reside) e restaure-o em connectedCallback usando sua fonte de verdade. Regra geral: estado personalizado para o que o componente deriva e possui no momento da execução e só precisa de estilo; um atributo para o que precisa ser definido a partir da marcação ou serializado.

Para estilizar esses estados, tanto dentro do componente quanto na folha de estilo de um consumidor, consulte Como estilizar componentes da Web.

Convenções de nomenclatura

  • Estado verdadeiramente privado: campos nativos # (#count) para encapsulamento rígido; um prefixo _ apenas quando as subclasses ainda precisam acessá-lo. Os campos # não são herdados. Uma subclasse não pode ver os campos particulares de uma classe de base.
  • As propriedades com chave de símbolo são um meio-termo: ocultas da enumeração comum, mas acessíveis por qualquer código que contenha o símbolo (exporte-o ou exponha-o como um campo static na classe). Como cada símbolo é único, dois símbolos que compartilham uma descrição nunca entram em conflito. Isso é útil quando uma subclasse ou um irmão precisa de acesso controlado que os campos # não podem fornecer.
  • Não oculte atributos globais (style, class, id, slot, part, title, lang, dir) com suas próprias propriedades. Isso interrompe o comportamento integrado deles. A observação disabled é global apenas em controles de formulário. Em outros elementos, você precisa implementar o efeito por conta própria.

Ao nomear uma nova superfície de API, siga as convenções da plataforma: use kebab-case em minúsculas com pelo menos um hífen para nomes de tags de elementos personalizados (por exemplo, my-toggle), lowerCamelCase para propriedades e métodos e nomes de atributos em minúsculas sincronizados com as propriedades correspondentes.