Bezpieczny dostęp do DOM za pomocą Angular SSR

Gerald Monako
Gerald Monako

W ciągu ostatniego roku w Angular pojawiły się nowe funkcje, takie jak nawodnienie i opóźnione wyświetlenia, które pomagają deweloperom w ulepszaniu podstawowych wskaźników internetowych i zapewnianiu użytkownikom doskonałych wrażeń. Trwają również badania nad dodatkowymi funkcjami związanymi z renderowaniem po stronie serwera, które bazują na tej funkcji, takimi jak strumieniowanie i częściowe nawodnienie.

Niestety występuje jeden wzorzec, który może uniemożliwić aplikacji lub bibliotece pełne wykorzystanie nowych i przyszłych funkcji: ręczne manipulacje bazową strukturą DOM. Angular wymaga, aby struktura DOM była spójna od momentu serializacji przez serwer aż do napełnienia komponentu w przeglądarce. Używanie interfejsów ElementRef, Renderer2 lub DOM API do ręcznego dodawania, przenoszenia lub usuwania węzłów z DOM, zanim nawilżenie może wprowadzić niespójności uniemożliwiające działanie tych funkcji.

Jednak nie każda ręczna manipulacja DOM i dostęp do nich wiążą się z problemami, a niekiedy jest to konieczne. Kluczem do bezpiecznego korzystania z DOM jest zminimalizowanie jego potrzeb w miarę możliwości i jak najdłuższe odroczenie jego użycia. Poniższe wskazówki wyjaśniają, jak to zrobić, i tworzą uniwersalne i odporne na przyszłość komponenty Angular, które w pełni wykorzystują wszystkie nowe funkcje Angular oraz te, które wkrótce zostaną wprowadzone.

Unikaj ręcznej manipulacji DOM

Najlepszą metodą uniknięcia problemów, które powoduje ręczna manipulacja DOM, jest, jak niespodziewane, całkowite unikanie tego typu problemów wszędzie tam, gdzie to możliwe. Angular ma wbudowane interfejsy API i wzorce, które mogą manipulować większością aspektów DOM. Lepiej korzystać z nich zamiast uzyskiwać dostęp do DOM.

Mutacja elementu DOM komponentu

Podczas tworzenia komponentu lub dyrektywy może być konieczne zmodyfikowanie elementu hosta (czyli elementu DOM odpowiadającego selektorowi dyrektywy) i np. dodanie klasy, stylu lub atrybutu zamiast kierowania czy wprowadzenia elementu kodu. kuszące jest sięgnięcie po narzędzie ElementRef, aby zmutować bazowy element DOM. Zamiast tego do deklaratywnego powiązania wartości z wyrażeniem należy używać powiązań hosta:

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

Tak jak w przypadku wiązania danych w języku HTML, możesz też na przykład powiązać z atrybutami i stylami oraz zmienić 'true' na inne wyrażenie, którego Angular będzie używać do automatycznego dodawania lub usuwania wartości w zależności od potrzeb.

W niektórych przypadkach klucz musi zostać obliczony dynamicznie. Możesz również powiązać sygnał lub funkcję, która zwraca zbiór 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 zastosowaniach ręczna manipulacja DOM w celu uniknięcia ExpressionChangedAfterItHasBeenCheckedError może być kusząca. Zamiast tego możesz powiązać tę wartość z sygnałem, tak jak w poprzednim przykładzie. Można to zrobić w razie potrzeby. Nie wymaga to przyjmowania sygnałów w całej bazie kodu.

Mutacja elementów DOM poza szablonem

Warto użyć modelu DOM, aby uzyskać dostęp do elementów, które zwykle nie są dostępne, np. należących do innych komponentów nadrzędnych lub podrzędnych. Takie rozwiązanie jest jednak podatne na błędy, narusza zasady hermetyczne i utrudnia zmianę lub uaktualnienie tych komponentów w przyszłości.

Zamiast tego należy traktować wszystkie pozostałe jako czarne skrzynki. Zastanów się, kiedy i kiedy inne komponenty (nawet w tej samej aplikacji lub bibliotece) będą musiały wchodzić w interakcje z innymi komponentami lub dostosowywać ich zachowanie bądź wygląd, a następnie udostępnij je bezpieczny i udokumentowany sposób. Używaj takich funkcji jak wstrzykiwanie zależności hierarchicznej, aby udostępnić interfejs API podrzędnemu drzewowi, gdy proste właściwości @Input i @Output nie wystarczą.

W przeszłości wdrażano takie funkcje jak okna modalne czy etykietki przez dodanie elementu na końcu elementu <body> lub innego elementu głównego, a następnie przenoszenie lub rzutowanie tam treści. Obecnie możesz jednak renderować w szablonie 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 wytycznych w celu zminimalizowania bezpośredniej manipulacji DOM i jak największego dostępu do witryny mogą się pojawić pewne pozostałości, których nie da się uniknąć. W takich przypadkach ważne jest, by odłożyć ją jak najdłużej. Wywołania zwrotne afterRender i afterNextRender to doskonały sposób, ponieważ działają tylko w przeglądarce, gdy Angular sprawdzi, czy nie ma zmian, i zatwierdzi je w DOM.

Uruchamianie JavaScriptu tylko w przeglądarce

W niektórych przypadkach dostępna jest biblioteka lub interfejs API, które działają tylko w przeglądarce (np. biblioteka wykresów, niektóre zastosowania IntersectionObserver itp.). Zamiast warunkowo sprawdzać, czy przeglądarka działa w przeglądarce, czy skracać działanie serwera, 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 do wykonania określonego układu, którego jeszcze nie obsługują przeglądarki docelowe, może być konieczne odczyt lub zapisanie w modelu DOM, na przykład pozycjonowanie etykietki. W tym przypadku najlepiej nadaje się do tego model afterRender, ponieważ masz pewność, że model DOM jest spójny. afterRender i afterNextRender akceptują wartość phase wynoszącą EarlyRead, Read lub Write. Odczytanie układu DOM po jego zapisaniu zmusza przeglądarkę do synchronicznego ponownego obliczania układu, co może znacząco wpłynąć na wydajność (patrz: thrashing). Dlatego ważne jest, aby ostrożnie podzielić swoją logikę na odpowiednie fazy.

Na przykład komponent etykietki, który chce wyświetlać etykietkę względem innego elementu na stronie, prawdopodobnie będzie korzystał z 2 faz. Aby określić rozmiar i pozycję elementów, należy najpierw użyć etapu EarlyRead:

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

Następnie etap Write użyje wcześniej odczytanej wartości do zmiany położenia etykietki:

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

Dzięki rozdzieleniu naszej logiki na odpowiednie fazy Angular jest w stanie skutecznie zgrupować operacje manipulacji DOM na wszystkich pozostałych elementach aplikacji, co zapewnia minimalny wpływ na wydajność.

Podsumowanie

Planujemy wprowadzić w Angular wiele nowych i ekscytujących ulepszeń renderowania po stronie serwera, które mają ułatwić korzystanie z aplikacji i usług użytkownikom. Mamy nadzieję, że poprzednie wskazówki okażą się przydatne do pełnego wykorzystania ich w aplikacjach i bibliotekach.