Entwickler, der außergewöhnliche CRM- und Laravel-Lösungen liefert

Als erfahrener Entwickler spezialisiere ich mich auf Laravel- und Vue.js-Entwicklung, die Implementierung von Vtiger CRM sowie auf vielfältige WordPress-Projekte. Meine Arbeit zeichnet sich durch kreative, dynamische und benutzerzentrierte Weblösungen aus, die individuell an die Bedürfnisse meiner Kunden angepasst werden.

Heute möchte ich ein einfaches, aber sehr nützliches Muster für die Arbeit mit Formularen in Vue 3 vorstellen, das ich oft in meinen Projekten verwende. Im Wesentlichen geht es darum, den internen Zustand des Formulars zu isolieren und nicht jede Änderung sofort an die übergeordnete Komponente via v-model weiterzugeben.

Isolierung des Status eines Formulars in Vue 3 unter Verwendung des „Pending v-model“-Musters

Heute möchte ich ein einfaches, aber sehr nützliches Muster für die Arbeit mit Formularen in Vue 3 vorstellen, das ich oft in meinen Projekten verwende. Im Wesentlichen geht es darum, den internen Zustand des Formulars zu isolieren und nicht jede Änderung sofort an die übergeordnete Komponente via v-model weiterzugeben. Stattdessen sammeln wir Änderungen innerhalb der Formularkomponente und senden sie nur bei einem bestimmten Ereignis „nach oben“, z.B. wenn der Submit-Button geklickt wird oder nach einer erfolgreichen Validierung.

Standard-Problem bei v-model

Das Standardverhalten des V-Modells bei Formularkomponenten in Vue ist die Zwei-Wege-Datenbindung. Sobald ein Benutzer ein Zeichen in ein Eingabefeld eingibt, wird diese Änderung sofort in einer über v-model gebundenen Variablen in der übergeordneten Komponente reflektiert. Das ist in vielen Fällen praktisch, aber was ist, wenn wir eine Logik ausführen müssen, bevor die aktualisierten Daten an die übergeordnete Komponente gelangen?

Das häufigste Beispiel ist die Validierung. Wir möchten überprüfen, ob die eingegebenen Daten korrekt sind (z. B. ob die E-Mail wirklich wie eine E-Mail aussieht und das Alter eine positive Zahl ist), und die Daten nur dann weitergeben, wenn alle Prüfungen erfolgreich waren. Ein weiteres Beispiel ist die Datenumwandlung: Vielleicht müssen wir Zeichenketten in Groß- und Kleinschreibung umwandeln oder eine Zahl formatieren, bevor wir sie senden.

Bei einem nativen V-Modell ist eine solche Zwischenverarbeitung unpraktisch, da das übergeordnete System bei jeder Änderung „rohe“ und potenziell ungültige Daten erhält.

Lösung: Das „Pending v-model“-Muster

Die Idee ist einfach:

Eine Formularkomponente empfängt Daten über ein V-Modell (oder modelValue-Props und update:modelValue-Ereignis).

Innerhalb der Komponente erstellen wir eine lokale Kopie dieser Daten (zum Beispiel mit ref).
In unserem Beispiel erstelle ich eine Kopie der Daten auf die einfachste Art und Weise, nämlich über JSON.parse. Beachten Sie, dass JSON.stringify/parse Einschränkungen hat (verliert Datum, Funktion, undefiniert, etc.). Für komplexe Fälle sollten Sie structuredClone (in modernen Umgebungen) oder Bibliotheken wie lodash/cloneDeep in Betracht ziehen.

Alle Formularfelder (input, select , usw.) sind mit dieser lokalen Kopie verknüpft, nicht direkt mit dem V-Modell.

Wenn der Benutzer das Formular abschickt (z. B. durch Klicken auf die Schaltfläche „Abschicken“):
Wir führen die gesamte erforderliche Logik aus: Validierung, Datentransformation usw. unter Verwendung der lokalen Kopie.

Wenn alle Validierungen erfolgreich sind, aktualisieren wir das ursprüngliche v-Modell (Aufruf update:modelValue) und übergeben ihm die verarbeiteten Daten aus der lokalen Kopie.

Außerdem verwenden wir watch, um Änderungen an der Eingabe modelValue zu verfolgen, so dass wir unsere lokale Kopie aktualisieren können, wenn sich die Daten extern ändern (z. B. beim Laden von Daten zur Bearbeitung).

Auf diese Weise erhält die übergeordnete Komponente nur dann aktualisierte Daten, wenn sich das Formular in einem gültigen und absendebereiten Zustand befindet.

Beispielimplementierung (Vue 3 + TypeScript)

Lassen Sie uns das ursprüngliche Beispiel in TypeScript umschreiben und aufschlüsseln.

Die Formular-Komponente UserForm.vue:

<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

const modelValue = defineModel<UserFormData | null>()

function clone<T>(obj: T): T {
  if (obj === undefined || obj === null) {
    return JSON.parse(JSON.stringify({ name: "", email: "", age: null }));
  }
  return JSON.parse(JSON.stringify(obj))
}

const form: Ref<UserFormData> = ref(clone(modelValue.value))

watch(modelValue, (newValue) => {
  form.value = clone(newValue)
}, { deep: true })

function handleSubmit() {
  // const isValid = validateForm(form.value);
  // if (!isValid) return;

  modelValue.value = clone(form.value)
  console.log('Form submitted and model updated:', modelValue.value);
}
</script>

<template>
  <form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
    <label class="input input-bordered flex items-center gap-2">
      Name
      <input class="flex-grow" type="text" v-model="form.name" required />
    </label>
    <label class="input input-bordered flex items-center gap-2">
      Email
      <input class="flex-grow" type="email" v-model="form.email" required />
    </label>
    <label class="input input-bordered flex items-center gap-2">
      Age
      <input class="flex-grow" type="number" v-model="form.age" min="3" required />
    </label>
    <button type="submit" class="btn btn-primary">
      {{ modelValue ? 'Edit' : 'Create' }} User
    </button>
  </form>
</template>

<style scoped>
.input {
  display: flex;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.input input {
  border: none;
  outline: none;
  background-color: transparent;
}
.btn {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.btn-primary {
  background-color: #007bff;
  color: white;
}
.flex { display: flex; }
.flex-col { flex-direction: column; }
.gap-3 { gap: 0.75rem; } /* 12px if base is 16px */
.items-center { align-items: center; }
.gap-2 { gap: 0.5rem; } /* 8px */
.flex-grow { flex-grow: 1; }
</style>

Beispiel für die Verwendung in einer übergeordneten Komponente:

<script setup lang="ts">
import { ref } from 'vue'
import UserForm from './UserForm.vue'
import type { Ref } from 'vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

const user: Ref<UserFormData | null> = ref(null)

// const user: Ref<UserFormData | null> = ref({ name: "John Doe", email: "john@example.com", age: 30 });

</script>

<template>
  <div>
    <h1>User Management</h1>
    <UserForm v-model="user" />

    <div class="mt-4">
      <h2>Current User Data (Parent Scope):</h2>
      <pre>{{ JSON.stringify(user, null, 2) }}</pre>
    </div>
  </div>
</template>

<style scoped>
.mt-4 { margin-top: 1rem; }
pre {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  white-space: pre-wrap;
  word-wrap: break-word;
}
</style>

Beachten Sie, dass das Testen dieser Komponente sehr einfach sein wird.
Nehmen wir an, wir haben eine Umgebung zum Testen mit Vitest eingerichtet. Wir werden eine UserForm.spec.ts-Datei neben unserer Komponente erstellen.

// UserForm.spec.ts

import { describe, it, expect, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import UserForm from './UserForm.vue'

interface UserFormData {
  name: string;
  email: string;
  age: number | null;
}

type UserFormWrapper = VueWrapper<InstanceType<typeof UserForm>>

describe('UserForm.vue', () => {
  let wrapper: UserFormWrapper;

  describe('Create Mode (modelValue is null)', () => {
    beforeEach(() => {
      wrapper = mount(UserForm, {
        props: {
          'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
        }
      }) as UserFormWrapper;
    });

    it('initialise form with empty values', () => {
      expect(wrapper.vm.form).toEqual({
        name: "",
        email: "",
        age: null
      });
      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe('');
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe('');
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe('');
    });

    it('updates local state after form updates', async () => {
      const nameInput = wrapper.find('input[type="text"]');
      const emailInput = wrapper.find('input[type="email"]');
      const ageInput = wrapper.find('input[type="number"]');

      await nameInput.setValue('Test User');
      await emailInput.setValue('test@example.com');
      await ageInput.setValue(25);

      expect(wrapper.vm.form).toEqual({
        name: "Test User",
        email: "test@example.com",
        age: 25
      });
    });

    it('Do not emits update:modelValue after user input', async () => {
      await wrapper.find('input[type="text"]').setValue('Testing');
      expect(wrapper.emitted('update:modelValue')).toBeUndefined();
    });

    it('emits update:modelValue with form data after (submit)', async () => {
      await wrapper.find('input[type="text"]').setValue('New User');
      await wrapper.find('input[type="email"]').setValue('new@example.com');
      await wrapper.find('input[type="number"]').setValue(30);

      await wrapper.find('form').trigger('submit.prevent');

      expect(wrapper.emitted('update:modelValue')).toBeTruthy();
      expect(wrapper.emitted('update:modelValue')).toHaveLength(1);
      expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{
        name: "New User",
        email: "new@example.com",
        age: 30
      }]);
    });

     it('Shows button "Create User"', () => {
       expect(wrapper.find('button[type="submit"]').text()).toContain('Create User');
     });
  });

  describe('Edit Mode (modelValue is provided)', () => {
    const initialData: UserFormData = {
      name: "Initial Name",
      email: "initial@example.com",
      age: 42
    };

    beforeEach(() => {
      wrapper = mount(UserForm, {
        props: {
          modelValue: initialData,
          'onUpdate:modelValue': (e) => wrapper.setProps({ modelValue: e })
        }
      }) as UserFormWrapper;
    });

    it('initialise form with data from modelValue', () => {
      expect(wrapper.vm.form).toEqual(initialData);
      expect(wrapper.vm.form).not.toBe(initialData);

      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe(initialData.name);
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe(initialData.email);
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe(initialData.age?.toString());
    });

    it('updates local state after change values', async () => {
      await wrapper.find('input[type="text"]').setValue('Updated Name');
      expect(wrapper.vm.form.name).toBe('Updated Name');
      expect(wrapper.props('modelValue')).toEqual(initialData);
    });

     it('emits update:modelValue with updated values after sends data', async () => {
       const updatedName = 'Updated Name';
       await wrapper.find('input[type="text"]').setValue(updatedName);

       await wrapper.find('form').trigger('submit.prevent');

       expect(wrapper.emitted('update:modelValue')).toBeTruthy();
       expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{
         ...initialData,
         name: updatedName
       }]);
     });

    it('updates local form, if modelValue changes from parent', async () => {
      const newData: UserFormData = {
        name: "External Update",
        email: "external@example.com",
        age: 99
      };

      await wrapper.setProps({ modelValue: newData });

      expect(wrapper.vm.form).toEqual(newData);
      expect((wrapper.find('input[type="text"]').element as HTMLInputElement).value).toBe(newData.name);
      expect((wrapper.find('input[type="email"]').element as HTMLInputElement).value).toBe(newData.email);
      expect((wrapper.find('input[type="number"]').element as HTMLInputElement).value).toBe(newData.age?.toString());
    });

    it('displays button "Edit User"', () => {
       expect(wrapper.find('button[type="submit"]').text()).toContain('Edit User');
     });
  });
});

Vorteile dieses Ansatzes

  • Kontrollierte Aktualisierungen: Die übergeordnete Komponente erhält nur dann Daten, wenn das Formular validiert wurde und zum Absenden bereit ist. Dadurch wird verhindert, dass ungültige oder Zwischenzustände verarbeitet werden.
  • Kapselung der Logik: Die gesamte Logik im Zusammenhang mit dem Formularstatus, einschließlich der Validierung und möglicher Datentransformationen, ist in der Formularkomponente konzentriert. Die übergeordnete Komponente bleibt sauber.
  • Einfachheit und Fokus: Die Formularkomponente kümmert sich nur um ihre direkte Aufgabe, nämlich die Anzeige von Feldern und die Verwaltung ihres internen Zustands. Sie interagiert nicht direkt mit der API, was sie überflüssig macht. Das Senden von Daten an den Server ist die Aufgabe der übergeordneten Komponente oder Serviceschicht.
  • Verbesserte Testbarkeit: Es ist einfach, eine solche Komponente isoliert zu testen (Unit-Tests). Sie können die Validierungslogik und die Korrektheit von V-Modell-Aktualisierungen testen, ohne sich mit API-Anfragen oder der komplexen Umgebung der übergeordneten Komponente herumschlagen zu müssen.

Nachteile und Überlegungen

  • Zusätzlicher Code (Boilerplate): Sie müssen eine lokale Kopie (ref) erstellen, watch verwenden, um mit modelValue zu synchronisieren, und eine Handler-Funktion, um das v-Modell zu aktualisieren. Dies ist etwas mehr Code als die direkte Verwendung von v-model.
  • Klonen: Die verwendete Methode JSON.parse(JSON.stringify(obj)) ist einfach, kann aber bei sehr großen und komplexen Objekten ineffizient sein. Sie hat auch Einschränkungen (kopiert keine Funktionen, Date konvertiert in Strings, undefiniert ersetzt durch null in Arrays oder überspringt in Objekten). In modernen Browsern und Node.js können Sie structuredClone() verwenden, das besser mit verschiedenen Datentypen umgehen kann. Zur Unterstützung älterer Umgebungen oder für mehr Flexibilität können Sie Bibliotheken wie lodash/cloneDeep verwenden.
  • Zustandssynchronisation: Sie müssen auf die Synchronisierung zwischen modelValue und der lokalen Kopie von form achten, insbesondere während der Initialisierung und externer Aktualisierungen. watch mit { deep: true } löst dieses Problem in den meisten Fällen.

Schlussfolgerung

Das Pending v-model pattern ist ein großartiges Werkzeug, um robuste und gut gekapselte Formularkomponenten in Vue 3 zu erstellen, insbesondere wenn eine Validierung oder Vorverarbeitung von Daten erforderlich ist, bevor sie an die übergeordnete Komponente übergeben werden. Obwohl es im Vergleich zu einem reinen V-Modell ein wenig mehr Code hinzufügt, überwiegen die Vorteile in Form von Kontrolle, Kapselung und Testbarkeit oft diesen kleinen Nachteil. Ich empfehle dringend, es in Situationen auszuprobieren, in denen das Standardverhalten des V-Modells nicht ganz richtig ist.