Wie eine CSS-Datei überhaupt auf 27 MB kommen kann, welche @extend-Ziele unbedenklich sind und wo der eigentliche Preis dieser Arbeitsweise liegt – diesen Fragen gehe ich in diesem Beitrag nach. Dazu gibt es eine Checkliste zum Kopieren und Messwerte aus meinem eigenen WordPress-Theme zum Vergleich zu sehen.

Bootstrap im HTML oder im SCSS

Bootstrap lässt sich auf zwei Arten einsetzen. Die bekannte: Utility-Klassen wie .d-flex oder .mb-5 direkt ins HTML schreiben. Die weniger verbreitete: dieselben Klassen im SCSS per @extend in einen eigenen, semantischen Selektor übernehmen – das HTML bleibt damit schlank, das Styling steht im Stylesheet.

// statt <div class="post-meta d-flex align-items-center gap-3">
.post-meta {
    @extend .d-flex;
    @extend .align-items-center;
    @extend .gap-3;
}

Beide Wege sind legitim und ich setze beide im Alltag ein, am liebsten aber den zweiten. Mein WordPress-Theme geht den zweiten Weg konsequent: 440 Mal steht dort ein @extend, am häufigsten auf .d-flex (40 Mal), .mb-5 (24 Mal) und .gap-3 (23 Mal). Die fertige style.css ist minifiziert 367 KB gross, über die Leitung gehen mit gzip 43 KB. Bootstrap selbst, komplett und minifiziert, kommt zum Vergleich auf 232 KB beziehungsweise 31 KB.

Um es klar zu sagen: Der Vorteil für mich in der Verwendung von @extend liegt in der Übersichtlichkeit des Codes und damit auch der einfachen Wartung.

Trotzdem begegnet mir regelmässig die Gegenposition: @extend mit Bootstrap lasse die CSS-Datei explodieren – Teufelszeug, ab an den Pranger!

Erfunden ist das nicht: Die Zahl vom Anfang – 27 MB für eine unkomprimierte CSS-Datei – hatte ich selbst in einem Kundenprojekt fabriziert.

Auch die Fachbeiträge zum Thema warnen seit Jahren. Fred Meyer rät 2014 in Don’t Over-@extend Yourself in Sass, die Klasse lieber gleich ins HTML zu schreiben, statt sie per @extend im Stylesheet zu verstecken. Alex Bea zeigt 2016 in Safe Sass Extending am Beispiel von Bootstraps .btn, wie viele Selektoren ein einziges @extend nach sich zieht, und empfiehlt Sass-Platzhalter-Selektoren. Tiffany B. Brown schliesslich titelt 2017 unmissverständlich No seriously: Don’t use @extend – weil ein extendender Selektor in jeder Regel landet, in der das Ziel vorkommt, auch in solchen, an die man beim Schreiben nie gedacht hat.

Alle drei haben im Kern recht und Fred Meyers Weg ist eine ebenso gute Wahl wie meiner (und Sass-Platzhalter-Selektoren sind cool, wenn richtig eingesetzt). Was keiner der drei Beiträge beantwortet, ist die Frage nach der Grenze: Welches @extend ist schlicht unbedenklich, ab welchem Ziel wird es teuer? Darüber lässt sich lange diskutieren. Oder man misst nach – an meinem Theme und am Kundenprojekt.

Die Checkliste

Die Kurzfassung vorweg. Entscheidend ist in erster Linie, worauf ein @extend zeigt, und erst danach, wo es steht.

@extend aufEinstufungWarum
Utilities wie .d-flex, .mb-5, .gap-3unbedenklicheine Regel, die selbst nichts extended
Utilities in einem Mixin, das oft aufgerufen wirdunbedenklichwächst nur additiv, im Theme 13 Aufrufe mit zusammen 140 Selektoren
responsive Utilities wie .d-md-flexunbedenklich, aber nur von ausserhalb einer Media Queryein @extend über eine Media-Query-Grenze bricht den Build ab, die responsive Utility bringt ihre Media Query selbst mit
.row, .row-cols-*, einzelnes .col-*unbedenklich1 bis 6 Regeln, keine Kette
kleine Komponenten wie .alert, .dropdown-item, .page-linkunbedenklich5 bis 6 Regeln
eine Utility mit einer einzigen Deklaration, wenn der Selektor schon eigene Properties hatunbedenklich, direkt deklariert minimal kleinerdisplay:flex; kostet 13 Byte, der Selektor in der Gruppe rund 30 Byte – mit gzip unter jeder Relevanzschwelle
mittlere Komponenten wie .nav-link, .form-select, .input-groupmit Bedacht17 bis 18 Regeln pro @extend
.btn, .form-controlmit Bedacht, einzeln67 beziehungsweise 25 Regeln inklusive aller Zustände
jede Utility, mit Blick auf die Kaskademit Bedacht (Kaskade)der Selektor rutscht an die Position der Utility und übernimmt ihr !important
.d-flex an Elementen, die JavaScript per .d-none ausblendetmit Bedacht (Kaskade), display direkt deklarierenbei verschachteltem Selektor gewinnt das extendete .d-flex gegen .d-none
Komponenten in einem Mixin oder einem Loop (@each, @for oder @while)vermeidenjede Aufrufstelle zieht alle Regeln der Komponente mit
Ketten wie .h3, das h3 extended, das wiederum %heading extendedvermeidenzieht jede Regel mit, in der h3 vorkommt, auch im eigenen Code – Ursache der 27 MB. Für die Schriftgrösse reicht @include font-size($h3-font-size)

Ein Warnsignal lässt sich zudem ohne Blick ins SCSS prüfen: Läuft eine einzelne Selektorliste im fertigen CSS in den sechsstelligen Zeichenbereich, lohnt sich die Suche nach einer Kette. Die längste Liste in meinem Theme hat 8’040 Zeichen. Wie sich das messen lässt, steht weiter unten im Abschnitt Das eigene Projekt nachmessen.

Checkliste kopieren

Im Markdown-Format – für deine eigene Projektdokumentation:

## @extend mit Bootstrap – Checkliste

### Unbedenklich

- Utilities wie `.d-flex`, `.mb-5`, `.gap-3`
  – eine Regel, die selbst nichts extended
- Utilities in einem Mixin, das oft aufgerufen wird
  – wächst nur additiv
- Responsive Utilities wie `.d-md-flex`, von ausserhalb einer Media Query
  – sie bringen ihre Media Query selbst mit, ein `@extend` aus einer
  Media Query heraus bricht den Build ab
- `.row`, `.row-cols-*`, einzelnes `.col-*`
  – 1 bis 6 Regeln, keine Kette
- Kleine Komponenten wie `.alert`, `.dropdown-item`, `.page-link`
  – 5 bis 6 Regeln
- Utility mit einer einzigen Deklaration, wenn der Selektor schon
  eigene Properties hat – direkt deklariert minimal kleiner,
  mit gzip nicht relevant

### Mit Bedacht

- Mittlere Komponenten wie `.nav-link`, `.form-select`, `.input-group`
  – 17 bis 18 Regeln pro `@extend`
- `.btn` und `.form-control` nur einzeln
  – 67 beziehungsweise 25 Regeln inklusive aller Zustände

### Mit Bedacht – Kaskade

- Jede Utility – der Selektor rutscht an die Position der Utility
  und übernimmt ihr `!important`
- `.d-flex` an Elementen, die JavaScript per `.d-none` ausblendet
  – `display` direkt deklarieren, sonst gewinnt bei verschachteltem
  Selektor das extendete `.d-flex`

### Vermeiden

- Komponenten in einem Mixin oder einem Loop (`@each`, `@for`
  oder `@while`)
  – jede Aufrufstelle zieht alle Regeln der Komponente mit
- Ketten wie `.h3` > `h3` > `%heading`
  – zieht jede Regel mit, in der `h3` vorkommt, auch im eigenen Code;
  für die Schriftgrösse reicht `@include font-size($h3-font-size)`

### Warnsignal

- Eine einzelne Selektorliste im fertigen CSS mit sechsstelliger
  Zeichenzahl – nach einer Kette suchen

Wie kommt ein Projekt auf 27 MB?

Durch ein @extend auf ein Ziel, das über eine Kette in sehr vielen Regeln vorkommt. Im Kundenprojekt, das über eine Asset Pipeline gebaut wurde, war das .h3.

Zur Einordnung: Bootstrap komplett liefert minifiziert 232 KB. 27 MB sind mehr als das Hundertfache davon. Aus Bootstrap selbst kann diese Grösse also nicht stammen, nur aus dem, was der eigene Code daraus macht.

Der Auslöser war harmlos. Einige Überschriften sollten optisch kleiner sein, strukturell aber <h2> bleiben. Gelöst wurde das mit @extend .h3 – dem teuersten denkbaren Weg, weil .h3 in Bootstrap selbst schon eine Kette ist:

// _type.scss
.h3 {
    @extend h3;
}

// _reboot.scss
h3 {
    @extend %heading; // gemeinsamer Sass-Platzhalter-Selektor aller Überschriften
    @include font-size($h3-font-size);
}

Ein @extend trägt den eigenen Selektor überall dort ein, wo das Ziel vorkommt. Wer .h3 extended, landet deshalb nicht nur bei .h3, sondern auch bei jeder Regel, in der h3 steht – in Bootstrap wie im eigenen Code – und über h3 bis hinauf in den Sass-Platzhalter-Selektor %heading. Eine einzige Zeile hängt sich so an eine ganze Kette.

Gebraucht wurde dabei eigentlich nur die Schriftgrösse. h2 und h3 unterscheiden sich in Bootstrap ausschliesslich in der font-size. Abstand, Schriftstärke, Zeilenhöhe, Schrift und Farbe kommen aus dem gemeinsamen %heading und das hat ein echtes <h2> bereits. Die Lösung ist ein Einzeiler mit Bootstraps eigenem Mixin für responsive Schriftgrössen (RFS):

h2.kleiner-titel {
    @include font-size($h3-font-size);
}

Das erzeugt genau das, was Bootstrap für h3 erzeugt, inklusive der fliessenden Grösse unter 1200 Pixeln. Im Build nachgeprüft:

h3, .h3 { font-size: calc(1.3rem + .6vw); }
h2.kleiner-titel, .kleiner-titel.h2 { font-size: calc(1.3rem + .6vw); }

@media (min-width: 1200px) {
    h3, .h3 { font-size: 1.75rem; }
    h2.kleiner-titel, .kleiner-titel.h2 { font-size: 1.75rem; }
}

Ein eigenes Mixin mit fest eingetragenen Werten wäre die schlechtere Variante: Es läuft bei einem Bootstrap-Update oder einer angepassten $h3-font-size auseinander. Voraussetzung ist lediglich, dass Bootstraps Funktionen, Variablen und Mixins vor der eigenen Datei geladen sind.

Übrigens: Selbst dieser Einzeiler zeigt, wie @extend arbeitet. Weil .h2 in Bootstrap h2 extended, schreibt Sass ungefragt auch .kleiner-titel.h2 mit in die Regel. Ein Selektor mehr, harmlos – aber genau der Mechanismus, der bei @extend .h3 die Datei aufbläht.

Nachdem das @extend .h3-Muster ersetzt war, lag das Kundenprojekt bei 994 KB roh und 74.6 KB mit gzip. Blankes Bootstrap kostet 31 KB gzip, das ganze Kundenprojekt liegt damit beim 2.4-Fachen davon. Ein normaler Wert.

Dass die Datei mit gzip gut 13-mal kleiner ist als roh, ist dabei kein Zufall, sondern die eigentliche Stärke von @extend: Anders als ein Mixin schreibt es keine Deklarationen doppelt, sondern hängt die Selektoren an eine bestehende Regel. Solche langen, gleichförmigen Selektorlisten lassen sich besonders gut komprimieren. Warum das bei der Transfergrösse hilft, beim Rendering aber nicht, steht im Abschnitt Transfergrösse ja, Rendering nein.

Dieselbe Struktur im eigenen WordPress-Theme

Als die Ursache für die Aufblähung klar war, lag die Kontrollfrage nahe: Steckt das Muster auch in anderen Projekten oder sogar in meinem WordPress-Theme?

Im Verdacht hatte ich den Kontext – ein @extend in einem Mixin, das vielfach aufgerufen wird. Und tatsächlich: Von sechs eigenen Mixins enthalten vier ein @extend. Eines davon, distance-after-block(), wird 13 Mal aufgerufen und extended .mb-5 jedes Mal mit 10 oder 11 :has()-Selektoren. Das ergibt 140 der 211 Selektoren in der .mb-5-Gruppe, der grössten im ganzen Build.

Strukturell ist das exakt die Form des Antipatterns. Und trotzdem ist es harmlos, denn der Unterschied liegt im Ziel:

KundenprojektMein Theme
Ziel.h3.mb-5, .d-flex
Art des ZielsKette: .h3 extended h3, h3 extended %headingLeaf: eine Deklaration, extended selbst nichts
Wachstummultiplikativ: jede extendende Stelle mal jede Regel, in der h3 vorkommtadditiv
Ergebnis27 MB140 Selektoren in einer Gruppe von 8 KB

Ein Leaf, also eine Klasse am Ende jeder Kette, hat nichts, was sich weitervererben könnte. Es bleibt bei einer einfachen Addition. Dieselbe Struktur, ein anderer Mechanismus – mein Verdacht auf den Kontext war zu kurz gegriffen. Entscheidend war das Ziel.

In meinem WordPress-Theme musste ich also nichts ändern. Wo ich in anderen Projekten über meinen Fehler mit @extend .h3 stolpere, korrigiere ich ihn einfach.

Was kostet ein @extend-Ziel?

So viel, wie das Ziel an Regeln mitbringt. Gezählt habe ich, wie oft ein Selektor im Bootstrap-SCSS vorkommt, also wie viele Regeln ein einzelnes @extend davon mitzieht:

@extend-Zielgezogene RegelnKosten
.btn67hoch
.form-control25hoch
.nav-link18mittel
.form-select18mittel
.input-group17mittel
.form-check-input8mittel
.dropdown-item6gering
.col6gering
.alert6gering
.page-link5gering
Utilities (.d-flex, .mb-*, .gap-*)1minimal

.btn ist mit Abstand der grösste Brocken, weil alle Zustände (:hover, :focus-visible, :active, .disabled) und die .btn-check-Kombinationen mitkommen. Die Utilities tauchen bei einer Suche im Bootstrap-SCSS gar nicht auf, weil sie nicht wörtlich dort stehen, sondern aus der $utilities-Map erzeugt werden. Im fertigen CSS ist jede Utility genau eine Regel.

Überrascht hat mich das Grid. .col-* gilt als Klassiker unter den teuren Zielen, ist als einzelnes @extend aber günstig: 1 bis 6 Regeln. Teuer wird es erst in einem Loop.

Grid: .row, .col-* und .row-cols-*

.row bringt zwei Regeln mit: die Zeile selbst und .row > * für die Kindelemente. Beide stammen aus Mixins, nicht aus einem @extend – es kann also nichts weiterwachsen. Ein @extend .row zieht die Regel für die Kindelemente bewusst mit, denn dort sitzen die Abstände zwischen den Spalten (Gutter).

.col-6 und .row-cols-3 kosten je eine Regel. Die Grösse entscheidet hier nichts, der Unterschied ist, wo sie ansetzen:

.col-6 { flex: 0 0 auto; width: 50%; }              /* am Kindelement */
.row-cols-3 > * { flex: 0 0 auto; width: 33.33%; }  /* am Container */

Sind alle Kindelemente gleich breit, ist .row-cols-* die bessere Wahl: ein @extend am Container statt eines pro Kindelement und die Kindelemente brauchen gar keinen eigenen Selektor. Bei dynamischem Inhalt – einem WordPress-Loop mit wiederholten Cards etwa – gibt es oft gar keinen stabilen Selektor für die Kindelemente, an dem ein @extend .col-6 hängen könnte.

.card-grid {
    @extend .row;
    @extend .row-cols-1;
    @extend .row-cols-md-2;
    @extend .row-cols-lg-3;
}

Sollen die Kindelemente unterschiedlich breit sein, führt kein Weg an .col-* am jeweiligen Kindelement vorbei. .row-cols-* setzt alle Kindelemente auf dieselbe Breite.

Merke: .row-cols-* setzt ausschliesslich flex und width. Abstände und negative Ränder kommen aus .row. Ohne zusätzliches @extend .row stimmen die Breiten, aber die Abstände fehlen.

Und innerhalb einer Media Query?

Dort geht @extend gar nicht. Sass bricht den Build ab, sobald man aus einer Media Query heraus eine Klasse extended, die ausserhalb davon definiert ist:

.site-nav {
    @include media-breakpoint-up(md) {
        @extend .d-flex; // bricht den Build ab
    }
}

Die Meldung von Dart Sass ist eindeutig: «You may not @extend selectors across media queries.»

Der Ausweg sind die responsiven Utilities. Eine Klasse wie .d-md-flex hat ihre Media Query bereits in sich. Sie lässt sich deshalb von ausserhalb ganz normal extenden und bringt die Media Query mit:

.site-nav {
    @extend .d-none;
    @extend .d-md-flex;
}
@media (min-width: 768px) {
    .d-md-flex, .site-nav {
        display: flex !important;
    }
}

Beides habe ich mit Dart Sass 1.99.0 und Bootstrap 5.3.8 gegengeprüft. Genau so arbeitet übrigens auch .row-cols-md-2 im .card-grid weiter oben.

Bootstrap erzeugt nur Varianten ab einem Breakpoint aufwärts, ein .d-md-down-flex existiert nicht. Der Ausgangszustand ist also immer der für kleine Bildschirme – das ist keine Einschränkung, sondern Mobile-First als Prinzip und aus meiner Sicht ohnehin der bessere Weg der Gestaltung.

Eine echte Einschränkung ist dagegen, dass nicht jede Utility von Haus aus responsiv ist. Über die Utility-API lässt sich das mit responsive: true nachrüsten. Und wo es keine passende Utility gibt, bleibt ein Mixin: @include funktioniert auch innerhalb einer Media Query.

Wann ist die direkte Deklaration kleiner?

Wenn es nur eine kurze Deklaration zu sparen gibt und der Selektor ohnehin schon einen eigenen Regelblock hat.

Die verbreitete Faustregel lautet: @extend spart Platz, weil Deklarationen nicht dupliziert werden. Das stimmt nur, wenn es überhaupt etwas Nennenswertes zu duplizieren gibt. Minifiziert nachgerechnet, pro Aufrufstelle:

VarianteKosten
@extend: Komma und Selektor in die Grupperund 30 Byte bei einem typisch verschachtelten Selektor
direkte Deklaration, der Selektor hat schon einen Regelblockdisplay:flex; = 13 Byte
direkte Deklaration, der Selektor braucht einen neuen RegelblockSelektor plus {display:flex} = rund 43 Byte

Bei einer einzelnen, kurzen Deklaration wie display: flex ist der Selektortext, den man in die Gruppe hängt, also länger als die Deklaration, die man dupliziert hätte. Bei .btn mit Dutzenden Deklarationen über 67 Regeln ist es umgekehrt eindeutig.

Daraus folgt die Regel aus der Checkliste: @extend für Komponenten mit vielen Deklarationen und Zuständen, die direkte Deklaration für Utilities mit einer einzigen Property – sofern der Selektor ohnehin eigene Properties hat.

Das «sofern» ist entscheidend, wie ausgerechnet mein eigenes Theme zeigt. Das Mixin distance-after-block() setzt an seinen Stellen nichts ausser dem @extend:

@mixin distance-after-block($paragraph: false) {
    @if($paragraph) {
        &:has(+ h2.wp-block-heading),
        &:has(+ h3.wp-block-heading),
        &:has(+ .wp-block-image),
        // … 6 weitere Block-Typen
        &:has(+ .wp-block-columns) {
            @extend .mb-5;
        }
    } @else {
        // dieselben Block-Typen, zusätzlich .wp-block-paragraph
    }
}

Die :has()-Selektoren fallen in beiden Varianten an, sie machen den Löwenanteil der Bytes aus. Der Unterschied liegt allein bei der Deklaration: Mit @extend teilen sich alle 140 Selektoren die eine margin-bottom-Deklaration von .mb-5. Direkt deklariert bekäme jeder der 13 Aufrufe einen eigenen Regelblock mit eigener Deklaration. Das wären rund 380 Byte mehr – für eine vermeintliche Optimierung.

Beim Mixin icon() mit 16 Aufrufen und je vier Utilities läge die theoretische Ersparnis bei grob 1 KB roh beziehungsweise rund 100 Byte mit gzip. Das ist unter jeder Relevanzschwelle.

Ob ein @extend in einem Mixin ein Problem ist, entscheiden also zwei Fragen. Wie viele Regeln zieht das Ziel mit? Eine Utility bringt genau eine mit, eine Komponente wie .btn Dutzende, eine Kette alle Regeln ihrer Glieder – erkennbar an einem @extend im Zielselektor selbst. Und haben die extendenden Selektoren einen eigenen Regelblock? Wenn nein, ist @extend die kleinere Variante, nicht die grössere.

Transfergrösse ja, Rendering nein

Lange Selektorlisten aus @extend seien unproblematisch, teils sogar gut für die Performance – so hatte ich es selbst lange formuliert. Und das stimmt nur zur Hälfte.

Bei der Transfergrösse ist @extend im Vorteil. Die langen Selektorlisten sind hochredundanter Text und genau den komprimieren gzip und Brotli fast vollständig weg. Bei meinem Theme liegt die Kompressionsrate bei 8.54:1. Beim Rendering dagegen gewinnt man nichts. Für die Stilberechnung des Browsers sind lange Selektorlisten neutral bis minimal teurer.

Der Vorteil liegt bei den Bytes über die Leitung, nicht bei der Geschwindigkeit. Diese Unterscheidung ist eigentlich der Kern des ganzen Hin und Her.

Aufschlussreich ist dabei der Vergleich der Kompressionsraten:

ProjektrohgzipRatio
Bootstrap (komplett, minifiziert)232 KB31 KB7.51:1
Mein Theme367 KB43 KB8.54:1
Kundenprojekt nach der Sanierung994 KB74.6 KB13.3:1

Je höher die Ratio, desto grösser der Anteil an redundantem Selektortext – also genau der Anteil, der über die Leitung praktisch nichts kostet. Eine auffällig hohe Ratio ist damit kein Alarmzeichen, sondern ein Hinweis darauf, dass weiteres Optimieren an der Roh-Grösse kaum noch etwas an der Transfergrösse bewegt.

Im Kundenprojekt hätte die Umstellung der verbleibenden @extend-Aufrufe in eigenen Mixins bei grosszügig geschätzten 500 Aufrufstellen rund 8 KB roh gespart, mit gzip etwa 1 KB. Die Rechnung lohnt sich nicht mehr. Der einzige verbleibende Hebel, ohne eine Codezeile zu verändern, ist Brotli statt gzip als Kompression auf dem Server zu verwenden. Bei CSS wird der Transfer damit typischerweise 15 bis 20 Prozent kleiner, hier also rund 60 bis 63 KB.

Nachgemessen am 18.09.2026: Inzwischen ist Brotli auf dem Server des Kundenprojekts aktiv. Die mittlerweile 941 KB grosse CSS-Datei geht mit gzip als 74.5 KB über die Leitung, mit Brotli als 62.3 KB – gut 16 Prozent weniger.

Der echte Preis ist die Kaskade

@extend .mb-5 verschiebt den eigenen Selektor in der Quellreihenfolge an die Stelle, an der .mb-5 definiert ist – also dorthin, wo Bootstrap seine Utilities ausgibt, und nicht dorthin, wo der Selektor im eigenen SCSS steht.

Die Kaskade verhält sich dadurch anders, als der Code aussieht. Wenn eine Utility-Klasse im HTML nicht überschreibt, was sie eigentlich überschreiben sollte, liegt die Ursache meist genau dort.

Dazu kommt ein Detail, das in der Diskussion gerne untergeht: Bootstrap gibt seine Utilities standardmässig mit !important aus, gesteuert über $enable-important-utilities. Wer .mb-5 extended, bekommt das !important mit – in meinem Build steht die ganze Gruppe mit margin-bottom:3rem!important. Eine eigene Regel, die für denselben Selektor später ein anderes margin-bottom setzt, kommt ohne eigenes !important nicht dagegen an, egal wo sie steht.

Ganz praktisch zeigt sich das, wenn JavaScript ein Element per .d-none ausblenden soll. Hat das Element ein extendetes .d-flex, tragen beide Regeln !important und es entscheidet die Spezifität. Bei einem einfachen Selektor wie .menu gewinnt .d-none, weil es in Bootstrap nach .d-flex steht. Bei einem verschachtelten Selektor wie .site-header .menu gewinnt dagegen .d-flex – das Element bleibt sichtbar. In so einem Fall ist es die bessere Wahl, display: flex; direkt zu setzen statt @extend .d-flex;. Denn ohne !important setzt sich .d-none dann immer durch.

Zusätzlich entstehen zusammengesetzte Selektoren mit eigener Spezifität. In meinem Theme ist das praktisch nur beim Drucken relevant geworden.

Das ist der Preis, den man bei dieser Arbeitsweise bewusst zahlt. Mit der Dateigrösse hat er nichts zu tun.

Das eigene Projekt nachmessen

Vier Befehle reichen, um die wichtigsten Werte am eigenen Projekt zu prüfen:

# Transfergrösse
gzip -9 -c build/assets/css/style.css | wc -c

# Anzahl @extend im Projekt
grep -rn "@extend" source --include="*.scss" | wc -l

# häufigste @extend-Ziele
grep -rho "@extend[^;]*" source --include="*.scss" \
  | sed 's/@extend  *//' | sort | uniq -c | sort -rn | head -30

# längste Selektorlisten im minifizierten CSS (Zeichen und Anzahl Selektoren)
sed -E 's/@(media|supports|container)[^{]*\{//g' build/assets/css/style.css \
  | tr '}' '\n' | grep -o '^[^{]*{' \
  | awk '{ n=gsub(/,/,","); print length($0)"\t"n+1 }' | sort -rn | head

Aussagekräftig ist vor allem der letzte Befehl. In meinem Theme liefert er diese drei Spitzenreiter:

GruppeZeichenSelektoren
.mb-58’040211
.btn .badge5’515101
.d-flex2’62275

Die längste Selektorliste macht damit 2.2 Prozent der Datei aus. Erst wenn eine einzelne Liste in den sechsstelligen Bereich läuft, lohnt sich der Blick auf die @extend-Ziele dahinter.

Drei weitere Befehle führen direkt zu den Stellen, die in der Checkliste unter «vermeiden» stehen – zu @extend in eigenen Mixins und Loops:

# eigene Mixins und Loops finden
grep -rn "^@mixin" source --include="*.scss"
grep -rn "@each\|@for\|@while" source --include="*.scss"

# @extend innerhalb von Mixins, mit Zuordnung zum Mixin-Namen
awk '/^@mixin/{m=$2} /@extend/{if(m) print NR": ["m"] "$0}' source/styles/utility/mixins.scss

Für jeden Treffer entscheiden dann die zwei Fragen aus dem Abschnitt zur direkten Deklaration, ob das @extend ein Problem ist: Wie viele Regeln zieht das Ziel mit und hat der extendende Selektor einen eigenen Regelblock?

Hinweis: Die Pfade source, build/assets/css/style.css und source/styles/utility/mixins.scss stammen aus meinem Theme und müssen an das eigene Projekt angepasst werden. Alle Grössen hier sind dezimal gerechnet, 1 KB entspricht 1000 Byte.

Die ehrlichen Grenzen

Die Regelzählung ist eine Rangfolge, keine Prognose. Gezählt wurden Vorkommen eines Selektors im Bootstrap-SCSS, nicht die tatsächlich ausgegebenen Deklarationen. Welches Ziel teurer ist als ein anderes, lässt sich damit sicher sagen. Wie viele Kilobyte ein @extend .btn genau kostet, nicht.

Die Byte-Rechnung hängt am Selektor. Sie arbeitet mit einem typischen Selektor von rund 30 Zeichen. Bei flacher Verschachtelung verschiebt sich das Verhältnis zugunsten von @extend.

Das Rendering ist nicht gemessen. Die Aussage, dass lange Selektorlisten beim Rendering keinen Vorteil bringen, beruht nicht auf einer eigenen Messung. Gemessen ist dagegen Brotli, allerdings an der Auslieferung des Servers mit dessen Kompressionsstufen – die übrigen gzip-Werte im Beitrag stammen aus gzip -9.

Zwei Projekte, zwei Build-Wege. Das Kundenprojekt lief über eine Asset Pipeline, mein Theme wird mit Dart Sass über CodeKit gebaut. Die absoluten Werte der beiden Projekte sind deshalb nur bedingt direkt vergleichbar. Wie ein @extend auf eine Kette wirkt, ist dagegen Teil von Sass selbst und hängt nicht am Build-Weg.

Die Zahlen gelten für Bootstrap 5.3.8. Die Rangfolge der Ziele ist über Bootstrap 5 stabil, die exakten Werte sind es nicht.

Fazit

Für mein Theme bleibt @extend von Utilities die bewusste Architektur. Die Messung stützt das, allerdings nicht über die Grösse: Die ist kein Argument für eine Umstellung, weder dafür noch dagegen. Es ist eine Abwägung: auf der einen Seite schlankes, semantisches HTML und übersichtlicher, einfach wartbarer Code, auf der anderen der Preis in der Kaskade – und die fällt für mich klar aus.

Die Warnung vor explodierenden Grössen der CSS-Dateien ist damit nicht falsch. Sie gilt nur nicht für @extend an sich, sondern für eine Handvoll Ziele, die sich vor dem Schreiben prüfen lassen. Das muss man nicht glauben – man kann es messen.