Manejando Observables con Directivas Estructurales en Angular
Mostrando valores reactivos en nuestro template
El manejo de Observables es un tema muy popular en Angular. Hay muchas formas de mostrar valores reactivos en nuestro template, pero no hay ninguna que sea perfecta… Vamos a explorar qué opciones tenemos, cómo funcionan y cómo podemos mejorarlas.
La directiva desarrollada en este artículo está disponible en la biblioteca ngx-observe 📚
Agradecería que le dieras una estrella ⭐️ en GitHub, ya que ayuda a que la gente sepa que existe.
Hay dos soluciones principales para manejar Observables que proporcionan datos a la vista de un componente:
Tomas Trajan ya ha escrito un artículo comparando ambas formas, declarando la segunda como ganadora. Aquí tenéis el artículo original:
The Ultimate Answer To The Very Common Angular Question: subscribe() vs | async Pipe
por Tomas Trajan
NgIf y AsyncPipe trabajan bien juntos, pero no son la pareja ideal. Esta técnica tiene inconvenientes serios:
- Los valores falsy
false
,0
,''
,null
,undefined
emitidos por nuestro Observable van a provocar que se muestre elelse-template
. Esto es porque NgIf no es consciente de la existencia de los Observables, y se dedica simplemente a evaluar lo que recibe de la AsyncPipe. - Solo podemos capturar un valor con NgIf, lo que conlleva no poder acceder a los errores emitidos por nuestro Observable.
- Se utiliza la misma referencia al template tanto para la carga del Observable, como para errores que se produzcan en el flujo, porque ambos desencadenan el
else-template
de NgIf.
Vamos a averiguar cómo funciona exactamente esta técnica, y cómo podemos mejorarla:
Deconstruyendo NgIf y AsyncPipe
Introducir datos reactivos en la vista implica definir el Observable en nuestro componente, y enlazarlo combinando la directiva NgIf y la AsyncPipe mediante la famosa sintaxis as
.
Tenemos que tener en cuenta que no podremos utilizar la AsyncPipe cuando tratemos con Observables que representen una acción — por ejemplo, cuando actualizamos un usuario en función de los clicks en un botón.
Utilizar este método es una bonita forma declarativa de manjear Observables. Vamos a echar un vistazo a las ventajas que tiene, una a una, y ver cómo funcionan.
Adiós al manejo de la suscripción
No tenemos que cancelar la suscripción, ya que nunca nos hemos suscrito manualmente al Observable users$
. De eso se encarga la AsyncPipe: si vemos su código en GitHub, podemos ver cómo se suscribe al Observable recibido, dentro de la función transform()
, y cancela la suscripción en la función ngOnDestroy()
— justo lo que habríamos hecho nosotros de forma manual, bien llamando a subscribe()
y a unsubscribe()
, o con el operador takeUntil.
OnPush Change Detection
Si usamos la AsyncPipe, podemos mejorar el rendimiento configurando nuestro componente para utilizar OnPush
como estrategia de detección de cambios (ChangeDetectionStrategy). Esto no está ligado mágicamente a la AsyncPipe, sino que la pipe desencadena la detección de cambios explícitamente una vez que recibe un valor nuevo del Observable (líneas 140–145 del código fuente.)
A día de hoy no hay documentación oficial acerca del funcionamiento de la detección de cambios OnPush
. Como no me gusta tener que depender de un artículo de terceros (y vosotros tampoco deberíais), vamos a echar un vistazo a más código fuente — o mejor dicho, a unos tests. Hay una test suite para OnPush que nos dice todo lo que necesitamos saber. En este modo, la detección de cambios se ejecuta solo en tres ocasiones:
- Cuando los inputs del componente se reasignan.
- Cuando se desencadenan eventos en el componente o uno de sus hijos.
- Cuando el componente está dirty (sucio), es decir, que está explícitamente marcado para la detección de cambios a través de una llamada a la función
markForCheck()
en un ChangeDetectorRef (justo lo que ocurre en el caso de la AsyncPipe.)
La detección de cambios significa que Angular actualizará los template bindings (enlaces al template) con valores de la instancia del componente. Cuando utilizamos la ChangeDetectionStrategy por defecto, esta acción se ejecuta en muchas ocasiones, no solo en las tres mencionadas anteriormente — por eso el cambiar la estrategia de detección de cambios a OnPush
implica una mejora de rendimiento.
Actualizar los template bindings suele implicar actualizar el DOM, que es una operación relativamente costosa. Por eso, si Angular tiene que hacerlo menos a menudo, nuestra aplicación irá más fluida. Sin embargo, tendremos que indicarle a Angular explícitamente cuándo ocurren los cambios — o delegar esta responsabilidad en la AsyncPipe.
Renderizar templates condicionalmente
NgIf es una directiva estructural — estructural, porque manipula el DOM:
Las directivas estructurales son responsables del layout del HTML. Le dan forma a la estructura del DOM: añadiendo, eliminando o manipulando elementos — Angular Docs
El asterisco (*) delante del nombre de la directiva le indica a Angular que tiene que evaluar la asignación mediante microsyntax (microsintaxis.) Aunque suene complicado, es una manera elegante de decir que se hacen llamadas a setters de JavaScript en la instancia de la directiva. Cada keyword (palabra clave) de una expresión de microsintaxis (como else
para NgIf) corresponde a un setter del código de la directiva. La nomenclatura de los setter sigue un patrón que comienza con el selector de la directiva, seguido por la palabra clave. Para else
, es set ngIfElse(templateRef: TemplateRef<NgIfContext<T>>|null)
, como podéis ver en la línea 187 de los recursos oficiales. Este setter recibe un TemplateRef, que es una referencia a una etiqueta de ng-template
. En nuestro ejemplo anterior, está etiquetada con #loading
. Una directiva estructural puede renderizar referencias al template en la vista, y proporcionar condicionalmente un contexto.
También hay un palabra clave then
que podemos utilizar para asignar dinámicamente un template a la rama truthy. Por defecto NgIf utilizará la etiqueta que se le asigne como template (ver línea 160).
Ahora, cada vez que el Observable emita un nuevo valor, la AsyncPipe se lo pasará a NgIf a través de nuestra expresión de microsintaxis, y desencadenará la re-evaluación. Además, la directiva añadirá el else-template
mientras no reciba ningún valor del Observable (bien porque esté cargando, bien porque haya habido un error), o mientras el valor en sí mismo sea falsy. El then-template
será añadido cuando el Observable emita un valor truthy.
Lo último que nos queda por ver es la palabra clave as
, que resulta que carece de un setter correspondiente en el código fuente de la directiva NgIf. Esto es debido a que no es específica de NgIf, sino que está relacionada con el contexto de una referencia al template. Este contexto es un tipo que declara todas las variables disponibles al renderizar el template. Para NgIf, este tipo es NgIfContext<T>
, y tiene la siguiente estructura:
export class NgIfContext<T> {
public $implicit: T;
public ngIf: T;
}
El tipo genérico T
se refiere al tipo que recibe la directiva. Por tanto, cuando enlazamos 'hello'
, el tipo será string
. Cuando pasamos un Observable<string>
a través de una AsyncPipe, la pipe desenvolverá el Observable y volverá a ser string
.
Podemos obtener cualquier elemento que esté en el contexto del template declarando una variable de input de template, utilizando la palabra clave let
con el siguiente patrón: let-<your-var-name>="<context-property>"
. Aquí tenemos un ejemplo para NgIf:
Aquí tenemos el ejemplo en acción, mostrando que las variables a
, b
, c
and d
serán asignadas a 'hello'
.
La propiedad $implicit
, en cualquier contexto del template, será asignada a una variable de input del template que no referencia una propiedad específica del contexto — en este caso, c
. Es un atajo muy útil para evitarnos el tener que conocer el contexto específico de cada directiva que utilicemos. También explica por qué a
y c
obtienen los mismos valores.
En el caso de NgIf, la propiedad del contexto ngIf
también hace referencia a la condición evaluada. Por tanto también se evalúa a 'hello'
. Esta es la base de la palabra clave as
. Para ser más precisos, Angular crea una variable de input de template basada en el literal que coloquemos después de as
, y la asigna a la propiedad del contexto que tenga el mismo nombre que la propia directiva. De nuevo, no hay documentación oficial disponible para esta funcionalidad, pero sí que hay tests.
Una directiva estructural para Observables
Ya hemos visto que no hay magia en Angular, no hay nada que no podamos implementar nosotros mismos. Así que, vamos a crear algo específico para renderizar Observables en templates, y después explorarlo paso a paso:
Vamos a crear también un ejemplo mostrando cómo utilizar nuestra directiva:
Empezando con el constructor, vemos que tenemos una ViewContainerRef, que nos permitirá manipular el DOM mediante el renderizado de templates.
Angular también nos proporciona una referencia a la etiqueta del template, en la que hemos puesto *observe
. En nuestro ejemplo, es la etiqueta del p
que está enlazando el valor del Observable. Podemos llamarla nextRef
, ya que mostrará el valor next
del Observable, y tipar su contexto de manera muy similar a como se hace para el NgIf. El ObserveContext<T>
se tipará genéricamente en función del Observable recibido, y enviará los valores emitidos por este bien a una variable de input del template implícita o bien a través de la palabra clave as
(ya que hay una propiedad del contexto que se llama igual que nuestra directiva).
También inyectaremos un ChangeDetectorRef
para que podamos hacer que nuestra directiva funcione con la estrategia de detección de cambios OnPush
.
Los setters observeError
y observeBefore
siguen la nomenclatura de la microsintaxis y se pueden utilizar para pasar templates que se mostrarán antes de que el Observable haya emitido un valor (así que básicamente, mientras se está cargando) y cuando el Observable tenga un error.
En el primer caso, no podemos proporcionar un contexto significativo, por eso el TemplateRef
para observeBefore
tiene un parámetro genérico de null
. Vamos a renderizar este template sin contexto, llamando a view.createEmbeddedView()
,como podéis ver en showBefore()
. También nos encargaremos de limpiar la vista antes, con clear()
, para evitar que se rendericen varios templates a la vez.
En el caso de que haya un error, podemos proporcionar un contexto que contenga el error, en la propiedad $implicit
, mencionada anteriormente. Vamos a crear otro tipo para este contexto específico llamado ErrorContext
, y lo utilizaremos para estrechar el correspondiente TemplateRef
que le pasamos a observeError
. Esto nos permite definir la variable de input de template let-error
, que podemos ver en nuestro ejemplo.
El AsyncSubject<void>
llamado init
es simplemente un wrapper observable sobre el OnInit hook. Una vez se complete dentro de ngOnInit()
, siempre emitirá sobre la suscripción. Con esto evitamos renderizar un template demasiado pronto.
El setter observe
es donde empieza a ponerse interesante la cosa. Es el setter principal de nuestra directiva, y en nuestro ejemplo recibe el Observable users$
. Cuando recibe un source
, cualquier suscripción que existiese anteriormente es cancelada mediante el this.unsubscribe.next(true)
combinado con el operador takeUntil
— muy similar a la manera en la que cancelaríamos suscripciones en el ngOnDestroy()
de forma manual. Entonces, nos aseguraremos de esperar al ngOnInit()
, haciendo un pipe sobre init
, y después mapeando al Observable recibido mediante el operador RxJS concatMapTo. Este operador espera a que el Observable anterior complete, antes de ponerse a ‘escuchar’ al siguiente.
Entonces, nos suscribimos al Observable y cada vez que obtenemos un nuevo valor, actualizaremos la vista: primero limpiándola, y después creando una vista embebida basada en nuestro template con un contexto que contenga el valor. Por último, avisaremos al detector de cambios con markForCheck()
para dar soporte a la detección OnPush
.
Cuando ocurra un error, haremos casi lo mismo, pero con el template para mostrar errores y solamente dando apoyo a una variable de input implícita — siempre y cuando haya un template de error disponible.
Conclusión
Nuestra directiva nueva está mejor preparada para manejar Observables que la combianción de NgIf y AsyncPipe:
- Muestra valores falsy.
- Nos permite definir templates diferentes para la carga y para errores.
- Nos permite acceder a los errores desde el interior del template de error.
Aquí tenéis un ejemplo en StackBlitz mostrando la directiva en acción. Creo que es más útil que NgIf+AsyncPipe en determinadas situaciones. De todas formas, hemos aprendido mucho sobre directivas estructurales y detección de cambios, lo que nos va a permitir entender el funcionamiento de Angular en profundidad.
Si tenéis preguntas, no dudéis en dejar un comentario o hacerme ping en Twitter. También podéis uniros a mi mailing list para estar siempre al corriente de mis artículos y consejos sobre Angular y desarrollo web en general.
Nota de la editora: Este artículo ha sido publicado originalmente por Nils MehlHorn en su blog: Handling Observables with Structural Directives in Angular.
Traducción por Estefanía García Gallardo.