Перейти к содержанию

RxJS и актуальность ответа списка

Запрос списка выполняется асинхронно. Пока API отвечает на поиск «проектор», диспетчер может выбрать статус active или другой текст. Если принять ответы в порядке прихода, старый запрос способен заменить новые данные. Построим поток чтения так, чтобы результат принадлежал текущему условию. RxJS switchMap создаст одну активную внутреннюю подписку, а состояние страницы явно различит loading, ready и error.

Нужно сначала определить владельца операции. В конечном кабинете им является ListPage: он знает текущий URL-фильтр, вызывает TicketsApi и обновляет signal state. Сам API-сервис не выбирает, какой запрос считать последним, поскольку разные страницы могут читать разные объекты. Если общая служба транспорта начнёт отменять любой свой предыдущий GET, карточка и список будут мешать друг другу. Актуальность относится к конкретному пользовательскому потоку, а не ко всему приложению сразу.

Один поток и три состояния

Фрагмент ниже расположен в constructor ListPage. Внешний поток url будет разобран следующей главой; сейчас он выдаёт пары q/status. Retry — Subject явного повторного чтения, а api и destroyRef уже получены через inject. Полный файл list-page.ts в архиве содержит эти определения, модель ListState и template. Фрагмент показывает вложенность подписок через операторы, поэтому отдельный subscribe внутри subscribe для каждого события здесь не требуется.

url.pipe(switchMap(filter => {
  this.filters.setValue(filter,{emitEvent:false});
  return merge(of(undefined),this.retry).pipe(switchMap(() =>
    this.api.list(filter.q,filter.status).pipe(
      map(list => ({kind:'ready',...list}) as ListState),
      catchError(error => of<ListState>({kind:'error',message:errorMessage(error)})),
      startWith<ListState>({kind:'loading'})
    )
  ));
}),takeUntilDestroyed(this.destroyRef)).subscribe(state => this.state.set(state));

Внешний switchMap реагирует на смену фильтра и отменяет прежнюю ветвь целиком, включая её retry-поток. Внутренний switchMap запускает чтение сразу через of(undefined) либо после retry. Map переводит TicketList в состояние ready. StartWith сообщает loading до ответа, а catchError формирует состояние error. Ожидается, что пользователь увидит индикатор, затем список или сообщение отказа, и все эти представления будут описывать одну текущую операцию.

CatchError расположен внутри ветви запроса. Если перехватить ошибку только снаружи всего URL-потока и заменить его одним of(error), наблюдение дальнейших параметров может завершиться. Тогда изменение фильтра после отказа перестанет инициировать чтение. В выбранной структуре ошибочная операция заканчивается своим состоянием, а внешний источник продолжает жить. Это существенная причина расположения оператора, которую не видно, если рассматривать catchError просто как универсальный try/catch для любого места.

Отмена чтения

Когда новый фильтр поступает до ответа прежнего GET, switchMap отписывается от старой внутренней операции. Для HttpClient отписка освобождает клиентский запрос; для fixture timer прекращается до обработки. Старая ветвь уже не получает право записать ready в текущий state. Мы не утверждаем, что удалённый сервер обязательно перестал вычислять SELECT: он мог получить и выполнить запрос. Для отображения достаточно прекратить потребление устаревшего результата на стороне владельца страницы.

Такой приём подходит чтению, где важен последний выбранный результат. Для сохранения диспетчерского изменения автоматическая замена операции имеет другую семантику. Отменённая подписка PATCH не доказывает отмену транзакции в PostgreSQL. Поэтому в редакторе блокируется повторное save до ответа, а результаты дополнительно связываются с поколением маршрута. Не переносите switchMap из поиска в мутацию только ради одинакового вида кода. Выбор оператора должен следовать значению пользовательского действия.

TakeUntilDestroyed завершает подписку при уничтожении ListPage. DestroyRef передан явно, потому что оператор находится внутри цепочки, построенной методом класса. Это удерживает lifecycle в понятном месте. Если пользователь покинул список, его старая подписка не должна обновлять уже ненужный state или держать источник событий живым. Root Session при этом продолжает существовать: завершение страницы относится к её операциям, а не ко всем зависимостям приложения.

Почему одно state лучше набора флагов

ListState использует discriminated union с kind. В loading нет готовых items, в error есть message, в ready есть items и total. Шаблон выбирает одну ветку switch. При независимых boolean loading/error/loaded возможно состояние, где спиннер и ошибка видны одновременно, а старый список притворяется новым. Union ограничивает комбинации и делает допустимые состояния видимыми типу. Signal здесь хранит синхронное текущее состояние, а RxJS управляет временем поступления следующих состояний.

В начале новой операции архив показывает loading без старых rows. Альтернативный интерфейс мог бы оставить предыдущий список и пометить его обновлением. Это отдельное решение модели состояния, например ready с refreshing и известным фильтром предыдущего ответа. Просто оставлять прежние items под новым заголовком без обозначения устаревания было бы неоднозначно. Для лаборатории выбран более простой вариант, который ясно связывает показанные строки с завершившимся текущим запросом.

Retry не меняет q/status и не выдумывает новый фильтр. Он повторяет текущую операцию через merge с первоначальным запуском. Если пользователь во время ошибки изменит URL, прежний retry-поток уничтожится внешним switchMap и новый будет работать с новым условием. Это объясняет, почему Subject не хранит копию параметров. Договор источников событий остаётся чистым: URL выбирает условие, кнопка просит повторить его, API возвращает результат или отказ.

Для самостоятельного разбора представьте два чтения A и B. A начался с q «проектор», B позже с q «Wi-Fi». Если B завершился раньше A, ready должен показывать rq-102. Если A затем отвечает успешно, текущая страница не принимает его результат. Если B завершился отказом, она показывает ошибку B, а не удачный список A. Такой анализ полезнее, чем обещание «гонок больше нет» без определения, какой запрос вообще имеет право изменить состояние.

Мы не выполняли эти запросы и не измеряли их время. Искусственные 250 миллисекунд fixture позволяют читателю увидеть переход loading, но не моделируют все сетевые случаи. Педагогический результат — одна операция на актуальный фильтр и завершение подписки вместе со страницей. Следующая глава задаст сам источник фильтра через URL, благодаря чему обновление, ссылка и кнопки назад/вперёд будут иметь одинаковое основание для чтения.

Статус примера — not_run. Зависимости, приложение, SQL, HTTP и тесты не запускались. Описанные ответы и изменения интерфейса ожидаемые; чтение документации не является проверкой выполнением. Полная конечная версия находится в архиве исходников, а её ограничения и действия читателя — в README и CONTRACT.md.

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