Un elemento personalizado es una clase registrada con un nombre de etiqueta a través de customElements.define(). El registro y el ciclo de vida básico se comprenden bien; las reglas que se indican a continuación se orientan a las partes que se implementan de forma incorrecta de manera rutinaria.
Ciclo de vida: Las partes que se implementan de forma incorrecta
Las devoluciones de llamada en sí están bien documentadas. Estas son las ideas erróneas que causan errores:
connectedCallbackse puede ejecutar más de una vez. Se activa en cada inserción, por lo que mover el elemento lo vuelve a ejecutar. Haz que la configuración sea idempotente; no asumas que se ejecutará una sola vez.connectedMoveCallbackomite el par de desconexión y reconexión en un movimiento. Cuando se reubica un elemento conNode.moveBefore(), esta devolución de llamada se activa en lugar dedisconnectedCallback→connectedCallback, por lo que el estado, el enfoque y la reproducción de iframe/contenido multimedia sobreviven al movimiento. Defínela cuando la configuración o el desmontaje sean costosos o se vuelvan a ejecutar de forma observable.- Se eliminará en
disconnectedCallback. Quita los objetos de escucha, los temporizadores y los observadores globales para evitar fugas, pero ten en cuenta que el elemento se puede volver a insertar, por lo que no debes tratar la eliminación como permanente. - Llama a
super()primero en el constructor y no dependas de que esté presente todo el DOM. Llamar asuper()después de tocarthisarroja unReferenceError. Los atributos suelen estar presentes (pero no siempre); es posible que las listas largas de elementos secundarios no hayan terminado de analizarse. UsaconnectedCallbackpara la inicialización única,slotchangepara supervisar los elementos secundarios directos yattributeChangedCallbackpara supervisar los atributos. Es posible queconnectedCallbackse active antes de que se analicen todos los elementos secundarios, por lo que no debes ignorar las llamadas posteriores aslotchangeoattributeChangedCallback. attributeChangedCallbackse activa solo paraobservedAttributesy debería mantenerse económico para los atributos que no afectan el resultado.- Se toman instantáneas de los hooks de ciclo de vida y de
observedAttributesdurante el registro.customElements.define()lee ambos una vez; agregar un hook o extenderobservedAttributesen la clase después de la llamada se ignora de forma silenciosa. En particular,attributeChangedCallbacknunca se activa, a menos queobservedAttributesya estuviera presente en el momentodefine().
Cronograma de actualización y :defined
Un elemento puede existir en el DOM antes de que se registre su clase (en HTML renderizado por el servidor o cuando la secuencia de comandos se carga tarde). Hasta que se defina, es un HTMLElement inerte sin ninguno de tus comportamientos. Cuando se ejecuta customElements.define(), el navegador actualiza cada elemento coincidente que ya está en el documento, ejecutando su constructor y, luego, connectedCallback.
Dos consecuencias que los modelos suelen pasar por alto:
- Es posible que los atributos y los elementos secundarios ya estén presentes cuando se ejecute tu constructor o
connectedCallbacken la actualización. Lee los atributos existentes enconnectedCallbackpara sincronizar el estado inicial y nunca asumas que el elemento comenzó vacío. - Es posible que aún falten elementos secundarios incluso en
connectedCallback, ya que el analizador de HTML puede producir un análisis intermedio de listas de elementos secundarios largas, por lo que la devolución de llamada a veces se ejecuta antes de que se adjunten todos los elementos secundarios. Consulta la viñeta del constructor en Ciclo de vida más arriba para ver el patrón (slotchange+attributeChangedCallback).
Oculta los elementos no definidos para evitar que se muestre contenido sin diseño, pero falla de forma abierta para que el contenido siga siendo accesible si la secuencia de comandos nunca se carga. Anima opacity con un retraso en lugar de ocultarlo con visibility: hidden, de modo que el elemento se revele después de un tiempo de espera, incluso cuando nunca se actualice:
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; } }
Limita estas protecciones a los elementos que las necesitan o a un subárbol específico a través de :has(:not(:defined)), en lugar de a todo el body. Un widget lento y no crítico (por ejemplo, un botón para compartir) no debería retrasar la renderización de toda la página.
Reflejo de atributo ⇄ propiedad
Mantén sincronizados el atributo HTML y la propiedad JS para que el elemento se pueda usar tanto desde el lenguaje de marcado como desde la secuencia de comandos. Los valores de los atributos siempre son cadenas; conviértelos.
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();
}
}
Dónde se encuentra el valor
En el ejemplo anterior, se mantiene el DOM como la fuente de información: cada método get lee getAttribute. Esa es la opción predeterminada correcta para un puñado de atributos simples de tipo cadena: solo hay un lugar donde puede residir el estado, por lo que no se puede desincronizar.
Para componentes más complejos, mantén la fuente de información en un campo de JS y refleja hacia afuera en un atributo solo cuando algo más deba verlo, como la orientación de CSS o un consumidor que lo lea. Leer un campo es mucho más económico que analizar y forzar un atributo en cada acceso, y los valores que no son de cadena nunca se serializan. Por este motivo, las bibliotecas como Lit mantienen el estado en JS y lo reflejan de forma selectiva en lugar de tratar los atributos como almacenamiento: solo reflejan lo que el mundo exterior debe observar, no todas las propiedades.
Dificultades
- Disciplina de
observedAttributes: Enumera solo los atributos cuyo cambio deba volver a renderizar o activar un efecto secundario. Observar todo agrega una devolución de llamada a cada mutación sin ningún beneficio. - Bucles de reflexión: Un método set que escribe un atributo volverá a ingresar a
attributeChangedCallback. Protégete con una verificación deoldVal !== newVal, como se indicó anteriormente. - Estado solo de estilo: Para el estado interno que existe solo para controlar el estilo (carga, activo, expandido), prefiere los estados personalizados en lugar de reflejar un atributo sintético. Consulta Estados personalizados a continuación. Refleja un atributo solo cuando los consumidores deban poder establecer el estado a partir del lenguaje de marcado.
Estados personalizados
Los estados personalizados son marcas booleanas que un elemento expone sobre sí mismo para que CSS las oriente, se establecen a través de ElementInternals y se hacen coincidir con la pseudoclase :state(). Úsalas para el estado interno de la IU en el entorno de ejecución (busy, expanded, invalid) en lugar de reflejar un atributo sintético: no pueden entrar en conflicto con los atributos de un consumidor, nunca aparecen en el DOM y son de solo lectura desde el exterior (un consumidor puede aplicar diseño a :state(), pero no puede falsificarlo).
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 es un conjunto, por lo que los estados independientes se componen (busy más error) sin inventar una enumeración data-status combinada.
El estado personalizado solo se aplica durante el tiempo de ejecución. No se serializa en el DOM, por lo que no sobrevive a una recarga, a la recreación del elemento ni a la renderización del servidor. Si el estado se debe establecer de forma declarativa en el lenguaje de marcado o persistir en las cargas, esa es una tarea para un atributo (consulta Dónde reside el valor). Restáuralo en connectedCallback desde tu fuente de información. Regla general: estado personalizado para lo que el componente deriva y posee en el entorno de ejecución y solo necesita estilo; un atributo para lo que se debe establecer a partir del lenguaje de marcado o serializado.
Para aplicar diseño a estos estados, tanto dentro del componente como desde la hoja de estilo de un consumidor, consulta Cómo aplicar diseño a componentes web.
Convenciones de nombres
- Estado verdaderamente privado: campos
#nativos (#count) para una encapsulación estricta; un prefijo_solo cuando las subclases aún deben alcanzarlo. Ten en cuenta que los campos#no se heredan; una subclase no puede ver los campos privados de una clase base. - Las propiedades con clave de símbolo son un punto intermedio: están ocultas de la enumeración común, pero cualquier código que contenga el símbolo puede acceder a ellas (exportarlo o exponerlo como un campo
staticen la clase). Como cada símbolo es único, dos símbolos que comparten una descripción nunca entran en conflicto, lo que resulta útil cuando una subclase o un elemento similar necesitan un acceso controlado que los campos#no pueden proporcionar. - No ocultes los atributos globales (
style,class,id,slot,part,title,lang,dir) con tus propias propiedades, ya que esto interrumpe su comportamiento integrado. Ten en cuenta quedisabledsolo es global en los controles de formulario; en otros elementos, debes implementar su efecto por tu cuenta.
Cuando asignes un nombre a una nueva plataforma de API, sigue las convenciones de la plataforma: usa minúsculas con guiones medios con al menos un guion para los nombres de las etiquetas de elementos personalizados (por ejemplo, my-toggle), lowerCamelCase para las propiedades y los métodos, y nombres de atributos en minúsculas sincronizados con sus propiedades correspondientes.