Aufbau einer skalierbaren CSS-Architektur mit BEM und Utility-Klassen

Avatar of Sebastiano Guerriero
Sebastiano Guerriero am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Die Wartung eines groß angelegten CSS-Projekts ist schwierig. Im Laufe der Jahre haben wir verschiedene Ansätze zur Erleichterung des Schreibens von skalierbarem CSS beobachtet. Am Ende versuchen wir alle, die folgenden beiden Ziele zu erreichen

  1. Effizienz: Wir wollen die Zeit reduzieren, die wir damit verbringen, darüber nachzudenken, wie Dinge getan werden sollten, und die Zeit erhöhen, die wir damit verbringen, Dinge zu tun.
  2. Konsistenz: Wir wollen sicherstellen, dass alle Entwickler auf dem gleichen Stand sind.

Seit anderthalb Jahren arbeite ich an einer Komponentenbibliothek und einem Front-End-Framework namens CodyFrame. Wir haben derzeit über 220 Komponenten. Diese Komponenten sind keine isolierten Module: Sie sind wiederverwendbare Muster, die oft miteinander verschmolzen werden, um komplexe Vorlagen zu erstellen.

Die Herausforderungen dieses Projekts haben unser Team gezwungen, eine Methode zum Aufbau skalierbarer CSS-Architekturen zu entwickeln. Diese Methode stützt sich auf CSS-Globale, BEM und Utility-Klassen.

Ich teile sie gerne! 👇

CSS Globals in 30 Sekunden

Globals sind CSS-Dateien, die Regeln enthalten, die übergreifend für alle Komponenten gelten (z. B. Abstands-Skala, Typografie-Skala, Farben usw.). Globals verwenden Tokens, um das Design über alle Komponenten hinweg konsistent zu halten und die Größe ihrer CSS zu reduzieren.

Hier ist ein Beispiel für globale Typografieregeln

/* Typography | Global */
:root {
  /* body font size */
  --text-base-size: 1em;


  /* type scale */
  --text-scale-ratio: 1.2;
  --text-xs: calc((--text-base-size / var(--text-scale-ratio)) / var(--text-scale-ratio));
  --text-sm: calc(var(--text-xs) * var(--text-scale-ratio));
  --text-md: calc(var(--text-sm) * var(--text-scale-ratio) * var(--text-scale-ratio));
  --text-lg: calc(var(--text-md) * var(--text-scale-ratio));
  --text-xl: calc(var(--text-lg) * var(--text-scale-ratio));
  --text-xxl: calc(var(--text-xl) * var(--text-scale-ratio));
}


@media (min-width: 64rem) { /* responsive decision applied to all text elements */
  :root {
    --text-base-size: 1.25em;
    --text-scale-ratio: 1.25;
  }
}


h1, .text-xxl   { font-size: var(--text-xxl, 2.074em); }
h2, .text-xl    { font-size: var(--text-xl, 1.728em); }
h3, .text-lg    { font-size: var(--text-lg, 1.44em); }
h4, .text-md    { font-size: var(--text-md, 1.2em); }
.text-base      { font-size: --text-base-size; }
small, .text-sm { font-size: var(--text-sm, 0.833em); }
.text-xs        { font-size: var(--text-xs, 0.694em); }

BEM in 30 Sekunden

BEM (Blocks, Elements, Modifiers) ist eine Benennungsmethodik, die darauf abzielt, wiederverwendbare Komponenten zu erstellen.

Hier ist ein Beispiel:

<header class="header">
  <a href="#0" class="header__logo"><!-- ... --></a>
  <nav class="header__nav">
    <ul>
      <li><a href="#0" class="header__link header__link--active">Homepage</a></li>
      <li><a href="#0" class="header__link">About</a></li>
      <li><a href="#0" class="header__link">Contact</a></li>
    </ul>
  </nav>
</header>
  • Ein Block ist eine wiederverwendbare Komponente
  • Ein Element ist ein Kind des Blocks (z. B. .block__element)
  • Ein Modifier ist eine Variation eines Blocks/Elements (z. B. .block--modifier, .block__element--modifier).

Utility-Klassen in 30 Sekunden

Eine Utility-Klasse ist eine CSS-Klasse, die nur eine einzige Aufgabe hat. Zum Beispiel

<section class="padding-md">
  <h1>Title</h1>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
</section>


<style>
  .padding-sm { padding: 0.75em; }
  .padding-md { padding: 1.25em; }
  .padding-lg { padding: 2em; }
</style>

Sie können potenziell ganze Komponenten aus Utility-Klassen aufbauen

<article class="padding-md bg radius-md shadow-md">
  <h1 class="text-lg color-contrast-higher">Title</h1>
  <p class="text-sm color-contrast-medium">Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
</article>

Sie können Utility-Klassen mit CSS-Globals verbinden

/* Spacing | Global */
:root {
  --space-unit: 1em;
  --space-xs:   calc(0.5 * var(--space-unit));
  --space-sm:   calc(0.75 * var(--space-unit));
  --space-md:   calc(1.25 * var(--space-unit));
  --space-lg:   calc(2 * var(--space-unit));
  --space-xl:   calc(3.25 * var(--space-unit));
}

/* responsive rule affecting all spacing variables */
@media (min-width: 64rem) {
  :root {
    --space-unit:  1.25em; /* 👇 this responsive decision affects all margins and paddings */
  }
}

/* margin and padding util classes - apply spacing variables */
.margin-xs { margin: var(--space-xs); }
.margin-sm { margin: var(--space-sm); }
.margin-md { margin: var(--space-md); }
.margin-lg { margin: var(--space-lg); }
.margin-xl { margin: var(--space-xl); }

.padding-xs { padding: var(--space-xs); }
.padding-sm { padding: var(--space-sm); }
.padding-md { padding: var(--space-md); }
.padding-lg { padding: var(--space-lg); }
.padding-xl { padding: var(--space-xl); }

Ein reales Beispiel

Die Erklärung einer Methodik anhand einfacher Beispiele bringt nicht die wirklichen Probleme oder die Vorteile der Methode selbst hervor.

Lassen Sie uns gemeinsam etwas aufbauen! 

Wir erstellen eine Galerie von Karten-Elementen. Zuerst tun wir dies nur mit dem BEM-Ansatz und weisen auf die Probleme hin, auf die Sie stoßen können, wenn Sie nur BEM verwenden. Als Nächstes sehen wir, wie Globals die Größe Ihres CSS reduzieren. Schließlich machen wir die Komponente anpassbar, indem wir Utility-Klassen hinzufügen.

Hier ist ein Blick auf das Endergebnis

Beginnen wir dieses Experiment mit der Erstellung der Galerie nur mit BEM

<div class="grid">
  <article class="card">
    <a class="card__link" href="#0">
      <figure>
        <img class="card__img" src="/image.jpg" alt="Image description">
      </figure>


      <div class="card__content">
        <h1 class="card__title-wrapper"><span class="card__title">Title of the card</span></h1>


        <p class="card__description">Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore, totam?</p>
      </div>


      <div class="card__icon-wrapper" aria-hidden="true">
        <svg class="card__icon" viewBox="0 0 24 24"><!-- icon --></svg>
      </div>
    </a>
  </article>


  <article class="card"><!-- card --></article>
  <article class="card"><!-- card --></article>
  <article class="card"><!-- card --></article>
</div>

In diesem Beispiel haben wir zwei Komponenten: .grid und .card. Die erste wird verwendet, um das Galerie-Layout zu erstellen. Die zweite ist die Kartenkomponente.

Zunächst einmal möchte ich die Hauptvorteile der Verwendung von BEM hervorheben: geringe Spezifität und Scope.

/* without BEM */
.grid {}
.card {}
.card > a {}
.card img {}
.card-content {}
.card .title {}
.card .description {}


/* with BEM */
.grid {}
.card {}
.card__link {}
.card__img {}
.card__content {}
.card__title {}
.card__description {}

Wenn Sie BEM (oder eine ähnliche Benennungsmethode) nicht verwenden, erstellen Sie Vererbungsbeziehungen (.card > a).

/* without BEM */
.card > a.active {} /* high specificity */


/* without BEM, when things go really bad */
div.container main .card.is-featured > a.active {} /* good luck with that 😦 */


/* with BEM */
.card__link--active {} /* low specificity */

Der Umgang mit Vererbung und Spezifität in großen Projekten ist schmerzhaft. Dieses Gefühl, wenn Ihr CSS nicht zu funktionieren scheint, und Sie feststellen, dass es von einer anderen Klasse überschrieben wurde 😡! BEM hingegen schafft eine Art Scope für Ihre Komponenten und hält die Spezifität niedrig.

Aber... es gibt zwei Hauptnachteile der ausschließlichen Verwendung von BEM:

  1. Zu viele Dinge zu benennen ist frustrierend
  2. Kleinere Anpassungen sind nicht einfach vorzunehmen oder zu warten

In unserem Beispiel haben wir zur Stilisierung der Komponenten die folgenden Klassen erstellt

.grid {}
.card {}
.card__link {}
.card__img {}
.card__content {}
.card__title-wrapper {}
.card__title {}
.card__description {}
.card__icon-wrapper {}
.card__icon {}

Die Anzahl der Klassen ist nicht das Problem. Das Problem ist, sich so viele aussagekräftige Namen auszudenken (und dafür zu sorgen, dass alle Ihre Teamkollegen die gleichen Benennungskriterien verwenden).

Stellen Sie sich zum Beispiel vor, Sie müssen die Kartenkomponente um einen zusätzlichen, kleineren Absatz erweitern

<div class="card__content">
  <h1 class="card__title-wrapper"><span class="card__title">Title of the card</span></h1>
  <p class="card__description">Lorem ipsum dolor...</p>
  <p class="card__description card__description--small">Lorem ipsum dolor...</p> <!-- 👈 -->
</div>

Wie nennen Sie ihn? Sie könnten ihn als Variation des .card__description-Elements betrachten und zu .card__description .card__description--small greifen. Oder Sie könnten ein neues Element erstellen, etwas wie .card__small, .card__small-p oder .card__tag. Sehen Sie, worauf ich hinauswill? Niemand möchte Zeit mit dem Nachdenken über Klassennamen verbringen. BEM ist großartig, solange man nicht zu viele Dinge benennen muss.

Das zweite Problem ist der Umgang mit kleineren Anpassungen. Stellen Sie sich zum Beispiel vor, Sie müssen eine Variation der Kartenkomponente erstellen, bei der der Text zentriert ausgerichtet ist.

Sie werden wahrscheinlich so etwas tun

<div class="card__content card__content--center"> <!-- 👈 -->
  <h1 class="card__title-wrapper"><span class="card__title">Title of the card</span></h1>
  <p class="card__description">Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore, totam?</p>
</div>


<style>
  .card__content--center { text-align: center; }
</style>

Einer Ihrer Teamkollegen, der an einer anderen Komponente (.banner) arbeitet, steht vor demselben Problem. Er erstellt ebenfalls eine Variation für seine Komponente

<div class="banner banner--text-center"></div>


<style>
  .banner--text-center { text-align: center; }
</style>

Stellen Sie sich nun vor, Sie müssen die Bannerkomponente in eine Seite einfügen. Sie benötigen die Variation, bei der der Text zentriert ausgerichtet ist. Ohne die CSS der Bannerkomponente zu überprüfen, könnten Sie instinktiv so etwas wie banner banner--center in Ihrem HTML schreiben, weil Sie immer --center verwenden, wenn Sie Variationen erstellen, bei denen der Text zentriert ausgerichtet ist. Funktioniert nicht! Ihre einzige Option ist, die CSS-Datei der Bannerkomponente zu öffnen, den Code zu inspizieren und herauszufinden, welche Klasse angewendet werden sollte, um den Text zu zentrieren.

Wie lange würde das dauern, 5 Minuten? Multiplizieren Sie 5 Minuten mit all den Zeiten, in denen dies am Tag passiert, für Sie und alle Ihre Teamkollegen, und Sie werden erkennen, wie viel Zeit verschwendet wird. Außerdem trägt das Hinzufügen neuer Klassen, die dasselbe tun, zur Aufblähung Ihres CSS bei.

CSS Globals und Utility-Klassen zur Rettung

Der erste Vorteil der Einrichtung globaler Stile ist, dass eine Reihe von CSS-Regeln vorhanden ist, die für alle Komponenten gelten.

Wenn wir beispielsweise responsive Regeln für die Abstands- und Typografieglobals festlegen, wirken sich diese Regeln auch auf die Grid- und Kartenkomponenten aus. In CodyFrame erhöhen wir die Schriftgröße des Körpers bei einem bestimmten Breakpoint; da wir für alle Ränder und Abstände "em"-Einheiten verwenden, wird das gesamte Abstandssystem auf einmal aktualisiert, was einen Kaskadeneffekt erzeugt.

Responsive Regeln für Abstände und Typografie – keine Media Queries auf Komponentenebene 

Folglich müssen Sie in den meisten Fällen keine Media Queries verwenden, um die Schriftgröße oder die Werte von Abständen und Rändern zu erhöhen!

/* without globals */
.card { padding: 1em; }


@media (min-width: 48rem) {
  .card { padding: 2em; }
  .card__content { font-size: 1.25em; }
}


/* with globals (responsive rules intrinsically applied) */
.card { padding: var(--space-md); }

Nicht nur das! Sie können die Globals verwenden, um Verhaltenskomponenten zu speichern, die mit allen anderen Komponenten kombiniert werden können. Zum Beispiel definieren wir in CodyFrame eine Klasse namens .text-component, die als "Text-Wrapper" verwendet wird. Sie kümmert sich um Zeilenhöhe, vertikale Abstände, Basisstile und andere Dinge.

Wenn wir zu unserem Kartenbeispiel zurückkehren, könnte das Element .card__content durch Folgendes ersetzt werden

<!-- without globals -->
<div class="card__content">
  <h1 class="card__title-wrapper"><span class="card__title">Title of the card</span></h1>
  <p class="card__description">Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore, totam?</p>
</div>


<!-- with globals -->
<div class="text-component">
  <h1 class="text-lg"><span class="card__title">Title of the card</span></h1>
  <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore, totam?</p>
</div>

Die Textkomponente kümmert sich um die Textformatierung und macht sie über alle Textblöcke in Ihrem Projekt konsistent. Außerdem haben wir bereits ein paar BEM-Klassen eliminiert.

Schließlich führen wir die Utility-Klassen ein!

Utility-Klassen sind besonders nützlich, wenn Sie die Komponente später anpassen möchten, ohne deren CSS überprüfen zu müssen.

So ändert sich die Struktur der Kartenkomponente, wenn wir einige BEM-Klassen durch Utility-Klassen ersetzen

<article class="card radius-lg">
  <a href="#0" class="block color-inherit text-decoration-none">
    <figure>
      <img class="block width-100%" src="image.jpg" alt="Image description">
    </figure>


    <div class="text-component padding-md">
      <h1 class="text-lg"><span class="card__title">Title of the card</span></h1>
      <p class="color-contrast-medium">Lorem ipsum dolor sit amet consectetur adipisicing elit. Tempore, totam?</p>
    </div>


    <div class="card__icon-wrapper" aria-hidden="true">
      <svg class="icon icon--sm color-white" viewBox="0 0 24 24"><!-- icon --></svg>
    </div>
  </a>
</article>

Die Anzahl der BEM-(Komponenten-)Klassen ist von 9 auf 3 gesunken

.card {}
.card__title {}
.card__icon-wrapper {}

Das bedeutet, Sie werden sich nicht viel mit der Benennung von Dingen beschäftigen. Dennoch können wir das Benennungsproblem nicht vollständig vermeiden: Selbst wenn Sie Vue/React/SomeOtherFramework-Komponenten aus Utility-Klassen erstellen, müssen Sie die Komponenten immer noch benennen.

Alle anderen BEM-Klassen wurden durch Utility-Klassen ersetzt. Was ist, wenn Sie eine Kartenvariation mit einer größeren Überschrift erstellen müssen? Ersetzen Sie text-lg durch text-xl. Was ist, wenn Sie die Farbe des Symbols ändern möchten? Ersetzen Sie color-white durch color-primary. Wie wäre es mit der zentrierten Textausrichtung? Fügen Sie text-center zum text-component-Element hinzu. Weniger Zeit zum Nachdenken, mehr Zeit zum Tun!

Warum verwenden wir nicht einfach Utility-Klassen?

Utility-Klassen beschleunigen den Designprozess und erleichtern die Anpassung von Dingen. Warum also nicht BEM vergessen und nur Utility-Klassen verwenden? Zwei Hauptgründe

Durch die Verwendung von BEM zusammen mit Utility-Klassen ist das HTML einfacher zu lesen und anzupassen.

Verwenden Sie BEM für

  • das Vermeiden von Redundanzen im HTML bei CSS, das Sie nicht anpassen möchten (z. B. verhaltensbezogenes CSS wie Übergänge, Positionierung, Hover/Fokus-Effekte),
  • erweiterte Animationen/Effekte.

Verwenden Sie Utility-Klassen für

  • die "häufig angepassten" Eigenschaften, die oft zur Erstellung von Komponentenvariationen verwendet werden (wie Abstände, Ränder, Textausrichtung usw.),
  • Elemente, die schwer mit einem neuen, aussagekräftigen Klassennamen zu identifizieren sind (z. B. Sie benötigen ein übergeordnetes Element mit position: relative → erstellen Sie <div class="position-relative"><div class="my-component"></div></div>).

Beispiel: 

<!-- use only Utility classes -->
<article class="position-relative overflow-hidden bg radius-lg transition-all duration-300 hover:shadow-md col-6@sm col-4@md">
  <!-- card content -->
</article>


<!-- use BEM + Utility classes -->
<article class="card radius-lg col-6@sm col-4@md">
  <!-- card content -->
</article>

Aus diesen Gründen empfehlen wir, die !important-Regel nicht zu Ihren Utility-Klassen hinzuzufügen. Die Verwendung von Utility-Klassen muss nicht wie ein Hammer sein. Glauben Sie, es wäre vorteilhaft, auf eine CSS-Eigenschaft im HTML zuzugreifen und sie zu ändern? Verwenden Sie eine Utility-Klasse. Benötigen Sie eine Reihe von Regeln, die nicht bearbeitet werden müssen? Schreiben Sie sie in Ihr CSS. Dieser Prozess muss nicht beim ersten Mal perfekt sein: Sie können die Komponente später bei Bedarf anpassen. Es mag mühsam klingen, "sich entscheiden zu müssen", aber es ist ziemlich einfach, wenn Sie es in die Praxis umsetzen.

Utility-Klassen sind nicht Ihr bester Verbündeter, wenn es um die Erstellung einzigartiger Effekte/Animationen geht.

Denken Sie an die Arbeit mit Pseudo-Elementen oder die Erstellung einzigartiger Bewegungseffekte, die benutzerdefinierte Bézier-Kurven erfordern. Dafür müssen Sie immer noch Ihre CSS-Datei öffnen.

Betrachten Sie zum Beispiel den animierten Hintergrundeffekt der von uns gestalteten Karte. Wie schwierig wäre es, einen solchen Effekt mit Utility-Klassen zu erstellen?

Dasselbe gilt für die Icon-Animation, die Animations-Keyframes benötigt, um zu funktionieren

.card:hover .card__title {
  background-size: 100% 100%;
}


.card:hover .card__icon-wrapper .icon {
  animation: card-icon-animation .3s;
}


.card__title {
  background-image: linear-gradient(transparent 50%, alpha(var(--color-primary), 0.2) 50%);
  background-repeat: no-repeat;
  background-position: left center;
  background-size: 0% 100%;
  transition: background .3s;
}


.card__icon-wrapper {
  position: absolute;
  top: 0;
  right: 0;
  width: 3em;
  height: 3em;
  background-color: alpha(var(--color-black), 0.85);
  border-bottom-left-radius: var(--radius-lg);
  display: flex;
  justify-content: center;
  align-items: center;
}


@keyframes card-icon-animation {
  0%, 100% {
    opacity: 1;
    transform: translateX(0%);
  }
  50% {
    opacity: 0;
    transform: translateX(100%);
  }
  51% {
    opacity: 0;
    transform: translateX(-100%);
  }
}

Endergebnis

Hier ist die endgültige Version der Karten-Galerie. Sie enthält auch Grid-Utility-Klassen zur Anpassung des Layouts.

Dateistruktur

So würde die Struktur eines Projekts aussehen, das nach der in diesem Artikel beschriebenen Methode aufgebaut ist

project/
└── main/
    ├── assets/
    │   ├── css/
    │   │   ├── components/
    │   │   │   ├── _card.scss
    │   │   │   ├── _footer.scss
    │   │   │   └── _header.scss
    │   │   ├── globals/
    │   │   │   ├── _accessibility.scss
    │   │   │   ├── _breakpoints.scss
    │   │   │   ├── _buttons.scss
    │   │   │   ├── _colors.scss
    │   │   │   ├── _forms.scss
    │   │   │   ├── _grid-layout.scss
    │   │   │   ├── _icons.scss
    │   │   │   ├── _reset.scss
    │   │   │   ├── _spacing.scss
    │   │   │   ├── _typography.scss
    │   │   │   ├── _util.scss
    │   │   │   ├── _visibility.scss
    │   │   │   └── _z-index.scss
    │   │   ├── _globals.scss
    │   │   ├── style.css
    │   │   └── style.scss
    │   └── js/
    │       ├── components/
    │       │   └── _header.js
    │       └── util.js
    └── index.html

Sie können das CSS (oder SCSS) jeder Komponente in einer separaten Datei speichern (und optional PostCSS-Plugins verwenden, um jede neue /component/componentName.css-Datei in style.css zu kompilieren). Organisieren Sie die Globals nach Belieben; Sie könnten auch eine einzige Datei globals.css erstellen und die Globals nicht in verschiedene Dateien aufteilen.

Fazit

Die Arbeit an groß angelegten Projekten erfordert eine solide Architektur, wenn Sie Ihre Dateien Monate später öffnen und sich nicht verlieren möchten. Es gibt viele Methoden, die dieses Problem angehen (CSS-in-JS, Utility-First, Atomic Design usw.).

Die Methode, die ich Ihnen heute vorgestellt habe, stützt sich auf übergreifende Regeln (Globals), die Verwendung von Utility-Klassen für schnelle Entwicklung und BEM für modulare (verhaltensbezogene) Klassen.

Sie können mehr über diese Methode auf CodyHouse erfahren. Jedes Feedback ist willkommen!