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

Gerald Monaco
Gerald Monaco

No ano passado, o Angular ganhou muitos novos recursos, como hidratação e visualizações adiáveis, para ajudar os desenvolvedores a melhorar os Core Web Vitals e garantir uma ótima experiência para os usuários finais. Uma pesquisa sobre outros recursos relacionados à renderização do lado do servidor que usam essa funcionalidade também está em andamento, 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 seja hidratado no navegador. O uso das APIs ElementRef, Renderer2 ou DOM para adicionar, mover ou remover nós manualmente do DOM antes da hidratação pode introduzir inconsistências que impedem o funcionamento desses recursos.

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

Evite a manipulação manual do DOM

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

Mutar o elemento DOM de um componente

Ao escrever um componente ou uma diretiva, talvez seja 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, um estilo ou um atributo, em vez de segmentar ou introduzir um elemento wrapper. É tentador usar ElementRef para modificar o elemento DOM. Em vez disso, use vinculações de host para declarativamente vincular 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 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 precisa ser computada dinamicamente. Também é possível se vincular a um indicador 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 usar a manipulação manual do DOM para evitar uma ExpressionChangedAfterItHasBeenCheckedError. Em vez disso, você pode vincular o valor a um sinal, 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.

Transformar elementos DOM fora de um modelo

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

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

Historicamente, era comum implementar recursos como caixas de diálogo modais ou dicas de ferramentas adicionando um elemento ao final do <body> ou algum outro elemento host e, em seguida, movendo ou projetando o conteúdo nele. No entanto, atualmente é possível 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 do DOM

Depois de usar as diretrizes anteriores para minimizar ao máximo sua manipulação direta de DOM e acesso, pode haver parte restante que é inevitável. Nesses casos, é importante adiar o máximo possível. Os callbacks afterRender e afterNextRender são uma ótima maneira de fazer isso, porque eles são executados apenas no navegador, depois que o Angular verifica se há alterações e as confirma no DOM.

Executar JavaScript somente do navegador

Em alguns casos, você terá uma biblioteca ou API que só funciona no navegador (por exemplo, uma biblioteca de gráficos, algum uso de IntersectionObserver etc.). Em vez de verificar condicionalmente se você está executando no navegador ou de substituir o 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, talvez seja necessário ler ou gravar no DOM para executar algum layout que os navegadores de destino ainda não oferecem suporte, como posicionar uma dica. afterRender é uma ótima escolha para isso, porque você pode 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 do DOM após a gravação força o navegador a recalcular o layout de forma síncrona, o que pode afetar seriamente o desempenho (consulte layout thrashing). Portanto, é importante dividir cuidadosamente sua lógica nas fases corretas.

Por exemplo, um componente de dica que quer exibir uma dica relativa a outro elemento na página provavelmente usaria duas fases. A fase EarlyRead seria usada primeiro para adquirir 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 realizar a manipulação de DOM em lote em todos os outros componentes do aplicativo, o que garante um impacto mínimo no desempenho.

Conclusão

Há muitas melhorias novas e interessantes 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ê aproveitar ao máximo os aplicativos e as bibliotecas.