Безопасный доступ к DOM с помощью Angular SSR

Джеральд Монако
Gerald Monaco

За последний год в Angular появилось множество новых функций, таких как гидратация и отложенные представления, которые помогают разработчикам улучшить свои основные веб-показатели и обеспечить удобство для конечных пользователей. Также ведутся исследования дополнительных функций рендеринга на стороне сервера, основанных на этой функциональности, таких как потоковая передача и частичная гидратация.

К сожалению, есть один шаблон, который может помешать вашему приложению или библиотеке в полной мере использовать все эти новые и будущие функции: ручное манипулирование базовой структурой DOM. Angular требует, чтобы структура DOM оставалась единообразной с момента сериализации компонента сервером до его гидратации в браузере. Использование API-интерфейсов ElementRef , Renderer2 или DOM для ручного добавления, перемещения или удаления узлов из DOM перед гидратацией может привести к несогласованности, препятствующей работе этих функций.

Однако не все ручные манипуляции с DOM и доступ к ним проблематичны, а иногда и необходимы. Ключ к безопасному использованию DOM — максимально свести к минимуму вашу потребность в нем, а затем отложить его использование как можно дольше. Следующие рекомендации объясняют, как вы можете добиться этого и создать действительно универсальные и перспективные компоненты Angular, которые смогут в полной мере использовать все новые и будущие функции Angular.

Избегайте ручных манипуляций с DOM

Лучший способ избежать проблем, которые вызывают ручные манипуляции с DOM, — это, что неудивительно, избегать их вообще, где это возможно. Angular имеет встроенные API и шаблоны, которые могут манипулировать большинством аспектов DOM: вам следует предпочесть их использование вместо прямого доступа к DOM.

Изменить собственный элемент DOM компонента

При написании компонента или директивы вам может потребоваться изменить элемент хоста (то есть элемент DOM, который соответствует селектору компонента или директивы), например, чтобы добавить класс, стиль или атрибут, а не нацеливать или вводить элемент-обертка. Соблазнительно просто воспользоваться ElementRef , чтобы изменить базовый элемент DOM. Вместо этого вам следует использовать привязки хоста для декларативной привязки значений к выражению:

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

Как и в случае с привязкой данных в HTML , вы также можете, например, привязать атрибуты и стили и изменить 'true' на другое выражение, которое Angular будет использовать для автоматического добавления или удаления значения по мере необходимости.

В некоторых случаях ключ необходимо будет вычислять динамически. Вы также можете привязаться к сигналу или функции, которая возвращает набор или карту значений:

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

В более сложных приложениях может возникнуть соблазн вручную манипулировать DOM, чтобы избежать ExpressionChangedAfterItHasBeenCheckedError . Вместо этого вы можете привязать значение к сигналу, как в предыдущем примере. Это можно сделать по мере необходимости и не требует внедрения сигналов по всей вашей кодовой базе.

Изменить элементы DOM за пределами шаблона

Соблазнительно попытаться использовать DOM для доступа к элементам, которые обычно недоступны, например к тем, которые принадлежат другим родительским или дочерним компонентам. Однако это чревато ошибками, нарушает инкапсуляцию и затрудняет изменение или обновление этих компонентов в будущем.

Вместо этого ваш компонент должен рассматривать любой другой компонент как «черный ящик» . Потратьте время на то, чтобы подумать, когда и где другим компонентам (даже в том же приложении или библиотеке) может потребоваться взаимодействовать с вашим компонентом или настроить его поведение или внешний вид, а затем предоставить безопасный и документированный способ сделать это. Используйте такие функции, как иерархическое внедрение зависимостей , чтобы сделать API доступным для поддерева, когда простых свойств @Input и @Output недостаточно.

Исторически сложилось так, что такие функции, как модальные диалоги или всплывающие подсказки, реализовывались путем добавления элемента в конец <body> или какого-либо другого основного элемента, а затем перемещения или проецирования туда контента. Однако в наши дни вы, вероятно, можете вместо этого отображать простой элемент <dialog> в своем шаблоне:

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

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

Отложите ручное манипулирование DOM

После использования предыдущих рекомендаций по минимизации прямого манипулирования DOM и максимально возможного доступа к нему у вас могут остаться кое-какие ресурсы, которые неизбежны. В таких случаях важно отложить это как можно дольше. Обратные вызовы afterRender и afterNextRender — отличный способ сделать это, поскольку они запускаются только в браузере после того, как Angular проверит наличие любых изменений и зафиксирует их в DOM.

Запускайте JavaScript только для браузера

В некоторых случаях у вас будет библиотека или API, которые работают только в браузере (например, библиотека диаграмм, использование IntersectionObserver и т. д.). Вместо условной проверки того, работаете ли вы в браузере или заглушки поведения на сервере, вы можете просто использовать afterNextRender :

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

Выполнить индивидуальный макет

Иногда вам может потребоваться прочитать или записать в DOM, чтобы выполнить какой-либо макет, который ваши целевые браузеры еще не поддерживают, например, размещение всплывающей подсказки. afterRender — отличный выбор для этого, поскольку вы можете быть уверены, что DOM находится в согласованном состоянии. afterRender и afterNextRender принимают значение phase EarlyRead , Read или Write . Чтение макета DOM после его записи заставляет браузер синхронно пересчитывать макет, что может серьезно повлиять на производительность (см.: перебор макета ). Поэтому важно тщательно разделить вашу логику на правильные фазы.

Например, компонент всплывающей подсказки, который хочет отображать всплывающую подсказку относительно другого элемента на странице, скорее всего, будет использовать две фазы. Фаза EarlyRead сначала будет использоваться для получения размера и положения элементов:

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

Затем на этапе Write будет использоваться ранее прочитанное значение для фактического изменения положения всплывающей подсказки:

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

Разделив нашу логику на правильные фазы, Angular может эффективно пакетно обрабатывать DOM для всех остальных компонентов приложения, обеспечивая минимальное влияние на производительность.

Заключение

На горизонте ожидается множество новых и интересных улучшений серверного рендеринга Angular, цель которых — облегчить вам задачу предоставления пользователям отличных возможностей. Мы надеемся, что предыдущие советы окажутся полезными и помогут вам в полной мере использовать их преимущества в своих приложениях и библиотеках!