Глобальные объекты в Angular

В JavaScript мы часто используем сущности вроде window, navigator, requestAnimationFrame или location. Некоторые из этих объектов существуют испокон веков, некоторые — часть вечно растущего набора Web API. Возможно, вы встречали класс Location или токен DOCUMENT в Angular. Давайте обсудим, для чего они нужны и чему мы можем у них научиться, чтобы сделать наш код чище и более гибким.

7d713ebc79b347645fe7bf66eacfd2f6.png

DOCUMENT

DOCUMENT — это токен из общего пакета Angular. Вот как можно им воспользоваться. Вместо этого:

constructor(private readonly elementRef: ElementRef) {} 

get isFocused(): boolean {
  return document.activeElement === this.elementRef.nativeElement;
}

Можно написать так:

constructor(
  @Inject(ElementRef) private readonly elementRef: ElementRef,
  @Inject(DOCUMENT) private readonly documentRef: Document,
) {} 

get isFocused(): boolean {
  return this.documentRef.activeElement === this.elementRef.nativeElement;
}

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

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

Вместо этого я покажу, почему подход с токеном лучше. Начнем с того, что посмотрим, откуда берется этот токен. В его объявлении в @angular/common нет ничего особенного:

export const DOCUMENT = new InjectionToken('DocumentToken');

Внутри @angular/platform-browser, однако, мы видим, как он получает свое значение (пример упрощенный):

{provide: DOCUMENT, useValue: document}

Когда вы добавляете BrowserModule в app.browser.module.ts, вы регистрируете целую кучу имплементаций для токенов, таких как RendererFactory2, Sanitizer, EventManager и наш DOCUMENT. Почему так? Потому что Angular — это кросс-платформенный фреймворк. Он оперирует абстракциями и активно использует механизм внедрения зависимостей для того, чтобы работать в браузере, на сервере и на мобильных устройствах. Чтобы разобраться, давайте заглянем в ServerModule — ещё одну платформу, доступную из коробки (пример упрощенный):

{provide: DOCUMENT, useFactory: _document, deps: [Injector]},

// ...

function _document(injector: Injector) {
  const config = injector.get(INITIAL_CONFIG);
  const window = domino.createWindow(config.document, config.url);

  return window.document;
}

Мы видим, что там используется domino для создания имитации документа на основе конфига, взятого из DI. Именно это вы получите, если запустите Angular Universal для рендеринга приложения на сервере. Мы уже видим первый и самый важный плюс данного подхода. Работа с DOCUMENT возможна в SSR-окружении, в то время как глобальный document в нем отсутствует.

Другие глобальные сущности

Что ж, команда Angular позаботилась о document для нас, это здорово. Но что, если мы хотим, например, проверить браузер через строку userAgent? Для этого мы обычно обращаемся к navigator.userAgent. На самом же деле это означает, что сначала мы запрашиваем глобальный объект, window в случае браузера, и потом берем его поле navigator. Так что давайте начнем с токена WINDOW. Его довольно просто реализовать благодаря фабрике, которую можно добавить к созданию токена:

export const WINDOW = new InjectionToken(
  'An abstraction over global window object',
  {
    factory: () => inject(DOCUMENT).defaultView!
  },
);

Этого достаточно, чтобы начать использовать WINDOW по аналогии с DOCUMENT. Теперь используем тот же подход для создания NAVIGATOR:

export const NAVIGATOR = new InjectionToken(
  'An abstraction over window.navigator object',
  {
    factory: () => inject(WINDOW).navigator,
  },
);

Мы шагнем дальше и сделаем отдельный токен под USER_AGENT тем же способом. Зачем? Увидим позже!

Иногда одного токена недостаточно. Location из Angular — это, по сути, обертка над location для упрощения работы с ним. Поскольку мы привыкли к RxJS, давайте заменим requestAnimationFrame на реализацию в виде Observable:

export const ANIMATION_FRAME = new InjectionToken<
  Observable
>(
  'Shared Observable based on `window.requestAnimationFrame`',
  {
    factory: () => {
      const performanceRef = inject(PERFORMANCE);

      return interval(0, animationFrameScheduler).pipe(
        map(() => performanceRef.now()),
        share(),
      );
    },
  },
);

Мы пропустили создание PERFORMANCE, потому что оно следует той же модели. Теперь у нас есть один общий стрим, основанный на requestAnimationFrame, который можно использовать по всему приложению. После того как мы заменили всё на токены, наши компоненты больше не полагаются на волшебным образом доступные сущности и получают всё, от чего они зависят, из DI. Классно!

Server Side Rendering

Теперь, допустим, мы хотим написать window.matchMedia('(prefers-color-scheme: dark)').

Конечно, на сервере в нашем WINDOW что-то да есть, но оно, безусловно, не поддерживает весь API объекта Window. Если мы попробуем сделать данный вызов в SSR, скорее всего, получим ошибку undefined is not a function. Один способ решить проблему — обернуть все подобные вызовы в проверку isPlatformBrowser, но это скукота. Преимущество DI в том, что значения можно переопределять. Так что вместо особой обработки таких случаев мы можем предоставить безопасный муляж WINDOW в app.server.module.ts, который защитит нас от несуществующих свойств.

Это демонстрирует еще одно достоинство данного подхода: значение токена можно менять. Благодаря этому тестировать код, зависящий от браузерного API, становится очень просто. Особенно если вы используете Jest, в котором нативный API по большей части отсутствует. Но муляж — это просто заглушка. Иногда мы можем подложить что-то осмысленное. В SSR-окружении у нас есть объект запроса, который содержит данные о user agent. Для этого мы и вынесли его в отдельный токен — иногда его можно заполучить отдельно. Вот как мы можем превратить запрос в провайдер:

function provideUserAgent(req: Request): ValueProvider {
  return {
    provide: USER_AGENT,
    useValue: req.headers['user-agent'],
  };
}

А затем добавим его в server.ts, когда будем настраивать Angular Universal:

server.get('*', (req, res) => {
  res.render(indexHtml, {
    req,
    providers: [
      {provide: APP_BASE_HREF, useValue: req.baseUrl},
      provideUserAgent(req),
    ],
  });
});

Node.js так же имеет собственную имплементацию Performance, которую можно использовать на сервере:

{
  provide: PERFORMANCE,
  useFactory: performanceFactory,
}

// ...

export function performanceFactory(): Performance {
  return require('perf_hooks').performance;
}

Однако в случае requestAnimationFrame он нам не понадобится. Скорее всего, мы не хотим гонять наши Observable-цепочки впустую на сервере, так что просто подложим в DI EMPTY:

{ 
  provide: ANIMATION_FRAME,
  useValue: EMPTY,
}

Следуя данной схеме, мы можем «токенизировать» все глобальные объекты в нашем приложении и предоставить замены для различных платформ, на которых мы его запускаем.

Подытожим

С таким подходом ваш код становится хорошо абстрагированным. Даже если вы не используете серверный рендеринг сейчас, возможно, он понадобится в будущем и вы будете к этому готовы. Кроме того, тестировать код гораздо легче, если все зависимости можно подменить. Мы создали крошечную библиотеку с токенами, которые используем сами:

Если вам требуется что-то, чего в ней еще нет, — не стесняйтесь заводить issue на «Гитхабе». Также у пакета есть напарник с версиями этих токенов под SSR:

Вы можете изучить этот проект по вселенной Rick and Morty от моего коллеги Игоря, чтобы увидеть это все в действии. Если вам в особенности интересен Angular Universal, прочитайте его статью о проблемах, с которыми он столкнулся, и как их решить.

Благодаря данному подходу наша библиотека Angular-компонентов Taiga UI без труда запустилась и под Angular Universal, и под Ionic. Надеюсь, что эти знания помогут и вам!

© Habrahabr.ru