Acceso seguro al DOM con el SSR de Angular

Gerald Mónaco
Gerald Mónaco

Durante el último año, Angular obtuvo muchas funciones nuevas, como la hidratación y las vistas diferibles, que ayudan a los desarrolladores a mejorar sus Métricas web esenciales y garantizar una gran experiencia para sus usuarios finales. También hay investigaciones en curso 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, existe 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 subyacente del DOM. Angular requiere que la estructura del DOM se mantenga coherente desde el momento en que el servidor serializa un componente hasta que se lo hidrata en el navegador. Usar ElementRef, Renderer2 o las APIs de DOM para agregar, mover o quitar nodos del DOM de forma manual antes de la hidratación puede introducir incoherencias que impidan que estas funciones funcionen

Sin embargo, no toda la manipulación y el acceso manuales del DOM son problemáticos y a veces son necesarios. La clave para usar el DOM de forma segura es minimizar la necesidad de usarlo tanto como sea posible y luego aplazar su uso tanto como sea 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.

Evita la manipulación manual del DOM

La mejor manera de evitar los problemas que causa la manipulación manual del DOM es, como era de esperarse, evitarla completamente siempre que sea posible. Angular cuenta con APIs y patrones integrados que pueden manipular la mayoría de los aspectos del DOM: debes preferir usarlos en lugar de acceder al DOM directamente.

Cómo modificar el elemento DOM propio de un componente

Cuando escribes un componente o una directiva, es posible que debas modificar el elemento 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 segmentar o introducir un elemento wrapper. Resulta tentador solo alcanzar ElementRef para mutar el elemento del DOM subyacente. 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 vincular a atributos y estilos, y cambiar 'true' a una expresión diferente que Angular usará automáticamente para agregar o quitar el valor según sea necesario.

En algunos casos, la clave deberá calcularse de forma dinámica. También puedes vincular 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 utilizar la manipulación manual del DOM para evitar una ExpressionChangedAfterItHasBeenCheckedError. En su lugar, puedes vincular el valor a una señal, como en el ejemplo anterior. Esto se puede hacer según sea necesario y no es necesario adoptar señales en toda tu base de código.

Cómo modificar 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, es propenso a errores, infringe el encapsulamiento y dificulta el cambio o la actualización de esos componentes en el futuro.

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

Históricamente, era común implementar funciones como diálogos modales o información sobre la herramienta agregando un elemento al final de <body> o de algún otro elemento de host y, luego, moviendo o proyectando contenido allí. Sin embargo, hoy en día es probable que 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 los lineamientos anteriores para minimizar la manipulación y el acceso directos al DOM tanto como sea posible, es posible que quede algunos que no se pueden evitar. En esos casos, es importante postergarlo el mayor tiempo 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 comprueba si hay cambios y los confirma en el DOM.

Ejecuta JavaScript solo en el navegador

En algunos casos, tendrás una biblioteca o API que solo funciona en el navegador (por ejemplo, una biblioteca de gráficos, uso parcial de IntersectionObserver, etcétera). En lugar de verificar condicionalmente si estás ejecutando en el navegador o descartar 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);
    });
  }
}

Realizar un diseño personalizado

A veces, es posible que necesites leer el DOM o escribir en él 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 seriamente el rendimiento (consulta la paginación de diseños). Por lo tanto, es importante dividir cuidadosamente tu lógica en las fases correctas.

Por ejemplo, es probable que un componente de información sobre la herramienta que quiera mostrar una información relacionada con otro elemento de la página utilice 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 cambiar la posición de 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 },
);

Mediante la división de nuestra lógica en las fases correctas, Angular puede agrupar eficazmente 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.