Verwendung von benutzerdefinierten Eigenschaften „Stacks“ zur Beherrschung des Cascades

Avatar of Miriam Suzanne
Miriam Suzanne am

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

Seit der Einführung von CSS im Jahr 1994 definieren Kaskade und Vererbung, wie wir im Web gestalten. Beides sind mächtige Funktionen, aber als Autoren hatten wir nur sehr wenig Kontrolle darüber, wie sie interagieren. Selektorspezifität und Quellreihenfolge bieten eine minimale „Layering“-Kontrolle, ohne viel Nuance – und Vererbung erfordert eine ununterbrochene Abstammung. Jetzt erlauben uns CSS Custom Properties, sowohl Kaskade als auch Vererbung auf neue Weise zu verwalten und zu kontrollieren.

Ich möchte Ihnen zeigen, wie ich benutzerdefinierte Eigenschafts-„Stacks“ verwendet habe, um einige der häufigen Probleme zu lösen, mit denen Menschen in der Kaskade konfrontiert sind: von gekapselten Komponentenstilen bis hin zu expliziteren Schichten von Absichten.

Eine kurze Einführung in benutzerdefinierte Eigenschaften

Auf die gleiche Weise, wie Browser neue Eigenschaften mit einem Vendor-Präfix wie -webkit- oder -moz- definiert haben, können wir unsere eigenen benutzerdefinierten Eigenschaften mit einem „leeren“ -- Präfix definieren. Wie Variablen in Sass oder JavaScript können wir sie verwenden, um Werte zu benennen, zu speichern und abzurufen – aber wie andere Eigenschaften in CSS, kaskadieren und vererben sie sich mit dem DOM.

/* Define a custom property */
html {
  --brand-color: rebeccapurple;
}

Um diese erfassten Werte abzurufen, verwenden wir die Funktion var(). Sie hat zwei Teile: zuerst den Namen unserer benutzerdefinierten Eigenschaft und dann einen Fallback, falls diese Eigenschaft nicht definiert ist

button {
  /* use the --brand-color if available, or fall back to deeppink */
  background: var(--brand-color, deeppink);
}

Dies ist kein Fallback für ältere Browser. Wenn ein Browser benutzerdefinierte Eigenschaften nicht versteht, ignoriert er die gesamte var() Deklaration. Stattdessen ist dies eine eingebaute Methode zur Handhabung undefinierter Variablen, ähnlich einem Font-Stack, der Fallback-Schriftarten definiert, wenn eine nicht verfügbar ist. Wenn wir keinen Fallback angeben, ist der Standardwert unset.

Erstellen von Variablen-„Stacks“

Diese Fähigkeit, einen Fallback zu definieren, ähnelt „Font-Stacks“, die für die Eigenschaft font-family verwendet werden. Wenn die erste Familie nicht verfügbar ist, wird die zweite verwendet, und so weiter. Die Funktion var() akzeptiert nur einen einzigen Fallback, aber wir können var() Funktionen verschachteln, um benutzerdefinierte Eigenschafts-Fallback-„Stacks“ beliebiger Größe zu erstellen

button {
  /* try Consolas, then Menlo, then Monaco, and finally monospace */
  font-family: Consolas, Menlo, Monaco, monospace;

  /* try --state, then --button-color, then --brand-color, and finally deeppink */
  background: var(--state, var(--button-color, var(--brand-color, deeppink)));
}

Wenn die verschachtelte Syntax für gestapelte Eigenschaften sperrig erscheint, können Sie einen Präprozessor wie Sass verwenden, um sie kompakter zu gestalten.

Diese Einschränkung auf einen einzelnen Fallback ist erforderlich, um Fallbacks mit einem Komma darin zu unterstützen – wie bei Font-Stacks oder geschichteten Hintergrundbildern

html {
  /* The fallback value is "Helvetica, Arial, sans-serif" */
  font-family: var(--my-font, Helvetica, Arial, sans-serif);
}

Definieren von „Geltungsbereich“

CSS-Selektoren erlauben uns, in den HTML-DOM-Baum einzudringen und Elemente überall auf der Seite oder Elemente in einem bestimmten verschachtelten Kontext zu gestalten.

/* all links */
a { color: slateblue; }

/* only links inside a section */
section a { color: rebeccapurple; }

/* only links inside an article */
article a { color: deeppink; }

Das ist nützlich, aber es erfasst nicht die Realität von „modularen“ objektorientierten oder komponentengetriebenen Stilen. Wir haben möglicherweise mehrere Artikel und Asides, die in verschiedenen Konfigurationen verschachtelt sind. Wir brauchen eine Möglichkeit zu klären, welcher Kontext oder Geltungsbereich Vorrang haben sollte, wenn sie sich überschneiden.

Nähe-Geltungsbereiche

Nehmen wir an, wir haben ein .light-Thema und ein .dark-Thema. Wir können diese Klassen auf dem Stamm-<html>-Element verwenden, um eine seitenweite Standardeinstellung zu definieren, aber wir können sie auch auf bestimmte Komponenten anwenden, die auf verschiedene Weise verschachtelt sind

Jedes Mal, wenn wir eine unserer Farbmodusklassen anwenden, werden die Eigenschaften background und color zurückgesetzt und dann von verschachtelten Überschriften und Absätzen geerbt. In unserem Hauptkontext erben Farben von der .light-Klasse, während die verschachtelte Überschrift und der Absatz von der .dark-Klasse erben. Die Vererbung basiert auf der direkten Abstammung, sodass der nächste Vorfahre mit einem definierten Wert Vorrang hat. Wir nennen das Nähe.

Nähe ist wichtig für die Vererbung, hat aber keinen Einfluss auf Selektoren, die auf Spezifität angewiesen sind. Das wird zu einem Problem, wenn wir etwas innerhalb der dunklen oder hellen Container gestalten wollen.

Hier habe ich versucht, sowohl helle als auch dunkle Button-Varianten zu definieren. Buttons im hellen Modus sollten rebeccapurple mit weißem Text sein, damit sie hervorstechen, und Buttons im dunklen Modus sollten plum mit schwarzem Text sein. Wir wählen die Buttons direkt basierend auf einem hellen und dunklen Kontext aus, aber es funktioniert nicht

Einige der Buttons befinden sich in beiden Kontexten, mit beiden .light- und .dark-Vorfahren. Was wir in diesem Fall wollen, ist, dass das nächste Thema die Oberhand gewinnt (Verhalten der Vererbung Nähe), aber stattdessen überschreibt der zweite Selektor den ersten (Kaskadenverhalten). Da die beiden Selektoren die gleiche Spezifität haben, bestimmt die Quellreihenfolge den Gewinner.

Benutzerdefinierte Eigenschaften und Nähe

Was wir hier brauchen, ist eine Möglichkeit, diese Eigenschaften vom Thema zu erben, sie aber nur auf bestimmte Kinder anzuwenden. Benutzerdefinierte Eigenschaften machen das möglich! Wir können Werte auf den hellen und dunklen Containern definieren und dabei nur ihre geerbten Werte auf verschachtelten Elementen wie unseren Buttons verwenden.

Wir beginnen damit, die Buttons so einzurichten, dass sie benutzerdefinierte Eigenschaften mit einem Standard-„Fallback“-Wert verwenden, falls diese Eigenschaften nicht definiert sind

button {
  background: var(--btn-color, rebeccapurple);
  color: var(--btn-contrast, white);
}

Jetzt können wir diese Werte kontextabhängig festlegen, und sie werden je nach Nähe und Vererbung dem entsprechenden Vorfahren zugeordnet

.dark {
  --btn-color: plum;
  --btn-contrast: black;
}

.light {
  --btn-color: rebeccapurple;
  --btn-contrast: white;
}

Als zusätzlicher Bonus verwenden wir insgesamt weniger Code und eine einheitliche button-Definition

Ich betrachte dies als die Erstellung einer API von verfügbaren Parametern für die Button-Komponente. Sara Soueidan und Lea Verou haben dies beide in kürzlichen Artikeln gut behandelt.

Komponenten-Eigentümerschaft

Manchmal reicht Nähe nicht aus, um den Geltungsbereich zu definieren. Wenn JavaScript-Frameworks „gekapselte Stile“ generieren, etablieren sie eine spezifische Objekt-Element-Eigentümerschaft. Eine „Tabellenlayout“-Komponente besitzt die Tabs selbst, aber nicht den Inhalt hinter jedem Tab. Das ist auch das, was die BEM-Konvention in komplexen .block__element Klassennamen zu erfassen versucht.

Nicole Sullivan prägte den Begriff „Donut-Geltungsbereich“, um dieses Problem bereits 2011 zu beschreiben. Obwohl ich sicher bin, dass sie neuere Gedanken zu diesem Thema hat, hat sich das grundlegende Problem nicht geändert. Selektoren und Spezifität sind großartig, um zu beschreiben, wie wir detaillierte Stile über breiten Mustern aufbauen, aber sie vermitteln kein klares Gefühl der Eigentümerschaft.

Wir können benutzerdefinierte Eigenschafts-Stacks verwenden, um dieses Problem zu lösen. Wir beginnen damit, „globale“ Eigenschaften auf dem <html>-Element zu erstellen, die für unsere Standardfarben bestimmt sind

html {
  --background--global: white;
  --color--global: black;
  --btn-color--global: rebeccapurple;
  --btn-contrast--global: white;
}

Dieses Standard-Globale Thema ist jetzt überall verfügbar, wo wir darauf verweisen wollen. Wir tun dies mit einem data-theme-Attribut, das unsere Vordergrund- und Hintergrundfarben anwendet. Wir möchten, dass die globalen Werte einen Standard-Fallback bieten, aber wir möchten auch die Möglichkeit haben, mit einem spezifischen Thema zu überschreiben. Hier kommen „Stacks“ ins Spiel

[data-theme] {
  /* If there's no component value, use the global value */
  background: var(--background--component, var(--background--global));
  color: var(--color--component, var(--color--global));
}

Jetzt können wir eine invertierte Komponente definieren, indem wir die *--component-Eigenschaften als Umkehrung der globalen Eigenschaften festlegen

[data-theme='invert'] {
  --background--component: var(--color--global);
  --color--component: var(--background--global);
}

Aber wir möchten nicht, dass diese Einstellungen über den Donut der Eigentümerschaft hinaus vererbt werden, daher setzen wir diese Werte auf initial (undefiniert) für jedes Thema zurück. Wir möchten dies mit geringerer Spezifität oder früher in der Quellreihenfolge tun, damit es einen Standard bietet, den jedes Thema überschreiben kann

[data-theme] {
  --background--component: initial;
  --color--component: initial;
}

Das Schlüsselwort initial hat eine spezielle Bedeutung, wenn es auf benutzerdefinierte Eigenschaften angewendet wird und sie in einen garantiert ungültigen Zustand zurückversetzt. Das bedeutet, dass die benutzerdefinierte Eigenschaft nicht weitergegeben wird, um background: initial oder color: initial festzulegen, sondern undefined wird und wir zum nächsten Wert in unserem Stack zurückfallen, den globalen Einstellungen.

Das Gleiche können wir mit unseren Buttons machen und dann sicherstellen, dass data-theme auf jede Komponente angewendet wird. Wenn kein spezifisches Thema angegeben ist, wird jede Komponente standardmäßig auf das globale Thema gesetzt

Definieren von „Ursprüngen“

Die CSS-Kaskade ist eine Reihe von Filterebenen, die verwendet werden, um zu bestimmen, welcher Wert Vorrang haben soll, wenn mehrere Werte für dieselbe Eigenschaft definiert sind. Wir interagieren am häufigsten mit den Spezifitäts-Ebenen oder der letzten Ebene basierend auf der Quellreihenfolge – aber die erste Ebene der Kaskade ist der „Ursprung“ eines Stils. Der Ursprung beschreibt, woher ein Stil stammt – oft der Browser (Standardwerte), der Benutzer (Präferenzen) oder der Autor (das sind wir).

Standardmäßig überschreiben Autorenstile Benutzerpräferenzen, die wiederum Browser-Standardwerte überschreiben. Das ändert sich, wenn jemand `!important` auf einen Stil anwendet, und die Ursprünge kehren sich um: Browser `!important`-Stile haben den höchsten Ursprung, dann wichtige Benutzerpräferenzen, dann unsere Autoren-wichtigen Stile, über allen normalen Ebenen. Es gibt einige zusätzliche Ursprünge, aber darauf werden wir hier nicht eingehen.

Wenn wir benutzerdefinierte Eigenschafts-„Stacks“ erstellen, bauen wir ein sehr ähnliches Verhalten auf. Wenn wir bestehende Ursprünge als einen Stack von benutzerdefinierten Eigenschaften darstellen wollten, würde das ungefähr so aussehen

.origins-as-custom-properties {
  color: var(--browser-important, var(--user-important, var(--author-important, var(--author, var(--user, var(--browser))))));
}

Diese Ebenen existieren bereits, daher gibt es keinen Grund, sie neu zu erstellen. Aber wir machen etwas sehr Ähnliches, wenn wir unsere „globalen“ und „Komponenten“-Stile oben schichten – eine „Komponenten“-Ursprungsschicht erstellen, die unsere „globale“ Schicht überschreibt. Derselbe Ansatz kann verwendet werden, um verschiedene Schichtungsprobleme in CSS zu lösen, die nicht immer durch Spezifität beschrieben werden können

  • Überschreiben » Komponente » Thema » Standard
  • Thema » Design-System oder Framework
  • Zustand » Typ » Standard

Betrachten wir noch einmal einige Buttons. Wir benötigen einen Standard-Button-Stil, einen deaktivierten Zustand und verschiedene Button-„Typen“, wie danger, primary und secondary. Wir möchten, dass der disabled-Zustand immer die Typvariationen überschreibt, aber Selektoren erfassen diesen Unterschied nicht

Aber wir können einen Stack definieren, der sowohl „Typ“- als auch „Zustand“-Ebenen in der Reihenfolge liefert, in der sie priorisiert werden sollen

button {
  background: var(--btn-state, var(--btn-type, var(--btn-default)));
}

Wenn wir jetzt beide Variablen setzen, hat der Zustand immer Vorrang

Ich habe diese Technik verwendet, um ein Cascading Colors-Framework zu erstellen, das ein benutzerdefiniertes Theming basierend auf Schichten ermöglicht

  • Vordefinierte Themeneigenschaften im HTML
  • Benutzer-Farbpräferenzen
  • Helle und dunkle Modi
  • Globale Themestandards

Kombinieren und Anpassen

Diese Ansätze können bis zum Extrem getrieben werden, aber die meisten alltäglichen Anwendungsfälle können mit zwei oder drei Werten in einem Stack gehandhabt werden, oft unter Verwendung einer Kombination der oben genannten Techniken

  • Ein Variablen-Stack zur Definition der Ebenen
  • Vererbung, um sie basierend auf Nähe und Geltungsbereich festzulegen
  • Sorgfältige Anwendung des `initial`-Werts, um verschachtelte Elemente aus einem Geltungsbereich zu entfernen

Wir verwenden diese benutzerdefinierten Eigenschafts-„Stacks“ seit einiger Zeit in unseren Projekten bei OddBird. Wir entdecken immer noch Neues, aber sie haben sich bereits als hilfreich erwiesen, um Probleme zu lösen, die mit Selektoren und Spezifität allein schwierig waren. Mit benutzerdefinierten Eigenschaften müssen wir uns nicht gegen Kaskade oder Vererbung wehren. Wir können sie wie beabsichtigt erfassen und nutzen, mit mehr Kontrolle darüber, wie sie in jedem Fall angewendet werden sollen. Für mich ist das ein großer Gewinn für CSS – besonders bei der Entwicklung von Stil-Frameworks, -Tools und Systemen.