Широковещательные или мультикаст операторы нередко кажутся самой сложной темой при изучении RxJS. В этой статье я попробую все доступно объяснить.
Мы рассмотрим внутреннее устройство мультикаст операторов и решаемые ими задачи.
Давайте начнем с описания основных строительных блоков RxJS.
Observable
В RxJS наблюдаемые объекты (далее «потоки») изначально являются холодными. Это значит, что каждый раз при подписке на поток выполняется коллбэк подписки.
Для лучшего понимания создадим такую реализацию:
class Observable { constructor(subscriptionFn) { this.subscriptionFn = subscriptionFn; } subscribe(observer) { return this.subscriptionFn(observer); } }
Конструктор
Observable принимает единственный параметр — коллбэк подписки subscriptionFn. Он будет вызываться каждый раз, когда мы подписываемся на поток (subscribe()).Иногда еще коллбэк подписки называют
producer, так как она так же «производит» значения для подписчика (объект observer в нашем коде).Метод
subscribe() принимает на вход observer. Это объект с тремя собственными методами: next(), error(), complete(). В живом RxJS вместо объекта можно передать три функции.Метод
subscribe() при вызове вызывает функцию подписки передавая ей на вход observer.Мы сейчас не упомянули метод
unsubscribe, но следует иметь в виду, что каждая подписка предоставить способ ее уничтожения. Чаще всего подписка возвращает функцию (или объект с соответствующим методом), при выполнении которой уничтожается связь между потоком и его подписчиками.Это все довольно просто. Давайте теперь перейдем ближе к реальности. Например завернем в поток нативный XHR API
function http(url) { // This function will be called when we call http().subscribe() const subscriptionFn = observer => { log('Observable execution: http'); const xhr = new XMLHttpRequest(); xhr.addEventListener('load', () => { if (xhr.readyState === 4 && xhr.status === 200) { observer.next(JSON.parse(xhr.responseText)); observer.complete(); } }); xhr.open('GET', url); xhr.send(); return () => xhr.abort() } return new Observable(subscriptionFn); }
Мы написали функцию
http, которая получает URL, выполняет http-запрос и возвращает поток, который эмитит полученный http-ответ.Теперь, глядя на нашу реализацию, как вы думаете, что произойдет когда мы подпишемся на этот поток дважды?
// A small observer helper const observer = tag => ({ next(value) { console.log(`${tag}:`, value); } }); http('https://jsonplaceholder.typicode.com/users') .subscribe(observer('subscriber-1')); http('https://jsonplaceholder.typicode.com/users') .subscribe(observer('subscriber-2'));
Правильно, выполнится два http-запроса. Если еще раз взглянуть на реализацию класса Observable, мы увидим почему так. Каждый подписчик вызывает коллбэк подписки, который в свою очередь каждый раз выполняет http-запрос.

Операторы
Оператор это функция, которая принимает на вход поток, производит какие-либо действия, и возвращает поток.
Напишем наш первый собственный оператор.
function map(fn) { return source => { return new Observable(observer => { log('Observable execution: map'); return source.subscribe({ next(value) { observer.next(fn(value)); } }); }); }; }
Функция
map() возвращает оператор, который принимает исходный поток и возвращает поток, в котором все проходящие значения будут пропущены через функцию fn. Т.е. внутри него обязательно присутствует подписка на входной поток.
Перед тем как использовать этот новый оператор, нам нужно как-то прикрепить его к потоку. Расширим наш класс
Observable методом pipe()class Observable { constructor(subscriptionFn) { this.subscriptionFn = subscriptionFn; } subscribe(observer) { return this.subscriptionFn(observer); } pipe(...operators) { return operators.reduce((source, next) => next(source), this); } }
Простой метод, всего одна строка кода.
pipe принимает массив операторов и вызывает их по очереди, передавая каждому на вход результат выполнения предыдущего.Давайте же используем наш оператор:
http('https://jsonplaceholder.typicode.com/users') .pipe(map(res => res[0])) .subscribe(observer('subscriber'));
При вызове
subscribe будет выполнена подписка на выходной поток из map(), и в свою очередь внутри map будет выполнена подписка на исходный поток.http-поток эмитит значение, оно попадает в
map. Затем, выполняется функция fn, поток из map эмитит значение в конечную подписку. Так работает observable chain — цепочка потоков.
Если мы подпишемся на цепочку дважды, каждая подписка в цепочке вызовется два раза.
const firstUser$ = http('https://jsonplaceholder.typicode.com/users') .pipe(map(res => res[0])); firstUser$.subscribe(observer('subscriber-1')); firstUser$.subscribe(observer('subscriber-2'));

А если нам не подходит такое поведение? Если мы хотим вызвать функцию подписки только один раз, сколько бы у нас не было подписок?
Например, что если мы хотим сделать один http-запрос и использовать результат всеми подписчиками? На этот случай нужен
Subject.Subjects
Subject это одновременно и поток и подписчик. Поток — потому что у него есть метод subscribe(), подписчик — потому что реализует интерфейс подписчика — методы next(), error(), complete().Давайте напишем его.
class Subject extends Observable { constructor() { super(); this.observers = []; } subscribe(observer) { this.observers.push(observer); } next(value) { this.observers.forEach(observer => observer.next(value)); } error(error) { this.observers.forEach(observer => observer.error(error)); } complete() { this.observers.forEach(observer => observer.complete()); } }
Subject может выполнять роль промежуточного звена между холодным потоком и множеством подписчиков.Изменим наш пример следующим образом:
const subject = new Subject(); subject.subscribe(observer('subscriber1')); subject.subscribe(observer('subscriber2')); http('https://jsonplaceholder.typicode.com/users') .pipe(map(res => res[0])) .subscribe(subject);
При вызове
subject.subscribe(someFn) выполняется лишь одна простая операция — добавление в массив subject.observers функции someFn.Ну а затем, поскольку
Subject ведет себя и как подписчик тоже, можно подписать его на исходный поток, т.е. когда исходный поток эмитит значение, вызывается subject.next(), что влечет за собой передачу этого значения каждому из подписчиков subject.Теперь у нас оригинальный коллбэк подписки выполняется один раз, и только один http-запрос будет выполнен.

Подписчики, опоздавшие на вечеринку
Что произойдет, если исходный поток уже сработал до того, как мы подписались?
Не получится показать это на прошлом примере, так как http — асинхронный, даже если подписаться на него сразу после, значение все равно придет уже после подписки.
Давайте быстро создадим порождающую функцию
of:function of(...values) { return new Observable(observer => { log('Observable execution: of'); values.forEach(value => observer.next(value)); }); }
Поток, созданный посредством
of(), эмитит значения синхронно, один за другим. Подпишемся на subject уже после того, как тот подписался на of.const subject = new Subject(); of(1, 2, 3).subscribe(subject); subject.subscribe(observer('subscriber1')); subject.subscribe(observer('subscriber2'));
Наши подписчики не получили ничего. Почему? Наша реализация не поддерживает «опоздавших» подписчиков. Когда исходный поток от
of() эмитит значения, подписчики еще не зарегистрированы, эти значения уйдут в никуда.В актуальных примерах на Angular вполне может быть такое, что исходный поток сработал, но ваш компонент еще не присутствует на странице. И когда компонент появится, он подпишется на источник, но не получит уже пролетевших значений.
Один из путей решения проблемы это
ReplaySubject. Набросаем его вариант и посмотрим как это работает.class ReplaySubject extends Subject { constructor(bufferSize) { super(); this.observers = []; this.bufferSize = bufferSize; this.buffer = []; } subscribe(observer) { this.buffer.forEach(val => observer.next(val)); this.observers.push(observer); } next(value) { if (this.buffer.length === this.bufferSize) { this.buffer.shift(); } this.buffer.push(value); this.observers.forEach(observer => observer.next(value)); } }
Концепция проста. Как и следует из названия,
ReplaySubject это особый Subject, который может воспроизводить старые значения всем новым подписчикам.Каждое выпущенное значение будет передано всем текущим подписчикам и сохранено для будущих, размер буфера
bufferSize устанавливается в конструкторе.Перепишем прошлый пример с помощью
ReplaySubject.const subject = new ReplaySubject(3); of(1, 2, 3).subscribe(subject); subject.subscribe(observer('subscriber1')); subject.subscribe(observer('subscriber2'));
Результат изменился.
Несмотря на позднюю подписку мы поймали их всех.

Резюмируя, предназначение
ReplaySubject — это рассылка значений всем подписчикам и кэширование их для будущих «опоздавших» подписчиков.Перед тем, как идти дальше я рекомендую вам попробовать написать свою реализацию
BehaviorSubject. Готовый код вы сможете найти в конце статьи.Теперь мы наконец переходим к мультикаст операторам. Надеюсь рассмотренные выше примеры выше вам помогут быстрее их понять.
Multicast Operators
Multicast и Connect
Оператор
multicast() использует Subject, чтобы эмитить исходный поток нескольким подписчикам.import { interval, Subject, ConnectableObservable } from 'rxjs'; import { multicast } from 'rxjs/operators'; const connectableObservable = interval(1000).pipe( multicast(new Subject()) ) const observer1 = connectableObservable.subscribe(log); const observer2 = connectableObservable.subscribe(log); const connectableSubscription = (connectableObservable as ConnectableObservable<any>) .connect();
multicast возвращает объект ConnectableObservable, у которого есть метод connect. Его назначение — подписать полученный subject на исходный поток.Метод
connect позволяет нам самим определять, когда запустить на выполнение исходный поток. Тут есть момент, о котором нужно помнить — чтобы отписаться от источника нужно выполнить:connectableSubscription.unsubscribe();
Мы не ограничены простым
Subject. Вместо него можно использовать любой производный класс, например ReplaySubject:import { interval, ReplaySubject, ConnectableObservable } from 'rxjs'; import { multicast } from 'rxjs/operators'; const connectableObservable = interval(1000).pipe( multicast(new ReplaySubject(1)) ) const observer1 = connectableObservable.subscribe(log); setTimeout(() => { // Late subscriber connectableObservable.subscribe(log); }, 3000) const connectable = (connectableObservable as ConnectableObservable<any>).connect();
По этому коду можно догадаться, что произойдет под капотом.
Когда мы используем
multicast, мы можем передать не только Subject, но и фабричную функцию, которая каждый раз возвращает новый Subject.Переиспользовать уже завершенный
subject нельзя, фабричная функция решает эту проблему.interval(1000).pipe( multicast(() => new Subject()) )
RefCount
Когда мы используем оператор
multicast(), мы сами отвечаем за вызов connect() для начала исполнения исходного observable. Плюс к тому мы еще должны следить за возможными утечками памяти, вручную отписываясь от ConnectableSubscription.Автоматизация процесса позволила бы избежать ошибок и упростила код. Добрые разработчики RxJS подумали об этом за нас и создали
refCount оператор.refCount считает подписки и при появлении первой, он вызывает connect(), т.е. подписывается. Когда оно обратно уменьшиться до нуля, будет вызвана отписка.const source = interval(1000).pipe( multicast(new Subject()), refCount() ) // refCount === 1 => source.subscribe(); const observer1 = source.subscribe(log); // refCount === 2 const observer2 = source.subscribe(log); setTimeout(() => { // refCount - 1 observer1.unsubscribe(); // refCount - 1 observer2.unsubscribe(); // refCount === 0 => source.unsubcribe(); }, 3000)
Заметим, что после
refCount мы получаем обычный observable, а не ConnectableObservable.Publish и его варианты
multicast() + Subject + refCount() это довольно типичный случай в RxJS и разработчики сократили его до одного оператора. Посмотрим какие у нас есть варианты.
publish()равнозначенmulticast(() => new Subject())
const connectableObservable = interval(1000).pipe( publish() ) connectableObservable.connect();
publishBehavior()равнозначенmulticast(new BehaviorSubject())
const connectableObservable = interval(1000).pipe( publishBehavior(100) ) connectableObservable.connect();
publishReplay()равнозначенmulticast(() => new ReplaySubject(x))
const connectableObservable = interval(1000).pipe( publishReplay(3) ) connectableObservable.connect();
publishLast()равнозначенmulticast(new AsyncSubject())
const connectableObservable = interval(1000).pipe( take(2), publishLast() ) connectableObservable.connect();
share()равнозначенmulticast(() => new Subject()) + refCount()
const source = interval(1000).pipe( share() )
shareReplay(bufferSize)это мультикаст оператор, который используетReplaySubject. У него внутри нетmulticast()и его результат это observable, а неConnectableObservable. Он может быть использован как сrefCount, так и без него. Вот оба варианта:
interval(1000).pipe( shareReplay({ refCount: true, bufferSize: 1 }) ) interval(1000).pipe( shareReplay(1) )
Когда
shareReplay вызван с { refCount: false } все равно что вызвать shareReplay(x).В этом случае подсчета ссылок не будет. Это значит что пока исходный поток не будет завершен,
shareReplay будет подписан на него, независимо есть у него самого конечные подписчики или нет. Все новые подписчики получат последние x значений.shareReplay vs publishReplay + refCount
На первый взгляд
shareReplay({ refCount: true, bufferSize: X }) идентичен publishReplay(X) + refCount() , однако это не совсем верно.Посмотрим в чем сходство и в чем разница.
У них одинаковое поведение
refCount — подписка и отписка от исходного потока на основании количества подписчиков. Еще у них одинаково реагируют когда исходный поток завершился — все новые подписчики получает X последних значений.Однако, если же исходный поток еще не завешен, в этом случае когда у нас
publishReplay(X) + refCount() — все новые подписчики получают X значений из буфера, а потом будут подписаны заново с помощью того же ReplaySubject.А вот в случае если мы используем
shareReplay({ refCount: true, bufferSize: 1 }) последних X значений они не получат, так как внутри оно создает новый ReplaySubject и использует его для переподписки на источник.Примеры, иллюстрирующие это:
const source = interval(1000).pipe( publishReplay(1), refCount() ); const one = source.subscribe(observer('subcriber-1')); setTimeout(() => { one.unsubscribe(); // This subscriber will get the last emitted values from the source const two = source.subscribe(observer('subcriber-2')); }, 3000);
const source = interval(1000).pipe( shareReplay({ refCount: true, bufferSize: 1 }) ); const one = source.subscribe(observer('subcriber-1')); setTimeout(() => { one.unsubscribe(); // This subscriber will NOT get the last emitted values from the source const two = source.subscribe(observer('subcriber-2')); }, 3000);


Актуальные примеры в Angular
Посмотрим как применять в боевых условиях изученные мультикаст операторы.
Используем share
Пусть у нас есть компонент, которому нужны данные из исходного потока. Это может быть запрос http, стейт или что-угодно. А еще нам нужно манипулирование данными, типа фильтрации, сортировки и т.п.
@Component({ template: ` <users-list [users]="allUsers$ | async"></users-list> `, }) export class UsersPageComponent { allUsers$: Observable<User[]>; constructor(private http: HttpClient) { } ngOnInit() { this.allUsers$ = this.http.get('https://api/users').pipe( map(users => filter/sort), ); } }
А теперь нам нужен другой компонент, который показывает только первого пользователя. Если мы подпишемся на исходных поток как он есть, то:
@Component({ template: ` <user [user]="firstUser$ | async"></user> <users-list [users]="allUsers$ | async"></users-list> `, }) export class UsersPageComponent { allUsers$: Observable<User[]>; firstUser$: Observable<User>; constructor(private http: HttpClient) { } ngOnInit() { this.allUsers$ = this.http.get('https://api/users').pipe( map(users => filter/sort), ); this.firstUser$ = this.allUsers$.pipe(map(users => users[0])); } }
И вот у нас два http-запроса, операции сортировки или фильтрации выполнятся дважды.
Применяем
share:@Component({ template: ` <user [user]="firstUser$ | async"></user> <users-list [users]="allUsers$ | async"></users-list> `, }) export class UsersPageComponent { allUsers$: Observable<User[]>; firstUser$: Observable<User>; constructor(private http: HttpClient) { } ngOnInit() { this.allUsers$ = this.http.get('https://api/users').pipe( map(users => filter/sort), share() ); this.firstUser$ = this.allUsers$.pipe(map(users => users[0])); } }
Мы уже знаем, что он создает новый
Subject, который подписывается на источник. Когда источник эмитит, subject передает это значение всем своим подписчикам.Проблема решена, и когда мы подписались на
firstUser$ — мы подписались на внутренний subject, а не на исходный поток напрямую.Используем ShareReplay
ShareReplay применяется когда нужно эмитить, кэшировать и повторять последние X значений. Типичный пример — синглтон сервис, выполняющий http запрос.@Injectable({ providedIn: 'root' }) export class BlogService { posts$ = this.http.get('https://jsonplaceholder.typicode.com/posts') .pipe(shareReplay(1)); constructor(private http: HttpClient) {} }
Тут уже неважно сколько компонентов сейчас или в будущем запросят данные, http запрос будет только один и результат будет сохранен во внутреннем буфере
ReplaySubject.Еще может встретиться случай, где нужно отменить незавершенный запрос, ведь подписчиков уже нет, тогда нужно будет применить
refCount.Полный код вы можете найти здесь.

