Acceso seguro al DOM con el SSR de Angular

Gerald Monaco
Gerald Monaco

Durante el último año, Angular adquirió muchas funciones nuevas, como la hidratación y las vistas diferibles, para ayudar a los desarrolladores a mejorar sus Métricas web esenciales y garantizar una experiencia excelente para sus usuarios finales. También se está llevando a cabo investigación sobre funciones adicionales relacionadas con la renderización del servidor que se basan en esta funcionalidad, como la transmisión y la hidratación parcial.

Lamentablemente, hay un patrón que podría impedir que tu aplicación o biblioteca aproveche al máximo todas estas funciones nuevas y futuras: la manipulación manual de la estructura del DOM subyacente. Angular requiere que la estructura del DOM se mantenga coherente desde el momento en que el servidor serializa un componente, hasta que se hidrata en el navegador. Usar las APIs de ElementRef, Renderer2 o DOM para agregar, mover o quitar nodos del DOM de forma manual antes de la hidratación puede generar inconsistencias que impidan que estas funciones funcionen.

Sin embargo, no toda manipulación y acceso manuales del DOM son problemáticos y, a veces, es necesario. La clave para usar el DOM de forma segura es minimizar su necesidad tanto como sea posible y luego postergar su uso el mayor tiempo posible. En los siguientes lineamientos, se explica cómo puedes lograr esto y compilar componentes de Angular verdaderamente universales y preparados para el futuro que puedan aprovechar al máximo todas las funciones nuevas y futuras de Angular.

Cómo evitar la manipulación manual del DOM

Como era de esperar, la mejor manera de evitar los problemas que causa la manipulación manual del DOM es evitarlos por completo siempre que sea posible. Angular cuenta con API y patrones integrados que pueden manipular la mayoría de los aspectos del DOM: debería usarlos en lugar de acceder al DOM directamente.

Mueve el propio elemento DOM de un componente

Al escribir un componente o una directiva, es posible que debas modificar el elemento de host (es decir, el elemento DOM que coincide con el selector del componente o la directiva) para, por ejemplo, agregar una clase, un estilo o un atributo, en lugar de orientar o introducir un elemento wrapper. Resulta tentador buscar ElementRef para mutar el elemento subyacente del DOM. En su lugar, debes usar vinculaciones de host para vincular de forma declarativa los valores a una expresión:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true'
  },
})
export class MyComponent {
  /* ... */
}

Al igual que con la vinculación de datos en HTML, también puedes, por ejemplo, vincular a atributos y estilos, y cambiar 'true' por una expresión diferente que Angular usará para agregar o quitar automáticamente el valor según sea necesario.

En algunos casos, la clave deberá calcularse de forma dinámica. También puedes vincularte a un indicador o una función que muestre un conjunto o un mapa de valores:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

En aplicaciones más complejas, puede ser tentador recurrir a la manipulación manual del DOM para evitar un ExpressionChangedAfterItHasBeenCheckedError. En su lugar, puedes vincular el valor a un indicador, como en el ejemplo anterior. Esto se puede hacer según sea necesario y no requiere la adopción de indicadores en toda la base de código.

Mueve elementos del DOM fuera de una plantilla

Resulta tentador intentar usar el DOM para acceder a elementos a los que normalmente no se puede acceder, como aquellos que pertenecen a otros componentes primarios o secundarios. Sin embargo, esto es propenso a errores, infringe el encapsulamiento y dificulta el cambio o la actualización de esos componentes en el futuro.

En cambio, el componente debe considerar que todos los demás componentes son una caja negra. Tómate el tiempo para considerar cuándo y dónde otros componentes (incluso dentro de la misma aplicación o biblioteca) pueden necesitar interactuar con el comportamiento o la apariencia de tu componente, o bien personalizarlo, y luego expón una forma segura y documentada de hacerlo. Usa funciones como la inserción de dependencias jerárquicas para que una API esté disponible para un subárbol cuando las propiedades simples @Input y @Output no sean suficientes.

Históricamente, era común implementar funciones como cuadros de diálogo modales o cuadros de información agregando un elemento al final de <body> o algún otro elemento de host y, luego, moviendo o proyectando contenido allí. Sin embargo, en la actualidad, es probable que en su lugar puedas renderizar un elemento <dialog> simple en tu plantilla:

@Component({
  selector: 'my-component',
  template: `<dialog #dialog>Hello World</dialog>`,
})
export class MyComponent {
  @ViewChild('dialog') dialogRef!: ElementRef;

  constructor() {
    afterNextRender(() => {
      this.dialogRef.nativeElement.showModal();
    });
  }
}

Aplaza la manipulación manual del DOM

Después de usar las pautas anteriores para minimizar la manipulación y el acceso directos al DOM en la mayor medida posible, es posible que queden algunos elementos inevitables. En esos casos, es importante postergarlo tanto como sea posible. Las devoluciones de llamada afterRender y afterNextRender son una excelente manera de hacerlo, ya que solo se ejecutan en el navegador después de que Angular verifica si hay cambios y los confirma en el DOM.

Ejecuta JavaScript solo en el navegador

En algunos casos, tendrás una biblioteca o una API que solo funcione en el navegador (por ejemplo, una biblioteca de gráficos, algún uso de IntersectionObserver, etcétera). En lugar de verificar condicionalmente si se está ejecutando en el navegador o inhabilitar el comportamiento en el servidor, puedes usar afterNextRender:

@Component({
  /* ... */
})
export class MyComponent {
  @ViewChild('chart') chartRef: ElementRef;
  myChart: MyChart|null = null;
  
  constructor() {
    afterNextRender(() => {
      this.myChart = new MyChart(this.chartRef.nativeElement);
    });
  }
}

Cómo realizar un diseño personalizado

A veces, es posible que necesites leer o escribir en el DOM para realizar algún diseño que tus navegadores de destino aún no admiten, como posicionar un cuadro de información. afterRender es una excelente opción para esto, ya que puedes estar seguro de que el DOM está en un estado coherente. afterRender y afterNextRender aceptan un valor phase de EarlyRead, Read o Write. Leer el diseño del DOM después de escribirlo obliga al navegador a volver a calcular el diseño de forma síncrona, lo que puede afectar gravemente el rendimiento (consulta: paginación excesiva de diseños). Por lo tanto, es importante dividir cuidadosamente tu lógica en las fases correctas.

Por ejemplo, un componente de información sobre la herramienta que desea mostrar un cuadro de información relativo a otro elemento de la página probablemente use dos fases. La fase EarlyRead se usaría primero para adquirir el tamaño y la posición de los elementos:

afterRender(() => {
    targetRect = targetEl.getBoundingClientRect();
    tooltipRect = tooltipEl.getBoundingClientRect();
  }, { phase: AfterRenderPhase.EarlyRead },
);

Luego, la fase Write usaría el valor leído anteriormente para reposicionar la información sobre la herramienta:

afterRender(() => {
    tooltipEl.style.setProperty('left', `${targetRect.left + targetRect.width / 2 - tooltipRect.width / 2}px`);
    tooltipEl.style.setProperty('top', `${targetRect.bottom - 4}px`);
  }, { phase: AfterRenderPhase.Write },
);

Al dividir nuestra lógica en las fases correctas, Angular puede agrupar de forma eficaz por lotes la manipulación del DOM en todos los demás componentes de la aplicación, lo que garantiza un impacto mínimo en el rendimiento.

Conclusión

Hay muchas mejoras nuevas y emocionantes en la renderización del servidor de Angular en el horizonte, con el objetivo de facilitarte la tarea de brindar una gran experiencia a tus usuarios. Esperamos que las sugerencias anteriores te resulten útiles para aprovecharlas al máximo en tus aplicaciones y bibliotecas.