На сегодняшний день написано очень много статей о том, что от подписок Observable RxJS надо отписываться, иначе произойдет утечка памяти. У большинства читателей таких статей в голове отложилось твёрдое правило "подписался? — отпишись!". Но, к сожалению, зачастую в подобных статьях информация искажается или что-то недоговаривается, а ещё хуже когда подменяются понятия. Об этом и поговорим.
Возьмем, к примеру, эту статью: https://medium.com/ngx/why-do-you-need-unsubscribe-ee0c62b5d21f
Когда мне говорят про "потенциальную возможность получить регрессию в производительности" я сразу вспоминаю про преждевременную оптимизацию.
Продолжим читать статью человека под ником Reactive Fox:
Дальше есть полезная информация и советы. Я согласен, что нужно всегда отписываться от бесконечных стримов в RxJS. Но я акцентирую внимание только на вредной информации (по моему мнению).
Ух… нагнал жути. Такие бездоказательные (нет метрик, цифр...) запугивания в настоящее время привели к тому, что для очень большого числа фронтендеров отсутствие отписки это как красная тряпка для быка. Когда они на это натыкаются, они больше не видят ничего вокруг, кроме этой тряпки.
Автор статьи даже сделал демо-приложение, где попытался доказать свои размышления:
https://stackblitz.com/edit/why-you-have-to-unsubscribe-from-observable-material
И действительно, на его стенде можно увидеть как процессор выполняет ненужную работу (когда я ничего не нажимаю) и как увеличивается расход памяти (изменение небольшое):
Как подтверждение того, что нужно всегда отписываться от подписок Observable запросов HttpClient, он добавил такой перехватчик запросов, который нам выводит в консоль "still alive… still alive… still alive...":
Т.е. человек перехватил конечный стрим, сделал из него бесконечный (в случае ошибки происходит повтор запроса, а ошибка происходит всегда) и выдает это за доказательство того, что нужно отписываться от конечных.
StackBlitz не очень подходит для измерения производительности приложений, т.к. там есть автоматическая синхронизация при обновлении и это отнимает ресурсы. Поэтому я сделал своё тестовое приложение: https://github.com/andchir/test-angular-app
Там есть два окошка. При открытии каждого отправляется запрос на action.php, в котором есть задержка в 3 секунды как имитация выполнения очень ресурсоёмкой операции. Также action.php логирует все запросы в файл log.txt.
<?php
header('Content-Type: application/json');
function logging($str, $fileName = 'log.txt')
{
if (is_array($str)) {
$str = json_encode($str);
}
$rootPath = __DIR__;
$logFilePath = $rootPath . DIRECTORY_SEPARATOR . $fileName;
$options = [
'max_log_size' => 200 * 1024
];
if (!is_dir(dirname($logFilePath))) {
mkdir(dirname($logFilePath));
}
if (file_exists($logFilePath) && filesize($logFilePath) >= $options['max_log_size']) {
unlink($logFilePath);
}
$fp = fopen( $logFilePath, 'a' );
$dateFormat = 'd/m/Y H:i:s';
$str = PHP_EOL . PHP_EOL . date($dateFormat) . PHP_EOL . $str;
fwrite( $fp, $str );
fclose( $fp );
return true;
}
$actionName = isset($_GET['a']) && !is_array($_GET['a']) ? $_GET['a'] : '1';
logging("STARTED-{$actionName}");
sleep(3);// Very resource-intensive operation that takes 3 seconds
logging("COMPLETED-{$actionName}");
echo json_encode([
'success' => true,
'data' => ['name' => 'test', 'title' => 'This is a test']
]);
Но сначала небольшое отступление. На картинке ниже (кликабельно) вы можете увидеть простой пример как работает сборщик мусора JavaScript в браузере Chrome. PUSH произошел, но setTimeout не помешал сборщику мусора очистить память.
Не забывайте вызывать сборщик мусора нажатием кнопки, когда будете экспериментировать.
Вернемся к моему тестовому приложению. Вот код обоих окошек:
@Component({
selector: 'app-bad-modal',
templateUrl: './bad-modal.component.html',
styleUrls: ['./bad-modal.component.css'],
providers: [HttpClient]
})
export class BadModalComponent implements OnInit, OnDestroy {
loading = false;
largeData: number[] = (new Array(1000000)).fill(1);
destroyed$ = new Subject<void>();
data: DataInterface;
constructor(
private http: HttpClient,
private bsModalRef: BsModalRef
) {
}
ngOnInit() {
this.loadData();
}
loadData(): void {
// For example only, not for production.
this.loading = true;
const subscription = this.http.get<DataInterface>('/action.php?a=2').pipe(
takeUntil(this.destroyed$),
catchError((err) => throwError(err.message)),
finalize(() => console.log('FINALIZE'))
)
.subscribe({
next: (res) => {
setTimeout(() => {
console.log(subscription.closed ? 'SUBSCRIPTION IS CLOSED' : 'SUBSCRIPTION IS NOT CLOSED!');
}, 0);
console.log('LOADED');
this.data = res;
this.loading = false;
},
error: (error) => {
setTimeout(() => {
console.log(subscription.closed ? 'ERROR - SUBSCRIPTION IS CLOSED' : 'ERROR - SUBSCRIPTION IS NOT CLOSED!');
}, 0);
console.log('ERROR', error);
},
complete: () => {
setTimeout(() => {
console.log(subscription.closed ? 'COMPLETED - SUBSCRIPTION IS CLOSED' : 'COMPLETED - SUBSCRIPTION IS NOT CLOSED!');
}, 0);
console.log('COMPLETED');
}
});
}
close(event?: MouseEvent): void {
if (event) {
event.preventDefault();
}
this.bsModalRef.hide();
}
ngOnDestroy() {
console.log('DESTROY');
this.destroyed$.next();
this.destroyed$.complete();
}
}
Как видим, здесь есть отписка (takeUntil). Всё как советовал нам "учитель". Также здесь есть большой массив.
@Component({
selector: 'app-good-modal',
templateUrl: './good-modal.component.html',
styleUrls: ['./good-modal.component.css']
})
export class GoodModalComponent implements OnInit, OnDestroy {
loading = false;
largeData: number[] = (new Array(1000000)).fill(1);
data: DataInterface;
constructor(
private http: HttpClient,
private bsModalRef: BsModalRef
) {
}
ngOnInit() {
this.loadData();
}
loadData(): void {
// For example only, not for production.
this.loading = true;
const subscription = this.http.get<DataInterface>('/action.php?a=1').pipe(
catchError((err) => throwError(err.message)),
finalize(() => console.log('FINALIZE'))
)
.subscribe({
next: (res) => {
setTimeout(() => {
console.log(subscription.closed ? 'SUBSCRIPTION IS CLOSED' : 'SUBSCRIPTION IS NOT CLOSED!');
}, 0);
console.log('LOADED');
this.data = res;
this.loading = false;
},
error: (error) => {
setTimeout(() => {
console.log(subscription.closed ? 'ERROR - SUBSCRIPTION IS CLOSED' : 'ERROR - SUBSCRIPTION IS NOT CLOSED!');
}, 0);
console.log('ERROR', error);
},
complete: () => {
setTimeout(() => {
console.log(subscription.closed ? 'COMPLETED - SUBSCRIPTION IS CLOSED' : 'COMPLETED - SUBSCRIPTION IS NOT CLOSED!');
}, 0);
console.log('COMPLETED');
}
});
}
close(event?: MouseEvent): void {
if (event) {
event.preventDefault();
}
this.bsModalRef.hide();
}
ngOnDestroy() {
console.log('DESTROY');
}
}
Здесь есть точно такое же свойство с большим массивом, но отписки нет. И это не мешает мне именно это окошко назвать хорошим. Почему — позже.
Смотрим видео:
Как видно, в обоих случаях после перехода на второй компонент сборщик мусора успешно вернул память к нормальным значениям. Да, можно было сделать возможность очистки памяти также после закрытия окошек, но в нашем эксперименте это не важно. Выходит, что "учитель" был не прав, когда говорил:
Например, вы сделали запрос, но когда ответ еще не пришел с бекенда, вы уничтожите компонент за ненадобностью, то ваша подписка будет удерживать ссылку на компонент, тем самым создавая потенциальную возможность утечки памяти.
Да, он говорит про "потенциальную" утечку. Но, если поток конечный, то утечки памяти не будет.
Предвижу возмущенные возгласы подобных "учителей". Они нам обязательно скажут что-то вроде: "ок, утечки памяти нет, но отпиской мы так же отменяем запрос, а значит мы будем уверены, что не будет больше выполняться никакой код после получения ответа от сервера". Во-первых, я не говорю, что отписка это всегда плохо, я лишь говорю, что вы подменяете понятия. Да, то, что после прихода ответа выполнится ещё какая-то бесполезная операция — это плохо, но защититься от реальной утечки памяти можно только отпиской (в данном случае), а защититься от других нежелательных эффектов можно другими способами. Не нужно запугивать читателей и навязывать им свой стиль написания кода.
Всегда ли нам нужно отменять запрос, если пользователь передумал? Не всегда! Не забывайте, что запрос-то вы отмените, но вы не отмените выполнение операции на сервере. Представим, что пользователь открыл какой-то компонент, там долго что-то грузится и он переходит на другой компонент. Возможны ситуации, что сервер загружен и не справляется со всеми запросами и операциями. В этом случае пользователь может начать судорожно тыкать все ссылки в навигации и создавать ещё бОльшую нагрузку серверу, потому что выполнение запроса не останавливается на стороне сервера (в большинстве случаев).
Смотрим следующее видео:
Я заставил пользователя дождаться ответа. В большинстве случаев ответ придет быстро и пользователь не будет испытывать неудобств. Но таким способом мы убережем сервер от выполнения повторных тяжелых операций, если такие возникнут.
Итоги:
- Я не говорю, что отписываться от подписок RxJS запросов HttpClient не нужно. Я лишь говорю, что бывают случаи когда этого делать не нужно. Не нужно подменять понятия. Если вы говорите про утечку памяти, покажите эту утечку. Не ваши бесконечные console.log, а именно утечку. Память в чём измеряется? Время выполнения операции в чём измеряется? Вот это и нужно показать.
- Я не называю своё решение, которое я применил в тестовом приложении, "серебряной пулей". Наоборот, я призываю дать фронтендеру больше свободы. Пусть он сам принимает решение как ему писать свой код. Не нужно его запугивать и навязывать свой стиль разработки.
- Я против фанатизма и преждевременной оптимизации. Этого в последнее время вижу слишком много.
- В браузере есть более продвинутые методы поиска утечек памяти, чем тот, который я показал. Считаю в моем случае применение этого простого способа достаточным. Но рекомендую ознакомиться с темой более подробно, например, в этой статье: https://habr.com/ru/post/309318/.
UPD #1
На данный момент пост провисел почти сутки. Сначала он уходил то в плюсы, то в минусы, потом оценка остановилась на нуле. Это значит, что аудитория разделилась ровно на два лагеря. Не знаю хорошо это или плохо.
UPD #2
В комментариях объявился Реактивный Лис (автор разбираемой статьи). Сначала он меня поблагодарил, был очень вежлив. Но, увидев пассивность аудитории, принялся прессинговать. Дошло до того, что он написал, что я должен извиниться. Т.е. наврал он (враньё выделено желтой рамочкой выше), а извиниться должен я.
Сначала я думал, что перехватчик потоков с бесконечными повторами (ладно бы 2-3 повтора), который он написал в своем демо-приложении, только для тестов и информирования. Но оказалось, что он его считает примером из жизни. Т.е. блокировать кнопочку окошка — нельзя. А создавать подобные перехватчики, нарушая принципы SOLID, нарушая модульность приложения (модули и компоненты должны быть независимыми друг от друга), пуская лесом unit-тесты ваших юнитов (компонентов, сервисов) — можно. Представьте ситуацию: Написали вы компонент, написали юнит-тесты к нему. А потом появляется такой Лис, добавляет в ваше приложение подобный перехватчик и ваши тесты становятся бесполезными. Потом он ещё заявляет вам: "А чё это ты не предугадал то, что я могу захотеть добавить такой перехватчик. Ну-ка исправляй свой код". Возможно это может быть реальностью в его команде, но я не считаю, что такое нужно поощрять или закрывать на это глаза.
UPD #3
В комментариях в основном обсуждают подписки и отписки. Разве пост называется "Отписка — зло"? Нет. Я не призываю вас не делать отписок. Делайте так же, как делали раньше. Но вы должны понимать почему вы это делаете. Отписка не является преждевременной оптимизацией. Но, вступая на путь защиты от потенциальных угроз (как призывает нас автор разбираемой статьи), вы можете переступить черту. Тогда ваш код может стать перегруженным и сложно поддерживаемым.
Эта статья про фанатизм, к которому приводит распространение непроверенной информации. Относиться к отсутствию отписки в некоторых случаях нужно более спокойно (нужно чётко понимать существует ли проблема в конкретном случае).
UPD #4
Наоборот, я призываю дать фронтендеру больше свободы. Пусть он сам принимает решение как ему писать свой код.
Тут нужно уточнить. Я за стандарты. Но стандарт может установить автор библиотеки или его команда, пока этого нет (в документации и официально). Например, в документации фреймворка Symfony есть раздел Best practices. Если бы такой же был бы в документации RxJS и там было бы написано "подписался — отпишись", у меня не возникло бы желания с ним спорить.
UPD #5
Важный комментарий с ответами от авторитетных людей:
https://habr.com/ru/post/479732/#comment_21012620
Рекомендация исполнять контракт "подписался — отпишись" от разработчика RxJS существует, но неофициально.