На протяжении первой, второй и третьей статей мы прошли вместе довольно увлекательный путь: от первого знакомства с Observables, где разобрались с основами реактивного подхода, через освоение операторов, которые позволили нам эффективно преобразовывать и фильтровать данные, до комбинирования потоков, открывшего возможности для синхронизации данных из нескольких источников. Мы постепенно превращали RxJS из интересного инструмента для экспериментов в мощный инструмент для реальных задач.
Теперь, сделав три уверенных шага, пришло время взглянуть на тёмную сторону реактивного программирования. Как и у любой технологии, у RxJS есть свои подводные камни. Один из самых коварных — это незакрытые подписки, которые могут привести к серьёзным проблемам, таким как утечки памяти, деградация производительности и даже краши приложения. Реальная мощь инструментов RxJS требует от разработчиков не только технических знаний, но и настоящего профессионального мастерства, чтобы создавать надёжные и быстрые приложения.
Если первые три шага были о создании и трансформации потоков, то четвёртый шаг — это о том, как брать ответственность за созданное. Это шаг к зрелости в работе с RxJS — понимание того, почему управление подписками важно и как оно влияет на качество ваших приложений.
Присоединяйтесь, чтобы шагнуть дальше в мир RxJS, избежать распространённых ошибок и стать разработчиком, который не только умеет создавать интересный код, но и делает его надёжным, оптимизированным и удобным для любых условий эксплуатации.
Угрозы при работе с RxJS
Без RxJS сегодня сложно представить современный Angular. Она помогает нам справляться с асинхронными задачами, реактивно управлять состоянием, реагировать на события и даже строить сложные цепочки обработки данных.
С RxJS вы работаете с потоками данных (Observable). Когда вы подписываетесь на Observable, можно представить это как подключение шланга к крану с водой. Поток данных начинает течь, события льются в приложение непрерывным потоком. Проблема в том, что кран не закроется, пока вы сами не повернёте ручку.
Подписки без
unsubscribe— тихие убийцы. Пока вы тестируете всё в процессе разработки небольшими сессиями, приложение работает идеально… а потом тестировщик жалуется на дым из ноутбука после 30 минут общения с результатом вашей работы.
Представьте, что вы сделали подписку внутри компонента Angular, но забыли её «закрыть», когда компонент больше не нужен (например, пользователь ушел в другой раздел приложения). Что будет происходить?
-
Данные всё еще текут. Даже если UI больше не использует эти данные, подписка остается активной. Это значит, даже если никто не смотрит, поток продолжает передавать события, загружая и вашу систему, и сервер.
-
Память перестает очищаться. RxJS потоки работают как долгоживущие объекты. Пока подписка активна, ссылка на данные сохраняется в памяти, а сборщик мусора (GC) не сможет их освободить.
-
Незаметные проблемы становятся серьёзными. На первых порах это может казаться неважным — всё работает. Но через пару часов работы приложения (или после нескольких переходов между компонентами) проблемы начинают накапливаться:
-
Падение производительности.
-
Утечка памяти.
-
Краш браузера (особенно на слабых устройствах).
-
Почему вы не заметите проблему сразу? В процессе разработки легко упустить из виду детали подписок в RxJS. Все выглядит хорошо: данные обновляются, ошибки в консоли отсутствуют, скорость в коротких сессиях нормальная. Но проблема с неоптимальной работой всегда догоняет. Первая серьезная утечка памяти может поймать вас спустя дни и недели, когда приложение попадёт в руки пользователю со слабым устройством. И тогда вам придется пройти все круги ада с поиском «плавающих» багов, ошибок окружения, сетования на кривые пользовательские руки…
Пример с утечками памяти
Вообразим, что необходимо разработать некий чат для сайта. Он получает сообщения в режиме реального времени и отображает их на странице. Всё выглядит прекрасно, но внутри есть ошибка. Настолько крохотная, что её сложно заметить… до первого отчёта о деградации производительности, которая приходит к вам прямиком от пользователя.
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit} from '@angular/core'; import {interval, map} from "rxjs"; import {NgForOf} from "@angular/common"; let instance = 1; @Component({ selector: 'app-chat', standalone: true, imports: [ NgForOf ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <h2>Real-time Chat</h2> <div class="chat-window"> <div *ngFor="let message of messages" class="message"> {{ message }} </div> </div> `, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnInit, OnDestroy { messages: string[] = []; instanceId = instance++; // создание поддельной строки private getRandomString = () => (Math.random() + 1).toString(36).substring(2); constructor(private detector: ChangeDetectorRef) { } ngOnInit(): void { // Создаем поток новых сообщений каждую половину секунды interval(500) .pipe( map((i) => `New message #${i + 1} - ${new Array(1000).fill(0) .map(() => this.getRandomString()).join('')}`) // Симуляция тяжелых данных ) .subscribe(message => { // Добавляем новое сообщение в список this.messages.push(message); this.detector.detectChanges(); console.log(`Подписка ${this.instanceId} отработала.`); }); } ngOnDestroy(): void { console.log(`ChatComponent ${this.instanceId} destroyed`); } }
Что не так с этим компонентом?
На первый взгляд, ничего катастрофического, UI обновляется, всё работает как задумывалось.
Но вот проблема: вы НЕ отписались от потока!
Когда пользователь скрывает чат, компонент ChatComponent уничтожается. Но подписка, которая срабатывает два раза в секунду, продолжает «жить». Она продолжает генерировать сообщения, чтобы каждый из них отправить… никуда. В результате:
-
Поток всё ещё активен. Сообщения продолжают генерироваться в памяти.
-
Ссылки на массив
messages[]остаются в памяти. -
Сборщик мусора (
GC) не сможет освободить занятые ресурсы, потому что подписка поддерживает «живую» ссылку.
Давайте посмотрим на это в жизни:
-
Скачайте проект отсюда и запустите.
-
Включите DevTools браузера и перейдите в раздел Console.
-
Понажимайте на кнопку открытия/закрытия чата несколько раз.
Вы увидите все увеличивающееся количество сообщений в консоли, что самое плохое, вы будете видеть сообщения от уже уничтоженных экземпляров чата. В разделе Memory мы сможете наблюдать бесконечный рост используемых приложением ресурсов.
Почему это плохо? Утечки памяти и их влияние
Когда вы подписываетесь на Observable-поток, он создаёт ссылку на ваш код-подписчик. RxJS поток продолжает «жить» до тех пор, пока есть хотя бы одна активная подписка. Это значит, что если вы не отписались, задействованные данные будут оставаться в памяти бесконечно долго.
В нашем примере подписка сохраняет ссылку на массив messages[]. Даже если пользователь закрывает чат, память не освобождается:
-
Генерируется очередной
message, добавляется в массив, но он уже никому не нужен. -
Старая ссылка на массив остаётся «живой», а сборщик мусора (GC) не может её освободить, потому что поток всё ещё активен.
Результат? Память начинает «забиваться». На ранних этапах этого незаметно, но через 5-10 минут работы приложение вдруг начнёт тормозить.
Чем больше подписок остаётся активными, тем больше данных обрабатывает ваш поток RxJS. В результате:
-
ЦП начинает загружаться.
-
Слабые устройства (например, старые телефоны) с трудом справляются с лишними вычислениями.
-
В самый неподходящий момент браузер «ложится» или появляется серьёзная задержка графического интерфейса.
В случае с нашим чатом баг особенно опасен, потому что новые сообщения генерируются каждые 500 мс. Умножьте это на количество оставленных подписок — и вы получите катастрофу.
Почему это неочевидно?
Ошибки, связанные с утечками памяти, часто развиваются постепенно. Вот несколько причин, почему многие забывают про unsubscribe на ранних этапах разработки:
-
Производительность на старте нормальная. В начале вы ничего не замечаете — подписка работает, UI обновляется.
-
Сложность диагностики. Утечки памяти проявляются только в долгосрочной перспективе. Вам редко удаётся «увидеть» что-то странное, пока приложение работает всего несколько минут.
-
Angular сам справляется с многими задачами за вас. Инструменты фреймворка часто «маскируют» проблему до тех пор, пока она не начнёт накапливаться.
-
Оптимистичное мышление. «Работает — значит, всё нормально». Но правда в том, что незакрытые подписки всегда найдут способ как создать вам проблему в самый неподходящий момент.
Как это исправить? Закрываем подписки
Итак, мы поняли, что оставлять подписки открытыми — плохая идея. Теперь давайте рассмотрим, как исправить эту проблему, используя несколько простых и надёжных методов. На самом деле, грамотно управлять подписками в вашем Angular-приложении не так сложно, как может показаться. Главное — сделать это правилом с самого начала.
1. Используйте единый подход к управлению подписками
1. Используем Subscription и ручное управление
RxJS предоставляет специальный класс Subscription, который помогает управлять подписками. Используя его, вы можете явно сохранять подписки, а затем отписываться, когда компонент больше не нужен. Давайте модифицируем наш проблемный код с использованием Subscription:
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit} from '@angular/core'; import {interval, map, Subscription} from "rxjs"; import {NgForOf} from "@angular/common"; let instance = 1; @Component({ selector: 'app-chat', standalone: true, imports: [ NgForOf ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <h2>Real-time Chat</h2> <div class="chat-window"> <div *ngFor="let message of messages" class="message"> {{ message }} </div> </div> `, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnInit, OnDestroy { messages: string[] = []; instanceId = instance++; private subscriptions: Subscription[] = []; // Сохраняем все подписки здесь private getRandomString = () => (Math.random() + 1).toString(36).substring(2); constructor(private detector: ChangeDetectorRef) { } ngOnInit(): void { // Добавляем наблюдаемый поток в список подписок this.subscriptions.push(interval(500) .pipe( map((i) => `New message #${i + 1} - ${new Array(1000) .fill(0).map(() => this.getRandomString()).join('')}`) ) .subscribe(message => { // Добавляем новое сообщение в список this.messages.push(message); this.detector.detectChanges(); console.log(`Подписка ${this.instanceId} отработала.`); })); } ngOnDestroy(): void { // Отписываемся от всех наблюдаемых потоков this.subscriptions.forEach(s => s.unsubscribe()); console.log(`ChatComponent ${this.instanceId} destroyed`); } }
Что здесь происходит?
-
Мы создаём объект
Subscription, куда добавляем наш поток сообщений. -
В момент уничтожения компонента (
ngOnDestroy) вызываем.unsubscribe(), и завершаем наблюдение за потоком данных.
Это самый простой и понятный подход. Если ваши подписки хранятся отдельно, вы всегда можете вручную ими управлять.
А еще можно создать специальный служебный класс, который содержит необходимые методы:
import { OnDestroy } from '@angular/core'; import { Subscription } from 'rxjs'; export abstract class DestructibleComponent implements OnDestroy { protected subs: Subscription[] = []; protected onDestroy?: () => void; ngOnDestroy(): void { this.subs.forEach(s => s.unsubscribe()); if (this.onDestroy) this.onDestroy(); } }
Любой компонент может унаследовать этому классу (extends) и добавлять свои подписки в массив subs, без необходимости определять ngOnDestroy. Если же ему понадобится совершить какие-то действия при уничтожении, то в наличии есть специальный метод onDestroy, созданный специально для этого и не влияющий на отписки.
2. Используем AsyncPipe: минимум кода
Если ваши данные отображаются только в шаблоне (например, через *ngFor или {{ }}), вы можете вообще обойтись без подписок в TypeScript. В этом случае используется Angular AsyncPipe, который сам заботится об отписке.
Вот пример:
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy} from '@angular/core'; import {interval, map, tap} from "rxjs"; import {AsyncPipe, NgForOf} from "@angular/common"; let instance = 1; @Component({ selector: 'app-chat', standalone: true, imports: [ NgForOf, AsyncPipe ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` <h2>Real-time Chat</h2> <div class="chat-window"> <div *ngFor="let message of messages$ | async" class="message"> {{ message }} </div> </div> `, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnDestroy { instanceId = instance++; private getRandomString = () => (Math.random() + 1).toString(36).substring(2); messages$ = interval(500) .pipe( tap(() => console.log(`Подписка ${this.instanceId} отработала.`)), map((i) => Array.from({length: i}).map((_, idx) => `New message #${idx + 1} - ${new Array(1000).fill(0) .map(() => this.getRandomString()).join('')}`)) ); constructor(private detector: ChangeDetectorRef) { } ngOnDestroy(): void { console.log(`ChatComponent ${this.instanceId} destroyed`); } }
Почему AsyncPipe — это круто?
-
Он автоматически подписывается на Observable, когда шаблон отображается.
-
Отписка выполняется автоматически, когда компонент уничтожается.
-
Код становится максимально лаконичным.
Когда это подходит?
Используйте AsyncPipe, если поток данных напрямую используется только в шаблоне и дальше нигде в коде не применяется.
Рекомендации для работы с подписками
1. Используйте единый подход к управлению подписками
Во всем проекте стоит придерживаться одного стиля работы с подписками. Например:
-
Используйте наследование
DestructibleComponentдля всех компонентов. -
Используйте широко известный подход с
takeUntil -
Или применяйте
Subscriptionдля ручного управления, если подписки более специфичны.
1. Используйте единый подход к управлению подписками
2. Минимизируйте количество подписок
Старайтесь комбинировать потоки с помощью операторов merge, combineLatest, switchMap, вместо создания нескольких небольших подписок.
3. Отдавайте предпочтение AsyncPipe
Если ваши данные отображаются только в шаблонах, используйте AsyncPipe.
4. Следите за производительностью в DevTools
Регулярно проводите анализ производительности приложения:
-
Используйте вкладку Memory для проверки утечек памяти.
-
Запускайте профайлинг через Performance, чтобы заметить подозрительное поведение.
5. Логируйте уничтожение подписок в процессе разработки
Добавляйте console.log в ngOnDestroy, чтобы убедиться, что подписки закрываются при уничтожении компонента.
Заключение
Работа с RxJS — это не просто освоение нового инструмента. Это путь к более эффективному, организованному и глубокому пониманию разработки современных приложений. На этом пути неизбежно будут ошибки, как те самые незакрытые подписки, но каждая из них — это шаг вперёд, шаг к вашему профессиональному росту.
RxJS, как и любое другое сложное, но полезное умение, требует терпения, практики и упрямой решимости стать лучше.
Продолжайте экспериментировать, изучать новое и разбираться в мелочах. Напоминайте себе, что каждый час, потраченный на освоение фундаментальных вопросов, окупится многократно: в скорости разработки, стабильности ваших приложений и уважении коллег, которые увидят в вас профессионала.
Память браузера, пользователи и ваши коллеги скажут вам спасибо. Уверенно пробуйте, ошибайтесь, исправляйтесь, делайте выводы и двигайтесь к новым горизонтам. Успех в каждом аспекте разработки — это результат собственной работы и стремления превратить сегодняшние сложности в завтрашнее уверенное знание.
ссылка на оригинал статьи https://habr.com/ru/articles/906284/
Добавить комментарий