Wenn Sie an einer Bibliothek wiederverwendbarer Komponenten arbeiten, ist die API-Frage besonders akut. Einerseits müssen Sie eine zuverlässige und genaue Entscheidung treffen, andererseits müssen Sie viele Sonderfälle erfüllen. Dies gilt für die Arbeit mit Daten und für die externen Funktionen verschiedener Anwendungsfälle. Darüber hinaus sollte alles einfach aktualisiert und in Projekten eingeführt werden können.
Solche Komponenten benötigen eine beispiellose Flexibilität. In diesem Fall kann die Einstellung nicht zu kompliziert gemacht werden, da sie sowohl von den Senioren als auch vom Juni verwendet wird. Das Reduzieren der Codeduplizierung ist eine der Aufgaben der Komponentenbibliothek. Daher kann die Konfiguration nicht in Kopiercode umgewandelt werden.

Datenunabhängige Komponenten
Angenommen, wir erstellen eine Schaltfläche mit einem Dropdown-Menü. Was wird seine API sein? Natürlich benötigt er einige Elemente als Eingabe - eine Reihe von Menüelementen. Höchstwahrscheinlich wird die erste Version der Benutzeroberfläche folgendermaßen aussehen:
interface MenuItem { readonly text: string; readonly onClick(): void; }
Ziemlich schnell disabled: boolean
wird dies ergänzen. Dann kommen Designer und zeichnen ein Menü mit Symbolen. Und die Jungs vom Nachbarprojekt werden dagegen Icons zeichnen. Und jetzt wächst die Schnittstelle, es ist notwendig, immer mehr Sonderfälle abzudecken, und aufgrund der Fülle an Flaggen beginnt die Komponente, der UN-Versammlung zu ähneln.

Generika kommen zur Rettung. Wenn Sie die Komponente so organisieren, dass sie sich nicht um das Datenmodell kümmert, verschwinden alle diese Probleme. Anstatt item.onClick
auf einen Klick item.onClick
, gibt das Menü einfach das angeklickte Element aus. Was danach damit zu tun ist, ist eine Aufgabe für Bibliotheksbenutzer. Auch wenn sie dasselbe item.onClick
.
Im Fall eines disabled
Status wird das Problem beispielsweise mithilfe spezieller Handler behoben. Die disabledItemHandler: (item: T) => boolean
Methode wird an die Komponente übergeben disabledItemHandler: (item: T) => boolean
, über die jedes Element ausgeführt wird. Das Ergebnis gibt an, ob dieses Element gesperrt ist.

Wenn Sie eine ComboBox ausführen , fällt Ihnen möglicherweise eine Schnittstelle ein, die eine Zeichenfolge für die Anzeige und einen echten beliebigen Wert speichert, der im Code verwendet wird. Diese Idee ist klar. Wenn der Benutzer den Text eingibt, sollte die ComboBox die Optionen nach der eingegebenen Zeile filtern.
interface ComboBoxItem { readonly text: string; readonly value: any; }
Aber auch hier werden die Grenzen eines solchen Ansatzes auftauchen - sobald ein Design erscheint, bei dem die Linie nicht ausreicht. Darüber hinaus enthält das Formular anstelle des tatsächlichen Werts einen Wrapper. Die Suche wird nicht immer ausschließlich über die Zeichenfolgendarstellung ausgeführt (z. B. können wir eine Telefonnummer eingeben, aber der Name der Person sollte angezeigt werden). Und die Anzahl der Schnittstellen wird mit dem Aufkommen anderer Komponenten zunehmen, selbst wenn das Datenmodell unter ihnen dasselbe ist.
Auch hier helfen Generika und Handler. Geben wir (item: T) => string
stringify
Komponente die Funktion (item: T) => string
. Der Standardwert ist item => String(item)
. Sie können also sogar Klassen als Optionen verwenden, indem Sie die toString()
-Methode in ihnen definieren. Wie oben erwähnt, müssen Optionen nicht nur nach Zeichenfolgendarstellung gefiltert werden. Dies ist auch ein guter Fall für die Verwendung von Handlern. Sie können einer Komponente eine Funktion bereitstellen, die eine Suchzeichenfolge und ein Element als Eingabe empfängt. Es wird boolean
- dies zeigt an, ob das Element für die Anforderung geeignet ist.
Ein weiteres häufiges Beispiel für die Verwendung einer Schnittstelle ist eine eindeutige ID, die Kopien von JavaScript-Objekten entspricht. Wenn wir den Formularwert sofort erhalten haben und die Auswahloptionen in einer separaten Anfrage vom Server eingegangen sind, haben sie nur eine Kopie des aktuellen Elements. Diese Aufgabe wird von einem Handler ausgeführt, der zwei Elemente als Eingabe empfängt und deren Gleichheit zurückgibt. Der Standardvergleich ist normal ===
.
Die Registerkarte-Anzeigekomponente muss in der Tat nicht wissen, in welcher Form die Registerkarte an sie übergeben wurde: zumindest mit Text, zumindest mit einem Objekt mit zusätzlichen Feldern, auch mit wie. Kenntnisse des Formats sind für die Implementierung nicht erforderlich, aber viele stellen die Verknüpfung zu einem bestimmten Format her. Das Fehlen von Bindungen bedeutet, dass die Komponenten während der Verfeinerung keine Änderungen bewirken, die Benutzer nicht dazu zwingen, ihre Daten für sie anzupassen, und dass atomare Komponenten wie Legowürfel miteinander kombiniert werden können.
Die gleiche Auswahl an Elementen eignet sich sowohl für das Kontextmenü als auch für Combobox-, Select-, Multi-Select- und einfache Komponenten, die in komplexeren Designs problemlos enthalten sind. Sie müssen jedoch in der Lage sein, beliebige Daten anzuzeigen.

Listen können Avatare, verschiedene Farben, Symbole, die Anzahl der ungelesenen Nachrichten und vieles mehr enthalten.
Dazu müssen die Komponenten ähnlich wie bei Generika mit dem Erscheinungsbild arbeiten.
Designunabhängige Komponenten
Angular bietet leistungsstarke Tools zum Definieren des Erscheinungsbilds.
Stellen Sie sich zum Beispiel eine ComboBox vor , da diese sehr unterschiedlich aussehen kann. Natürlich wird in der Komponente ein gewisses Maß an Einschränkungen festgelegt, da diese dem Gesamtdesign der Anwendung entsprechen muss. Seine Größe, Standardfarben, Polsterung - all dies sollte von selbst funktionieren. Wir möchten Benutzer nicht dazu zwingen, über alles in Bezug auf das Erscheinungsbild nachzudenken.

Beliebige Daten sind wie Wasser: Sie haben keine Form, sie tragen nichts Spezifisches in sich. Unsere Aufgabe ist es, ihnen die Möglichkeit zu geben, ein „Schiff“ für sie einzurichten. In diesem Sinne ist die Entwicklung einer vom Erscheinungsbild abstrahierten Komponente wie folgt:

Die Komponente ist eine Art Regal mit der erforderlichen Größe, und eine benutzerdefinierte Vorlage wird verwendet, um den Inhalt anzuzeigen, der darauf „platziert“ wird. In der Komponente ist zunächst eine Standardmethode festgelegt, z. B. die Ausgabe einer Zeichenfolgendarstellung, und der Benutzer kann komplexere Optionen von außen übertragen. Werfen wir einen Blick auf die Möglichkeiten, die Angular dafür hat.
Der einfachste Weg, das Erscheinungsbild zu ändern, ist die Linieninterpolation. Eine unveränderliche Zeile eignet sich jedoch nicht zum Anzeigen von Menüelementen, da sie nichts über jedes Element weiß - und alle gleich aussehen. Eine statische Zeichenfolge wird des Kontexts beraubt . Es ist jedoch durchaus geeignet, den Text "Nothing Found" festzulegen, wenn die Liste der Optionen leer ist.
<div>{{content}}</div>
Wir haben bereits über die Zeichenfolgendarstellung beliebiger Daten gesprochen. Das Ergebnis ist ebenfalls eine Zeichenfolge, wird jedoch durch den Eingabewert bestimmt. In dieser Situation ist der Kontext ein Listenelement. Dies ist eine flexiblere Option, obwohl das Ergebnis nicht gestylt werden kann - die Zeichenfolge wird nicht in HTML interpoliert - und noch mehr, dass keine Anweisungen oder Komponenten verwendet werden dürfen.
<div>{{content(context)}}</div>
Angular bietet ng-template
und die Strukturanweisung *ngTemplateOutlet
, *ngTemplateOutlet
. Mit ihrer Hilfe können wir ein Stück HTML definieren, das die Eingabe einiger Daten erwartet, und diese an die Komponente übergeben. Dort wird er mit einem bestimmten Kontext instanziiert. Wir werden unser Element weitergeben, ohne uns um das Modell zu kümmern. Die Erstellung der richtigen Vorlage für Ihre Objekte ist Aufgabe des Consumer-Entwicklers unserer Komponente.
<ng-container *ngTemplateOutlet="content; context: context"></ng-container>
Eine Vorlage ist ein sehr leistungsfähiges Werkzeug, das jedoch in einer vorhandenen Komponente definiert werden muss. Dies erschwert die Wiederverwendung erheblich. Manchmal ist in verschiedenen Teilen der Anwendung und sogar in verschiedenen Anwendungen das gleiche Erscheinungsbild erforderlich. In meiner Praxis ist dies beispielsweise das Erscheinungsbild der Kontoauswahl mit der Anzeige von Name, Währung und Kontostand.

Die komplexeste Möglichkeit, das Erscheinungsbild anzupassen, mit dem dieses Problem gelöst wird, sind dynamische Komponenten. In Angular gibt es die *ngComponentOutlet
Direktive schon lange, um sie deklarativ zu erstellen. Es erlaubt keine Übertragung von Kontext, aber dieses Problem wird durch die Implementierung von Abhängigkeiten gelöst. Wir können ein Token für den Kontext Injector
und es dem Injector
hinzufügen, mit dem die Komponente erstellt wird.
<ng-container *ngComponentOutlet="content; injector: injector"></ng-container>
Es ist erwähnenswert, dass der Kontext nicht nur das Element sein kann, das wir anzeigen möchten, sondern auch die Umstände, unter denen er sich befindet:
<ng-template let-item let-focused="focused"> </ng-template>
Wenn sich beispielsweise ein Konto abhebt, spiegelt sich der Fokusstatus des Elements im Erscheinungsbild wider - der Hintergrund des Symbols wechselt von grau zu weiß. Im Allgemeinen ist es sinnvoll, die Bedingungen, die sich möglicherweise auf die Anzeige der Vorlage auswirken, in den Kontext zu übertragen. Dieser Punkt ist möglicherweise die einzige Einschränkungsschnittstelle dieses Ansatzes.

Universal Outlet
Die oben beschriebenen Tools sind ab der fünften Version in Angular verfügbar. Wir möchten jedoch problemlos von einer Option zur anderen wechseln. Zu diesem Zweck stellen wir eine Komponente zusammen, die Inhalt und Kontext als Eingabe akzeptiert und die entsprechende Methode zum automatischen Einfügen dieses Inhalts implementiert. Im Allgemeinen reicht es aus, zu lernen, zwischen den Typen string
, number
, (context: T) => string | number
(context: T) => string | number
, TemplateRef<T>
und Type<any>
(aber es gibt hier einige Nuancen, die wir unten diskutieren werden).
Die Komponentenvorlage sieht folgendermaßen aus:
<ng-container [ngSwitch]="type"> <ng-container *ngSwitchCase="'primitive'">{{content}}</ng-container> <ng-container *ngSwitchCase="'function'">{{content(context)}}</ng-container> <ng-container *ngSwitchCase="'template'"> <ng-container *ngTemplateOutlet="content; context: context"></ng-container> </ng-container> <ng-container *ngSwitchCase="'component'"> <ng-container *ngComponentOutlet="content; injector: injector"></ng-container> </ng-container> </ng-container>
Der Code erhält einen Typ Getter, um die entsprechende Methode auszuwählen. Es ist zu beachten, dass wir eine Komponente im Allgemeinen nicht von einer Funktion unterscheiden können. Bei Verwendung von Lazy-Modulen benötigen wir einen Injector
, der über die Existenz der Komponente Bescheid weiß. Dazu erstellen wir eine Wrapper-Klasse. Dies ermöglicht es auch, es anhand der folgenden instanceof
zu bestimmen:
export class ComponentContent<T> { constructor( readonly component: Type<T>, private readonly injector: Injector | null = null, ) {} }
Fügen Sie eine Methode hinzu, um einen Injektor mit dem übergebenen Kontext zu erstellen:
createInjectorWithContext(injector: Injector, context: C): Injector { return Injector.create({ parent: this.injector || injector, providers: [ { provide: CONTEXT, useValue: context, }, ], }); }
In den meisten Fällen können Sie mit den Vorlagen so arbeiten, wie sie sind. Wir müssen jedoch berücksichtigen, dass die Vorlage Änderungen an der Stelle ihrer Definition überprüft werden muss. Wenn Sie es vom Definitionsort in die Ansicht übertragen , die parallel oder höher im Baum liegt, werden die Änderungen, die darin ausgelöst werden können, nicht in der ursprünglichen Ansicht übernommen .
Um diese Situation zu korrigieren, verwenden wir nicht nur eine Vorlage, sondern eine Direktive, deren Definitionsort auch ChangeDetectorRef
. Auf diese Weise können wir bei Bedarf nach Änderungen suchen.
Polymorphe Muster
In der Praxis kann es hilfreich sein, das Verhalten der Vorlage abhängig von der Art des Inhalts zu steuern, der in die Vorlage eingegangen ist.
Zum Beispiel möchten wir die Möglichkeit geben, eine Vorlage für etwas Besonderes auf eine Komponente zu übertragen. Gleichzeitig benötigen Sie in den meisten Fällen nur ein Symbol. In einer solchen Situation können Sie das Standardverhalten konfigurieren und verwenden, wenn ein Grundelement oder eine Funktion in die Eingabe eingegeben wurde. Manchmal ist sogar die Art des Grundelements wichtig: Wenn Sie beispielsweise eine Ausweiskomponente haben, um die Anzahl der ungelesenen Nachrichten auf einer Registerkarte anzuzeigen, aber Seiten, die Aufmerksamkeit erfordern, mit einem speziellen Symbol hervorheben möchten.

Dazu müssen Sie noch etwas hinzufügen - eine Vorlage übergeben, um Grundelemente anzuzeigen. Fügen @ContentChild
der Komponente @ContentChild
, die TemplateRef
aus dem Inhalt übernimmt. Wenn eine gefunden wird und eine Funktion, Zeichenfolge oder Zahl an den Inhalt übergeben wird, können wir sie mit dem Grundelement als Kontext instanziieren:
<ng-container *ngSwitchCase="'interpolation'"> <ng-container *ngIf="!template; else child">{{primitive}}</ng-container> <ng-template #child> <ng-container *ngTemplateOutlet="template; context: { $implicit: primitive }" ></ng-container> </ng-template> </ng-container>
Jetzt können wir die Interpolation formatieren oder das Ergebnis sogar zur Anzeige an eine Komponente übergeben:
<content-outlet [content]="content" [context]="context"> <ng-template let-primitive> <div class="primitive">{{primitive}}</div> </ng-template> </content-outlet>
Es ist Zeit, unseren Code in die Praxis umzusetzen.
Verwenden Sie
Zum Beispiel skizzieren wir zwei Komponenten: Registerkarten und ComboBox . Die Registerkartenvorlage besteht einfach aus einem Inhaltsausgang für jede Registerkarte, wobei das vom Benutzer übergebene Objekt der Kontext ist:
<content-outlet *ngFor="let tab of tabs" [class.disabled]="disabledItemHandler(tab)" [content]="content" [context]="getContext(tab)" (click)="onClick(tab)" ></content-outlet>
Sie müssen Standardstile festlegen: z. B. Schriftgröße, Unterstreichung unter der aktuellen Registerkarte, Farbe. Aber wir werden das konkrete Erscheinungsbild dem Inhalt überlassen. Der Komponentencode lautet ungefähr so:
export class TabsComponent<T> { @Input() tabs: ReadonlyArray<T> = []; @Input() content: Content = ({$implicit}) => String($implicit); @Input() disabledItemHandler: (tab: T) => boolean = () => false; @Input() activeTab: T | null = null; @Output() activeTabChange = new EventEmitter<T>(); getContext($implicit: T): IContextWithActive<T> { return { $implicit, active: $implicit === this.activeTab, }; } onClick(tab: T) { this.activeTab = tab; this.activeTabChange.emit(tab); } }
Wir haben eine Komponente, die mit einem beliebigen Array arbeiten kann und diese als Registerkarten anzeigt. Sie können dort einfach Zeichenfolgen übergeben und erhalten das grundlegende Aussehen:

Sie können Objekte und eine Vorlage übertragen, um sie anzuzeigen und das Erscheinungsbild an Ihre Bedürfnisse anzupassen. Fügen Sie HTML, Symbole und Indikatoren hinzu:

Im Fall von ComboBox werden wir zunächst zwei grundlegende Komponenten erstellen, aus denen es besteht: ein Eingabefeld mit einem Symbol und einem Menü. Letzteres ist nicht sinnvoll, um im Detail zu malen - es ist Tabs sehr ähnlich, nur vertikal und hat andere grundlegende Stile. Das Eingabefeld kann wie folgt implementiert werden:
<input #input [(ngModel)]="value"/> <content-outlet [content]="content" (mousedown)="onMouseDown($event, input)" > <ng-template let-icon> <div [innerHTML]="icon"></div> </ng-template> </content-outlet>
Wenn Sie den Eingang absolut positioniert machen, blockiert er den Ausgang und alle Klicks sind darauf. Dies ist praktisch für ein einfaches Eingabefeld mit einem dekorativen Symbol, z. B. einem Lupensymbol. Im obigen Beispiel wird der Ansatz der polymorphen Vorlage angewendet. Die übertragene Zeichenfolge wird als innerHTML
zum Einfügen des SVG-Symbols verwendet. Wenn wir zum Beispiel den Avatar des eingegebenen Benutzers anzeigen müssen, können wir die Vorlage dorthin übertragen.
ComboBox benötigt ebenfalls ein Symbol, muss jedoch interaktiv sein. onMouseDown
Sie den onMouseDown
Handler zum Ausgang hinzu, um zu verhindern, dass der Fokus onMouseDown
:
onMouseDown(event: MouseEvent, input: HTMLInputElement) { event.preventDefault(); input.focus(); }
Wenn Sie die Vorlage als Inhalt übergeben, können Sie sie durch CSS erhöhen, indem Sie einfach das Symbol position: relative festlegen. Dann können Sie Klicks in der ComboBox selbst abonnieren :
<app-input [content]="icon"></app-input> <ng-template #icon> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="icon" [class.icon_opened]="opened" (click)="onClick()" > <polyline points="7,10 12,15 17,10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" /> </svg> </ng-template>
Dank einer solchen Organisation erhalten wir das gewünschte Verhalten:

Der Komponentencode verzichtet wie bei Registerkarten auf die Kenntnis des Datenmodells. Es sieht ungefähr so aus:
export class ComboBoxComponent<T> { @Input() items: ReadonlyArray<T> = []; @Input() content: Content = ({$implicit}) => String($implicit); @Input() stringify = (item: T) => String(item); @Input() value: T | null = null; @Output() valueChange = new EventEmitter<T | null>(); stringValue = '';
Mit diesem einfachen Code können Sie beliebige Objekte in ComboBox verwenden und ihre Anzeige sehr flexibel anpassen. Nach einigen Verbesserungen, die nicht mit dem beschriebenen Konzept zusammenhängen, ist es einsatzbereit. Das Aussehen kann für jeden Geschmack individuell angepasst werden:

Fazit
Durch die Erstellung agnostischer Komponenten muss nicht jeder Einzelfall berücksichtigt werden. Gleichzeitig erhalten Benutzer ein einfaches Tool, um die Komponente für eine bestimmte Situation zu konfigurieren. Diese Lösungen sind einfach wiederzuverwenden. Die Unabhängigkeit vom Datenmodell macht den Code universell, zuverlässig und erweiterbar. Gleichzeitig haben wir nicht so viele Zeilen geschrieben und hauptsächlich die integrierten Angular-Tools verwendet.
Wenn Sie den beschriebenen Ansatz verwenden, werden Sie schnell feststellen, wie bequem es ist, inhaltlich und nicht in bestimmten Linien oder Mustern zu denken. Anzeigen von Validierungsfehlermeldungen, QuickInfos und modalen Fenstern - dieser Ansatz eignet sich nicht nur zum Anpassen des Erscheinungsbilds, sondern auch zum Übertragen von Inhalten als Ganzes. Das Skizzieren von Layouts und das Testen der Logik ist einfach! Um beispielsweise das Popup anzuzeigen, muss der Benutzer weder eine Komponente noch eine Vorlage erstellen. Sie können einfach die Stub-Zeichenfolge übergeben und später darauf zurückgreifen.
Wir bei Tinkoff.ru haben den beschriebenen Ansatz lange Zeit erfolgreich angewendet und ihn in eine winzige Open-Source-Bibliothek (1 KB gzip) namens ng-polymorpheus verschoben.
Quellcode
npm-Paket
Interaktive Demo und Sandbox
Haben Sie auch etwas, das Sie in Open Source einbinden wollten, aber haben Sie Angst vor den damit verbundenen Aufgaben? Probieren Sie den Angular Open-Source Library Starter aus , den wir für unsere Projekte erstellt haben. Es hat bereits CI konfiguriert, prüft Commits, Linters, CHANGELOG-Generierung, Testabdeckung und all das Zeug.