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:
connectedCallbackpode 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.connectedMoveCallbackpula o par de desconexão/reconexão em uma movimentação. Quando um elemento é realocado comNode.moveBefore(), esse callback é acionado em vez dedisconnectedCallback→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. Chamarsuper()depois de tocar emthisgera umReferenceError. Os atributos geralmente estão presentes, mas nem sempre. Listas longas de filhos podem não ter terminado a análise. UseconnectedCallbackpara inicialização única,slotchangepara monitorar filhos diretos eattributeChangedCallbackpara monitorar atributos.connectedCallbackainda pode ser disparado antes que todos os filhos sejam analisados. Portanto, não ignore as chamadasslotchange/attributeChangedCallbacksubsequentes. attributeChangedCallbacké acionado apenas paraobservedAttributese precisa permanecer barato para atributos que não afetam a saída.- Os hooks de ciclo de vida e
observedAttributessão capturados no momento do registro.customElements.define()lê ambos uma vez. Adicionar um hook ou estenderobservedAttributesna classe depois da chamada é ignorado silenciosamente. Em particular,attributeChangedCallbacknunca é acionado, a menos queobservedAttributesjá estivesse presente no momento dedefine().
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:
- Os atributos e filhos já podem estar presentes quando o construtor/
connectedCallbacké executado no upgrade. Leia os atributos atuais emconnectedCallbackpara sincronizar o estado inicial e nunca presuma que o elemento começou vazio. - 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çãooldVal !== 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
staticna 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çãodisabledé 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.