Skip to content

Carga de Componentes Dinámica en Angular con Ivy

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. Se ve como sigue:

representacion visual de la aplicacion que se va a desarrollar

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

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:

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:

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:

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:

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:

// _variables.scss
$card-width: 400px;
$avatar-width: 80px;
$container-margin: 20px;
// _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:

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

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

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:

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

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

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:

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

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

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.

Conclusion

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