wandres.dev
EL SELECTOR DE PADRE · :has() y lo que desbloquea

El coste real de :has() y cómo medirlo

Por qué el emparejamiento de derecha a izquierda hacía imposible el selector relacional, dónde se paga de verdad su coste, y qué escribir para que ese coste sea despreciable.

⏱ 17 min

Circula una idea vaga de que :has() es lento y hay que usarlo con cuidado. Es cierta a medias y por razones equivocadas. El emparejamiento inicial casi nunca es el problema: lo caro es la invalidación, que se paga en cada mutación del DOM y la paga código que no sabe que tu selector existe. Entender dónde está el coste te permite escribir :has() sin miedo en el noventa por ciento de los casos y reconocer el diez por ciento restante antes de que aparezca en un perfil.

🎯 Al terminar esta lección sabrás
  • Explicar el emparejamiento de derecha a izquierda y por qué el selector relacional lo rompe.
  • Distinguir el coste del emparejamiento del coste de la invalidación.
  • Ordenar los cuatro combinadores del argumento por lo que cuesta cada uno.
  • Medir el gasto real de una regla con las estadísticas de selectores del navegador.

Por qué el motor lee al revés

Un navegador nunca resuelve la pregunta “qué elementos encajan con esta regla”. Resuelve la contraria: “qué reglas encajan con este elemento”. La diferencia es de órdenes de magnitud, porque hay decenas de miles de reglas y millones de combinaciones posibles, pero solo un elemento a la vez.

Para responderla, el motor indexa cada regla por su compuesto clave, que es el de más a la derecha, y lo guarda en cubos separados por identificador, clase, etiqueta y atributo. Cuando llega el momento de calcular el estilo de un a.enlace, el motor solo mira los cubos de a y de .enlace, y descarta todo lo demás sin leerlo. De las reglas candidatas comprueba primero el compuesto clave, y solo si encaja empieza a subir por los ancestros verificando el resto de la cadena. Un selector como body main article section p span.x es barato porque casi nunca llega a la segunda comprobación.

:has() no encaja en ese modelo. En article:has(.error), el compuesto clave es article, así que la indexación sigue funcionando. Pero verificar la condición obliga a descender por el subárbol del article, que puede tener miles de nodos. Y aún peor: en li:has(+ li.x) hay que mirar hacia delante entre los hermanos, una dirección que el algoritmo clásico nunca necesitó recorrer.

Aun así, ese coste sería asumible si solo se pagase una vez. El problema real es otro.

El coste está en la invalidación

Cuando el DOM cambia, el motor no recalcula todos los estilos: recalcula el conjunto mínimo que puede haberse visto afectado. Para eso mantiene conjuntos de invalidación precalculados a partir de las hojas de estilo, que responden a preguntas del tipo “si a un elemento se le añade la clase .error, qué descendientes suyos pueden cambiar de estilo”.

Ese mecanismo se apoya en una propiedad que fue cierta durante veinticinco años: el estilo de un elemento solo puede depender de sus ancestros y de sus hermanos anteriores. Por tanto, tocar un nodo solo puede afectar a ese nodo y a lo que cuelga de él. La invalidación siempre va hacia abajo.

:has() destruye esa propiedad. Añadir un .error en la hoja del árbol puede cambiar el estilo de su padre, de su abuelo, y de los hermanos anteriores de cualquiera de ellos. La invalidación ahora tiene que poder ir hacia arriba y hacia los lados, y hacerlo sin recorrer el documento entero en cada mutación.

La solución que hizo viable la implementación tiene dos piezas. Primero, durante el cálculo de estilos el motor marca con banderas los elementos que participan en algún :has(): si un elemento puede ser sujeto de una condición relacional, si un cambio en su subárbol puede afectar a un ancestro, si un cambio entre sus hermanos puede afectar a un hermano anterior. Segundo, indexa qué aparece dentro de los argumentos de todos los :has() de la página. Con las dos piezas, una mutación que no toca ninguna de las clases, etiquetas o atributos que figuran en algún argumento no despierta a nadie, y una que sí los toca solo hace subir la invalidación por las ramas marcadas.

flowchart TB
M[Mutacion en el DOM] --> Q{Lo que cambia aparece en algun argumento de has}
Q -- No --> Z[Invalidacion normal hacia abajo]
Q -- Si --> A[Subir por los ancestros marcados]
A --> B[Reevaluar la condicion en cada candidato]
B --> C[Recalculo de estilo de los sujetos afectados]
style Z fill:#a6e3a1,color:#11111b
style A fill:#f9e2af,color:#11111b
style C fill:#89b4fa,color:#11111b

La consecuencia práctica es la que casi nadie enuncia: el coste de un :has() es proporcional a la frecuencia con la que muta lo que hay dentro de su argumento, no a lo elegante que sea el selector ni a cuántas veces aparezca en la hoja. Una regla html:has(dialog[open]) es baratísima aunque el sujeto sea la raíz del documento, porque los diálogos se abren y se cierran unas pocas veces por sesión. Una regla .fila:has(.celda-activa) en una tabla donde el JavaScript mueve esa clase en cada mousemove es cara aunque el subárbol sea diminuto.

El combinador decide el ámbito de búsqueda

Dentro del argumento, el combinador inicial acota cuánto tiene que recorrer el motor para evaluar la condición. De más barato a más caro:

Argumento Qué recorre Coste
:has(+ .x) un solo hermano constante
:has(> .x) los hijos directos lineal en el número de hijos
:has(~ .x) todos los hermanos posteriores lineal en el número de hermanos
:has(.x) el subárbol completo lineal en el tamaño del subárbol

La lectura correcta de esta tabla no es “no uses el descendiente”. Es: si la relación que quieres expresar es de hijo directo, escríbelo. Muchísimo :has(.x) que se ve por ahí quería decir :has(> .x), y quien lo escribió no puso el combinador porque no hacía falta para que funcionase.

/* Recorre todo el subarbol de cada tarjeta en cada evaluacion. */
.card:has(img) { }

/* Mira solo los hijos directos. Misma intencion, coste acotado. */
.card:has(> img) { }

El sujeto importa igual de mucho, y por el mismo motivo. *:has(.error) obliga a marcar como candidato a cada elemento del documento; .formulario:has(.error) solo marca a los formularios. La diferencia no está en el emparejamiento sino en cuántos nodos llevan banderas y por tanto cuántas ramas puede subir la invalidación.

💡
Tres reglas que cubren casi todo

Ancla siempre el sujeto a una clase o etiqueta concreta, nunca a *. Usa el combinador más restrictivo que exprese tu intención, y por defecto prefiere > y +. Y desconfía de cualquier :has() cuyo argumento mencione una clase que el JavaScript añade o quita muchas veces por segundo: ahí es donde aparecen los milisegundos.

Medirlo en lugar de suponerlo

Chrome DevTools tiene una opción específica para esto en el panel de rendimiento: las estadísticas de selectores, que hay que activar en los ajustes de la grabación porque tiene coste propio. Con ella activada, cada evento de recálculo de estilo trae una tabla con el tiempo consumido por selector, el número de intentos de emparejamiento, el número de aciertos y el porcentaje de fallos que pasaron por la ruta lenta.

El flujo de trabajo es siempre el mismo. Graba la interacción sospechosa —no la carga, la interacción—, busca los eventos de recálculo de estilo en la línea de tiempo, ordena la tabla por tiempo consumido y mira si alguno de tus :has() aparece arriba. Si el número de intentos de emparejamiento es enorme y el de aciertos es minúsculo, tienes un selector que se evalúa constantemente para no hacer nada: o el sujeto es demasiado amplio, o el argumento menciona algo que muta sin parar.

Un experimento que conviene hacer una vez para calibrar la intuición: monta una lista de mil elementos, aplica li:has(> .marca) y mide el recálculo al añadir y quitar .marca de un solo elemento. Después cambia la regla a *:has(.marca) y repite. La forma del gráfico cambia, y ver esa diferencia con tus propios ojos vale más que cualquier regla memorizada.

/* Version acotada: sujeto concreto, combinador de hijo. */
li:has(> .marca) { background: color-mix(in oklch, canvas 90%, currentColor); }

/* Version que conviene medir antes de enviar a produccion. */
*:has(.marca) { outline: 1px dashed; }
El coste lo paga código que no sabe que existes

Aquí está el matiz que separa a quien ha sufrido esto de quien solo lo ha leído. Todas las demás construcciones de CSS tienen un coste que se paga donde se escribe: un selector complicado se evalúa cuando toca calcular el estilo de un elemento, y si eso va lento, el perfil lo señala junto a la regla. :has() no. Su coste se paga en la mutación del DOM, es decir, dentro de un bloque de JavaScript que alguien escribió sin saber que existía tu hoja de estilos. El síntoma clásico llega meses después del cambio que lo causó: una tabla que se volvió pegajosa al pasar el ratón, un editor que se atasca al teclear, un carrusel que da tirones. Nadie tocó el CSS. Alguien añadió un classList.toggle en un bucle caliente, y ese toggle resultó mencionar la clase que estaba dentro de tu :has(). Por eso :has() merece una regla de convivencia que no aparece en ninguna documentación: lo que va dentro del argumento es parte del contrato de rendimiento de la aplicación, y debería estar restringido a estados de baja frecuencia —abierto o cerrado, válido o inválido, vacío o con contenido— y jamás apoyarse en clases que se muevan al ritmo del puntero o del teclado. Cuando necesites reaccionar a algo de alta frecuencia, mueve el estado a una custom property y deja que la cascada lo propague sin tocar selectores. Es más barato en varios órdenes de magnitud y no tiene efectos a distancia.

⚔️ Ponle números
  1. Explica con tus palabras por qué el emparejamiento de derecha a izquierda es más rápido que el contrario.
  2. Reescribe .tabla:has(.celda.activa) para acotar el recorrido sabiendo que las celdas son nietas de la tabla.
  3. Monta una lista de mil elementos y compara el recálculo de li:has(> .m) frente a *:has(.m) al alternar la clase.
  4. Encuentra en un proyecto real un :has() cuyo argumento mencione una clase que muta con el puntero, y propón la alternativa.
  5. Activa las estadísticas de selectores y localiza el selector con peor relación entre intentos y aciertos de tu página.