На протяжении многих лет RxJS был основой реактивности в Angular. Однако одним из его главных недостатков с точки зрения синхронизации данных и представлений является его статичность. С концептуальной точки зрения, функции observables — это скорее про обработку событий, чем про управление данными. По этой причине команда Angular представила новый примитив — сигналы. Но как нам теперь относиться к RxJS

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

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

Будем следовать простому правилу

Больше никаких observables в шаблонах. До свидания, async!

До появления сигналов реактивность в Angular основывалась на подписке на observables в шаблонах с помощью AsyncPipe. AsyncPipe был разработан для решения двух ключевых задач: пометки представления как «нечистого» для обнаружения изменений (это необходимо при использовании стратегии OnPush) и привязки к жизненному циклу компонента для отмены подписки на observable после уничтожения представления.

Однако это не решило фундаментальную проблему, о которой я говорил ранее: observables не гарантируют состояние после подписки, что привело к тому, что Angular стал выводить возвращаемое значение конвейера в виде объединения с null. Это вынуждало разработчиков добавлять дополнительные проверки, что оказалось довольно раздражающим.

Настало время полностью отказаться от использования observables в шаблонах и перейти к сигналам, которые отслеживают состояние и обладают дополнительными преимуществами, которые мы рассмотрим в примерах.

Предупреждение: в примерах кода используется API RxJS Interop, который пока находится в стадии предварительного рассмотрения разработчиками.

Кнопка копирования (copy) 

Решим простую задачу: при нажатии на кнопку копирования нам нужно обеспечить обратную связь с пользователем, изменив иконку и текст на пару секунд, а затем вернув их в исходное состояние:

readonly copied = toSignal(
  fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
    exhaustMap(() =>
      timer(2000).pipe(
        map(() => false),
        startWith(true)
      )
    )
  ),
  { initialValue: false }
);

Результат (stackblitz):

Используя преимущества операторов RxJS, мы декларативно описали, как изменяется состояние copied:

  • Если пользователь нажимает на кнопку несколько раз, exhaustMap гарантирует завершение текущего timer должным образом, прежде чем состояние сбросится в false.
  • Логика синхронизации выделяемых значений с сигналом и отписки от исходной observable обрабатывается toSignal, что устраняет необходимость в ручных подписках.

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

Подсветка анкоров

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

private readonly id = inject(new HostAttributeToken('id'));
private readonly route = inject(ActivatedRoute);

readonly highlighted = toSignal(
  this.route.fragment.pipe(
    startWith(this.route.snapshot.fragment),
    filter(fragment => this.id === fragment),
    switchMap(() =>
      concat(
        fromEvent(window, 'scroll').pipe(
          startWith(true),
          debounceTime(100),
          take(1)
        ),
        timer(2000).pipe(map(() => false))
      )
    )
  ),
  { initialValue: false }
);

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

host: {
  '[class.highlighted]': 'highlighted()'
}

Результат (stackblitz):

RxJS в очередной раз помог нам избежать каскада императивного кода:

  • debounceTime вместе с take позволяет нам дождаться окончания автоматической прокрутки, прежде чем обновить состояние до true.
  • timer сбрасывает состояние до начального значения.
  • Порядок подписок управляется concat, который подписывается на таймер сброса только после завершения работы первой observable.

Автоматическое отключение

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

private readonly el = inject(ElementRef).nativeElement;

readonly duration = input(Infinity);

readonly close = outputFromObservable(
  toObservable(this.duration).pipe(
    switchMap(value => Number.isFinite(value) ? timer(value) : EMPTY),
    takeUntil(fromEvent(this.el, 'mouseenter')),
    repeat({ delay: () => fromEvent(this.el, 'mouseleave') })
  )
);

Результат (stackblitz):

В этой задаче мы использовали две вспомогательные функции для работы с обновленными API input и output в Angular.

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

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

outputFromObservable создает новый вывод из observable. С введением новых входов Angular пришлось отказаться от EventEmitter, который ранее был расширен за счет Subject из RxJS. Хотя здесь и нет ничего принципиально нового, стоит отметить, что «под капотом» вывод теперь использует экземпляр нового класса OutputEmitterRef. Этот класс следует той же ментальной модели, что и Subject, но в более легковесном варианте. 

Отслеживание размеров элемента

Когда речь заходит об API в браузерной среде, бывает неудобно использовать их в декларативном виде. Большинство таких API основаны на обратных вызовах, а некоторые также включают логику очистки, подобную той, что известна специалистам по RxJS под названием TeardownLogic.

Однако, хотя RxJS и предоставляет унифицированный механизм для построения реактивных цепочек, работа с императивными API часто требует написания более многословных и последовательных инструкций.

Поскольку сигналы легко интегрируются с observables, с императивными API можно обращаться как с потоками. Определим для примера пользовательский оператор для превращения ResizeObserver в observable:

export function fromResizeObserver(
  target: Element | SVGElement,
  options?: ResizeObserverOptions,
): Observable<ResizeObserverEntry[]> {
  return new Observable(subscriber => {
    const ro = new ResizeObserver(entries => subscriber.next(entries));
    ro.observe(target, options);
    return () => ro.disconnect();
  });
}

Этот оператор можно повторно использовать во всей кодовой базе. Например, мы можем прибегнуть к нему при создании реактивного состояния для параметра width элемента хоста:

private readonly el = inject(ElementRef).nativeElement;

readonly width = toSignal(
  fromResizeObserver(this.el).pipe(map(() => this.el.offsetWidth)),
  { initialValue: 0 }
);

Для точного отслеживания ширины элемента часто требуется не только ResizeObserver. В пограничных случаях могут понадобиться дополнительные наблюдатели, такие как MutationObserver. Здесь RxJS демонстрирует свою истинную силу: операторы слияния позволяют декларативно объединять события для надежного обнаружения изменений ширины элемента:

private readonly el = inject(ElementRef).nativeElement;

readonly width = toSignal(
  merge(
    fromResizeObserver(this.el),
    fromMutationObserver(this.el, {
      childList: true,
      subtree: true,
      characterData: true,
    })
    // ...а также другие пограничные случаи
  ).pipe(
    map(() => this.el.offsetWidth),
    distinctUntilChanged()
  ),
  { initialValue: 0 }
);

Повышение эффективности API Angular

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

В некоторых предыдущих фрагментах кода я использовал глобальные объекты, доступные только в среде браузера (например, window). Однако, чтобы гарантировать правильное выполнение кода на сервере (SSG/SSR), нам нужно убедиться, что определенная логика инициализируется только во время рендеринга на стороне клиента. В таких случаях мы можем использовать API afterNextRender (все еще на стадии рассмотрения разработчиками), который основан на обратном вызове, и превратить его в observable:

export function fromAfterNextRender(options?: AfterRenderOptions): Observable<void> {
  if (!options?.injector) {
    assertInInjectionContext(fromAfterNextRender);
  }

  return new Observable(subscriber => {
    const ref = afterNextRender(() => {
      subscriber.next();
      subscriber.complete();
    }, options);

    return () => ref.destroy();
  });
}

Затем можно использовать его для вычисления состояния исключительно на стороне клиента:

readonly state = toSignal(
  fromAfterNextRender().pipe(
    switchMap(() => {
      // здесь мы находимся в контексте браузера
    }),
  ),
);

Еще один пример — создание пользовательского оператора на основе частой последовательности операций для сокращения количества шаблонного кода и улучшения читабельности.

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

private readonly router = inject(Router);

constructor() {
  this.events.subscribe(e => {
    if (e instanceof NavigationEnd) {
      // Сужение типа `e` доступно только в этом блоке
    }
  })
}

Гораздо удобнее писать вот так:

/*
Теперь мы можем использовать наблюдаемую переменную с типизированным значением соответствующего события любым удобным для нас способом

  Returns Observable<NavigationEnd>
*/
fromRouterEvent(NavigationEnd);

Для этого достаточно написать обычный оператор, используя существующий оператор filter и предикат типа для обеспечения сужения типа:

import { Event, Router } from '@angular/router';

export function fromRouterEvent<T extends Event>(
  event: { new (...args: any[]): T },
  options?: { injector: Injector },
): Observable<T> {
  let router: Router;

  if (!options?.injector) {
    assertInInjectionContext(fromRouterEvent);
    router = inject(Router);
  } else {
    router = options.injector.get(Router);
  }

  return router.events.pipe(filter((e: unknown): e is T => e instanceof event));
}

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

Заключение

Описанные выше кейсы представляют собой практические примеры из реальных проектов. Как видно из приведенных решений, RxJS отлично подходит для выполнения событийно-управляемых задач. Вдобавок ко всему, реактивная библиотека остается интегрированной в экосистему Angular на уровне публичных API (например, в @angular/{router,forms,сommon/http,cdk}). 

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Vyacheslav Borodin: RxSignals: The most powerful synergy in the history of Angular

Предыдущая статьяДевять вопросов на собеседованиях для разработчиков Android
Следующая статьяСоздание многофункционального калькулятора на чистом JavaScript