Composants agulaires en angulaire

Lorsque vous travaillez sur une bibliothèque de composants réutilisables, la question de l'API est particulièrement aiguë. D'une part, vous devez prendre une décision fiable et précise, d'autre part, pour satisfaire de nombreux cas particuliers. Cela s'applique à l'utilisation des données et aux fonctionnalités externes de divers cas d'utilisation. De plus, tout doit être facilement mis à jour et déployé sur les projets.


Ces composants nécessitent une flexibilité sans précédent. Dans le même temps, le cadre ne peut pas être rendu trop compliqué, car ils seront utilisés à la fois par les seniors et les juin. La réduction de la duplication de code est l'une des tâches de la bibliothèque de composants. Par conséquent, la configuration ne peut pas être transformée en code de copie.


Bruce Lee


Composants agnostiques aux données


Disons que nous créons un bouton avec un menu déroulant. Quelle sera son API? Bien sûr, il a besoin de certains éléments en entrée - un tableau d'éléments de menu. Très probablement, la première version de l'interface sera comme ceci:


interface MenuItem { readonly text: string; readonly onClick(): void; } 

Assez rapidement, disabled: boolean ajoutera à cela. Ensuite, les concepteurs viendront dessiner un menu avec des icônes. Et les gars du projet voisin, ils dessineront des icônes d'autre part. Et l'interface se développe, il est nécessaire de couvrir de plus en plus de cas particuliers, et à partir de l'abondance de drapeaux, le composant commence à ressembler à l'Assemblée des Nations Unies.



Les génériques viennent à la rescousse. Si vous organisez le composant de sorte qu'il ne se soucie pas du modèle de données, tous ces problèmes disparaîtront. Au lieu d'appeler item.onClick sur un clic, le menu émettra simplement l'élément cliqué. Que faire ensuite est une tâche pour les utilisateurs de la bibliothèque. Même s'ils appellent le même item.onClick .


Dans le cas d'un état disabled , par exemple, le problème est résolu à l'aide de gestionnaires spéciaux. La méthode disabledItemHandler: (item: T) => boolean est transmise au composant disabledItemHandler: (item: T) => boolean , à travers lequel chaque élément est exécuté. Le résultat indique si cet élément est verrouillé.



Si vous faites un ComboBox , une interface peut vous venir à l'esprit qui stocke une chaîne pour l'affichage et une vraie valeur arbitraire qui est utilisée dans le code. Cette idée est claire. Après tout, lorsque l'utilisateur tape le texte, la zone de liste déroulante doit filtrer les options en fonction de la ligne entrée.


 interface ComboBoxItem { readonly text: string; readonly value: any; } 

Mais ici aussi, les limites d'une telle approche apparaîtront - dès qu'un design apparaîtra dans lequel la ligne ne suffira pas. De plus, le formulaire contiendra un wrapper au lieu de la valeur réelle, la recherche n'est pas toujours effectuée exclusivement par la représentation sous forme de chaîne (par exemple, nous pouvons entrer un numéro de téléphone, mais le nom de la personne doit être affiché). Et le nombre d'interfaces augmentera avec l'arrivée d'autres composants, même si le modèle de données sous eux est le même.


Les génériques et les gestionnaires seront également utiles ici. Donnons la fonction (item: T) => string composant stringify . La valeur par défaut est item => String(item) . Ainsi, vous pouvez même utiliser des classes comme options en y définissant la toString() . Comme mentionné ci-dessus, il est nécessaire de filtrer les options non seulement par représentation de chaîne. C'est également un bon cas pour utiliser des gestionnaires. Vous pouvez fournir un composant avec une fonction qui reçoit une chaîne de recherche et un élément en entrée. Il renverra boolean - cela dira si l'article convient à la demande.


Un autre exemple courant d'utilisation d'une interface est un identifiant unique, qui correspond à des copies d'objets JavaScript. Lorsque nous avons reçu la valeur du formulaire immédiatement et que les options de sélection sont entrées dans une demande distincte du serveur - ils n'auront qu'une copie de l'élément actuel. Cette tâche est gérée par un gestionnaire qui reçoit deux éléments en entrée et renvoie leur égalité. La comparaison par défaut est normale === .

Le composant d'affichage d'onglet, en fait, n'a pas besoin de savoir sous quelle forme l'onglet lui a été transmis: au moins avec du texte, au moins avec un objet avec des champs supplémentaires, même avec comment. La connaissance du format n'est pas nécessaire pour l'implémentation, mais beaucoup font le lien vers un format spécifique. L'absence de liens signifie que les composants n'entraîneront pas de changements de rupture pendant le raffinement, ne forceront pas les utilisateurs à adapter leurs données pour eux et permettront de combiner des composants atomiques les uns avec les autres comme des cubes lego.


Le même choix d'éléments convient à la fois au menu contextuel et à la zone de liste déroulante, les composants simples, à sélection multiple et à sélection multiple sont facilement inclus dans des conceptions plus complexes. Cependant, vous devez pouvoir afficher en quelque sorte des données arbitraires.



Les listes peuvent contenir des avatars, différentes couleurs, icônes, le nombre de messages non lus et bien plus encore.


Pour ce faire, les composants doivent fonctionner avec l'apparence d'une manière similaire aux génériques.

Composants indépendants de la conception


Angular fournit des outils puissants pour définir l'apparence.


Par exemple, considérez un ComboBox , car il peut sembler très divers. Bien sûr, un certain niveau de restrictions sera fixé dans le composant, car il doit obéir à la conception globale de l'application. Sa taille, ses couleurs par défaut, son rembourrage - tout cela devrait fonctionner par lui-même. Nous ne voulons pas forcer les utilisateurs à penser à tout ce qui concerne l'apparence.



Les données arbitraires sont comme l'eau: elles n'ont pas de forme, elles ne portent rien de spécifique en elles-mêmes. Notre tâche est de leur offrir la possibilité de définir un «navire». En ce sens, le développement d'un composant abstrait de l'apparence est le suivant:



Le composant est une sorte d'étagère de la taille requise, et un modèle personnalisé est utilisé pour afficher le contenu, qui est «mis dessus». Une méthode standard, telle que la sortie d'une représentation sous forme de chaîne, est initialement définie dans le composant, et l'utilisateur peut transférer des options plus complexes de l'extérieur. Jetons un coup d'œil aux possibilités qu'Angular a pour cela.


  1. L'interpolation de lignes est la manière la plus simple de modifier l'apparence. Mais une ligne invariable ne convient pas pour afficher les éléments de menu, car elle ne sait rien de chaque élément - et ils auront tous la même apparence. Une chaîne statique est privée de contexte . Mais il est tout à fait approprié pour définir le texte "Nothing Found" si la liste des options est vide.


     <div>{{content}}</div> 

  2. Nous avons déjà parlé de la représentation sous forme de chaîne de données arbitraires. Le résultat est également une chaîne, mais est déterminé par la valeur d'entrée. Dans cette situation, le contexte sera un élément de liste. Il s'agit d'une option plus flexible, bien qu'elle ne permette pas de styliser le résultat - la chaîne n'est pas interpolée en HTML - et plus encore elle ne permettra pas l'utilisation de directives ou de composants.


     <div>{{content(context)}}</div> 

  3. Angular fournit ng-template et la directive structurelle *ngTemplateOutlet pour *ngTemplateOutlet . Avec leur aide, nous pouvons définir un élément HTML qui attend que certaines données soient saisies et les transmettre au composant. Là, il sera instancié avec un contexte spécifique. Nous lui transmettrons notre élément sans nous soucier du modèle. L'élaboration du bon modèle pour vos objets est la tâche du développeur-consommateur de notre composant.


     <ng-container *ngTemplateOutlet="content; context: context"></ng-container> 

    Un modèle est un outil très puissant, mais il doit être défini dans un composant existant. Cela complique grandement sa réutilisation. Parfois, la même apparence est requise dans différentes parties de l'application et même dans différentes applications. Dans ma pratique, c'est, par exemple, l'apparence de la sélection de compte avec l'affichage du nom, de la devise et du solde.


  4. La façon la plus complexe de personnaliser l'apparence qui résout ce problème est les composants dynamiques. Dans Angular, la directive *ngComponentOutlet existe depuis longtemps pour les créer de manière déclarative. Il ne permet pas le transfert de contexte, mais ce problème est résolu par l'implémentation de dépendances. Nous pouvons créer un jeton pour le contexte et l'ajouter à l' Injector avec lequel le composant est créé.


     <ng-container *ngComponentOutlet="content; injector: injector"></ng-container> 

    Il convient de noter que le contexte peut être non seulement l'élément que nous voulons afficher, mais également les circonstances dans lesquelles il se trouve:


     <ng-template let-item let-focused="focused"> <!-- ... --> </ng-template> 

    Par exemple, dans le cas du retrait d'un compte, l'état de mise au point de l'élément se reflète dans l'apparence - l'arrière-plan de l'icône passe du gris au blanc. De manière générale, il est judicieux de transférer dans le contexte les conditions susceptibles d'affecter l'affichage du modèle. Ce point est peut-être la seule interface de limitation de cette approche.




Prise universelle


Les outils décrits ci-dessus sont disponibles en angulaire à partir de la cinquième version. Mais nous voulons passer facilement d'une option à une autre. Pour ce faire, nous assemblerons un composant qui accepte le contenu et le contexte en entrée et implémente la manière appropriée d'insérer automatiquement ce contenu. En général, il nous suffit d'apprendre à distinguer les types string , number , (context: T) => string | number (context: T) => string | number , TemplateRef<T> et Type<any> (mais il y a quelques nuances ici, dont nous discuterons ci-dessous).


Le modèle de composant ressemblera à ceci:


 <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> 

Le code obtiendra un getter de type pour sélectionner la méthode appropriée. Il est à noter qu'en général on ne peut pas distinguer un composant d'une fonction. Lorsque vous utilisez des modules paresseux, nous avons besoin d'un Injector qui connaît l'existence du composant. Pour ce faire, nous allons créer une classe wrapper. Cela permettra également de le déterminer par instanceof :


 export class ComponentContent<T> { constructor( readonly component: Type<T>, private readonly injector: Injector | null = null, ) {} } 

Ajoutez une méthode pour créer un injecteur avec le contexte passé:


 createInjectorWithContext(injector: Injector, context: C): Injector { return Injector.create({ parent: this.injector || injector, providers: [ { provide: CONTEXT, useValue: context, }, ], }); } 

Quant aux modèles, dans la plupart des cas, vous pouvez les utiliser tels quels. Mais nous devons garder à l'esprit que le modèle est soumis à la vérification des changements à la place de sa définition. Si vous le transférez dans la vue , qui est parallèle ou supérieure dans l'arborescence à partir du lieu de définition, les modifications qui peuvent y être déclenchées ne seront pas récupérées dans la vue d' origine.


Pour corriger cette situation, nous utiliserons non seulement un modèle, mais une directive qui aura également ChangeDetectorRef son lieu de définition. De cette façon, nous pouvons commencer à vérifier les modifications si nécessaire.


Modèles polymorphes


En pratique, il peut être utile de contrôler le comportement du modèle en fonction du type de contenu qui y est entré.


Par exemple, nous voulons donner la possibilité de transférer un modèle vers un composant pour quelque chose de spécial. Dans le même temps, dans la plupart des cas, vous avez juste besoin d'une icône. Dans une telle situation, vous pouvez configurer le comportement par défaut et l'utiliser quand une primitive ou une fonction est entrée dans l'entrée. Parfois, même le type de primitive est important: par exemple, si vous avez un composant de badge pour afficher le nombre de messages non lus sur un onglet, mais que vous souhaitez mettre en évidence les pages qui nécessitent une attention particulière avec une icône spéciale.



Pour ce faire, vous devez ajouter une dernière chose: passer un modèle pour afficher les primitives. Ajoutez @ContentChild au composant, ce qui prend TemplateRef du contenu. Si une est trouvée et qu'une fonction, une chaîne ou un nombre est passé au contenu, nous pouvons l'instancier avec la primitive comme contexte:


  <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> 

Maintenant, nous pouvons styliser l'interpolation ou même transmettre le résultat à un composant pour l'affichage:


  <content-outlet [content]="content" [context]="context"> <ng-template let-primitive> <div class="primitive">{{primitive}}</div> </ng-template> </content-outlet> 

Il est temps de mettre notre code en pratique.


Utiliser


Pour des exemples, nous décrivons deux composants: les onglets et ComboBox . Le modèle d'onglet se composera simplement d'une sortie de contenu pour chaque onglet, où l'objet transmis par l'utilisateur sera le contexte:


 <content-outlet *ngFor="let tab of tabs" [class.disabled]="disabledItemHandler(tab)" [content]="content" [context]="getContext(tab)" (click)="onClick(tab)" ></content-outlet> 

Vous devez définir des styles par défaut: par exemple, la taille de la police, le soulignement sous l'onglet actuel, la couleur. Mais nous laisserons une apparence concrète au contenu. Le code du composant ressemblera à ceci:


 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); } } 

Nous avons obtenu un composant qui peut fonctionner avec un tableau arbitraire, en l'affichant sous forme d'onglets. Vous pouvez simplement y passer des chaînes et obtenir le look de base:



Et vous pouvez transférer des objets et un modèle pour les afficher et personnaliser l'apparence selon vos besoins, ajouter du HTML, des icônes, des indicateurs:



Dans le cas de ComboBox, nous allons d'abord faire deux composants de base dont il se compose: un champ de saisie avec une icône et un menu. Ce dernier n'a pas de sens pour peindre en détail - il est très similaire aux onglets, uniquement verticalement et a d'autres styles de base. Et le champ de saisie peut être implémenté comme suit:


 <input #input [(ngModel)]="value"/> <content-outlet [content]="content" (mousedown)="onMouseDown($event, input)" > <ng-template let-icon> <div [innerHTML]="icon"></div> </ng-template> </content-outlet> 

Si vous faites l' entrée absolument positionnée, elle bloquera la sortie et tous les clics y seront. Ceci est pratique pour un champ de saisie simple avec une icône décorative, comme une icône de loupe. Dans l'exemple ci-dessus, l'approche du modèle polymorphe est appliquée - la chaîne transmise sera utilisée comme innerHTML pour insérer l'icône SVG. Si, par exemple, nous devons montrer l'avatar de l'utilisateur saisi, nous pouvons y transférer le modèle.


ComboBox a également besoin d'une icône, mais elle doit être interactive. Pour l'empêcher de rompre le focus, ajoutez le gestionnaire onMouseDown à la prise:


 onMouseDown(event: MouseEvent, input: HTMLInputElement) { event.preventDefault(); input.focus(); } 

Passer le modèle en tant que contenu nous permettra de l'augmenter plus haut via CSS simplement en faisant l'icône position: relative . Ensuite, vous pouvez vous abonner à des clics sur elle dans le ComboBox lui-même:


 <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> 

Grâce à une telle organisation, nous obtenons le comportement souhaité:



Le code composant, comme dans le cas des onglets, dispense de la connaissance du modèle de données. Cela ressemble à ceci:


 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 = ''; //          get filteredItems(): ReadonlyArray<T> { return this.items.filter(item => this.stringify(item).includes(this.stringValue), ); } } 

Ce code simple vous permet d'utiliser n'importe quel objet dans ComboBox et de personnaliser leur affichage de manière très flexible. Après quelques améliorations qui ne sont pas liées au concept décrit, il est prêt à l'emploi. L'apparence peut être personnalisée pour tous les goûts:



Conclusion


La création de composants agnostiques élimine la nécessité de prendre en compte chaque cas particulier. Dans le même temps, les utilisateurs obtiennent un outil simple pour configurer le composant pour une situation spécifique. Ces solutions sont faciles à réutiliser. L'indépendance par rapport au modèle de données rend le code universel, fiable et extensible. Dans le même temps, nous avons écrit moins de lignes et utilisé principalement les outils angulaires intégrés.


En utilisant l'approche décrite, vous remarquerez rapidement à quel point il est pratique de penser en termes de contenu plutôt qu'en termes de lignes ou de motifs spécifiques. Affichage des messages d'erreur de validation, des info-bulles, des fenêtres modales - cette approche est bonne non seulement pour personnaliser l'apparence, mais aussi pour transférer le contenu dans son ensemble. Esquisser des dispositions et tester la logique est facile! Par exemple, pour afficher la fenêtre contextuelle, l'utilisateur n'a pas besoin de créer un composant ou même un modèle, vous pouvez simplement passer la chaîne de stub et y revenir plus tard.


Chez Tinkoff.ru, nous avons depuis longtemps appliqué avec succès l'approche décrite et l'avons déplacée vers une petite bibliothèque open source (1 KB gzip) appelée ng-polymorpheus .


Code source


paquet npm


Démo interactive et sandbox


Avez-vous aussi quelque chose que vous vouliez mettre en open source, mais êtes-vous effrayé par les corvées associées? Essayez Angular Open-source Library Starter , que nous avons conçu pour nos projets. Il a déjà configuré CI, vérifie les validations, les linters, la génération de CHANGELOG, la couverture des tests et tout ça.

Source: https://habr.com/ru/post/fr473108/


All Articles