Composing Components in Vue

Einleitung

 

Wir streben bei myposter danach, dass unsere umfangreiche Webseite verständlich und einfach zu benutzen ist. Dafür ist es wichtig, dass sowohl die einzelnen Features intuitiv zu benutzen sind, als auch das Gesamtbild stimmig ist. Das kann unter anderem durch Wiedererkennbarkeit einzelner Komponenten erreicht werden. Aber was ist eine Komponente? Unter einer Komponente kann man sich etwas wie einen Lego-Baustein vorstellen. Die Webseite von myposter besteht aus vielen verschiedenen Lego-Bausteinen wie Boxen, Listen, Buttons, Teasern und vielen mehr. Für ein gutes Nutzererlebnis ist es wichtig, dass alle identisch aussehenden Lego-Bausteine gleich funktionieren bzw. alle identisch funktionierende Lego-Bausteine gleich aussehen. Damit lässt sich die Erwartungshaltung des Nutzers, wenn er auf einen Button klickt, leicht erfüllen, in dem jedes Mal die selbe Aktion ausgeführt wird. Um dies zu ermöglichen, setzen wir bei myposter das progressive JavaScript Framework Vue.js ein, das Tools zur Erstellung einzelner Komponenten bereitstellt. So muss eine Komponente nur ein einziges Mal implementiert werden und kann beliebig oft auf der Webseite eingesetzt werden. Die Folge davon sind leichte Wiederverwendbarkeit von Code und risikofreie Anpassungen an einer Komponente.

Komponentensystem

 

Aufgrund der Abstraktionsmöglichkeiten ist das Komponentensystem eines der wichtigsten Konzepte von Vue.js. Die größten Vorteile dieses Konzepts sind die Abgeschlossenheit, die Wartbarkeit und die Wiederverwendbarkeit jeder einzelnen Komponente. Der Aufbau von kleinen und selbstständigen Komponenten ermöglicht die Entwicklung von größeren und komplexeren Applikationen. Weiterhin wird dadurch die Veränderbarkeit und Skalierbarkeit deutlich erleichtert. Kleine Komponenten lassen sich leicht testen und abändern. Denkt man wieder in Lego-Bausteinen, so kann man sich die Verzweigung von Komponenten auch wie einen Baum vorstellen:

Ein weiteres Kernprinzip von Vue.js ist das Format der sogenannten „Single File Components“ (SFC). Dieses Format wird mit der Dateiendung .vue gekennzeichnet. Eine Single File Component kann aus den folgenden drei Bestandteilen, die zusammen eine gesamte Komponente bilden, bestehen:

  • Template
  • Script
  • Style

Templates und Styles sind dabei optional. Das Template beinhaltet das notwendige HTML, das Script stellt das JavaScript der Komponente bereit und Style umfasst das CSS. Im Folgenden sehen wir eine Komponente eines einfachen Call-to-Action Buttons.

Basic Button Components

<template>
  <button
    :class="{'btn-cta--disabled': !isActive}"
    @click.prevent="submit()"
    class="btn btn-cta btn--full-width btn-cta--green"
  >
    Add to Cart
  </button>
</template>

<script>
export default {
  name: 'CTA',
  data() {
    return {
      isActive: true,
      counter: 0,
    };
  },
  methods: {
    submit() {
      if (this.isActive) {
        this.addToCart();
      }
      this.isActive = !this.isActive;
      this.counter = this.counter += 1;
      // track counter to some analytics tool
    },
    addToCart() {
      // add item to cart
    },
  },
};
</script>

<style scoped>
.btn {
  cursor: pointer;
  display: inline-block;
  text-align: center;
  text-decoration: none;
}

.btn--full-width {
  width: 100%;
}

.btn-cta {
  font-size: 16px;
  padding: 15px 32px;
}

.btn-cta--green {
  background-color: #64B587;
  border: none;
  color: white;
}

.btn-cta--disabled {
  background-color: #ddd;
}
</style>

Im Rahmen dieses Blog-Artikels soll nicht weiter auf das reaktive Data-Binding von Vue.js eingegangen werden. Vielmehr wollen wir uns damit beschäftigen, welche Tools uns Vue.js bereitstellt, die wir zur Code Wiederverwendung benutzen können.

Gehen wir nun davon aus, dass wir auf myposter einen zweiten Call-to-Action Button mit ähnlicher Funktionalität, aber anderem Aussehen bereitstellen möchten. Im Folgenden gehen wir auf die unterschiedlichen Möglichkeiten ein, mit denen wir das technisch umsetzen können.

 

Tools zur Code Wiederverwendbarkeit

 

Extends

Mit dem Keyword extends lässt sich das Prinzip der Vererbung in Vue.js darstellen. Eine einzelne Komponente (Child) kann dadurch von genau einer anderen Komponente (Parent) erben. Folgendes Codebeispiel zeigt die Komponente des Call-to-Actions Buttons, der von CTA-Skeleton über das Schlüsselwort extend erbt:

Example Extends – Child

<template>
<!-- button -->
</template>

<script>
import 'CTA-Skeleton' from './CTA-Skeleton.vue';

export default {
  name: 'CTA',
  extends: 'CTA-Skeleton',
  data() {
    return {
      counter: 0,
      isActive: true,
    };
  },
  methods: {
    submit() {
      if (this.isActive) {
        this.addToCart();
      }
      this.isActive = !this.isActive;
      this.counter = this.counter += 1;
      // track counter to some analytics tool
    },
  },
};
</script>

<style scoped>
// Styles
</style>

Und hier ist der zugehörige Parent:

Example Extends – Parent

<template>
<!-- button -->
</template>

<script>
export default {
  name: 'CTA-Skeleton',
  methods: {
    addToCart() {
      // add item to cart
    },
  },
};
</script>

<style scoped>
// Styles
</style>

Die Parent Komponente CTA-Skeleton stellt die Methode addToCart zur Verfügung. Alle Komponenten, die von diesem Skelett erben, können daher diese Methode verwenden. Für extends gilt, dass bei den Lifecycle-Hooks von Vue.js, wie mountedcreated und destroyed, immer zuerst die Methode des Childs und im Anschluss die Methode des Parents ausgeführt wird. Obwohl es diese Möglichkeit in Vue.js gibt, wird extends nicht als ein offizielles Feature vertreten. Der Grund dafür ist, dass extends zwar im Kern von Vue.js zur Erstellung der App-Komponente benutzt wird, aber üblicherweise bei SFC nicht notwendig sind, da die gleiche Funktionalität durch Mixins abgedeckt werden kann, die wiederum weitere Vorteile mit sich bringen. Daher sehen wir uns als nächstes Mixins an.

 

Mixins

Mixins erlauben uns von mehreren Komponenten gleichzeitig zu erben. Dadurch können wir Mixins für wiederverwendbare Methoden und Daten über eine beliebige Anzahl an Komponenten hinweg benutzen. Die folgenden Codebeispiele zeigen uns, wie wir die Methode addToCart an unsere CTA-Komponente weitergeben können.

Example Mixins – CTA.vue

<template>
<!-- button -->
</template>

<script>
import 'addToCartMixin' from '../mixins/addToCart.vue';
export default {
  name: 'CTA',
  data() {
    return {
      counter: 0,
      isActive: true,
    };
  },
  mixins: ['addToCartMixin'],
  methods: {
    submit() {
      if (this.isActive) {
        this.addToCart();
      }
      this.isActive = !this.isActive;
      this.counter = this.counter += 1;
      // track counter to some analytics tool
    },
  },
};
</script>

<style scoped>
// Styles
</style>

Example Mixins – addToCart.vue

<script>
export default {
  methods: {
    addToCart() {
      // add item to cart
    },
  },
};
</script>

Ein Mixin besteht nur aus dem script Teil einer Komponente, die jedoch identisch zu denen „vollständiger“ Komponenten aufgebaut ist. Der Hauptunterschied zu einem extends, neben der Tatsache, dass wir von beliebig vielen Mixins erben können, ist die Reihenfolge der Ausführung von Methoden und Lifecycle-Hooks. Überschreiben wir eine Methode des Mixins in einer Komponente, wird ausschließlich die Methode der Komponente ausgeführt, nicht die des Mixins. Bei Lifecycle-Hooks werden zuerst die Befehle des Mixins und im Anschluss die Befehle der Komponente, dementsprechend genau andersherum zu einer Vererbung über extends ausgeführt.

Dadurch lassen sich mit Mixins bestimmte Definitionen einer Komponente, wie computed propertiesmethods und watcher, über mehrere Komponenten hinweg benutzen. Dabei sehen sie genauso aus wie Standard-Komponenten und sind daher sehr einfach zu erstellen, auch wenn man selten welche benötigen sollte. Ein Beispiel für ein sinnvolles Mixin wäre ein fetch request.

Mixins können ebenfalls global registriert werden:

Example Mixins – global

import Vue from 'vue';

Vue.mixin({
  methods: {
    addToCart() {
      // add item to cart
    },
  },
})

Slots

Nachdem wir nun bereits wissen, wie wir bestimmte Teile einer Komponente wiederverwendbar gestalten können, wollen wir als nächstes besprechen, welches Tool von Vue.js wir benutzen können, um dynamischen Inhalt in einer Komponente zu rendern. Nehmen wir an, wir haben nun zwei verschiedene Buttons: einen Button in grün (im folgenden CTA genannt) und einen Ghost-Button in weiß (im folgenden CTA-Ghost genannt), ob mit gleicher oder unterschiedlicher Funktionalität spielt an dieser Stelle keine Rolle – das folgende gilt für beide Varianten. Ein Ghost-Button stellt den invertierten Zustand eines Buttons dar: hat ein Button eine grüne Hintergrundfarbe und weiße Schrift, ist sein Ghost-Button ein weißer Button mit einem grünen Rand und grüner Schrift. Folgende Abbildung zeigt ein Beispiel beider Buttons in einer identischen Box:

Die Funktionalität der beiden Buttons konnten wir bereits mit Mixins wiederverwenden. Nun soll es Boxen geben, eine mit dem CTA und mit dem CTA-Ghost. Der Rest der Boxen ist identisch. Die native Herangehensweise wäre es, je eine Box.vue und eine BoxGhost.vue Komponente anzulegen, die den jeweiligen Button beinhaltet:

CTA.vue

<template>
  <div class="box">
    <div class="title>
      Item
    </div>
    <CTA />
  </div>
</template>

<script>
import CTA from './CTA';

export default {
  name: 'Box',
  components: {
    CTA,
  },
};
</script>

CTAGhost.vue

<template>
  <div class="box">
    <div class="title>
      Item
    </div>
    <CTAGhost />
  </div>
</template>

<script>
import CTAGhost from './CTAGhost';

export default {
  name: 'BoxGhost',
  components: {
    CTAGhost,
  },
};
</script>

An dieser Stelle verstoßen wir gegen das DRY Prinzip: Don’t repeat yourself. Diese Herangehensweise würde für die Zukunft bedeuten, dass wir bei jeder Änderung an der sonst identischen Box beide Dateien anpassen müssen. Das kann zu Fehlern und aufwendigem Debugging führen. Ein Mixin wäre an dieser Stelle jedoch nicht das richtige Tool, da es uns in diesem Beispiel keine Vorteile bringt. Doch Vue.js stellt uns hierfür ein anderes Tool zur Verfügung: Slots.

Wie wir wissen, können wir Komponenten ineinander verschachteln:

<component>
  <other-component />
</component>

Doch oft wollen wir zusätzliches Layout und Logik von Komponenten enkapsulieren und dennoch einer Komponente zusätzlichen Inhalt mitgeben. Und dieses Problem lässt sich durch einen <slot> lösen. Einen Slot kann man anstelle einer Child Komponente verschachtelt einfügen.

<div class="my-box">
  <slot></slot>
</div>

Es ist ebenfalls möglich, das HTML-Element selbstschließend zu schreiben, da es nicht zu den void Elementen gehört.

<div class="my-box">
  <slot />
</div>

Das folgende Codebeispiel zeigt unsere Komponente Box.vue und eine Parent Komponente mit dem Namen MixinSlotWrapper.vue, die der Box den richtigen Button mitgibt. Der Parent MixinSlotWrapper.vue importiert seine beiden Child Komponenten Box und CTA. Im Templatebereich der MixinSlotWrapper Komponente fügen wir die Komponente Box ein und geben ihr wiederum als Child Element CTA mit. Die Komponente Box wird das erhaltene Child in den freien <slot /> einfügen. Übergeben wir der Komponente Box kein Element für den Slot, wird Vue.js an dieser Stelle nichts rendern – die Übergabe ist optional und wird keinen Fehler werfen, wenn ein einzufügendes Element fehlt.

MixinSlotWrapper.vue

<template>
  <div>
    <h1>{{ msg }}</h1>
    <div class="wrapper">
      <Box>
        <CTA />
      </Box>
    </div>
  </div>
</template>

<script>
import Box from './Box';
import CTA from './CTA';

export default {
  name: 'MixinSlotWrapper',
  components: {
    Box,
    CTA,
  },
  data() {
    return {
      msg: 'Vue Composing Components',
    };
  },
};
</script>

Box.vue

<template>
  <div class="box">
    <div class="title">
      Item
    </div>
    <slot />
  </div>
</template>

<script>
export default {
  name: 'Box',
</script>

<style scoped>
.box {
  border: 1px solid #64B587;
  padding: 20px;
}

.title {
  margin-bottom: 10px;
}
</style>

Von daher ist es in Vue.js auch möglich, dem Slot einen Default-Wert mitzugeben, der eingefügt wird, wenn der Komponente kein Inhalt für den Slot übergeben wird. Sinnvoll ist das in Situationen, wenn die Komponente in den meisten Fällen das gleiche anzeigen und nur für Spezialfälle anderen Inhalt annehmen soll. Ein Beispiel wäre ein Button, um ein Formular abzuschicken. Üblicherweise reicht es, wenn in diesen Fällen auf dem Button das Wort „Abschicken“ steht.

<template>
  <button type="submit">
    <slot>
      <span>
        Abschicken
      </span>
    </slot>
  </button>
</template>

Dieser Default Slot Content kann von der Parent Komponente überschrieben werden, wenn der Text beispielsweise zu „Speichern“ oder „Hochladen“ geändert werden sollte.

<!-- wird "Hochladen" anzeigen -->
<ButtonComponent>
  <span>
    Hochladen
  </span>
</ButtonComponent>

<!-- wird "Speichern" anzeigen -->
<ButtonComponent>
  <span>
    Speichern
  </span>
</ButtonComponent>

<!-- wird den Default Slot Content "Abschicken" anzeigen -->
<ButtonComponent />

Vue.js stellt uns auch die Möglichkeit bereit, mehrere Slots pro Komponente zu benutzen. Dieser Mechanismus wird Named Slots genannt. Ein gutes Beispiel hierfür wäre eine Single File Componentdie das Basislayout repräsentiert und wir variable Header-, Main- und Footerbereiche ermöglichen wollen, ohne das Grundgerüst des HTMLs immer und immer wieder zu duplizieren. Durch die Möglichkeit der Default Slot Contents, die wir eben besprochen haben, ist es ebenfalls ein Leichtes, dem Header und dem Footer standardmäßigen Inhalt mitzugeben, falls wir für bestimmte Seiten keine besonderen Änderungen benötigen. Damit die Child Komponente weiß, welche Inhalte sie welchen Slots zuweisen soll, wird das Attribute name mit einer eindeutigen Beschreibung als Wert verwendet.

Layout.vue

<template>
  <div class="container">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

Um den Inhalt für benannte Slots zur Verfügung zu stellen, können wir das Attribute slot in einem template Element der Parent Komponente verwenden:

App.vue

<Layout>
  <template slot="header">
    <h1>Impressum Überschrift</h1>
  </template>

  <div class="content-container">
    Hier ist der Inhalt des Slots <strong>main</strong>.
  </div>
  <div class="cta-container">
    Dieser Container wird ebenfalls im Slot <strong>main</strong> angezeigt
  </div>

  <template slot="footer">
     <p>
       Weitere Kontakt Informationen
     </p>
     <a 
       v-bind:="privacy"
       class="footer-link"
      >
        Datenschutzerklärung
      </a>
  </template>
</Layout>

In Vue.js ist es genauso möglich, das Attribut slot nicht nur einem template Tag, sondern auch einem Standard HTML5 Tag mitzugeben. Ein Vorteil des Template Tags wäre, dass es im Document Object Model (DOM) nicht eingefügt wird, sondern für Entwickler nur als Schatten existiert und somit die Kopplung zusammengehöriger Elemente des Slots ermöglicht, wie wir im Footer sehen:

App.vue

<Layout>
  <h1 slot="header">Impressum Überschrift</h1>

  <div class="content-container">
    Hier ist der Inhalt des Slots <strong>main</strong>.
  </div>
  <div class="cta-container">
    Dieser Container wird ebenfalls im Slot <strong>main</strong> angezeigt
  </div>

  <template slot="footer">
     <p>
       Weitere Kontakt Informationen
     </p>
     <a 
       v-bind:="privacy"
       class="footer-link"
      >
        Datenschutzerklärung
      </a>
  </template>
</Layout>

Fügen wir alles zusammen, erhalten wir eine Komponente mit Named Slots und Default Slot Content. An diesem Beispiel sehen wir, dass der Default Slot Content nicht mit dem dynamischen Inhalt zusammengefügt, sondern ersetzt wird.

<template>
  <div class="container>
    <header>
      <slot name="header">
        <img src="/assets/logo.svg" alt="myposter" title="myposter">
      </slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer">
        <a 
          v-bind:="imprint"
          class="footer-link"
        >
          Impressum
        </a>
        <a 
          v-bind:="privacy"
          class="footer-link"
        >
          Datenschutzerklärung
        </a>
      </slot>
    </footer>
  </div>
</template>

Wir können einer Child Komponente nicht nur valides HTML5 als Inhalt zur Verfügung stellen, sondern wiederum Child Komponenten:

App.vue

<Layout>
  <Header slot="header" />
  <Home />
  <Footer slot="footer" />
</Layout>

Abschließend ist es bei Slots wichtig zu verstehen, dass die verwendeten Komponenten direkte Children der Komponente sind, die die Abhängigkeit importieren und benutzen. Das heißt, dass der Inhalt eines Slots ein Child vom Parent ist und nicht der Komponente, die den Inhalt des Slots im Endeffekt anzeigt. Folgende Grafik veranschaulicht die Zugehörigkeit:

Zusammenfassung

 

Dieser Artikel zeigt zwei Tools von Vue.js, um Code Duplikationen zu verhindern und Wiederverwendbarkeit zu ermöglichen. Mit Mixins lassen sich Funktionalitäten einer Komponente einfach auslagern, ohne dass das bekannte Format verändert werden müsste. Hierbei gilt die Regel, dass Methoden des Mixins und der Komponente, die das Mixin einbaut, mit dem gleichen Namen zusammengefügt und beide ausgeführt werden. Bei Lifecycle-Hooks wird nur der Code der Komponente ausgeführt. Möchte man dynamischen Inhalt in eine Komponente einfügen und die Entscheidung, welcher Inhalt gezeigt werden soll, nicht durch einen längeren if-else Block anhand einer übergebenen Property realisieren, eignen sich Slots sehr gut.