Bezpieczny dostęp do DOM za pomocą Angular SSR

Gerald Monaco
Gerald Monaco

W zeszłym roku w Angular dodano wiele nowych funkcji, takich jak nawodnienie czy widoki z możliwością odroczenia. Pomagają one deweloperom w ulepszaniu podstawowych wskaźników internetowych i zapewniają użytkownikom doskonałe wrażenia. Trwają również badania nad dodatkowymi funkcjami związanymi z renderowaniem po stronie serwera, które wykorzystują tę funkcję, takimi jak strumieniowanie i częściowe nawodnienie.

Niestety, istnieje jeden wzorzec, który może uniemożliwić Twojej aplikacji lub bibliotece pełne wykorzystanie wszystkich tych nowych i nadchodzących funkcji: ręczne manipulacje bazową strukturą DOM. Angular wymaga, by struktura DOM pozostawała spójna od momentu zserializacji komponentu przez serwer aż do jego hydratacji 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.

Ręczne manipulacje i dostęp do modelu DOM nie stanowią jednak problemu i czasem są konieczne. Kluczem do bezpiecznego korzystania z modelu DOM jest zminimalizowanie jego potrzeby w miarę możliwości i jak najdłuższe opóźnienie w korzystaniu z niego. 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

Najprostszym sposobem uniknięcia problemów, które powoduje ręczna manipulacja DOM, jest jak najbardziej oczywiste unikanie ich, gdy tylko jest to możliwe. Angular ma wbudowane interfejsy API i wzorce, które mogą modyfikować większość aspektów DOM. Zalecamy korzystanie z nich zamiast bezpośredniego dostępu do DOM.

Mutacja 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. Może się wydawać, że wystarczy użyć adresu ElementRef, aby zmodyfikować bazowy element DOM. Zamiast tego użyj wiązań hosta, by deklaratywnie powią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 kodzie HTML możesz też na przykład powiązać atrybuty i style, a następnie zmienić 'true' na inne wyrażenie, którego Angular będzie używać do automatycznego dodawania lub usuwania wartości zależnie od potrzeb.

W niektórych przypadkach klucz musi być obliczany dynamicznie. Możesz również powiązać z sygnałem 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 aplikacjach kusząca może być ręczna manipulacja DOM w celu uniknięcia użycia ExpressionChangedAfterItHasBeenCheckedError. Zamiast tego możesz powiązać wartość z sygnałem jak w poprzednim przykładzie. Możesz to zrobić w razie potrzeby i nie wymaga stosowania sygnałów w całej bazie kodu.

Mutowanie elementów DOM poza szablonem

Może wydawać się kuszące, by używać interfejsu DOM do uzyskiwania dostępu do elementów, które nie są normalnie dostępne, np. należących do innych komponentów nadrzędnych lub podrzędnych. Taki stan jest jednak podatny na błędy, narusza enkapsulację i utrudnia zmianę lub uaktualnienie tych komponentów w przyszłości.

Zamiast tego komponent powinien traktować każdy inny komponent jako czarną skrzynkę. Zastanów się, kiedy i gdzie inne komponenty (nawet w tej samej aplikacji lub bibliotece) mogą wymagać interakcji z działaniem lub wyglądem komponentu bądź dostosowania ich, a następnie udostępnij bezpieczny, udokumentowany sposób na to. Używaj funkcji takich jak wstrzykiwanie zależności hierarchicznych, aby udostępnić interfejs API danemu podrzędnemu, gdy proste właściwości @Input i @Output nie wystarczą.

W przeszłości często wdrażano takie funkcje jak okna modalne czy etykietki, dodając element na końcu <body> lub innego elementu głównego, a następnie przenosząc lub wyświetlając tam treści. Jednak w tych dniach lepiej jest 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 powyższych wytycznych w celu zminimalizowania bezpośrednich manipulacji DOM i uzyskania jak największego dostępu do nich może pozostało nie do uniknięcia. W takich przypadkach ważne jest, aby odłożyć tę czynność na jak najdłuższy możliwy czas. Świetnie nadają się do tego wywołania zwrotne afterRender i afterNextRender, ponieważ działają tylko w przeglądarce dopiero po sprawdzeniu przez Angular zmian i zatwierdzeniu ich w DOM.

Uruchom JavaScript tylko w przeglądarce

W niektórych przypadkach będziesz mieć bibliotekę lub interfejs API, które działają tylko w przeglądarce (np. biblioteka wykresów, niektóre użycie IntersectionObserver itp.). Zamiast warunkowo sprawdzać, czy działasz w przeglądarce, lub skracać działanie serwera, możesz użyć parametru 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. Doskonałym wyborem w tym przypadku jest afterRender, ponieważ możesz mieć pewność, że DOM ma spójny stan. afterRender i afterNextRender akceptują wartość phase EarlyRead, Read lub Write. Odczytanie układu DOM po jego napisaniu wymusza na przeglądarce synchroniczne przeliczanie układu, co może znacząco wpłynąć na wydajność (patrz: thrashout). Ważne jest więc dokładne podzielenie metody logicznej na odpowiednie fazy.

Na przykład komponent etykietki, który chce wyświetlać etykietkę względem innego elementu na stronie, prawdopodobnie będzie miał 2 fazy. Najpierw należy użyć fazy EarlyRead, aby uzyskać rozmiar i pozycję elementów:

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

Następnie etap Write użyje wartości odczytanej wcześniej, aby zmienić położenie 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 },
);

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

Planujemy wprowadzić w Angular wiele nowych i ekscytujących ulepszeń, których celem jest ułatwienie zapewniania użytkownikom doskonałych wrażeń. Mamy nadzieję, że poprzednie wskazówki pomogą Ci w pełni wykorzystać ich możliwości w aplikacjach i bibliotekach.