Bezpieczny dostęp do DOM za pomocą Angular SSR

Gerald Monaco
Gerald Monaco

W ciągu ostatniego roku w ramach Angular dodano wiele nowych funkcji, takich jak hydratacjaodkładanie wyświetlania, aby pomóc deweloperom poprawić podstawowe wskaźniki internetowe i zapewnić użytkownikom wygodę. Trwają też badania nad dodatkowymi funkcjami związanymi z renderowaniem po stronie serwera, które opierają się na tej funkcjonalności, np. strumieniowanie i częściowe nawilżanie.

Niestety, jest jeden schemat, który może uniemożliwić Twojej aplikacji lub bibliotece pełne korzystanie z tych nowych i przyszłych funkcji: ręczna manipulacja podstawową strukturą DOM. Angular wymaga, aby struktura DOM była spójna od momentu serializacji komponentu przez serwer do momentu jego utworzenia w przeglądarce. Użycie interfejsów ElementRef, Renderer2 lub DOM API do ręcznego dodawania, przenoszenia lub usuwania węzłów z DOM przed hydratacją może powodować niespójności, które uniemożliwiają działanie tych funkcji.

Nie wszystkie ręczne manipulacje DOM-em i dostęp do niego są jednak problematyczne, a czasami są wręcz niezbędne. Kluczem do bezpiecznego korzystania z DOM jest jak największe ograniczenie potrzeby korzystania z niego, a następnie odłożenie jego użycia na jak najdłuższy czas. Z poniższych wskazówek dowiesz się, jak to osiągnąć i tworzyć w pełni uniwersalne i przyszłościowe komponenty Angular, które będą w pełni korzystać ze wszystkich nowych i nadchodzących funkcji Angular.

Unikanie ręcznego manipulacji DOM

Najlepszym sposobem na uniknięcie problemów spowodowanych ręczną manipulacją DOM jest, co nie powinno dziwić, całkowite unikanie tego w miarę możliwości. Angular ma wbudowane interfejsy API i wzorce, które mogą modyfikować większość aspektów DOM. Lepiej używać ich zamiast bezpośredniego dostępu do DOM.

Mutowanie własnego elementu DOM komponentu

Podczas pisania komponentu lub dyrektywy może być konieczne zmodyfikowanie elementu głównego (czyli elementu DOM pasującego do selektora komponentu lub dyrektywy), np. dodanie klasy, stylu lub atrybutu zamiast kierowania lub wprowadzania elementu opakowującego. Kuszące jest użycie funkcji ElementRef do zmutowania elementu DOM. Zamiast tego użyj wiązań hosta, aby deklaratywnie związać wartości z wyrażeniem:

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

Podobnie jak w przypadku wiązania danych w HTML, możesz też na przykład wiązać atrybuty i style oraz zmieniać 'true' na inny wyrażenie, którego Angular użyje do automatycznego dodawania lub usuwania wartości w odpowiednich przypadkach.

W niektórych przypadkach klucz musi być obliczany dynamicznie. Możesz również związać sygnał lub funkcję z zbiorem lub mapą wartości:

@Component({
  selector: 'my-component',
  template: `...`,
  host: {
    '[class.foo]': 'true',
    '[class]': 'classes()'
  },
})
export class MyComponent {
  size = signal('large');
  classes = computed(() => {
    return [`size-${this.size()}`];
  });
}

W bardziej złożonych aplikacjach może pojawić się pokusa ręcznej manipulacji DOM-em, aby uniknąć korzystania z elementu ExpressionChangedAfterItHasBeenCheckedError. Zamiast tego możesz związać wartość z sygnałem, tak jak w poprzednim przykładzie. Możesz to zrobić w dowolnym momencie, nie wymaga to stosowania sygnałów w całej bazie kodu.

Mutowanie elementów DOM poza szablonem

Kuszące jest próbowanie korzystania z DOM do uzyskiwania dostępu do elementów, które normalnie są niedostępne, takich jak elementy należące do innych komponentów nadrzędnych lub podrzędnych. Takie rozwiązanie jest jednak podatne na błędy, narusza zasadę enkapsulacji i utrudnia późniejszą zmianę lub uaktualnienie tych komponentów.

Zamiast tego Twój komponent powinien traktować wszystkie inne komponenty jako czarne pudełko. Zastanów się, kiedy i gdzie inne komponenty (nawet w tej samej aplikacji lub bibliotece) mogą potrzebować interakcji z Twoim komponentem lub dostosowywania jego zachowania lub wyglądu, a potem udostępnij bezpieczny i udokumentowany sposób na wykonanie tych czynności. Użyj funkcji takich jak hierarchiczne wstrzykiwanie zależności, aby udostępnić interfejs API poddrzewu, gdy proste właściwości @Input@Output nie wystarczają.

W przeszłości często wdrażano funkcje takie jak modalne okna dialogowe czy tooltipy, dodając element do końca <body> lub innego elementu hosta, a następnie przenosząc lub wyświetlając w nim zawartość. Obecnie w szablonie możesz jednak renderować prosty element <dialog>:

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

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

Odrocz ręczną manipulację DOM

Po zastosowaniu poprzednich wskazówek, aby zminimalizować bezpośrednią manipulację DOM i dostęp do niego, możesz mieć jeszcze pewne nieuniknione pozostałości. W takich przypadkach ważne jest, aby odroczyć to na jak najdłużej. Do tego celu świetnie nadają się funkcje zwrotne afterRenderafterNextRender, ponieważ są one wykonywane tylko w przeglądarce, gdy Angular sprawdzi, czy zaszły jakieś zmiany, i zatwierdzi je w DOM.

Uruchom JavaScript tylko w przeglądarce

W niektórych przypadkach biblioteka lub interfejs API działa tylko w przeglądarce (np. biblioteka wykresów, niektóre IntersectionObserver itp.). Zamiast sprawdzać warunkowo, czy działasz w przeglądarce, lub tworzyć na serwerze stuby zachowania, możesz użyć afterNextRender:

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

Wykonaj układ niestandardowy

Czasami może być konieczne odczytanie lub zapis w DOM w celu wykonania układu, którego jeszcze nie obsługują jeszcze docelowe przeglądarki, np. umieszczenia etykietki. afterRender jest w tym przypadku świetnym wyborem, ponieważ możesz mieć pewność, że DOM jest w spójnym stanie. Parametry afterRenderafterNextRender mogą mieć wartość phase EarlyRead, Read lub Write. Odczytywanie układu DOM po zapisaniu powoduje, że przeglądarka musi ponownie obliczyć układ synchronicznie, co może poważnie wpłynąć na wydajność (patrz przeładowanie układu). Ważne jest więc dokładne podzielenie metody logicznej na odpowiednie fazy.

Na przykład komponent etykiety, który ma wyświetlać etykietę względem innego elementu na stronie, prawdopodobnie będzie używać 2 faz. Faza EarlyRead służy do określenia rozmiaru i pozycji elementów:

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

Następnie faza Write używa odczytanej wcześniej wartości, aby zmienić pozycję tooltipa:

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

Dzieląc naszą logikę na odpowiednie fazy, Angular jest w stanie skutecznie grupować manipulacje DOM z pozostałymi komponentami aplikacji, co ma minimalny wpływ na wydajność.

Podsumowanie

Wkrótce wprowadzimy wiele nowych i ciekawych ulepszeń renderowania po stronie serwera w Angular, aby ułatwić Ci zapewnianie użytkownikom wygodnej obsługi. Mamy nadzieję, że te wskazówki pomogą Ci w całkowitym wykorzystaniu tych funkcji w aplikacjach i bibliotekach.