Acceso seguro al DOM con el SSR de Angular

Gerald Monaco
Gerald Monaco

En el último año, Angular incorporó muchas funciones nuevas, como la hidratación y las vistas diferidas, 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á investigando la incorporación de 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 por completo 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 hidrata en el navegador. El uso de ElementRef, Renderer2 o las APIs del DOM para agregar, mover o quitar nodos del DOM de forma manual antes de la hidratación puede generar inconsistencias que impiden 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.

Evita la manipulación manual del DOM

No es de extrañar que la mejor manera de evitar los problemas que causa la manipulación manual del DOM sea evitarla 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ías preferir usarlos en lugar de acceder al DOM directamente.

Mutación del propio elemento DOM de un componente

Cuando escribas 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 orientar o introducir un elemento de wrapper. Es tentador usar ElementRef para mutar el elemento 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, por ejemplo, vincular atributos y estilos, y cambiar 'true' a 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 que adoptes indicadores en toda tu base de código.

Muta los elementos DOM fuera de una plantilla

Es tentador intentar usar el DOM para acceder a elementos a los que no se puede acceder de forma normal, como los que pertenecen a otros componentes superiores o secundarios. Sin embargo, esto es propenso a errores, incumple el encapsulamiento y dificulta cambiar o actualizar 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 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 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 diálogos modales o cuadros de herramientas agregando un elemento al final de <body> o algún otro elemento de host y, luego, mover o proyectar contenido allí. Sin embargo, en la actualidad, 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 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 aplazarlo 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 API que solo funciona en el navegador (por ejemplo, una biblioteca de gráficos, algún uso de IntersectionObserver, etcétera). En lugar de verificar de forma condicional si se ejecuta en el navegador o anular 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 asegurarte de que el DOM esté en un estado coherente. afterRender y afterNextRender aceptan un valor phase de EarlyRead, Read o Write. Si lees el diseño del DOM después de escribirlo, el navegador se ve obligado a volver a calcularlo de forma síncrona, lo que puede afectar seriamente el rendimiento (consulta fragmentación del diseño). Por lo tanto, es importante dividir cuidadosamente tu lógica en las fases correctas.

Por ejemplo, un componente de información sobre herramientas que desea mostrar una información sobre herramientas en relación con otro elemento de la página probablemente usaría 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 herramientas:

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 },
);

Cuando dividimos nuestra lógica en las fases correctas, Angular puede realizar de forma eficaz la manipulación por lotes 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.