Acessar o DOM com segurança usando a SSR do Angular

Geraldo Mônaco
Gerald Mônaco

No ano passado, o Angular ganhou vários recursos novos, como hidratação e visualizações adiáveis, para ajudar os desenvolvedores a melhorar as Core Web Vitals e garantir uma ótima experiência para os usuários finais. Também estão em andamento pesquisas sobre outros recursos relacionados à renderização do lado do servidor que se baseiam nessa funcionalidade, como streaming e hidratação parcial.

Infelizmente, há um padrão que pode impedir que seu aplicativo ou biblioteca aproveite ao máximo todos esses recursos novos e futuros: a manipulação manual da estrutura DOM subjacente. O Angular exige que a estrutura do DOM permaneça consistente desde o momento em que um componente é serializado pelo servidor até que ele seja hidratado no navegador. Usar as APIs ElementRef, Renderer2 ou DOM para adicionar, mover ou remover manualmente nós do DOM antes da hidratação pode introduzir inconsistências que impedem o funcionamento desses recursos.

No entanto, nem toda manipulação e acesso manual do DOM é problemático e, às vezes, é necessário. A chave para usar o DOM com segurança é minimizar a necessidade o máximo possível e, depois, adiar o uso dele o máximo possível. As diretrizes a seguir explicam como fazer isso e criar componentes do Angular realmente universais e preparados para o futuro, que podem aproveitar ao máximo todos os recursos novos e futuros do Angular.

Evitar a manipulação manual de DOM

Sem surpresa, a melhor maneira de evitar os problemas causados pela manipulação manual de DOM é evitá-la sempre que possível. O Angular tem APIs e padrões integrados que podem manipular a maioria dos aspectos do DOM. É melhor usá-los em vez de acessar o DOM diretamente.

Transformar o próprio elemento DOM de um componente

Ao criar um componente ou uma diretiva, pode ser necessário modificar o elemento host (ou seja, o elemento DOM que corresponde ao seletor do componente ou da diretiva) para, por exemplo, adicionar uma classe, estilo ou atributo, em vez de segmentar ou introduzir um elemento de wrapper. É tentador usar ElementRef para mudar o elemento DOM subjacente. Em vez disso, use vinculações de host para vincular declarativamente os valores a uma expressão:

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

Assim como na vinculação de dados em HTML, você também pode, por exemplo, vincular atributos e estilos e mudar 'true' para uma expressão diferente que o Angular vai usar para adicionar ou remover o valor automaticamente, conforme necessário.

Em alguns casos, a chave precisará ser calculada dinamicamente. Também é possível vincular a um sinal ou função que retorna um conjunto ou 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()}`];
  });
}

Em aplicativos mais complexos, pode ser tentador recorrer à manipulação manual do DOM para evitar uma ExpressionChangedAfterItHasBeenCheckedError. Em vez disso, é possível vincular o valor a um indicador, como no exemplo anterior. Isso pode ser feito conforme necessário e não exige a adoção de indicadores em toda a base de código.

Modificar elementos DOM fora de um modelo

É tentador tentar usar o DOM para acessar elementos que não são normalmente acessíveis, como os que pertencem a outros componentes pai ou filho. No entanto, isso é propenso a erros, viola o encapsulamento e dificulta a alteração ou o upgrade desses componentes no futuro.

Em vez disso, o componente precisa considerar todos os outros como uma caixa preta. Considere quando e onde outros componentes (mesmo no mesmo aplicativo ou biblioteca) podem precisar interagir ou personalizar o comportamento ou a aparência do seu componente e expor uma maneira segura e documentada de fazer isso. Use recursos como a injeção de dependência hierárquica para disponibilizar uma API a uma subárvore quando as propriedades simples @Input e @Output não forem suficientes.

Antes, era comum implementar recursos como caixas de diálogo ou dicas modais adicionando um elemento ao final da <body> ou algum outro elemento host e depois movendo ou projetando o conteúdo. No entanto, hoje em dia é provável que você possa renderizar um elemento <dialog> simples no seu modelo:

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

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

Adiar a manipulação manual de DOM

Depois de usar as diretrizes anteriores para minimizar a manipulação direta de DOM e o máximo possível, pode haver algumas coisas que são inevitáveis. Nesses casos, é importante adiar o máximo possível. Os callbacks afterRender e afterNextRender são uma ótima maneira de fazer isso, já que são executados apenas no navegador, depois que o Angular verifica se há mudanças e as confirma no DOM.

Executar JavaScript somente para navegador

Em alguns casos, você terá uma biblioteca ou API que só funciona no navegador (por exemplo, uma biblioteca de gráficos, uso de IntersectionObserver etc.). Em vez de verificar condicionalmente se você está executando no navegador ou eliminar um comportamento no servidor, basta usar afterNextRender:

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

Realizar layout personalizado

Às vezes, pode ser necessário ler ou gravar no DOM para executar algum layout que ainda não é compatível com os navegadores de destino, como posicionar uma dica. afterRender é uma ótima opção para isso, porque é possível ter certeza de que o DOM está em um estado consistente. afterRender e afterNextRender aceitam um valor phase de EarlyRead, Read ou Write. A leitura do layout DOM depois de escrevê-lo força o navegador a recalcular o layout de maneira síncrona, o que pode afetar significativamente o desempenho (consulte a sobrecarga de layout). Portanto, é importante dividir cuidadosamente sua lógica nas fases corretas.

Por exemplo, um componente de dica que queira exibir uma dica relativa a outro elemento na página provavelmente usaria duas fases. A fase EarlyRead seria usada primeiro para saber o tamanho e a posição dos elementos:

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

Em seguida, a fase Write usaria o valor lido anteriormente para reposicionar a dica:

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

Ao dividir nossa lógica nas fases corretas, o Angular consegue agrupar de maneira eficiente a manipulação de DOM em todos os outros componentes do aplicativo, garantindo um impacto mínimo no desempenho.

Conclusão

Estamos lançando muitas melhorias novas e empolgantes na renderização do lado do servidor do Angular, com o objetivo de facilitar a experiência dos usuários. Esperamos que as dicas anteriores sejam úteis para você aproveitá-las ao máximo nos seus aplicativos e bibliotecas.