Carga de Componentes Dinámica en Angular con Ivy
Tags: angular, typescript, webdev
Published: 2020-12-29 20:45:44

Este artículo te mostrará cómo comenzar a cargar componentes dinámicamente usando Angular con Ivy. Esto no es exactamente nuevo y exclusivo de Angular 9+, ya que ahora podemos tener componentes sin módulo y al hacer que se carguen dinámicamente obtenemos los beneficios de la carga diferida(_lazy loading_).

> Para abreviar, reduciremos el tamaño del paquete principal(_bundle size_) cargando solo los componentes que necesitamos.

Imagina que tienes un módulo muy grande que consta de varios componentes. Cada usuario tiene necesidades únicas, lo que significa que solo usará un subconjunto de todos los componentes disponibles. El objetivo de este artículo es explorar una posible solución para abordar este problema.

Para hacerlo más fácil, trabajemos en un caso de uso conocido.

Si deseas ir directamente al código, se ha creado un repositorio con la [versión final de la aplicación](https://github.com/danmt/dynamic-component-loader). Se ve como sigue:

![representacion visual de la aplicacion que se va a desarrollar](//images.ctfassets.net/zojzzdop0fzx/6hmB9STqj5BFvDt5EuiKPC/954682af6195a4ce8dd1953252dc4f79/Peek_2020-03-21_19-48.gif)

### El Problema
Supongamos que tenemos esta aplicación, con la que los usuarios pueden iniciar sesión y realizar algunas acciones. Independientemente de si el usuario es un invitado o un usuario registrado, ambos tienen una página de perfil. Cada tipo de usuario tiene diferentes acciones que puede realizar.

### La Solución
Una forma de resolver este problema sería usando condicionales con la ayuda de la directiva estructural `ngIf`. Esto permite tener un diseño diferente para cada uno. Funciona, pero ¿Es la mejor solución? Recuerda que ahora ambos usuarios tienen que descargar todo el componente junto a  las acciones, tanto si las utilizan como si no.

> Una aclaración, se ha utilizado la estrategia `ngIf` en aplicaciones que llevan años en producción.

Hagamos algo diferente esta vez. Vamos a crear un componente para cada tipo de usuario y los cargaremos dinámicamente. De esta manera, el paquete principal no tendrá ninguno de ellos y se descargarán bajo demanda.

### Implementación
Es hora de la diversión. Antes de comenzar, asegúrate de instalar [Angular CLI](https://cli.angular.io/). Si necesitas ayuda en este paso, simplemente deje un comentario. Una vez tengas Angular CLI instalado, sigue estos pasos:

* Abre la terminal de tu elección.
* Ejecuta el comando `ng new {your-app-name}`
* Abre el nuevo proyecto en el editor de tu preferencia.

Comencemos con la carga de componentes. Vamos a crear un nuevo servicio llamado `AppService`. Una vez que lo hayas creado, abre el archivo `src/app/app.service.ts` en tu editor y pega lo siguiente:

```ts
import {
  Injectable,
  ComponentFactoryResolver,
  ViewContainerRef
} from '@angular/core';
import { from } from 'rxjs';
import { map } from 'rxjs/operators';

export interface ComponentLoader {
  loadChildren: () => Promise<any>;
}

@Injectable({
  providedIn: 'root'
})
export class AppService {
  constructor(private cfr: ComponentFactoryResolver) {}

  forChild(vcr: ViewContainerRef, cl: ComponentLoader) {
    return from(cl.loadChildren()).pipe(
      map((component: any) => this.cfr.resolveComponentFactory(component)),
      map(componentFactory => vcr.createComponent(componentFactory))
    );
  }
}
```

A primera vista se ve `ComponentFactoryResolver`, `ViewContainerRef`, `ComponentLoader` y piensas:

> ¿Qué clase de brujería es esta?

Es más simple de lo que puedas pensar. Es solo que hay algunas cosas nuevas. Estamos inyectando `ComponentFactoryResolver`, el cual, dado un Componente, retorna un _Factory_(fábrica) que se puede usar para crear nuevas instancias del mismo. `ViewContainerRef` es una referencia a un elemento en el que vamos a insertar el componente recién creado. `ComponentLoader` es una interfaz sencilla, la cual define una función `loadChildren` que retorna un _Promise_(promesa). Esta promesa, una vez resuelta, retorna un `Component`.

Finalmente, estamos uniendo todo. Usando la función `from` de RxJS, se puede transformar la promesa en un _Observable_. Luego, este componente se mapea en un _Factory_(fábrica), y finalmente se inyecta el componnte y retorna la instancia.

Ahora creamos otro servicio llamado `ProfileService` que usará `AppService` para cargar el componente respectivo. También mantiene el estado de inicio de sesión. Crea un archivo en `src/app/profile/profile.service.ts`:

```ts
import { Injectable, ViewContainerRef } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { AppService } from '../app.service';

@Injectable({ providedIn: 'root' })
export class ProfileService {
  private isLoggedIn = new BehaviorSubject(false);
  isLoggedIn$ = this.isLoggedIn.asObservable();

  constructor(private appService: AppService) {}

  private guestProfile() {
    return () =>
      import('./guest-profile/guest-profile.component').then(
        m => m.GuestProfileComponent
      );
  }

  private clientProfile() {
    return () =>
      import('./client-profile/client-profile.component').then(
        m => m.ClientProfileComponent
      );
  }

  login() {
    this.isLoggedIn.next(true);
  }

  logout() {
    this.isLoggedIn.next(false);
  }

  loadComponent(vcr: ViewContainerRef, isLoggedIn: boolean) {
    vcr.clear();

    return this.appService.forChild(vcr, {
      loadChildren: isLoggedIn ? this.clientProfile() : this.guestProfile()
    });
  }
}
```

Este servicio es más simple que el anterior. Se crea un `Subject` para manejar el estado dado por `isLoggedIn` junto a dos métodos para eventos relacionados. Se definen dos métodos privados que retornan una función que devuelve un _Promise_(promesa) de un `Component`(component).

> Sí, al igual que la interfaz ComponentLoader.

Finalmente, un método mágico: `loadComponent` toma un `ViewContainerRef` y el estado `isLoggedIn`. Limpia `ViewContainerRef`, vaciándolo por completo. Entonces se llama al método `forChild` desde `AppService` con el `ViewContainerRef` que acabamos de limpiar, y para el `ComponentLoader`, tiene una expresión ternaria que determina qué `Component` cargar.

Para facilitar la carga de los componentes, vamos a crear una directiva que ayuda con este proceso. Crea un archivo `src/app/profile/profile-host.directive.ts`:

```ts
import { Directive, ViewContainerRef } from '@angular/core';

@Directive({ selector: '[appProfileHost]' })
export class ProfileHostDirective {
  constructor(public viewContainerRef: ViewContainerRef) {}
}
```

Esto es solo un truco para facilitar la obtención del `ViewContainerRef` que estamos buscando. Ahora crea un archivo `src/app/profile/profile.component.ts`:

```ts
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core';
import { ProfileHostDirective } from './profile-host.directive';
import { ProfileService } from './profile.service';
import { mergeMap, takeUntil } from 'rxjs/operators';
import { Subject } from 'rxjs';

@Component({
  selector: 'app-profile-container',
  template: `
    <ng-template appProfileHost></ng-template>
  `
})
export class ProfileComponent implements OnInit, OnDestroy {
  @ViewChild(ProfileHostDirective, { static: true })
  profileHost: ProfileHostDirective;
  private destroySubject = new Subject();

  constructor(private profileService: ProfileService) {}

  ngOnInit() {
    const viewContainerRef = this.profileHost.viewContainerRef;

    this.profileService.isLoggedIn$
      .pipe(
        takeUntil(this.destroySubject),
        mergeMap(isLoggedIn =>
          this.profileService.loadComponent(viewContainerRef, isLoggedIn)
        )
      )
      .subscribe();
  }

  ngOnDestroy() {
    this.destroySubject.next();
    this.destroySubject.complete();
  }
}
```

Todo lo que estamos haciendo aquí es crear una plantilla vía `ng-template` en la que adjuntamos la directiva `ProfileHostDirective`, para que se pueda usar el decorador `ViewChild` y obtener el objeto `viewContainerRef`. En la función `OnInit` se obtiene el `viewContainerRef`, y usamos el observable `isLoggedIn$` de `ProfileService` para saber cada vez que cambia el estado de `isLoggedIn`. Luego, usando el operador `mergeMap`, se invoca a la función `loadComponent` que está haciendo la verdadera magia.

Si observas el archivo `src/app/profile/profile.service.ts`, notarás que se hace referencia a los components `GuestProfileComponent` y `ClientProfileComponent`. Ahora es el momento de crearlos.

Primero, ve al archivo a `src/styles.scss` e incluye:

```scss
html,
body {
  margin: 0;
  padding: 0;
}
```

Para facilitar los estilos, se ha creado una carpeta de estilos dentro del directorio de _assets_, en el que se encuentran dos archivos `.scss`:

* _variables.scss
* _mixins.scss

Ambos contienen todos los estilos compartidos, para que sea más fácil de mantener:

```scss
// _variables.scss
$card-width: 400px;
$avatar-width: 80px;
$container-margin: 20px;
```

```scss
// _mixins.scss
@import './variables.scss';

@mixin button($color) {
  display: inline-block;
  padding: 0.5rem 1rem;
  border: 1px solid $color;
  border-bottom-color: darken($color, 10);
  border-radius: 5px;
  background: linear-gradient(180deg, $color, darken($color, 10));
  color: white;
  cursor: pointer;
  font-family: Arial, Helvetica, sans-serif;
  box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.2);
  font-size: 1rem;

  &:hover {
    background: $color;
    box-shadow: 1px 4px 6px rgba(0, 0, 0, 0.2);
  }

  &:active {
    background: darken($color, 10);
  }
}

@mixin card {
  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
  border: 1px solid #eee;
  width: $card-width;
  padding: 1rem;
}
```

También se crea una carpeta de imágenes y se incluye una imagen de nombre `profile.png`. Puedes usar cualquier imagen siempre que sea cuadrada.

Vayamos a crear el componente `GuestProfileComponent`. Para esto necesitaremos tres archivos; una plantilla, una hoja de estilos y un archivo TypeScript. Empecemos con la plantilla: crea un archivo `src/app/profile/guest-profile/guest-profile.component.html` con lo siguiente:

```html
<section class="card">
  <div class="card__avatar">
    <div class="card__avatar__head"></div>
    <div class="card__avatar__body"></div>
  </div>

  <div class="container">
    <h2 class="card__title">Guest Profile</h2>

    <p class="card__subtitle">
      Thank you for visiting us. If you want to take your experience to the next
      level, all you need is to log in.
    </p>

    <div class="card__toolbar">
      <button (click)="login()">Login</button>
    </div>
  </div>
</section>
```

Ahora creamos la hoja de estilos en `src/app/profile/guest-profile/guest-profile.component.scss`:

```scss
@import '~src/assets/styles/mixins.scss';

.card {
  display: flex;
  @include card();

  &__title {
    margin: 0 0 0.5rem 0;
  }

  &__subtitle {
    margin: 0 0 0.5rem 0;
  }

  &__toolbar button {
    @include button(#145092);
  }

  &__avatar {
    height: 80px;
    width: $avatar-width;
    border: 2px solid #bbb;
    background: #666;
    position: relative;
    overflow: hidden;

    &__head {
      position: absolute;
      border-radius: 50%;
      background: #bbb;
      width: 35px;
      height: 35px;
      top: 15px;
      left: 22px;
    }

    &__body {
      position: absolute;
      border-radius: 50%;
      background: #bbb;
      width: 70px;
      height: 50px;
      top: 55px;
      left: 5px;
    }
  }
}

.container {
  width: $card-width - $avatar-width - $container-margin;
  margin: 0 $container-margin;
}
```

Finalmente, el archivo TypeScript en `src/app/profile/guest-profile/guest-profile.component.ts`:

```ts
import { Component } from '@angular/core';
import { ProfileService } from '../profile.service';

@Component({
  selector: 'app-guest-profile',
  templateUrl: './guest-profile.component.html',
  styleUrls: ['./guest-profile.component.scss']
})
export class GuestProfileComponent {
  constructor(private profileService: ProfileService) {}

  login() {
    this.profileService.login();
  }
}
```

Todo lo que tenemos que hacer ahora es crear el compoente `ClientProfileComponent`. Necesitaremos los mismos archivos que el componente `GuestProfileComponent`. Empecemos con la plantilla `src/app/profile/client-profile/client-profile.component.html`:

```html
<section class="card">
  <figure class="card__avatar">
    <img src="assets/images/profile.png" />
  </figure>

  <h2 class="card__title" contenteditable="true">Daniel Marin</h2>

  <p class="card__subtitle" contenteditable="true">
    Senior Software Engineer at This Dot Labs, a company specializing in Modern
    Web Technologies, designing, and developing software to help companies
    maximize efficiency in their processes.
  </p>

  <div class="card__toolbar">
    <button (click)="logout()">Logout</button>
  </div>
</section>
```

Ahora creamos la hoja de estilos en `src/app/profile/client-profile/client-profile.component.scss`:

```scss
@import '~src/assets/styles/mixins.scss';

.card {
  @include card();

  &__avatar {
    height: $avatar-width;
    width: $avatar-width;
    margin: 0 auto;
    border-radius: 50%;
    overflow: hidden;

    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  }

  &__title {
    margin: 1rem 0 0.5rem 0;
    text-align: center;
  }

  &__subtitle {
    margin: 0 0 1rem 0;
    text-align: center;
  }

  &__toolbar {
    display: flex;
    justify-content: center;

    button {
      @include button(#a80000);
    }
  }
}
```

Y finalmente, el archivo TypeScript en `src/app/profile/client-profile/client-profile.component.ts`:

```ts
import { Component } from '@angular/core';
import { ProfileService } from '../profile.service';

@Component({
  selector: 'app-client-profile',
  templateUrl: './client-profile.component.html',
  styleUrls: ['./client-profile.component.scss']
})
export class ClientProfileComponent {
  constructor(private profileService: ProfileService) {}

  logout() {
    this.profileService.logout();
  }
}
```

Ahora, todo lo que se tiene que hacer es actualizar el component principal `AppComponent`. Ve al archivo `src/app/app.component.html`, elimina todo su contenido y pega el siguiente código en su lugar:

```html
<h1 class="header">Dynamic components</h1>
<main class="container">
  <app-profile-container></app-profile-container>
</main>
```

Ve al archivo `src/app/app.component.scss` y agrega lo siguiente:

```scss
.header {
  background: #ddd;
  border-bottom: 1px solid #ccc;
  margin: 0;
  padding: 1rem;
  box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2);
}

.container {
  display: flex;
  justify-content: center;
  margin-top: 2rem;
}
```

Ahora, lo único que no podemos olvidar es agregar el componente `ProfileComponent` y la directiva  `ProfileHostDirective` al arreglo de declaraciones en `AppModule`. Ve al archivo `src/app/app.module.ts`:

```ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { ProfileHostDirective } from './profile/profile-host.directive';
import { ProfileComponent } from './profile/profile.component';

@NgModule({
  declarations: [AppComponent, ProfileHostDirective, ProfileComponent],
  imports: [BrowserModule, AppRoutingModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}
```

Y estamos listos.

### Conclusión

Espero te hayas divertido tanto como el autor mientras escribía este código. Ahora sabes cómo cargar componentes dinámicamente considerando una carga diferida. Con este conocimiento, puedes reducir el tamaño del paquete principal(_main bundle size_) y mejorar la experiencia de tus usuarios.

--- 

Este artículo es una traducción al español de su [versión en inglés](https://labs.thisdot.co/blog/loading-components-dynamically-in-angular-9-with-ivy)