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

Gerald Monaco
Gerald Monaco

No ano passado, o Angular ganhou muitos 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. 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 ele seja hidratado no navegador. O uso das APIs ElementRef, Renderer2 ou DOM para adicionar, mover ou remover nós manualmente do DOM antes que a hidratação introduz inconsistências que impedem o funcionamento desses recursos.

No entanto, nem toda manipulação e acesso manuais do DOM são problemáticos e, às vezes, necessários. A chave para usar o DOM com segurança é minimizar ao máximo sua necessidade e adiar o uso dele 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

Obviamente, a melhor maneira de evitar os problemas causados pela manipulação manual de DOM é evitá-lo completamente 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.

Transformar o próprio elemento DOM de um componente

Ao escrever 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 wrapper. É tentador apenas pegar o 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, também é possível, por exemplo, vincular a atributos e estilos e mudar 'true' para uma expressão diferente que o Angular 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 um indicador ou uma 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 realizar a 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 sinais 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 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 componentes como uma caixa preta. Reserve um tempo para considerar quando e onde outros componentes (mesmo dentro do mesmo aplicativo ou biblioteca) podem precisar interagir ou personalizar o comportamento ou a aparência do seu componente e, em seguida, exponha uma maneira segura e documentada de fazer isso. Use recursos como a injeção de dependência hierárquica para disponibilizar uma API para uma 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 adiá-la o máximo possível. Os callbacks afterRender e afterNextRender são uma ótima maneira de fazer isso, porque são executados apenas no navegador depois que o Angular verifica as mudanças e as confirma no DOM.

Executar JavaScript somente no 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 está sendo executado no navegador ou fragmentar 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);
    });
  }
}

Executar layout personalizado

Às vezes, pode ser necessário ler ou gravar no DOM para executar algum layout que ainda não seja compatível com os navegadores de destino, como posicionar uma dica. afterRender é uma ótima opção 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 DOM depois de gravá-lo força o navegador a recalcular de forma síncrona o layout, o que pode afetar seriamente o desempenho (consulte Troca frequente de layouts). 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 é 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 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 ajudar você a aproveitá-las ao máximo em seus aplicativos e bibliotecas.