Как стать автором
Обновить
289.49
TINKOFF
IT’s Tinkoff — просто о сложном

Angular Universal: проблемы реального приложения

Время на прочтение 9 мин
Количество просмотров 17K

Angular Universal — это опенсорсный проект, который расширяет функциональность @angular/platform-server. Он делает возможным Server Side Rendering в Angular.

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

  1. Express.

  2. ASP.NET Core.

  3. hapi.

Еще один пакет Socket Engine — это фреймворк-агностик, который теоретически позволяет подключать к SSR-серверу любой бэкенд. В этой статье мы разберем проблемы, с которыми мы столкнулись при разработке реального приложения с Angular Universal и Express, и их решения.

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

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

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

Проблемы SSR в Angular

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

Ситуация

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

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

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

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

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

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

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

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

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

Чтобы избежать ситуации с долгими запросами, используйте оператор timeout:

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()

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

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

  • нет удобного разделения логики по платформам;

  • оператор timeout нужно прописывать руками для каждого запроса.

Как более простое решение можно использовать модуль 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 {}

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

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()
	}
}

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

2. Нет кэша «из коробки»

Ситуация

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

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

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

На помощь приходят разные техники кэширования. Мы рассмотри две: in-memory cache и браузерный кэш.

Браузерный кэш. При использовании браузерного кэша все сводится к установке правильных заголовков ответа на сервере. В них указывается время жизни кэша и политика кэширования:

Cache-Control: max-age=31536000

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

Более подробно о браузерном кэше можно почитать тут.

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

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

Свойство 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 is not defined

Ситуация

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

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

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

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

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

Для решения этой проблемы можно обратиться к Web APIs for 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.

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

C помощью структурных директив и 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. Memory leak

Ситуация

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

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

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

Этот код не влияет на стабильность приложения, но при разрушении приложения на сервере колбэк, переданный в 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. Нельзя прервать рендер

Ситуация

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

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

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

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

Решения этой проблемы сейчас не существует.

7. Нет регидрации

Ситуация

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

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

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

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

Его все еще не существует. Но есть надежда, что решение все же будет. В roadmap Angular Universal есть пункт «Full client rehydration strategy that reuses DOM elements/CSS rendered on the server».

Выводы

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

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

Теги:
Хабы:
+26
Комментарии 5
Комментарии Комментарии 5

Публикации

Информация

Сайт
www.tinkoff.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия