wandres.dev
SELECTORES MODERNOS · :is, :where y la especificidad cero

Límites y coste de los selectores funcionales

Por qué no admiten pseudo-elementos, cómo se calcula su especificidad sin mirar el documento, qué pasa cuando las listas crecen y en qué casos no son la herramienta correcta.

⏱ 15 min

:is(), :where() y :not() resuelven tanto que es fácil empezar a meterlos en todas partes, y hay tres sitios donde no encajan: dentro no caben los pseudo-elementos, su especificidad se decide sin mirar el documento, y cuando las listas crecen el trabajo de emparejamiento crece con ellas. Ninguna de las tres cosas es un problema grave; las tres producen desconcierto si aparecen sin aviso.

🎯 Al terminar esta lección sabrás
  • Explicar por qué un pseudo-elemento no puede ir dentro de estas funciones.
  • Justificar que la especificidad se calcule de forma estática y qué implica.
  • Describir cómo emparejan los motores y dónde conviene colocar las listas.
  • Reconocer los casos en que la respuesta correcta no es un selector funcional.

Los pseudo-elementos se quedan fuera

Ninguna de las tres admite pseudo-elementos en su lista. :is(::before, ::after) no es válido, y en :is() y :where(), que tienen lista tolerante, el argumento se descarta en silencio, así que la regla se sigue aplicando pero a menos cosas de las que creías.

La razón no es una limitación de implementación sino conceptual. Estas funciones casan con elementos, y un pseudo-elemento no es un elemento del documento: es una caja que el motor genera y que la gramática de selectores trata aparte, permitiéndole aparecer solo al final del selector y solo una vez. Meterla en una lista de alternativas rompería esa colocación.

La consecuencia práctica es que la agrupación que más apetece hacer es justamente la que no se puede:

/* No funciona: el argumento se descarta */
.boton:is(::before, ::after) { content: ""; }

/* La forma correcta sigue siendo una lista clasica */
.boton::before,
.boton::after { content: ""; }

Y como esa lista es clásica, si algún día metes en ella un pseudo-elemento que algún motor no conozca, se llevará la regla entera por delante. Es el único rincón del lenguaje de selectores donde el problema que :is() vino a resolver sigue vivo.

La especificidad se decide sin mirar el documento

Ya sabes que :is() y :not() toman la especificidad de su argumento más específico, y que ese argumento puede no ser el que casó. Conviene entender por qué se diseñó así, porque la alternativa parecía más justa.

Calcular la especificidad a partir del selector que efectivamente casó exigiría que la especificidad dependiera del elemento, y por tanto que se recalculara por cada elemento en cada emparejamiento. Hoy la especificidad es una propiedad estática del texto del selector: se calcula una vez, al analizar la hoja, y se guarda junto a la regla. Esa decisión es lo que permite que ordenar declaraciones durante el recálculo de estilo sea comparar números ya calculados en lugar de recalcularlos, que en un documento con decenas de miles de elementos es una diferencia enorme.

Es un intercambio explícito: se sacrifica algo de intuición a cambio de que el criterio sea barato y predecible. Y como toda decisión de este tipo, se paga cuando escribes listas que mezclan tipos de selector muy distintos, que es exactamente el caso que conviene evitar.

Dónde pones la lista importa, y la razón no es la que te imaginas

Los motores no evalúan los selectores de izquierda a derecha: parten del componente de más a la derecha —el selector clave— y comprueban hacia arriba. Además indexan las reglas por ese componente, de modo que ante un elemento con clase .titulo solo se consideran las reglas cuyo selector clave sea esa clase, su tipo o el universal. Eso tiene una consecuencia concreta sobre dónde conviene colocar un :is(). Si lo pones a la izquierda, en la posición de ancestro, el selector clave sigue siendo un componente único y el índice hace su trabajo con normalidad:

:is(.tarjeta, .panel, .aviso) .titulo { font-weight: 600; }

Si lo pones a la derecha, el selector clave pasa a ser una lista, y la regla tiene que quedar indexada bajo todas sus alternativas, con lo que se considerará para más elementos:

.tarjeta :is(.titulo, .subtitulo, .pie) { font-weight: 600; }

Ahora la parte honesta, porque este tipo de observación se convierte con demasiada facilidad en una superstición como la de los identificadores rápidos. La diferencia es irrelevante en cualquier página normal. El emparejamiento de selectores en un recálculo de estilo típico se mide en fracciones de milisegundo, y no vas a poder distinguir las dos versiones ni con instrumentación. Solo empieza a importar en documentos con decenas de miles de nodos combinados con hojas de estilo de decenas de miles de reglas, y aun ahí el cuello de botella suele estar en otro sitio. Entonces, ¿por qué contarlo? Por dos razones. La primera, porque saber cómo empareja el motor te inmuniza contra los mitos de rendimiento de selectores, que siguen circulando. Y la segunda, porque hay una razón no relacionada con el rendimiento para preferir el :is() a la izquierda, y es mejor: agrupar contextos se lee bien, mientras que agrupar sujetos suele indicar que esos tres elementos deberían compartir una clase. La forma que resulta ser más barata es también la que expresa mejor la intención, y esa coincidencia es la única justificación que necesitas.

Cuando las listas crecen

Una lista de alternativas hace que el motor tenga que comprobar más condiciones por elemento. Conceptualmente, :is(a, b, c) :is(d, e, f) equivale a nueve selectores; en la práctica los motores lo evalúan sin expandirlo, pero el número de comprobaciones sigue creciendo con el tamaño de las listas.

Lo que casi nunca se dice es que el coste importante de una lista larga no es el de la máquina, es el del lector. Una lista de nueve selectores dentro de un :is() es una regla que nadie puede leer de un vistazo y cuyo alcance real solo se descubre probando. Cuando una lista pasa de cuatro o cinco elementos, la pregunta correcta no es si el navegador lo aguanta —lo aguanta— sino si esos elementos no deberían compartir una clase o un atributo que exprese lo que tienen en común.

/* Ilegible y frágil: la lista hay que mantenerla al día */
:is(.aviso, .error, .exito, .info, .ayuda, .nota) { padding: 1rem; }

/* Explícito: el marcado dice lo que son */
[data-mensaje] { padding: 1rem; }

La segunda versión, además, no se rompe cuando alguien añada un séptimo tipo de mensaje.

Cuando la respuesta no es un selector funcional

Cuatro señales de que la herramienta correcta es otra.

Estás agrupando por conveniencia, no por concepto. Si los elementos de la lista no tienen nada en común salvo que hoy comparten un estilo, la agrupación se romperá en cuanto uno diverja. Una clase compartida documenta la relación; una lista la oculta.

Estás usando :where() para no pelear con una capa que existe. Si el conflicto es entre bloques de código, el criterio es @layer y no la especificidad. :where() puede tapar el síntoma, pero el orden seguirá decidiendo por ti.

Estás encadenando :not() para ganar peso. Ya visto: es escalada con otro nombre.

Estás construyendo el selector por exclusión cuando el estado se puede afirmar. Negar tres cosas para acertar con la cuarta es más frágil que declarar la cuarta con un atributo, y el fallo cuando alguien añada una quinta será silencioso.

Con esto queda cerrada la mecánica. Falta juntarlo todo en la forma de escribir CSS que otros van a usar, que es el cierre del nivel.