
Hoy analizaremos en detalle una aplicación angular reactiva (
repositorio github ), escrita completamente sobre la estrategia
OnPush . Otra aplicación usa formas reactivas, lo cual es bastante típico para una aplicación empresarial.
No utilizaremos Flux, Redux, NgRx y, en cambio, aprovecharemos las capacidades ya disponibles en Typecript, Angular y RxJS. El hecho es que estas herramientas no son una bala de plata y pueden agregar complejidad innecesaria incluso a aplicaciones simples. Honestamente,
uno de los autores de Flux , el
autor de Redux y el
autor de NgRx nos advirtieron sobre esto.
Pero estas herramientas le dan a nuestras aplicaciones características muy agradables:
- Flujo de datos predecible;
- Soporte OnPush por diseño;
- La inmutabilidad de los datos, la falta de efectos secundarios acumulados y otras cosas agradables.
Intentaremos obtener las mismas características, pero sin introducir una complejidad adicional.
Como verá al final del artículo, esta es una tarea bastante simple: si elimina los detalles de Angular y OnPush del artículo, solo hay algunas ideas simples.
El artículo no ofrece un nuevo patrón universal, sino que solo comparte con el lector varias ideas que, por toda su simplicidad, por alguna razón no se le ocurrieron de inmediato. Además, la solución desarrollada no contradice ni reemplaza Flux / Redux / NgRx. Se pueden conectar, si esto es
realmente necesario .
Para una lectura cómoda del artículo, se requiere una comprensión de los términos componentes inteligentes, de presentación y de contenedor .Plan de acción
La lógica de la aplicación, así como la secuencia de presentación del material, se pueden describir en la forma de los siguientes pasos:
- Datos separados para lectura (GET) y escritura (PUT / POST)
- Estado de carga como flujo en componente contenedor
- Distribuir estado a una jerarquía de componentes OnPush
- Notificar a Angular sobre cambios en los componentes
- Edición de datos encapsulados
Para implementar OnPush, necesitamos analizar todas las formas de ejecutar la detección de cambios en Angular. Solo hay cuatro de estos métodos, y los consideraremos sucesivamente a lo largo del artículo.
Entonces vamos.
Compartir datos para leer y escribir
Por lo general, las aplicaciones frontend y backend usan contratos escritos (de lo contrario, ¿por qué mecanografiar?)
El proyecto de demostración que estamos considerando no tiene un backend real, pero contiene un archivo de descripción preparado previamente
swagger.json . En función de ello, la utilidad
sw2dts genera contratos mecanografiados.
Los contratos generados tienen dos propiedades importantes.
En primer lugar, la lectura y la escritura se realizan utilizando diferentes contratos. Usamos una pequeña convención y nos referimos a leer contratos con el sufijo "Estado", y escribir contratos con el sufijo "Modelo".
Al separar los contratos de esta manera, estamos compartiendo el flujo de datos en la aplicación. De arriba a abajo, un estado de solo lectura se propaga a través de la jerarquía de componentes. Para modificar los datos, se crea un modelo que se llena inicialmente con datos del estado, pero que existe como un objeto separado. Al final de la edición, el modelo se envía al backend como un comando.
El segundo punto importante es que todos los campos de estado están marcados con un modificador de solo lectura. Por lo tanto, recibimos inmunidad a nivel mecanografiado. Ahora no podemos cambiar accidentalmente el estado en el código o enlazarlo usando [(ngModel)] - al compilar la aplicación en modo AOT, obtendremos un error.
Estado de carga como flujo en componente contenedor
Para cargar e inicializar el estado, utilizaremos servicios angulares ordinarios. Serán responsables de los siguientes escenarios:
- Un ejemplo clásico es cargar a través de HttpClient usando el parámetro id obtenido por el componente del enrutador.
- Inicializando un estado vacío al crear una nueva entidad. Por ejemplo, si los campos tienen valores predeterminados o para inicializar, debe solicitar datos adicionales del back-end.
- Reiniciar un estado ya cargado después de que el usuario realiza una operación que cambia los datos al backend.
- Reinicio del estado mediante notificación push, por ejemplo, al coeditar datos. En este caso, el servicio fusiona el estado local y el estado obtenido del backend.
En la aplicación de demostración, consideraremos los dos primeros escenarios como los más típicos. Además, estos escenarios son simples y permiten que el servicio se implemente como simples objetos sin estado y no se distraiga por la complejidad, que no es el tema de este artículo en particular.
Un ejemplo de un servicio se puede encontrar en el archivo
some-entity.service.ts .
Queda por obtener el servicio a través de DI en el componente contenedor y el estado de carga. Esto generalmente se hace así:
route.params .pipe( pluck('id'), filter((id: any) => { return !!id; }), switchMap((id: string) => { return myFormService.get(id); }) ) .subscribe(state => { this.state = state; });
Pero con este enfoque, surgen dos problemas:
- Debe darse de baja manualmente de la suscripción creada, de lo contrario se producirá una pérdida de memoria.
- Si cambia el componente a la estrategia OnPush, dejará de responder a la carga de datos.
La tubería asíncrona viene al rescate. Él escucha directamente al Observable y se da de baja de él cuando es necesario. Además, cuando se utiliza una tubería asíncrona, Angular activa automáticamente la detección de cambios cada vez que el Observable publica un nuevo valor.
Un ejemplo de uso de tubería asíncrona se puede encontrar en la plantilla para
algún componente de entidad .
Y en el código del componente, eliminamos la lógica repetida en operadores RxJS personalizados, agregamos el script para crear un estado vacío, fusionamos ambas fuentes de estado en una secuencia con el operador de fusión y creamos un formulario para editar, que discutiremos más adelante:
this.state$ = merge( route.params.pipe( switchIfNotEmpty("id", (requestId: string) => requestService.get(requestId) ) ), route.params.pipe( switchIfEmpty("id", () => requestService.getEmptyState()) ) ).pipe( tap(state => { this.form = new SomeEntityFormGroup(state); }) );
Esto es todo lo que debía hacerse en el componente contenedor. Y colocamos en la alcancía la primera forma de llamar a la detección de cambios en el componente OnPush: tubería asíncrona. Nos será útil más de una vez.
Distribuir estado a una jerarquía de componentes OnPush
Cuando necesita mostrar un estado complejo, creamos una jerarquía de componentes pequeños: así es como lidiamos con la complejidad.
Como regla general, los componentes se dividen en una jerarquía similar a la jerarquía de datos, y cada componente recibe su propio dato a través de los parámetros de entrada para mostrarlos en la plantilla.
Como vamos a implementar todos los componentes como OnPush, divaguemos por un momento y discutamos qué es y cómo funciona Angular con los componentes de OnPush. Si ya conoce este material, no dude en desplazarse hasta el final de la sección.
Durante la compilación de la aplicación, Angular genera un detector de cambio de clase especial para cada componente, que "recuerda" todos los enlaces utilizados en la plantilla del componente. En tiempo de ejecución, la clase generada comienza a verificar las expresiones almacenadas con cada bucle de detección de cambio. Si la comprobación mostró que el resultado de cualquier expresión ha cambiado, Angular vuelve a dibujar el componente.
De forma predeterminada, Angular no sabe nada acerca de nuestros componentes y no puede determinar a qué componentes afectará, por ejemplo, el setTimeout que se acaba de activar o una solicitud AJAX que ha finalizado. Por lo tanto, se ve obligado a verificar toda la aplicación literalmente para cada evento dentro de la aplicación, incluso un simple desplazamiento de ventana desencadena repetidamente la detección de cambios para toda la jerarquía de componentes de la aplicación.
Aquí radica una fuente potencial de problemas de rendimiento: cuanto más complejas son las plantillas de componentes, más difíciles son las comprobaciones del detector de cambios. Y si hay muchos componentes y las comprobaciones se ejecutan con frecuencia, la detección de cambios comienza a tomar un tiempo considerable.
Que hacer
Si el componente no depende de ningún efecto global (por cierto, es mejor diseñar los componentes de esta manera), entonces su estado interno está determinado por:
- Parámetros de entrada ( @Input );
- Eventos que ocurrieron en el componente mismo ( @Output ).
Pospondremos el segundo punto por ahora y supondremos que el estado de nuestro componente depende solo de los parámetros de entrada.
Si todos los parámetros de entrada del componente son objetos inmutables, entonces podemos marcar el componente como OnPush. Luego, antes de ejecutar la detección de cambios, Angular verificará si los enlaces a los parámetros de entrada del componente han cambiado desde la verificación anterior. Y, si no han cambiado, Angular omitirá la detección de cambios para el componente en sí y todos sus componentes secundarios.
Por lo tanto, si creamos nuestra aplicación completa de acuerdo con la estrategia de OnPush, eliminaremos toda una clase de problemas de rendimiento desde el principio.
Dado que el estado en nuestra aplicación ya es inmutable, los objetos inmutables también se transfieren a los parámetros de entrada de los componentes secundarios. Es decir, estamos listos para habilitar OnPush para componentes secundarios y responderán a los cambios de estado.
Por ejemplo, estos son
componentes readonly-info.component y
nested-items.componentAhora veamos cómo implementar el cambio en el estado de los componentes en el paradigma OnPush.
Hable con Angular sobre su condición.
Estado de presentación: estos son los parámetros responsables de la apariencia del componente: indicadores de carga, indicadores de visibilidad de elementos o accesibilidad para el usuario de una acción en particular, pegados desde tres campos a una línea del nombre del usuario, etc.
Cada vez que cambia el estado de presentación de un componente, debemos notificar a Angular para que pueda mostrar los cambios en la interfaz de usuario.
Dependiendo de cuál sea la fuente del estado del componente, hay varias formas de notificar a Angular.
Estado de presentación, calculado en base a parámetros de entrada
Esta es la opción más fácil. Ponemos la lógica de cálculo del estado de presentación en el gancho ngOnChanges. La detección de cambios comenzará por sí misma cambiando los parámetros de entrada @. En la demostración, esto es
readonly-info.component .
export class ReadOnlyInfoComponent implements OnChanges { @Input() public state: Backend.SomeEntityState; public traits: ReadonlyInfoTraits; public ngOnChanges(changes: { state: SimpleChange }): void { this.traits = new ReadonlyInfoTraits(changes.state.currentValue); } }
Todo es extremadamente simple, pero hay un punto al que se debe prestar atención.
Si el estado de presentación del componente es complejo, y especialmente si algunos de sus campos se calculan sobre la base de otros, también calculados por los parámetros de entrada, coloque el estado del componente en una clase separada, hágalo inmutable y vuelva a crear ngOnChanges cada vez que se inicia. En un proyecto de demostración, un ejemplo es la clase
ReadonlyInfoComponentTraits . Con este enfoque, se protege de la necesidad de sincronizar datos dependientes cuando cambian.
Al mismo tiempo, vale la pena considerarlo: quizás el componente tiene un estado difícil debido al hecho de que tiene demasiada lógica. Un ejemplo típico es un intento en un componente de ajustar representaciones para diferentes usuarios que tienen formas muy diferentes de trabajar con el sistema.
Eventos nativos componentes
Para la comunicación entre los componentes de la aplicación, utilizamos eventos de salida. Esta es también la tercera forma de ejecutar la detección de cambios. Angular asume razonablemente que si un componente genera un evento, entonces algo podría haber cambiado en su estado. Por lo tanto, Angular escucha todos los eventos de salida de componentes y activa la detección de cambios cuando ocurren.
En el proyecto de demostración, es completamente sintético, pero un ejemplo es el componente
submit-button.component , que
genera un evento
formSaved . El componente contenedor se suscribe a este evento y muestra una alerta con una notificación.
Utilice los eventos de salida para su propósito previsto, es decir, créelos para la comunicación con los componentes principales y no para activar la detección de cambios. De lo contrario, es probable, después de meses y años, no recordar por qué este evento es innecesario para nadie aquí, y eliminarlo, rompiendo todo.
Cambios en componentes inteligentes
A veces, el estado de un componente está determinado por una lógica compleja: llamar asincrónicamente al servicio, conectarse a un socket web, verificaciones que se ejecutan a través de setInterval, pero nunca se sabe qué más. Dichos componentes se denominan componentes inteligentes.
En general, mientras menos componentes inteligentes en la aplicación no sean componentes de contenedor, más fácil será vivir. Pero a veces no puedes prescindir de ellos.
La forma más simple de asociar el estado de un componente inteligente con la detección de cambios es convertirlo en un Observable y usar la
tubería asíncrona ya discutida anteriormente. Por ejemplo, si la fuente de los cambios es una llamada de servicio o un estado de formulario reactivo, entonces este es un Observable listo para usar. Si el estado se forma a partir de algo más complejo, puede usar
fromPromise ,
websocket ,
timer ,
intervalo desde la composición de RxJS. O genere una secuencia usted mismo usando
Asunto .
Si ninguna de las opciones es adecuada
En los casos en que ninguno de los tres métodos ya estudiados es adecuado, todavía tenemos una opción a prueba de balas: usar
ChangeDetectorRef directamente. Estamos hablando de los métodos detectChanges y markForCheck de esta clase.
La documentación completa responde a todas las preguntas, por lo que no nos detendremos en su trabajo. Pero tenga en cuenta que el uso de
ChangeDetectorRef debe limitarse a casos en los que entienda claramente lo que está haciendo, ya que esta sigue siendo la cocina angular interna.
Durante todo el tiempo encontramos solo unos pocos casos en los que este método puede ser necesario:
- Trabajo manual con detección de cambios: se utiliza en la implementación de componentes de bajo nivel y es solo el caso "usted comprende claramente lo que está haciendo".
- Relaciones complejas entre componentes, por ejemplo, cuando necesita crear un enlace a un componente en una plantilla y pasarlo como parámetro a otro componente ubicado más arriba en la jerarquía o incluso en otra rama de la jerarquía de componentes. ¿Suena complicado? Así es Y es mejor simplemente refactorizar dicho código, porque traerá dolor no solo con la detección de cambios.
- Los detalles del comportamiento de Angular en sí mismo: por ejemplo, al implementar un ControlValueAccessor personalizado , es posible que Angular cambie el valor de control de forma asincrónica y que los cambios no se apliquen al ciclo de detección de cambio deseado.
Como ejemplos de uso en la aplicación de demostración, existe la clase base
OnPushControlValueAccessor , que resuelve el problema descrito en el último párrafo. También en el proyecto hay un heredero de esta clase:
componente de botón de radio personalizado.
Ahora hemos discutido las cuatro formas de ejecutar la detección de cambios y las opciones de implementación de OnPush para los tres tipos de componentes: contenedor, inteligente, presentacional. Pasamos al punto final: editar datos con formas reactivas.
Edición de datos encapsulados
Las formas reactivas tienen varias limitaciones, pero aún así esta es una de las mejores cosas que sucedieron en el ecosistema angular.
En primer lugar, encapsulan el buen funcionamiento con el estado y proporcionan todas las herramientas necesarias para responder a los cambios de manera reactiva.
De hecho, la forma reactiva es una especie de mini-tienda que encapsula el trabajo con el estado: datos y estados deshabilitados / válidos / pendientes.
Nos queda por apoyar esta encapsulación tanto como sea posible y evitar mezclar la lógica de presentación y la lógica del formulario.
En la aplicación de demostración, puede ver
clases de formulario individuales que encapsulan los detalles de su trabajo: validación, creación de grupos de formularios secundarios, trabajo con el estado deshabilitado de los campos de entrada.
Creamos el formulario raíz en el componente contenedor en el momento en que se carga el estado, y con cada reinicio del estado, se recrea el formulario. Esto no es un requisito previo, pero de esta manera podemos estar seguros de que no hay efectos acumulados en la lógica de formulario que queda del estado cargado anterior.
Dentro del formulario mismo, construimos los controles y "empujamos" los datos que provienen de ellos, convirtiéndolos del contrato estatal al contrato modelo. La estructura de los formularios, en la medida de lo posible, coincide con los contratos de los modelos. Como resultado, la propiedad de valor del formulario nos da un modelo listo para enviar al backend.
Si en el futuro el estado o la estructura del modelo cambian, obtendremos un error de compilación de mecanografiado exactamente en el lugar donde necesitamos agregar / eliminar campos, lo cual es muy conveniente.
Además, si los objetos de estado y modelo tienen una estructura absolutamente idéntica, la tipificación estructural utilizada en el mecanografiado elimina la necesidad de construir un mapeo sin sentido de uno a otro.
Total, la lógica de formulario está aislada de la lógica de presentación en componentes y vive "por sí misma", sin aumentar la complejidad del flujo de datos de nuestra aplicación como un todo.
Eso es casi todo. Quedan casos límite cuando no podemos aislar la lógica del formulario del resto de la aplicación:
- Cambios en la forma que conducen a un cambio en el estado de presentación, por ejemplo, la visibilidad de un bloque de datos dependiendo del valor ingresado. Lo implementamos en el componente suscribiéndonos para formar eventos. Puede hacerlo a través de los rasgos inmutables discutidos anteriormente.
- Si necesita un validador asíncrono que llame al backend, construimos AsyncValidatorFn en el componente y lo pasamos al constructor del formulario, no al servicio.
Por lo tanto, toda la lógica "límite" permanece en el lugar más destacado: en los componentes.
Conclusiones
Resumamos lo que obtuvimos y qué otros puntos hay para estudiar y desarrollar.
En primer lugar, el desarrollo de la estrategia OnPush nos obliga a diseñar cuidadosamente el flujo de datos de la aplicación, ya que ahora estamos dictando las reglas del juego para Angular, y no para él.
Hay dos consecuencias para esta situación.
En primer lugar, tenemos una agradable sensación de control sobre la aplicación. Ya no hay ninguna magia que "de alguna manera funcione". Usted es claramente consciente de lo que está sucediendo en cualquier momento en su solicitud. La intuición se está desarrollando gradualmente, lo que le permite comprender el motivo del error encontrado, incluso antes de abrir el código.
En segundo lugar, ahora tenemos que pasar más tiempo diseñando la aplicación, pero el resultado siempre será la solución más "directa" y, por lo tanto, la más simple. Esto reduce notablemente la probabilidad de una situación en la que, a medida que la aplicación crece, se convierte en un monstruo de enorme complejidad, los desarrolladores han perdido el control de esta complejidad y el desarrollo ahora se parece más a ritos místicos.
La complejidad controlada y la ausencia de "magia" reducen la probabilidad de que surja una clase completa de problemas, por ejemplo, de actualizaciones de datos cíclicos o efectos secundarios acumulados. En cambio, estamos lidiando con problemas que ya son notables durante el desarrollo, cuando la aplicación simplemente no funciona. Y, por lo tanto, debe hacer que la aplicación funcione de manera simple y clara.
También mencionamos buenos efectos sobre el rendimiento. Ahora, usando herramientas muy simples, como
profiler.timeChangeDetection , podemos verificar en cualquier momento que nuestra aplicación todavía está en buena forma.
Además, ahora es un pecado no intentar
deshabilitar NgZone . En primer lugar, le permitirá no cargar toda la biblioteca al iniciar la aplicación. En segundo lugar, eliminará una buena cantidad de magia de su aplicación.
Ahí es donde terminamos nuestra historia.
Estaremos en contacto!