Angular Universal - это проект с открытым исходным кодом, расширяющий функциональные возможности @angular/platform-server. Проект делает возможным рендеринг на стороне сервера в Angular.

Angular Universal поддерживает несколько бэкэндов:

  1. "Выражать"
  2. ASP.NET Core
  3. Хапи

Другой пакет Socket Engine - это фреймворк, который теоретически позволяет подключать любой бэкэнд к SSR-серверу.

В этой статье мы обсудим проблемы и возможные решения, с которыми мы столкнулись при разработке реального приложения с Angular Universal и Express.

Как работает Angular Universal

Для рендеринга на сервере Angular использует реализацию DOM для node.js - домино. Для каждого запроса GET domino создает аналогичный объект документа браузера. В контексте этого объекта Angular инициализирует приложение.

Приложение отправляет запросы к бэкэнду, выполняет различные асинхронные задачи и применяет обнаружение любых изменений от компонентов к DOM, продолжая работать в среде node.js. Затем механизм рендеринга сериализует DOM в строку и передает ее на сервер. Сервер отправляет этот HTML-код в ответ на запрос GET. Angular-приложение на сервере после рендеринга уничтожается.

Проблемы SSR в Angular

1. Бесконечная загрузка страницы

Ситуация

Пользователь открывает страницу вашего сайта и видит белый экран. Другими словами, время до первого байта занимает слишком много времени. Браузер хочет получить ответ от сервера, но запрос заканчивается тайм-аутом.

Почему это происходит

Скорее всего, проблема кроется в специфичном для Angular механизме SSR. Прежде чем мы поймем, в какой момент отображается страница, давайте определим Zone.js и ApplicationRef.

Zone.js - это инструмент, позволяющий отслеживать асинхронные операции. С его помощью Angular создает свою зону и запускает в ней приложение. В конце каждой асинхронной операции в зоне Angular срабатывает обнаружение изменений.

ApplicationRef - ссылка на запущенное приложение (документы). Из всей функциональности этого класса нас интересует свойство ApplicationRef # isStable. Это Observable, который испускает логическое значение. isStable имеет значение true, если в зоне Angular не выполняются асинхронные задачи, и false, если они есть.

Итак, стабильность приложения - это состояние приложения, которое зависит от наличия асинхронных задач в зоне Angular.

Итак, в момент первого наступления стабильности Angular отображает текущее состояние приложений и уничтожает платформу. И платформа уничтожит приложение.

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

Возможное решение

Чтобы избежать ситуации с длинными запросами, используйте оператор тайм-аута из библиотеки rxjs:

import { timeout, catchError } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';

http.get('https://example.com')
  .pipe(
    timeout(2000),
    catchError(e => of(null))
  ).subscribe()

Оператор вызовет исключение после указанного, если не будет получен ответ от сервера.

У этого подхода есть 2 минуса:

  • Нет удобного разделения логики по платформам.
  • Оператор тайм-аута необходимо писать вручную для каждого запроса.

В качестве более простого решения вы можете использовать модуль NgxSsrTimeoutModule из пакета @ ngx-ssr / timeout. Импортируйте модуль со значением тайм-аута в корневой модуль приложения. Если модуль импортируется в AppServerModule, таймауты HTTP-запроса будут работать только для сервера.

import { NgModule } from '@angular/core';
import {
	ServerModule,
} from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { NgxSsrTimeoutModule } from '@ngx-ssr/timeout';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
    NgxSsrTimeoutModule.forRoot({ timeout: 500 }),
  ],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

Воспользуйтесь сервисом NgZone для вывода асинхронных операций из зоны Angular.

import { Injectable, NgZone } from "@angular/core";

@Injectable()
export class SomeService {
  constructor(private ngZone: NgZone){
    this.ngZone.runOutsideAngular(() => {
      interval(1).subscribe(() => {
        // somo code
      })
    });
  }
}

Чтобы решить эту проблему, вы можете использовать tuiZonefree из @taiga-ui/cdk:

import { Injectable, NgZone } from "@angular/core";
import { tuiZonefree } from "@taiga-ui/cdk";

@Injectable()
export class SomeService {
  constructor(private ngZone: NgZone){
    interval(1).pipe(tuiZonefree(ngZone)).subscribe()
  }
}

Но есть нюанс. Любая задача должна быть прервана при уничтожении приложения. В противном случае вы можете поймать утечку памяти (см. Проблему №5). Вы также должны понимать, что задачи, удаленные из зоны, не вызовут обнаружение изменений.

2. Отсутствие кеша из коробки

Ситуация

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

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

Возможное решение

На помощь приходят различные техники кеширования. Мы рассмотрим два: кеш в памяти и HTTP-кеш.

Кеш HTTP. При использовании сетевого кеша все сводится к установке правильных заголовков ответов на сервере. Они определяют время жизни кеша и политику кеширования:

Cache-Control: max-age = 31536000

Этот вариант подходит для неавторизованной зоны и при наличии долго неизменяемых данных.

Подробнее о HTTP-кеше можно прочитать здесь.

Кэш в памяти. Кэш в памяти можно использовать как для отображаемых страниц, так и для запросов API в самом приложении. Обе возможности - это пакет @ngx-ssr/cache.

Добавьте модуль NgxSsrCacheModule в AppModule для кеширования запросов API и на сервере в браузере.

Свойство maxSize отвечает за максимальный размер кеша. Значение 50 означает, что кеш будет содержать более 50 последних запросов GET, сделанных из приложения.

Свойство maxAge отвечает за время жизни кеша. Указывается в миллисекундах.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { NgxSsrCacheModule } from '@ngx-ssr/cache';
import { environment } from '../environments/environment';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    NgxSsrCacheModule.configLruCache({ maxAge: 10 * 60_000, maxSize: 50 }),
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Вы можете кэшировать сам HTML.

Например, все в одном пакете @ngx-ssr/cache имеет подмодуль @ngx-ssr/cache/express. Он импортирует одну withCache функцию. Функция является оболочкой над движком рендеринга.

import { ngExpressEngine } from '@nguniversal/express-engine';
import { LRUCache } from '@ngx-ssr/cache';
import { withCache } from '@ngx-ssr/cache/express';

server.engine(
  'html',
  withCache(
    new LRUCache({ maxAge: 10 * 60_000, maxSize: 100 }),
    ngExpressEngine({
      bootstrap: AppServerModule,
    })
  )
);

3. Ошибки сервера типа ReferenceError: localStorage не определен

Ситуация

Разработчик вызывает localStorage прямо в теле сервиса. Он извлекает данные из локального хранилища по ключу. Но на сервере этот код вылетает с ошибкой: ReferenceError: localStorage is undefined.

Почему это происходит

При запуске приложения Angular на сервере стандартный API браузера отсутствует в глобальном пространстве. Например, нет глобального объекта document, которого можно было бы ожидать в среде браузера. Чтобы получить ссылку на документ, необходимо использовать токен DOCUMENT и DI.

Возможное решение

Не используйте API браузера через глобальное пространство. Для этого есть DI. С помощью DI вы можете заменить или отключить реализации браузера для их безопасного использования на сервере.

Веб-API для Angular может быть использован для решения этой проблемы.

Например:

import {Component, Inject, NgModule} from '@angular/core';
import {LOCAL_STORAGE} from '@ng-web-apis/common';

@Component({...})
export class SomeComponent {
  constructor(@Inject(LOCAL_STORAGE) localStorage: Storage) {
    localStorage.getItem('key');
  }
}

В приведенном выше примере используется токен LOCAL_STORAGE из пакета @ ng-web-apis / common. Но когда мы запустим этот код на сервере, мы получим ошибку из описания. Просто добавьте UNIVERSAL_LOCAL_STORAGE из пакета @ ng-web-apis / universal в провайдеры AppServerModule, и по токену LOCAL_STORAGE вы получите реализацию localStorage для сервера.

import { NgModule } from '@angular/core';
import {
  ServerModule,
} from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { UNIVERSAL_LOCAL_STORAGE } from '@ngx-ssr/timeout';

@NgModule({
  imports: [
    AppModule,
    ServerModule,
  ],
  providers: [UNIVERSAL_LOCAL_STORAGE],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

4. Неудобное разделение логики

Ситуация

Если вам нужно отрендерить блок только в браузере, вам нужно написать примерно такой код:

@Component({
  selector: 'ram-root',
  template: '<some-сomp *ngIf="isServer"></some-сomp>',
  styleUrls: ['./app.component.less'],
})
export class AppComponent {
  isServer = isPlatformServer(this.platformId);
	
  constructor(@Inject(PLATFORM_ID) private platformId: Object){}
}

Компонент должен получить PLATFORM_ID, целевую платформу и понять общедоступное свойство класса. Это свойство будет использоваться в шаблоне вместе с директивой ngIf.

Возможное решение

С помощью структурных директив и DI описанный выше механизм можно значительно упростить.

Во-первых, давайте заключим определение сервера в токен.

export const IS_SERVER_PLATFORM = new InjectionToken<boolean>('Is server?', {
  factory() {
    return isPlatformServer(inject(PLATFORM_ID));
  },
});

Создайте структурированную директиву с использованием токена IS_SERVER_PLATFORM с одной простой целью: визуализировать компонент только на сервере.

@Directive({
  selector: '[ifIsServer]',
})
export class IfIsServerDirective {
  constructor(
    @Inject(IS_SERVER_PLATFORM) isServer: boolean,
    templateRef: TemplateRef<any>,
    viewContainer: ViewContainerRef
  ) {
    if (isServer) {
      viewContainer.createEmbeddedView(templateRef);
    }
  }
}

Код похож на директиву IfIsBowser.

Теперь давайте проведем рефакторинг компонента:

@Component({
  selector: 'ram-root',
  template: '<some-сomp *ifIsServer"></some-сomp>',
  styleUrls: ['./app.component.less'],
})
export class AppComponent {}

Из компонента удалены лишние свойства. Шаблон компонента теперь немного проще.

Такие директивы декларативно скрывают и отображают контент в зависимости от платформы.

Мы собрали токены и директивы в пакете @ngx-ssr/platform.

5. Утечка памяти

Ситуация

При инициализации служба запускает интервал и выполняет некоторые действия.

import { Injectable, NgZone } from "@angular/core";
import { interval } from "rxjs";

@Injectable()
export class LocationService {
  constructor(ngZone: NgZone) {
    ngZone.runOutsideAngular(() => interval(1000).subscribe(() => {
      ...
    }));
  }
}

Этот код не влияет на стабильность приложения, но обратный вызов, переданный подписке, будет продолжать вызываться, если приложение будет уничтожено на сервере. Каждый запуск приложения на сервере оставляет после себя артефакт в виде интервала. А это потенциальная утечка памяти.

Возможное решение

В нашем случае проблема решается с помощью хука ngOnDestoroy. Он работает как для компонентов, так и для служб. Нам нужно сохранить подписку и прекратить ее при разрушении сервиса. Есть много способов отписаться, но вот только один:

import { Injectable, NgZone, OnDestroy } from "@angular/core";
import { interval, Subscription } from "rxjs";

@Injectable()
export class LocationService implements OnDestroy {
  private subscription: Subscription;

  constructor(ngZone: NgZone) {
    this.subscription = ngZone.runOutsideAngular(() =>
      interval(1000).subscribe(() => {})
    );
  }

  ngOnDestroy(): void {
    this.subscription.unsubscribe();
  }
}

6. Отсутствие регидратации.

Ситуация

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

Почему это происходит

Angular не знает, как повторно использовать то, что он отрисовал на сервере. Он удаляет весь HTML из корневого элемента и начинает рисовать заново.

Возможное решение

Его до сих пор не существует. Но есть надежда, что решение будет. В дорожной карте Angular Universal есть пункт: «Стратегия полной регидратации клиента, которая повторно использует элементы DOM / CSS, отображаемые на сервере».

7. Невозможность прервать рендеринг.

Ситуация

Мы ловим критическую ошибку. Рендеринг и ожидание стабильности бессмысленны. Вам нужно прервать процесс и предоставить клиенту файл index.html по умолчанию.

Почему это происходит

Вернемся к моменту рендеринга приложения. Это происходит, когда приложение становится стабильным. Мы можем сделать наше приложение стабильнее быстрее, используя решение проблемы №1. Но что, если мы хотим прервать процесс рендеринга при первой обнаруженной ошибке? Что, если мы хотим установить ограничение по времени на попытку рендеринга приложения?

Возможное решение

На данный момент решения этой проблемы нет.

Резюме

Angular Universal - единственное поддерживаемое и наиболее широко используемое решение для рендеринга приложений Angular на сервере. Сложность интеграции в существующее приложение во многом зависит от разработчика.

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