Fallstricke verschachtelter Komponenten in einem Designsystem vermeiden

Avatar of Dan Christofi
Dan Christofi am

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

Bei der Erstellung einer komponentenbasierte Frontend-Infrastruktur ist einer der größten Schmerzpunkte, auf die ich persönlich gestoßen bin, die Erstellung von Komponenten, die sowohl wiederverwendbar als auch responsiv sind, wenn es verschachtelte Komponenten innerhalb von Komponenten gibt.

Nehmen wir zum Beispiel die folgende „Call to Action“-Komponente (<CTA />)

Auf kleineren Geräten soll sie so aussehen

Dies ist mit einfachen Media Queries einfach genug. Wenn wir Flexbox verwenden, kann eine Media Query die Flex-Richtung ändern und den Button über die gesamte Breite anzeigen. Aber wir stoßen auf ein Problem, wenn wir andere Komponenten darin verschachteln. Sagen wir zum Beispiel, wir verwenden eine Komponente für den Button und diese hat bereits eine Prop, die sie vollwertig macht. Wir duplizieren tatsächlich das Styling des Buttons, wenn wir eine Media Query auf die Elternkomponente anwenden. Der verschachtelte Button ist bereits in der Lage, damit umzugehen!

Dies ist ein kleines Beispiel und wäre kein allzu großes Problem, aber in anderen Szenarien könnte dies zu viel dupliziertem Code führen, um das Styling zu replizieren. Was, wenn wir in Zukunft etwas an der Art und Weise ändern wollten, wie vollwertige Buttons gestylt werden? Wir müssten alle diese verschiedenen Stellen durchgehen und sie ändern. Wir sollten in der Lage sein, es in der Button-Komponente zu ändern und es überall aktualisiert zu haben.

Wäre es nicht schön, wenn wir von Media Queries weggehen und mehr Kontrolle über das Styling haben könnten? Wir sollten die vorhandenen Props einer Komponente nutzen und unterschiedliche Werte basierend auf der Bildschirmbreite übergeben können.

Nun, ich habe einen Weg gefunden, das zu tun, und werde Ihnen zeigen, wie ich es gemacht habe.

Ich bin mir bewusst, dass Container Queries viele dieser Probleme lösen können, aber sie stecken noch in den Anfängen und lösen nicht das Problem des Übergens einer Vielzahl von Props basierend auf der Bildschirmbreite.

Verfolgen der Fensterbreite

Zuerst müssen wir die aktuelle Breite der Seite verfolgen und einen Breakpoint festlegen. Dies kann mit jedem Frontend-Framework erfolgen, aber ich verwende hier ein Vue Composable, um die Idee zu demonstrieren

// composables/useBreakpoints.js

import { readonly, ref } from "vue";

const bps = ref({ xs: 0, sm: 1, md: 2, lg: 3, xl: 4 })
const currentBreakpoint = ref(bps.xl);

export default () => {
  const updateBreakpoint = () => {
  
    const windowWidth = window.innerWidth;
    
    if(windowWidth >= 1200) {
      currentBreakpoint.value = bps.xl
    } else if(windowWidth >= 992) {
      currentBreakpoint.value = bps.lg
    } else if(windowWidth >= 768) {
      currentBreakpoint.value = bps.md
    } else if(windowWidth >= 576) {
      currentBreakpoint.value = bps.sm
    } else {
      currentBreakpoint.value = bps.xs
    }
  }

  return {
    currentBreakpoint: readonly(currentBreakpoint),
    bps: readonly(bps),
    updateBreakpoint,
  };
};

Der Grund, warum wir Zahlen für das currentBreakpoint-Objekt verwenden, wird später klar werden.

Jetzt können wir auf Fenstergrößenänderungsereignisse hören und den aktuellen Breakpoint mit dem Composable in der Hauptdatei App.vue aktualisieren

// App.vue

<script>
import useBreakpoints from "@/composables/useBreakpoints";
import { onMounted, onUnmounted } from 'vue'

export default {
  name: 'App',
  
  setup() {
    const { updateBreakpoint } = useBreakpoints()

    onMounted(() => {
      updateBreakpoint();
      window.addEventListener('resize', updateBreakpoint)
    })

    onUnmounted(() => {
      window.removeEventListener('resize', updateBreakpoint)
    })
  }
}
</script>

Wir wollen das wahrscheinlich debouncen, aber ich halte es der Kürze halber einfach.

Styling von Komponenten

Wir können die <CTA /> Komponente aktualisieren, um eine neue Prop für die Art und Weise zu akzeptieren, wie sie gestylt werden soll

// CTA.vue
props: {
  displayMode: {
    type: String,
    default: "default"
  }
}

Die Benennung hier ist völlig willkürlich. Sie können beliebige Namen für jeden der Komponentenmodi verwenden.

Wir können diese Prop dann verwenden, um den Modus basierend auf dem aktuellen Breakpoint zu ändern

<CTA :display-mode="currentBreakpoint > bps.md ? 'default' : 'compact'" />

Sie können jetzt sehen, warum wir eine Zahl verwenden, um den aktuellen Breakpoint darzustellen – damit der richtige Modus auf alle Breakpoints unter oder über einer bestimmten Zahl angewendet werden kann.

Wir können dies dann in der CTA-Komponente verwenden, um sie entsprechend dem übergebenen Modus zu stylen

// components/CTA.vue

<template>
  <div class="cta" :class="displayMode">
    
    <div class="cta-content">
      <h5>title</h5>
      <p>description</p>
    </div>
    
    <Btn :block="displayMode === 'compact'">Continue</Btn>
    
  </div>
</template>

<script>
import Btn from "@/components/ui/Btn";
export default {
  name: "CTA",
  components: { Btn },
  props: {
    displayMode: {
      type: String,
      default: "default"
    },
  }
}
</script>

<style scoped lang="scss">
.cta {
  display: flex;
  align-items: center;
  
  .cta-content {
    margin-right: 2rem;
  }

  &.compact {
    flex-direction: column;
    .cta-content {
      margin-right: 0;
      margin-bottom: 2rem;
    }
  }
}
</style>

Bereits haben wir die Notwendigkeit von Media Queries beseitigt! Sie können dies in Aktion auf einer Demo-Seite sehen, die ich erstellt habe.

Zugegebenermaßen mag dies wie ein langer Prozess für etwas so Einfaches erscheinen. Aber wenn dieser Ansatz auf mehrere Komponenten angewendet wird, kann er die Konsistenz und Stabilität der Benutzeroberfläche massiv verbessern und gleichzeitig die Gesamtmenge an Code reduzieren, die wir schreiben müssen. Diese Art der Verwendung von JavaScript und CSS-Klassen zur Steuerung des responsiven Stylings hat noch einen weiteren Vorteil…

Erweiterbare Funktionalität für verschachtelte Komponenten

Es gab Szenarien, in denen ich zu einem früheren Breakpoint für eine Komponente zurückkehren musste. Zum Beispiel, wenn sie 50% des Bildschirms einnimmt, möchte ich, dass sie im kleinen Modus angezeigt wird. Aber ab einer bestimmten Bildschirmgröße wird sie vollwertig. Mit anderen Worten, der Modus sollte sich beim Größenänderungsereignis in die eine oder andere Richtung ändern.

Showing three versions of a call-to-action components with nested components within it.

Ich war auch in Situationen, in denen die gleiche Komponente in verschiedenen Modi auf verschiedenen Seiten verwendet wird. Dies ist nichts, was Frameworks wie Bootstrap und Tailwind tun können, und die Verwendung von Media Queries, um dies zu erreichen, wäre ein Albtraum. (Sie können diese Frameworks weiterhin mit dieser Technik verwenden, nur ohne die Notwendigkeit der responsiven Klassen, die sie bereitstellen.)

Wir *könnten* eine Media Query verwenden, die nur für mittelgroße Bildschirme gilt, aber das löst nicht das Problem der variablen Props basierend auf der Bildschirmbreite. Glücklicherweise kann der von uns behandelte Ansatz das lösen. Wir können den vorherigen Code modifizieren, um einen benutzerdefinierten Modus pro Breakpoint zu ermöglichen, indem wir ihn über ein Array übergeben, wobei das erste Element des Arrays die kleinste Bildschirmgröße ist.

<CTA :custom-mode="['compact', 'default', 'compact']" />

Zuerst aktualisieren wir die Props, die die <CTA /> Komponente akzeptieren kann

props: {
  displayMode: {
    type: String,
    default: "default"
  },
  customMode: {
    type: [Boolean, Array],
    default: false
  },
}

Dann können wir Folgendes hinzufügen, um den richtigen Modus zu generieren

import { computed } from "vue";
import useBreakpoints from "@/composables/useBreakpoints";

// ...

setup(props) {

  const { currentBreakpoint } = useBreakpoints()

  const mode = computed(() => {
    if(props.customMode) {
      return props.customMode[currentBreakpoint.value] ?? props.displayMode
    }
    return props.displayMode
  })

  return { mode }
},

Dies nimmt den Modus aus dem Array basierend auf dem aktuellen Breakpoint und greift standardmäßig auf den displayMode zurück, wenn keiner gefunden wird. Dann können wir mode verwenden, um die Komponente zu stylen.

Extraktion für Wiederverwendbarkeit

Viele dieser Methoden können in zusätzliche Composables und Mixins extrahiert werden, die mit anderen Komponenten wiederverwendet werden können.

Extrahieren des berechneten Modus

Die Logik zur Rückgabe des korrekten Modus kann in ein Composable extrahiert werden

// composables/useResponsive.js

import { computed } from "vue";
import useBreakpoints from "@/composables/useBreakpoints";

export const useResponsive = (props) => {

  const { currentBreakpoint } = useBreakpoints()

  const mode = computed(() => {
    if(props.customMode) {
      return props.customMode[currentBreakpoint.value] ?? props.displayMode
    }
    return props.displayMode
  })

  return { mode }
}

Extrahieren von Props

In Vue 2 konnten wir Props mit Mixins wiederholen, aber es gibt bemerkenswerte Nachteile. Vue 3 ermöglicht es uns, diese mit anderen Props mithilfe desselben Composables zusammenzuführen. Es gibt einen kleinen Vorbehalt, da IDEs Props für die Autovervollständigung mit dieser Methode anscheinend nicht erkennen können. Wenn dies zu störend ist, können Sie stattdessen ein Mixin verwenden.

Optional können wir auch eine benutzerdefinierte Validierung übergeben, um sicherzustellen, dass wir nur die Modi verwenden, die für jede Komponente verfügbar sind, wobei der erste übergebene Wert der Validator ist.

// composables/useResponsive.js

// ...

export const withResponsiveProps = (validation, props) => {
  return {
    displayMode: {
      type: String,
      default: validation[0],
      validator: function (value) {
        return validation.indexOf(value) !== -1
      }
    },
    customMode: {
      type: [Boolean, Array],
      default: false,
      validator: function (value) {
        return value ? value.every(mode => validation.includes(mode)) : true
      }
    },
    ...props
  }
}

Lassen Sie uns nun die Logik auslagern und diese stattdessen importieren

// components/CTA.vue

import Btn from "@/components/ui/Btn";
import { useResponsive, withResponsiveProps } from "@/composables/useResponsive";

export default {
  name: "CTA",
  components: { Btn },
  props: withResponsiveProps(['default 'compact'], {
    extraPropExample: {
      type: String,
    },
  }),
  
  setup(props) {
    const { mode } = useResponsive(props)
    return { mode }
  }
}

Fazit

Das Erstellen eines Designsystems von wiederverwendbaren und responsiven Komponenten ist eine Herausforderung und anfällig für Inkonsistenzen. Außerdem haben wir gesehen, wie leicht es ist, sich mit einer Menge dupliziertem Code wiederzufinden. Es gibt eine feine Balance, wenn es darum geht, Komponenten zu erstellen, die nicht nur in vielen Kontexten funktionieren, sondern auch gut mit anderen Komponenten zusammenspielen, wenn sie kombiniert werden.

Ich bin sicher, Sie sind schon einmal auf eine solche Situation gestoßen. Die Verwendung dieser Methoden kann das Problem reduzieren und hoffentlich die Benutzeroberfläche stabiler, wiederverwendbarer, wartbarer und einfacher zu bedienen machen.