Componentes agulares en angular

Cuando trabajas en una biblioteca de componentes reutilizables, la pregunta API es especialmente aguda. Por un lado, debe tomar una decisión confiable y precisa, por otro lado, para satisfacer muchos casos especiales. Esto se aplica al trabajo con datos y a las características externas de varios casos de uso. Además, todo debe actualizarse y desplegarse fácilmente en los proyectos.


Dichos componentes necesitan una flexibilidad sin precedentes. En este caso, el entorno no se puede hacer demasiado complicado, ya que serán utilizados tanto por los seniors como por June. La reducción de la duplicación de código es una de las tareas de la biblioteca de componentes. Por lo tanto, la configuración no se puede convertir en código de copia.


bruce lee


Componentes independientes de datos


Digamos que hacemos un botón con un menú desplegable. ¿Cuál será su API? Por supuesto, necesita algunos elementos como entrada: una variedad de elementos de menú. Lo más probable es que la primera versión de la interfaz sea así:


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

Bastante rápido, disabled: boolean se sumará a esto. Luego, los diseñadores vendrán y dibujarán un menú con iconos. Y los chicos del proyecto vecino, por otro lado, dibujarán íconos. Y ahora la interfaz está creciendo, es necesario cubrir más y más casos especiales, y de la abundancia de banderas, el componente comienza a parecerse a la Asamblea de la ONU.



Los genéricos vienen al rescate. Si organiza el componente para que no le importe el modelo de datos, todos estos problemas desaparecerán. En lugar de llamar a item.onClick en un clic, el menú simplemente emitirá el elemento item.onClick . Lo que debe hacer después es una tarea para los usuarios de la biblioteca. Incluso si llaman al mismo item.onClick . En item.onClick .


En el caso de un estado disabled , por ejemplo, el problema se resuelve utilizando controladores especiales. El método disabledItemHandler: (item: T) => boolean se pasa al componente disabledItemHandler: (item: T) => boolean , a través del cual se ejecuta cada elemento. El resultado dice si este elemento está bloqueado.



Si está haciendo un ComboBox , puede pensar en una interfaz que almacena una cadena para mostrar y un valor arbitrario real que se utiliza en el código. Esta idea es clara. Después de todo, cuando el usuario escribe el texto, ComboBox debe filtrar las opciones de acuerdo con la línea ingresada.


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

Pero aquí, también, surgirán las limitaciones de este enfoque, tan pronto como aparezca un diseño en el que la línea no sea suficiente. Además, el formulario contendrá un contenedor en lugar del valor real, la búsqueda no siempre se realiza exclusivamente por la representación de cadena (por ejemplo, podemos ingresar un número de teléfono, pero se debe mostrar el nombre de la persona). Y la cantidad de interfaces crecerá con el advenimiento de otros componentes, incluso si el modelo de datos bajo ellos es el mismo.


Los genéricos y los controladores también ayudarán aquí. Démosle la función (item: T) => string componente stringify . El valor predeterminado es item => String(item) . Por lo tanto, incluso puede usar las clases como opciones definiendo el método toString() en ellas. Como se mencionó anteriormente, es necesario filtrar las opciones no solo por representación de cadena. Este también es un buen caso para usar controladores. Puede proporcionar un componente con una función que recibe una cadena de búsqueda y un elemento como entrada. boolean ; esto indicará si el elemento es adecuado para la solicitud.


Otro ejemplo común de uso de una interfaz es una identificación única, que coincide con copias de objetos JavaScript. Cuando recibimos el valor del formulario de inmediato, y las opciones de selección vinieron en una solicitud separada del servidor: solo tendrán una copia del elemento actual. Esta tarea es manejada por un controlador que recibe dos elementos como entrada y devuelve su igualdad. La comparación predeterminada es normal === .

El componente de visualización de pestañas, de hecho, no necesita saber en qué forma se le pasó la pestaña: al menos con texto, al menos con un objeto con campos adicionales, incluso con cómo. El conocimiento del formato no es necesario para la implementación, pero muchos hacen el enlace a un formato específico. La ausencia de vínculos significa que los componentes no implicarán cambios importantes durante el refinamiento, no obligarán a los usuarios a adaptar sus datos para ellos y permitirán combinar componentes atómicos entre sí como cubos de lego.


La misma opción de elementos es adecuada tanto para el menú contextual como para el cuadro combinado, selección, selección múltiple, componentes simples que se incluyen fácilmente en diseños más complejos. Sin embargo, debe poder mostrar de alguna manera datos arbitrarios.



Las listas pueden contener avatares, diferentes colores, íconos, la cantidad de mensajes no leídos y mucho más.


Para hacer esto, los componentes deben trabajar con la apariencia de manera similar a los genéricos.

Componentes agnósticos de diseño


Angular proporciona herramientas poderosas para definir la apariencia.


Por ejemplo, considere un ComboBox , ya que puede parecer muy diverso. Por supuesto, se establecerá un cierto nivel de restricciones en el componente, ya que debe obedecer el diseño general de la aplicación. Su tamaño, colores predeterminados, relleno: todo esto debería funcionar solo. No queremos obligar a los usuarios a pensar en todo lo relacionado con la apariencia.



Los datos arbitrarios son como el agua: no tienen forma, no llevan nada específico en sí mismos. Nuestra tarea es proporcionar una oportunidad para establecer un "recipiente" para ellos. En este sentido, el desarrollo de un componente abstraído de la apariencia es el siguiente:



El componente es una especie de estante del tamaño requerido, y se utiliza una plantilla personalizada para mostrar el contenido, que se "pone". Inicialmente, se establece un método estándar, como la salida de una representación de cadena, en el componente, y el usuario puede transferir opciones más complejas desde el exterior. Echemos un vistazo a las posibilidades que Angular tiene para esto.


  1. La forma más sencilla de cambiar la apariencia es la interpolación de líneas. Pero una línea invariable no es adecuada para mostrar elementos del menú, ya que no sabe nada acerca de cada elemento, y todos se verán iguales. Una cadena estática está privada de contexto . Pero es bastante adecuado para configurar el texto "No se encontró nada" si la lista de opciones está vacía.


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

  2. Ya hemos hablado sobre la representación de cadenas de datos arbitrarios. El resultado también es una cadena, pero está determinado por el valor de entrada. En esta situación, el contexto será un elemento de la lista. Esta es una opción más flexible, aunque no permite diseñar el resultado (la cadena no está interpolada en HTML) y aún más, no permitirá el uso de directivas o componentes.


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

  3. Angular proporciona ng-template y la directiva estructural *ngTemplateOutlet para *ngTemplateOutlet . Con su ayuda, podemos definir una pieza de HTML que espera que se ingresen algunos datos y pasarlos al componente. Allí será instanciado con un contexto específico. Le transmitiremos nuestro elemento sin preocuparnos por el modelo. Elaborar la plantilla adecuada para sus objetos es tarea del consumidor-desarrollador de nuestro componente.


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

    Una plantilla es una herramienta muy poderosa, pero necesita ser definida en algún componente existente. Esto complica enormemente su reutilización. A veces se requiere la misma apariencia en diferentes partes de la aplicación e incluso en diferentes aplicaciones. En mi práctica, esto, por ejemplo, es la aparición de la selección de cuenta con la visualización del nombre, la moneda y el saldo.


  4. La forma más compleja de personalizar el aspecto que resuelve este problema son los componentes dinámicos. En Angular, la directiva *ngComponentOutlet ha existido durante mucho tiempo para crearlos declarativamente. No permite la transferencia de contexto, pero este problema se resuelve mediante la implementación de dependencias. Podemos hacer un token para el contexto y agregarlo al Injector con el que se crea el componente.


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

    Vale la pena señalar que el contexto puede ser no solo el elemento que queremos mostrar, sino también las circunstancias en las que se encuentra:


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

    Por ejemplo, en el caso del retiro de una cuenta, el estado de enfoque del elemento se refleja en la apariencia: el fondo del icono cambia de gris a blanco. En términos generales, tiene sentido transferir al contexto aquellas condiciones que potencialmente afectan la visualización de la plantilla. Este punto es quizás la única interfaz de limitación de este enfoque.




Salida universal


Las herramientas descritas anteriormente están disponibles en Angular desde la quinta versión. Pero queremos cambiar fácilmente de una opción a otra. Para hacer esto, ensamblaremos un componente que acepte contenido y contexto como entrada e implemente la forma apropiada de insertar este contenido automáticamente. En general, es suficiente para nosotros aprender a distinguir entre tipos string , number , (context: T) => string | number (context: T) => string | number , TemplateRef<T> y Type<any> (pero hay algunos matices aquí, que discutiremos a continuación).


La plantilla del componente se verá así:


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

El código obtendrá un tipo getter para seleccionar el método apropiado. Cabe señalar que, en general, no podemos distinguir un componente de una función. Cuando se usan módulos perezosos, necesitamos un Injector que sepa sobre la existencia del componente. Para hacer esto, crearemos una clase de contenedor. Esto también permitirá determinarlo por instanceof :


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

Agregue un método para crear un inyector con el contexto pasado:


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

En cuanto a las plantillas, en la mayoría de los casos puede trabajar con ellas tal cual. Pero debemos tener en cuenta que la plantilla está sujeta a verificación de cambios en lugar de su definición. Si lo transfiere a la Vista , que es paralela o más alta en el árbol desde el lugar de la definición, los cambios que pueden activarse en ella no se recogerán en la Vista original.


Para corregir esta situación, utilizaremos no solo una plantilla, sino una directiva que también tendrá ChangeDetectorRef su lugar de definición. De esta manera, podemos comenzar a verificar los cambios cuando sea necesario.


Patrones polimórficos


En la práctica, puede ser útil controlar el comportamiento de la plantilla según el tipo de contenido que ingresó.


Por ejemplo, queremos dar la oportunidad de transferir una plantilla a un componente para algo especial. Al mismo tiempo, en la mayoría de los casos, solo necesita un icono. En tal situación, puede configurar el comportamiento predeterminado y usarlo cuando una primitiva o función ingresó a la entrada. A veces, incluso el tipo de primitivo es importante: por ejemplo, si tiene un componente de insignia para mostrar el número de mensajes no leídos en una pestaña, pero desea resaltar las páginas que requieren atención con un icono especial.



Para hacer esto, debe agregar una cosa más: pasar una plantilla para mostrar primitivas. Agregue @ContentChild al componente, que toma TemplateRef del contenido. Si se encuentra uno y se pasa una función, cadena o número al contenido, podemos instanciarlo con el primitivo como contexto:


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

Ahora podemos diseñar la interpolación o incluso pasar el resultado a algún componente para su visualización:


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

Es hora de poner nuestro código en práctica.


Uso


Por ejemplo, describimos dos componentes: pestañas y ComboBox . La plantilla de pestañas consistirá simplemente en una salida de contenido para cada pestaña, donde el objeto pasado por el usuario será el contexto:


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

Debe establecer estilos predeterminados: por ejemplo, tamaño de fuente, subrayado debajo de la pestaña actual, color. Pero dejaremos una apariencia concreta al contenido. El código del componente será algo como esto:


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

Tenemos un componente que puede funcionar con una matriz arbitraria, mostrándolo como pestañas. Simplemente puede pasar cadenas allí y obtener el aspecto básico:



Y puede transferir objetos y una plantilla para mostrarlos y personalizar la apariencia según sus necesidades, agregar HTML, iconos e indicadores:



En el caso de ComboBox, primero haremos dos componentes básicos que consisten en: un campo de entrada con un icono y un menú. Lo último no tiene sentido para pintar en detalle: es muy similar a las pestañas, solo verticalmente y tiene otros estilos básicos. Y el campo de entrada se puede implementar de la siguiente manera:


 <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 coloca la entrada en una posición absolutamente absoluta, bloqueará la salida y todos los clics estarán en ella. Esto es conveniente para un campo de entrada simple con un ícono decorativo, como un ícono de lupa. En el ejemplo anterior, se aplica el enfoque de plantilla polimórfica: la cadena transmitida se usará como innerHTML para insertar el icono SVG. Si, por ejemplo, necesitamos mostrar el avatar del usuario ingresado, podemos transferir la plantilla allí.


ComboBox también necesita un ícono, pero debe ser interactivo. Para evitar que rompa el foco, agregue el controlador onMouseDown a la salida:


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

Pasar la plantilla como contenido nos permitirá elevarla más a través de CSS simplemente haciendo la posición: icono relativo . Luego puede suscribirse a los clics en el ComboBox :


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

Gracias a dicha organización, obtenemos el comportamiento deseado:



El código de componente, como en el caso de las pestañas, prescinde del conocimiento del modelo de datos. Se parece a esto:


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

Este código simple le permite usar cualquier objeto en ComboBox y personalizar su pantalla de manera muy flexible. Después de algunas mejoras que no están relacionadas con el concepto descrito, está listo para usar. La apariencia se puede personalizar para todos los gustos:



Conclusión


La creación de componentes agnósticos elimina la necesidad de tener en cuenta cada caso particular. Al mismo tiempo, los usuarios obtienen una herramienta simple para configurar el componente para una situación específica. Estas soluciones son fáciles de reutilizar. La independencia del modelo de datos hace que el código sea universal, confiable y extensible. Al mismo tiempo, escribimos no tantas líneas y utilizamos principalmente las herramientas angulares integradas.


Usando el enfoque descrito, notará rápidamente lo conveniente que es pensar en términos de contenido en lugar de líneas o patrones específicos. Mostrar mensajes de error de validación, información sobre herramientas, ventanas modales: este enfoque es bueno no solo para personalizar la apariencia, sino también para transferir contenido como un todo. ¡Dibujar diseños y probar la lógica es fácil! Por ejemplo, para mostrar la ventana emergente, el usuario no necesita crear un componente o incluso una plantilla, simplemente puede pasar la cadena de código auxiliar y volver a ella más tarde.


Nosotros en Tinkoff.ru hemos aplicado con éxito el enfoque descrito y lo hemos movido a una pequeña biblioteca de código abierto (1 KB gzip) llamada ng-polymorpheus .


Código fuente


paquete npm


Demo interactiva y sandbox


¿También tiene algo que quería poner en código abierto, pero está asustado por las tareas asociadas? Pruebe el iniciador de biblioteca de código abierto angular , que creamos para nuestros proyectos. Ya tiene CI configurado, verifica confirmaciones, linters, generación de CHANGELOG, cobertura de prueba y todo eso.

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


All Articles