Composable — Kapselung wiederverwendbarer Logik

In diesem Kapitel lernst du die Best Practice von Vue3 kennen — die Verwendung von Composable-Funktionen zum Kapseln wiederverwendbarer Logik, wodurch der Code sauberer und leichter wartbar wird.


Was ist ein Composable?

Mit zunehmendem Funktionsumfang wird die JS-Logik in Komponenten länger. Sowohl die Startseite als auch die Detailseite müssen Artikeldaten laden, sodass der Code zweimal dupliziert wird.

Composable (Composable-Funktion)Sie extrahiert wiederverwendbare Logik in unabhängige JS-Funktionen, und jede Komponente, die sie benötigt, kann sie dort aufrufen.

Im Kern ist eseine gewöhnliche Funktion, die reaktive Daten und Methoden zurückgibt.。


Warum braucht man Composable?

Vergleiche die Codestruktur vor und nach der Änderung:

Ohne Composable (vorher)Mit Composable (nachher)
Jede Komponente schreibt ihre eigene Fetch-LogikEin usePosts() wird überall wiederverwendet
Datenladen und UI-Logik sind miteinander vermischtDatenlogik ist unabhängig, Komponenten kümmern sich nur um die UI
Das Ändern eines Teils der Logik erfordert das Ändern mehrerer DateienÄndere eine Stelle, alle Komponenten aktualisieren sich synchron
Der Komponenten-Code bläht sich schnell auf, wenn das Geschäft wächstDer Komponenten-Code hat eine einzige Verantwortlichkeit, und seine Größe ist kontrollierbar

Wenn Sie die Mixins von Vue2 verwendet haben, lässt sich Composable als eine klarere und sicherere Alternative verstehen – es gibt keine Namenskonflikte, und die Datenquelle ist transparent.


Namenskonventionen und Regeln

RegelBeschreibungBeispiel
withusePräfixAuf einen Blick erkennen, dass dies ein Composable istusePosts、useDarkMode
Ablegen incomposables/Verzeichnis sichernLeicht zu finden und zu wartensrc/composables/usePosts.js
Reaktive Daten zurückgebenref oder reactive zurückgeben, den Aufrufer reaktiv haltenreturn { articles, isLoading }
Eine Funktion erledigt nur eine SacheEinzelverantwortung, einfach zu kombinierenusePosts kümmert sich nur um Daten, useDarkMode nur um Themes

usePosts — Kapselung der Artikeldatenlogik

Dieses Composable ist zuständig für:Daten laden + Kategoriefilterung + Suchfilterung。

Beispiel

// Dateipfad: src/composables/usePosts.js
import { ref, computed, onMounted } from 'vue'

export function usePosts() {
  const articles = ref([])           // Alle Artikeldaten
  const isLoading = ref(true)        // Ladezustand
  const error = ref(null)            // Fehlermeldung
  const activeCategory = ref('All') // Aktuell ausgewählte Kategorie
  const keyword = ref('')            // Suchbegriff

  // Alle Kategorien extrahieren (Duplikate entfernen)
  const categories = computed(() => {
    const cats = articles.value.map(a => a.category)
    return ['All', ...new Set(cats)]
  })

  // Gefilterte Artikel: zuerst nach Kategorie, dann nach Stichwort
  const filteredArticles = computed(() => {
    let result = articles.value

    // Nach Kategorie filtern
    if (activeCategory.value !== 'All') {
      result = result.filter(a => a.category === activeCategory.value)
    }

    // Nach Stichwort filtern (Titel oder Zusammenfassung abgleichen)
    if (keyword.value.trim()) {
      const kw = keyword.value.trim().toLowerCase()
      result = result.filter(a =>
        a.title.toLowerCase().includes(kw) ||
        a.summary.toLowerCase().includes(kw)
      )
    }

    return result
  })

  // Einen einzelnen Artikel anhand der ID finden
  function getArticleById(id) {
    return articles.value.find(a => a.id === id)
  }

  // Kategorie wechseln
  function setCategory(cat) {
    activeCategory.value = cat
  }

  // Daten laden
  async function fetchPosts() {
    isLoading.value = true
    error.value = null
    try {
      const res = await fetch('/posts.json')
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      articles.value = await res.json()
    } catch (err) {
      error.value = err.message
    } finally {
      isLoading.value = false
    }
  }

  // Automatisch laden, wenn die Komponente gemountet ist
  onMounted(() => {
    fetchPosts()
  })

  // Daten und Methoden zurückgeben, die Komponenten verwenden können
  return {
    articles,
    isLoading,
    error,
    activeCategory,
    keyword,
    categories,
    filteredArticles,
    getArticleById,
    setCategory,
    fetchPosts
  }
}

Jetzt wird die Homepage-Komponente sehr knapp, rufe einfach usePosts auf:

Beispiel

<!-- Dateipfad: src/views/HomeView.vue -->
<script setup>
import { usePosts } from '../composables/usePosts.js'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'

// Alle artikelbezogenen Daten und Methoden mit einer Zeile Code abrufen
const {
  isLoading,
  error,
  activeCategory,
  keyword,
  categories,
  filteredArticles,
  setCategory,
  fetchPosts
} = usePosts()
</script>

<template>
  <div class="home">
    <div class="search-bar">
      <input
       v-model="keyword"
       type="text"
       placeholder="Titel oder Zusammenfassung des Artikels suchen..."
     />
    </div>

    <CategoryFilter
     :="categories"
     active-category="activeCategory"
     @update-category="setCategory"
   />

    <p v-if="isLoading">Wird geladen...</p>
    <p v-else-if="error">Laden fehlgeschlagen: {{ error }}
      <button @click="fetchPosts">Erneut versuchen</button>
    </p>
    <p v-else-if="filteredArticles.length === 0">Keine passenden Artikel</p>
    <div v-else class="article-grid">
      <BlogCard
       v-for="article in filteredArticles"
       :key="article.id"
       :id="article.id"
       :title="article.title"
       :summary="article.summary"
       :date="article.date"
       :category="article.category"
     />
    </div>
  </div>
</template>

Die Detailseite kann usePosts ebenfalls wiederverwenden, ohne fetch erneut zu schreiben. Beachte, dass du es separat aus Vue importieren musst.computed:

Beispiel

<!-- Dateipfad: src/views/PostView.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { usePosts } from '../composables/usePosts.js'

const route = useRoute()
const id = Number(route.params.id)

const { isLoading, error, getArticleById } = usePosts()

// articles sind reaktive Daten, computed wird nach Abschluss des fetch automatisch neu berechnet
const article = computed(() => getArticleById(id))
</script>

<template>
  <div>
    <p v-if="isLoading">Wird geladen...</p>
    <p v-else-if="error">Laden fehlgeschlagen: {{ error }}</p>
    <div v-else-if="!article">Artikel existiert nicht</div>
    <article v-else>
      <h1>{{ article.title }}</h1>
      <div v-html="article.content"></div>
    </article>
  </div>
</template>

Hinweis: Wenn der Benutzer direkt auf die URL der Detailseite zugreift (z. B. durch Aktualisieren der Seite), ruft usePosts die Daten erneut ab. Da die Startseite und die Detailseite unterschiedliche Komponenteninstanzen sind, führt jeder Aufruf von usePosts den fetch in onMounted aus, und die Daten werden nicht geteilt. Wenn Sie dieselben Daten seitenübergreifend teilen müssen, können Sie den State in einen Pinia-Store heben oder ihn einmal in einer Komponente der obersten Ebene aufrufen und über provide / inject weitergeben.


useDarkMode — Dunkelmodus mit einer Zeile Code umschalten

Die Kernidee des Dunkelmodus: auf derhtmltag, füge eine Klasse hinzu, dann verwende CSS-Variablen, um Farben zu steuern.

Die Auswahl des Benutzers wird in localStorage gespeichert und beim nächsten Besuch automatisch wiederhergestellt.

Beispiel

// Dateipfad: src/composables/useDarkMode.js
import { ref, watchEffect } from 'vue'

export function useDarkMode() {
  // Lese die vorherige Einstellung des Benutzers aus localStorage (Standard: hell, falls keine vorhanden)
  const saved = localStorage.getItem('blog-theme')
  const isDark = ref(saved === 'dark')

  // Theme auf DOM anwenden
  function applyTheme(dark) {
    if (dark) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('blog-theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('blog-theme', 'light')
    }
  }

  // watchEffect wird einmal sofort ausgeführt, um das anfängliche Theme anzuwenden
  // Danach, wann immer sich isDark ändert, automatisch mit DOM synchronisieren
  watchEffect(() => {
    applyTheme(isDark.value)
  })

  // Dunkelmodus umschalten
  function toggleDark() {
    isDark.value = !isDark.value
  }

  return { isDark, toggleDark }
}

Um es in NavBar zu verwenden, sind nur zwei Zeilen erforderlich:

Beispiel

<!-- Dateipfad: src/components/NavBar.vue -->
<script setup>
import { useDarkMode } from '../composables/useDarkMode.js'

const { isDark, toggleDark } = useDarkMode()
</script>

<template>
  <header class="navbar">
    <a href="/" class="logo">BEISPIEL Blog</a>
    <nav>
      <a href="/">Startseite</a>
      <button class="theme-btn" @click="toggleDark">
        {{ isDark ? '&#x2600;`Hell' : '☾ Dunkel' }}`
      </button>
    </nav>
  </header>
</template>

Mit globalen CSS-Variablen kombinieren, um den Theme-Wechsel umzusetzen

Beispiel

/* Dateipfad: src/assets/main.css */
/* Heller Modus (Standard) */
:root {
  --bg-primary:    #f5f5f5;
  --bg-card:       #ffffff;
  --text-primary:  #333333;
  --text-secondary:#666666;
  --border-color:  #eeeeee;
}

/* Dunkelmodus (wird angewendet, wenn das html-Tag die Klasse .dark hat) */
html.dark {
  --bg-primary:    #1a1a2e;
  --bg-card:       #16213e;
  --text-primary:  #e0e0e0;
  --text-secondary:#a0a0a0;
  --border-color:  #2a2a4a;
}

/* Referenzieren Sie diese Variablen in Komponenten; beim Wechseln von Themes muss kein Komponenten-Code geändert werden */
body {
  background: var(--bg-primary);
  color: var(--text-primary);
}

.card {
  background: var(--bg-card);
  border: 1px solid var(--border-color);
}

Zu diesem Zeitpunkt schaltet ein Klick auf die Umschaltfläche die Farben der gesamten Website sofort um, und Ihre Auswahl wird nach dem Aktualisieren beibehalten.


Vergleich mit Vue2 Mixin

MerkmalMixin(Vue2)Composable(Vue3)
DatenquelleUndurchsichtig, Sie wissen nicht, woher die Eigenschaften stammenExplizit per Destrukturierung aus dem Rückgabewert erhalten, die Quelle ist auf einen Blick ersichtlich
NamenskonfliktGleichnamige Eigenschaften aus mehreren Mixins überschreiben sich gegenseitigSie legen die Variablennamen fest, es gibt keine Konflikte
TypinferenzSchlechte TypeScript-UnterstützungNative TypeScript-Unterstützung, automatische Typinferenz
LogikorganisationNach Optionen kategorisiert (data/methods/computed)Nach Funktionalität kategorisiert, ein Composable pro Datei
LogikwiederverwendungMixins sind schwer zu kombinieren und neigen dazu, implizite Abhängigkeiten zu erzeugenComposables können sich gegenseitig aufrufen, flexible Komposition
Weitere Erweiterungen