Navigation überspringen
Foto: Martin Adams/Unsplash

Angular: eigene Formular-Komponenten und der ControlValueAccessor

Wenn wir für unsere Kunden Webanwendungen bauen, dann machen wir das oftmals mit Angular. Hin und wieder bauen wir dann auch eine UI-Library, die sich an der Corporate Identity orientiert und möglichst auf die Anforderungen der erwarteten Anwendungsfälle zugeschnitten ist.

Wissen
Software-Architektur

Dazu gehört dann natürlich auch, übliche Formular-Komponenten bereitzustellen.

In diesem Artikel will ich zwei Wege vergleichen, Formularkomponenten zu bauen. Welche Vor- und Nachteile haben sie und wann ist welcher Weg empfehlenswert? Um besser auf einige Details eingehen zu können, gibt es vorher einen kurzen Überblick zu Angular Forms und insbesondere der Funktion des ControlValueAccessor-Interfaces.

Angular Forms in a Nutshell

Angular Forms bietet gleich zwei verschiedene APIs, um mit Formular-Elementen zu interagieren: Reactive Forms und Template-driven Forms. Bei den Reactive Forms nutzt man als Anwendungsentwickler ein Objekt vom Typ FormControl, um programmatisch mit dem Formular-Element zu interagieren. Dieses wird per Direktive an das Objekt gebunden. Bei den Template-driven Forms kann man mithilfe einer anderen Direktive direkt eine Variable mit dem Inhalt an das Formular-Element binden. Durch Angulars two-way-binding werden Änderungen in diesem Fall automatisch beidseitig weitergegeben.

<!-- template-driven form -->
<input [(ngModel)]="myFormValue" />
 
<!-- reactive form -->
<input [formControl]="myFormControl" />

Beispiel: Template-Ausschnitt mit beiden Forms APIs.

export class MyComponent implements OnInit, OnDestroy {
 
    // template-driven form
    public myFormValue = 'initial value';
 
    // reactive form START
    public myFormControl = new FormControl(
        'initial value'
    );
    private _formSubscription: Subscription;
 
    ngOnInit(): void {
        this._formSubscription = this.myFormControl
            .valueChanges.subscribe((value) => {
                // use the form value...
        });
    }
 
    ngOnDestroy(): void {
        this._formSubscription?.unsubscribe();
    }
    // reactive form END
}

Beispiel: Der zugehörige Typescript-Ausschnitt mit beiden Forms APIs.

Das Beispiel zeigt anschaulich, dass die Reactive Forms im Umgang etwas aufwändiger sind. Dafür kann man auf mehr Funktionen zugreifen, oder sich zunutze machen, dass man die Eingabewerte als Observable bekommt. Ein gutes Beispiel dafür würde leider den Umfang dieses Artikels sprengen.

Was an dieser Stelle erstmal nicht ersichtlich, aber später wichtig ist: Unter der Haube wird auch bei den template-driven Forms eine FormControl-Instanz verwendet, von Angular versteckt und für den Anwendungsentwickler unsichtbar.

Der ControlValueAccessor

Stellt man sich nun die Frage, wie der Inhalt des (auf welchem Wege auch immer erstellten) FormControls mit dem entsprechenden DOM-Element in Verbindung gebracht wird, sollte man früher oder später auf das ControlValueAccessor-Interface stoßen. Dieses muss man implementieren, um eigene Formular-Komponenten zu entwickeln – und es ist genau das Bindeglied an dieser Stelle.

Aber wo genau kommt es zum Einsatz, wenn man keine eigene Komponente baut, sondern direkt mit den entsprechenden HTML-Komponenten arbeitet? Diese Frage beantwortet sich mit einem kurzen Blick in den folgenden Codeschnipsel, der aus dem Angular Forms Package stammt:

@Directive({
  selector:
      'input:not([type=checkbox])[formControlName],' +
      'textarea[formControlName],' +
      'input:not([type=checkbox])[formControl],' +
      'textarea[formControl],' +
      'input:not([type=checkbox])[ngModel],' +
      'textarea[ngModel],' +
      '[ngDefaultControl]',
      //...
})
export class DefaultValueAccessor
        implements ControlValueAccessor {
    // ...
}

Ein Ausschnitt aus dem DefaultValueAccessor im @angular/forms Package.

Was wir hier sehen, ist eine Implementierung des ControlValueAccessors, die automatisch für alle nativen Text-inputs (also inputs, die keine checkbox sind, oder textareas) instanziiert wird. An diese ist eine der Direktiven der Angular Forms API (formControlNameformControl, oder ngModel) gebunden. Der letzte Selektor in der Liste hat mit der automatischen Instanziierung nichts zu tun, wird aber später noch von Belang sein.

Wichtig ist: Nicht nur für eigene, sondern genauso für native Formular-Komponenten wird ein ControlValueAccessor verwendet.

Die Qual der Wahl: eigene Komponenten bauen

Nachdem die Funktion von Angular Forms beleuchtet wurde, geht es jetzt um die beiden eingangs erwähnten API-Ansätze. In einem der beiden Fälle kann dieses Wissen über die Funktionsweise von Angular Forms die Implementierung deutlich vereinfachen.

Die beiden Ansätze lassen sich gut durch die Qualitätsziele unterscheiden, die sie erfüllen. Der erste zielt darauf ab, den Anwendungsentwicklern möglichst viel Freiheit und Flexibilität zu überlassen. Der zweite bietet ein deutlich schmaleres API, was zum einen den Wartungsaufwand reduziert und die Möglichkeit einer Fehlbenutzung begrenzt.

Der offene Ansatz

Der erste, „offene“ Ansatz eignet sich besonders für Situationen, in denen eine große Menge an Anwendungsfällen abgedeckt werden sollen. Angular Material hat sich als Open Source Bibliothek z.B. dafür entschieden:

<mat-form-field>
    <mat-label>Label</mat-label>
    <input
        matInput
        type="text"
        [formControl]="myFormControl" />
</mat-form-field>

Offen gestaltete Formular-Komponente aus Angular Material.

Hier hat der Nutzer volle Kontrolle, kann z.B. am input-Element weitere Accessibility-Attribute hinzufügen, oder im Label individuelle Textauszeichnung verwenden. Auch optionale Pre- und Suffix-Elemente lassen sich so flexibel einbauen, ohne dass Änderungen an der Bibliothek nötig wären. Dieser Ansatz funktioniert gut für große general-purpose Bibliotheken, die möglichst viele Anwendungsfälle abdecken wollen, die einzelnen Anwender vielfach gar nicht kennen und dementsprechend nicht auf Einzelwünsche eingehen können oder wollen.

So behält die Anwendung die Kontrolle über das native input-Element und es sind keine Eingriffe in die Formular-Mechanik nötig. Dieser Ansatz soll allerdings nur der Vollständigkeit halber angerissen und nicht tiefergehend beschrieben werden.

Der geschlossene Ansatz

Im Gegensatz dazu eignet sich der zweite, „geschlossene“ Ansatz eher, wenn die Anwendungsfälle bekannt sind und gegebenenfalls auch auf einzelne Wünsche der Anwendungen eingegangen werden kann.

<lib-text-field
    [formControl]="myFormControl"
    label="Label"
></lib-text-field>

Geschlossen gestaltete Formular-Komponente, wie ich sie schon in Kundenprojekten gebaut habe.

Die Möglichkeiten sind hier vergleichsweise eingeschränkt, erfahrungsgemäß reicht das in den meisten Anwendungsfällen aber aus. Da es hier nur eine Angular-Komponente gibt, die für die Anwendung sichtbar ist, steigt die Flexibilität innerhalb der Bibliothek. Diese schmalere API vereinfacht so die Wartung, weil dadurch die Wahrscheinlichkeit geringer ist, das Änderungen am Code sich auch in einer geänderten API widerspiegeln.

Es können für spezielle Komponenten auch Dritt-Bibliotheken genutzt werden, um deren Verhalten abzubilden, die Nutzer der UI-Bibliothek bekommen davon aber nichts mit.

Der Haken an diesem Ansatz ist, dass das native input-Element für die Anwendung nicht mehr sichtbar ist. Das bedeutet, dass die eigene Formular-Komponente, die ja eigentlich nur die Darstellung beeinflussen soll, jetzt selbst das ControlValueAccessor-Interface implementieren muss, um mit einer der Forms-APIs genutzt werden zu können – auch, wenn das Verhalten des gekapselten Form-Elements gar nicht beeinflusst werden soll. Das bedeutet im ersten Moment, dass mehr eigener Code geschrieben werden muss und entsprechend auch mehr Code gewartet werden muss. Gibt es also einen Weg, diesen zusätzlichen Code auf ein absolutes Minimum zu beschränken?

Don’t reinvent the wheel

Die erstbeste Möglichkeit, das ControlValueAccessor-Interface zu implementieren, wäre vermutlich, es manuell auszuimplementieren; dabei ein weiteres FormControl zu instanziieren, das an das native input-Element gebunden wird und die Methoden des Interfaces irgendwie daranzuflanschen. Das funktioniert grundlegend zwar relativ simpel, bietet gleichzeitig aber Gelegenheiten, Fehler zu machen – zum Beispiel wenn ein Element als touched markiert werden soll. Angulars eingebaute ControlValueAccessoren sind für mehrere Browser und Endgeräte ausgelegt und getestet – ein Aufwand, den man nicht grundlos auf sich laden sollte.

Bei meiner ursprünglichen Recherche zu der Frage, wie man das besser machen kann, bin ich auf den Artikel Don’t reinvent the wheel gestoßen. Während die grundlegende Idee zwar gut ist, krankt leider die Umsetzung. Um den ControlValueAccessor zu instanziieren, wird dabei ein formControl-Attribut am input-Element genutzt. Diesem wird allerdings kein FormControl übergeben, sondern ein leerer Wert. Das führt zum einen dazu, dass zur Laufzeit unschöne Fehler in der Browser-Konsole erscheinen, zum anderen werden zwangsläufig alle Unit-Tests der Komponente fehlschlagen.

Nutze den Selector, Luke

An dieser Stelle sollte man noch einmal einen Blick in das Listing des DefaultValueAccessors werfen und sich die Selektoren anschauen. Dabei wird man feststellen, dass man auch mit ngDefaultControl eine Instanz dieses ValueAccessors erstellen kann. Auf den kann man dann per @ViewChild Decorator zugreifen und alle relevanten Aufrufe weiterleiten.

<label>{{label}}</label>
<input ngDefaultControl />

Template einer geschlossenen Input-Komponente.

@Component({
    selector: 'lib-text-field',
    templateUrl: './text-field.component.html',
    styleUrls: ['./text-field.component.css'],
    providers: [{
        provide: NG_VALUE_ACCESSOR,
        useExisting: forwardRef(
            () => TextFieldComponent
        ),
        multi: true
    }]
})
export class TextFieldComponent
        implements ControlValueAccessor {
 
    @Input()
    label: string;
 
    @ViewChild(DefaultValueAccessor, {static: true})
    valueAccessor: ControlValueAccessor;
 
    // actual ControlValueAccessor methods,
    // directly forwarding to the view child.
 
    registerOnChange(fn: any): void {
        this.valueAccessor.registerOnChange(fn);
    }
 
    registerOnTouched(fn: any): void {
        this.valueAccessor.registerOnTouched(fn);
    }
 
    writeValue(obj: any): void {
        this.valueAccessor.writeValue(obj);
    }
 
    setDisabledState(isDisabled: boolean): void {
        this.valueAccessor.setDisabledState(isDisabled);
    }
}

Typescript-Code einer geschlossenen Input-Komponente.

Dieser Ansatz lässt sich noch verfeinern, indem man eine abstrakte Klasse baut, die das Durchreichen der Interface-Methoden übernimmt. In eine solche Oberklasse kann man z. B. auch Aspekte zur Anzeige von Fehlerzuständen mit aufnehmen. Ich habe dazu ein Beispiel auf Github gebaut.

Fazit

Ich denke, der hier gezeigte geschlossene Ansatz ist in vielen Fällen passend und etwas einfacher zu warten als der offene Ansatz. Mit dem richtigen Wissen über Angular Forms ist er auch ohne viel Aufwand zu implementieren.

Autor

Lukas Taake

Entwickler mit Vorliebe für FOSS. Versucht den Blick für das Wesentliche zu behalten, immer auf der Suche nach neuen Herausforderungen.

Über uns

Holisticon in 66 Worten

Holisticon steht für einen integrierten Beratungsansatz: Indem wir Technologie, Strategie und Organisation ganzheitlich denken und steuern, können wir die Digitalisierung innovativer Geschäftsmodelle zukunftssicher ermöglichen.

Das Unternehmen wurde 2007 von Oliver Ihns und Dierk Harbeck gegründet. Heute begleiten rund 80 Mitarbeitende an den Standorten Hamburg, Hannover und München Kunden aus verschiedensten Industrien erfolgreich durch die Transformation. Holisticon ist Partner führender Technologiemarken und Mitglied der internationalen Nexer Group.