L'imbrication des sélecteurs dans Sass
L'imbrication de sélecteurs est une fonctionnalité des préprocesseurs CSS d'utilisation tellement courante qu'elle peut devenir problématique. Par Kitty Giraudel.
L'imbrication de sélecteurs est une fonctionnalité des préprocesseurs CSS d'utilisation courante. Tellement courante qu'elle peut devenir problématique. Kitty Giraudel, notre grand spécialiste de Sass, défend une position originale et radicale.
Il y a quelques jours, j'ai envoyé un tweet au sujet de l'imbrication des sélecteurs (selector nesting) pour dire qu'ils me créaient plus de problèmes qu'ils n'en résolvaient.
Certains étaient d'accord, d'autres non, mais en tout cas cela a permis d'échanger des idées intéressantes et je me suis dit que j'en ferais bien un petit article.
Qu'est-ce que l'imbrication de sélecteurs?
L'imbrication de sélecteurs est une fonctionnalité des préprocesseurs CSS qui permet d'emboîter des sélecteurs à l'intérieur d'autres sélecteurs pour créer des raccourcis d'écriture. Par exemple :
.parent {
color: red;
.child {
color: blue;
}
}
...sera compilé en ceci :
.parent {
color: red;
}
.parent .child {
color: blue;
}
Dans cet exemple, .child
est emboîté dans .parent
afin d'éviter la répétition du sélecteur parent.
Ça peut-être tout à fait utile, mais j'ai le sentiment que cette fonctionnalité est très largement sur-utilisée au point que nous en arrivons aujourd'hui à devoir résoudre des problèmes créés par une imbrication inconsidérée.
Où est le problème de l'imbrication?
En soi, il n'y a pas de problème, la fonctionnalité a un sens. Le problème, comme souvent, est dans la façon dont nous utilisons cette fonctionnalité. Permettez-moi de commencer par deux exemples.
Le premier est de Micah Godbolt :
.tabs {
.tab {
background: red;
&:hover {
background: white;
}
.tab-link {
color: white;
@at-root #{selector-replace(&, '.tab', '.tab:hover')}{color: red;}
}
}
}
Le second est de Zi Qiu :
.root {
width: 400px;
margin: 0 auto;
.links {
.link {
display: inline-block;
& ~ .link {
margin-left: 10px;
}
a {
padding: 10px 40px;
cursor: pointer;
background: gray;
&:hover {
background: blue;
color: white;
font-size: 700;
}
.icon {
margin-right: 5px;
@include selector-modifier(-2 ':hover', 1 suffix '.zh'){
color: red;
background: green;
}
@include selector-modifier(-2 ':hover', 1 suffix '.en') {
color: yellow;
background: green;
}
}
}
}
}
}
Une précision pour commencer : Ce code est intelligent. Je ne prétends en aucune façon qu'il s'agisse d'un mauvais code, et je suppose qu'il fonctionne exactement comme il faut.
Maintenant, si je vous demande ce que ces deux exemples sont censés réaliser ? En y jetant un coup d'oeil rapide, seriez-vous capable de le dire ?
Moi non plus. Parce que c'est compliqué.
L'imbrication complique le code
Les deux exemples qui précèdent utilisent des fonctions de sélecteurs (de Sass 3.4) pour réécrire partiellement le contexte du sélecteur courant (&
).
Donc, si je ne me trompe pas, ils utilisent du code supplémentaire afin d'écrire moins de code, et ajoutent une couche de complexité. Pourquoi ne pas écrire un code simple dès l'origine ?
Je l'ai déjà dit : les fonctions de sélecteurs ne sont pas faites pour une utilisation standard. Je crois que Chris Eppstein et Natalie Weizenbaum ont dit explicitement qu'ils ajoutaient cette fonctionnalité pour aider les développeurs de frameworks.
Remarque : si vous trouvez un cas d'utilisation légitime pour les fonctions de sélecteurs, qui résoud vraiment un problème, je vous serais reconnaissant de me le montrer, ça m'intéresserait beaucoup.
Le sélecteur de référence est ambigu
Dans Sass, le sélecteur de référence (&
) peut parfois être ambigu. Selon la façon dont on l'utilise, il peut produire un résultat totalement différent. Voici une série d'exemples simples.
/* SCSS */
.element {
&:hover {
color: red;
}
}
/* CSS */
.element:hover {
color: red;
}
/* SCSS */
.element {
&hover {
color: red;
}
}
/* CSS */
.element:hover {
color: red;
}
/* SCSS */
.element {
& .hover {
color: red;
}
}
/* CSS */
.element .hover {
color: red;
}
/* SCSS */
.element {
&-hover {
color: red;
}
}
/* CSS */
.element-hover {
color: red;
}
/* SCSS */
.element {
&.hover {
color: red;
}
}
/* CSS */
.element.hover {
color: red:
}
/* SCSS */
.element {
.hover& {
color: red;
}
}
/* Syntax Error */
Invalid CSS after ".hover": expected "{", was "&"`
`"&" may only be used at the beginning of a compound selector.
/* SCSS */
.element {
&:hover & {
color: red;
}
}
/* CSS */
.element:hover .element {
color: red;
}
/* SCSS */
.element {
&:hover {
& {
color: red;
}
}
}
/* CSS */
.element:hover {color: red;
}
Et nous restons ici dans la simplicité en n'utilisant qu'un seul sélecteur. Inutile de préciser que lorsque vous multipliez les références à l'intérieur d'une règle, les choses peuvent vite devenir complexes.
En fait, certaines opérations fonctionnent, d'autres non (remarquez le message d'erreur dans l'un des exemples). Certains génèrent un sélecteur composé, d'autres non. En fonction du projet, en fonction de l'expérience Sass du prochain développeur qui utilisera le code, ces choses peuvent s'avérer difficiles à débugger.
Des sélecteurs introuvables
Là, je commence à devenir tatillon. Mais il y a quelque chose que je n'aime pas et qui consiste à utiliser l'imbrication pour créer des sélecteurs du type BEM
.block {
/* Some CSS declarations */
&--modifier {
/* Some CSS declarations for the modifier */
}
&__element {
/* Some CSS for the element */
&--modifier {
/* Some CSS for the modifier of the element */
}
}
}
Avant d'expliquer pourquoi je n'aime pas cela, regardons comment ce code est compilé :
.block {
/* Some CSS declarations */
}
.block--modifier {
/* Some CSS declarations for the modifier */
}
.block__element {
/* Some CSS for the element */
}
.block__element--modifier {
/* Some CSS for the modifier of the element */
}
D'un côté, ça permet d'éviter la répétition de .block
dans chaque sélecteur, ce qui pourrait être dommage quand vous avez des noms de blocs comme .profil-utilisateur
.
Mais d'un autre côté, ça crée de nouveaux sélecteurs sortis de nulle part et sur lesquels aucune recherche n'est possible : que se passe-t-il si un développeur veut trouver le CSS de .block__element
? Il y a des chances qu'il le cherche à partir de son environnement de développement et qu'il ne trouve rien parce que ce sélecteur n'a jamais été créé comme tel.
En fait, j'ai l'impression que je ne suis pas le seul à penser ainsi. Kaelig, qui a travaillé un temps au Guardian, a envoyé un tweet allant dans le même sens.
De plus, je pense qu'il est meilleur de répéter le nom de base. De cette manière, on voit clairement ce qui se passe.
Je devrais ajouter que les source maps peuvent aider dans une certaine mesure, mais ils ne changent rien au fait qu'on ne peut effectuer de recherche sur la base du code.
Quand peut-on imbriquer?
Si vous-même et votre équipe êtes à l'aise avec la complexité, on peut toujours imbriquer !
Si vous me posez la question, mon sentiment est que l'ajout de pseudo-classes et de pseudo-éléments est à peu près le seul cas où ça vaut la peine. Par exemple
.element {
/* Some CSS declarations */
&:hover,
&:focus {
/* More CSS declarations for hover/focus state */
}
&::before {
/* Some CSS declarations for before pseudo-element */
}
}
C'est le meilleur cas d'utilisation de l'imbrication de sélecteurs. Non seulement il permet d'éviter la répétition du même sélecteur, mais en plus il permet de définir l'étendue de cet élément (états et enfants virtuels) à l'intérieur du même ensemble de règles CSS. Enfin, &
n'est pas ambigu : il signifie .element
, ni plus, ni moins.
Un autre cas d'utilisation intéressante de l'imbrication est lorsque vous voulez appliquer quelques styles personnalisés à un sélecteur simple en fonction du contexte. Par exemple lorsqu'on utilise les hooks CSS de Modernizr :
.element {
/* Some CSS declarations */
.no-csstransforms & {
/* Some CSS declarations when CSS transforms are not supported */
}
}
Dans ce scénario, j'ai l'impression qu'il est clair que .no-csstransforms &
sert à contextualiser le sélecteur courant à chaque fois que les transformations CSS ne sont pas supportées. Cela dit, c'est quand même à la limité de ce que je considère acceptable.
Quelles sont les recommandations?
Écrire un CSS simple. Reprenons l'exemple de Micah, qui est un peu compliqué, mais pas suffisamment pour être difficile à réécrire.
Le code d'origine, dont l'objectif est d'appliquer des styles à une table :
.tabs {overflow: hidden;
.tab {
background: red;
&:hover {
background: white;
}
.tab-link {
color: white;
@at-root #{
selector-replace(&, '.tab', '.tab:hover') {
color: red;
}
}
}
}
...pourrait être écrit ainsi :
.tabs {
overflow: hidden;
}
.tab {
background: red;
&:hover {
background: white;
}
}
.tab-link {
color: white;
.tab:hover &
{
color: red;
}
}
Je ne peux pas imaginer une seule raison de préférer la première version du code. Elle est non seulement plus longue mais également moins explicite et elle utilise des fonctionnalités Sass qui ne sont pas nécessairement connues de tous les développeurs.
Remarquez que le résultat en CSS n'est pas exactement identique car nous avons aussi simplifié le code. Plutôt que d'avoir des sélecteurs de la taille de .tabs .tab .tab-link
, nous avons utilisé .tab-link
, plus simple.
Sur ce point, on n'est plus vraiment dans une discussion sur l'imbrication des sélecteurs, mais plutôt sur les conventions de nommage et sur la méthodologie des sélecteurs. Lorsqu'on utilise BEM par exemple, on nomme les choses selon ce qu'elles sont, plutôt que selon l'endroit où elles sont, ce qui aboutit souvent à des sélecteurs simples (c'est à dire non composés), et donc à moins d'imbrication.
Selon les directives CSS de Harry Roberts :
Il est important lorsqu'on écrit du CSS de donner la bonne portée à nos sélecteurs, et de sélectionner la bonne chose pour les bonnes raisons (...) Étant donnée la nature toujours changeante de la plupart des projets UI, et la tendance à aller vers des architectures basées sur des composants, il est de notre intérêt d'appliquer un style aux choses en fonction non de l'endroit où elles sont, mais de ce qu'elles sont.
Une bonne règle générale, s'agissant des sélecteurs CSS, est plus on fait court et mieux c'est. Non seulement à tout niveau (performance, simplicité, portabilité, intention, etc.) mais il se trouve qu'il est beaucoup plus simple d'éviter les accidents d'imbrication de sélecteurs lorsque les sélecteurs sont courts.
Pour conclure
À chaque fois que je dis que l'imbrication de sélecteurs n'est pas une bonne idée, les gens me disent “mais je n'ai jamais rencontré un tel problème”, “je l'utilise tous les jours sans problème”, “c'est parce que tu fais des trucs de dingue avec ça”. Bien sûr, il n'y a pas de problème avec la fonctionnalité elle-même.
Les préprocesseurs ne produisent pas de mauvais code, ce sont les mauvais développeurs qui le font. Et ici, on ne parle même pas du résultat produit, mais de ce qu'on entre initialement. Le code devient de moins en moins lisible lorsqu'on y ajoute des couches de complexité supplémentaires. L'imbrication des sélecteurs est l'une d'elles.
On nous a donné l'imbrication et nous en avons abusé. Puis on nous a donné les fonctions de sélecteurs pour réparer la confusion que nous avions créée. Tout ça est erroné.
Utilisez Sass, ou n'importe quel préprocesseur, pour simplifier votre code, pas pour le rendre plus complexe.