
Привет, Хабр! Я Антон Марченко, разработчик в Т-Банке. Поделюсь интересной задачей по работе с потоками на RxJS, которую мы однажды решали. Представьте HR-портал ИТ-компании, в котором реализован поиск по постам и статьям. Нам предстояло внедрить на сайт несколько внешних поисков: по пользователям, ссылкам и исходникам. А еще предусмотреть скелетоны и обработку ошибок и заложить возможность добавлять новые внешние источники поиска динамически, не дорабатывая каждый раз пользовательский интерфейс.
В статье сфокусируюсь на работе с потоками данных, поэтому для создания интерфейса воспользуюсь библиотекой Taiga UI — там есть уже готовый компонент поиска InputSearch.
Внешний вид UI поисковой выдачи
Результаты поиска в интерфейсе будут разбиты по секциям:

Элементам выдачи можно задавать разнообразные иконки из набора Lucide Icons. Для отрисовки иконок используется компонент Avatar из Taiga UI.
Секция может находиться в состоянии загрузки, тогда для нее выводим скелетоны:

При старте поиска, когда еще ничего не ввели, вместо выдачи отобразятся популярные запросы и история поиска:

Вместо выдачи могут выводиться разные заглушки. Например, если в поиске вбить менее трех символов, увидим заглушку:

Обработка состояний: успех, загрузка, ошибка
У каждого элемента в выдаче обязательно есть заголовок и ссылка. Опционально — подзаголовок и иконка. В результате модель данных будет выглядеть так:
export interface SearchItemDto {
title: string;
subtitle?: string;
href: string;
icon?: string;
}
Все элементы сгруппированы по секциям. В общем виде поисковая выдача — это объект, ключи которого — имена секций. Компонент tui-search-results на вход принимает модель данных:
// Модель поисковой выдачи
// string (ключ) - имя секции
// T[] - массив элементов внутри секции
// если передан null - вместо выдачи выведется блок с популярными запросами
Record<string, readonly T[]> | null
По ключу (названию секции) передается всегда массив. Зная эти ограничения, реализуем управление состоянием в секции:
{
// Если ошибка: передаем пустой массив, и секция скроется из выдачи
"Links": [],
// Если загрузка: передаем один элемент с флагом
// и внутри секции отрисуется скелетон
"Users": [{ "loading": true }]
}
Получим модель элемента секции:
interface SearchItemResult<T> {
loading?: boolean;
data?: T;
}
В шаблоне будем выводить скелетон по флагу loading:
@if (item.loading) {
<!-- Отрисуем скелетон -->
} @else {
<!-- Отрисуем элемент поисковой выдачи -->
}
Концепт нескольких поисковых движков
У нас будет основной поиск и внешний. Для основного поиска с фронта будет отправляться запрос:
"GET /api/main-search?q=ant"
Основной поиск ищет и возвращает результат сразу по двум секциям, по постам и статьям:
// GET /api/main-search?q=статья
// Response:
{
"Posts": [...],
"Articles": [...]
}
Внешний поиск — это массив запросов вида:
"GET /api/extra-search/links?q=ivan"
"GET /api/extra-search/users?q=ivan"
Каждый внешний поиск ходит в какой-то отдельный поисковик и возвращает массив элементов для определенной секции. Например, поиск по пользователям:
// GET /api/extra-search/users?q=ivan
// Response:
[
{
"title": "Ivan Ivanov",
"href": "https://my.example.com/user/1",
}
]
Секции основного поиска (Posts, Articles) всегда находятся вверху поисковой выдачи. Остальные секции (Links, Users, Code и другие) — внешние поиски, находятся строго под основным и сортируются по приоритету.

Эндпоинт основного поиска считается максимально стабильным. Если он упал, мы игнорируем внешние поиски и выводим заглушку с сообщением об ошибке:

Внешние поиски — дополнение к основному поиску. Поэтому, если при запросе к одному из внешних поисков произошла ошибка или ничего не нашлось, мы просто скрываем секцию этого поиска, оставляя в выдаче только основной поиск.
Мы хотим добавлять новые внешние поиски динамически, не дорабатывая каждый раз пользовательский интерфейс. Создадим эндпоинт на бэке, который будет возвращать список внешних поисков. При старте приложения сходим в этот эндпоинт и вытянем конфиг внешних поисков:
// GET /api/extra-search-resources
// Response:
[
{
"sourceId": "users",
"name": "Users",
"priority": 2
},
{
"sourceId": "code",
"name": "Code",
"priority": 1
},
{
"sourceId": "links",
"name": "Links",
"priority": 1
}
]
Конфиг скажет нам, в какие внешние поиски будем ходить и в каком порядке выводить секции в результатах выдачи. Мы знаем, что часть сервисов нестабильна, они могут долго отвечать или вовсе не работать. Поэтому для всех внешних поисков мы выставим приоритет (поле priority). Самые медленные и нестабильные будут иметь самый низкий приоритет и выводиться внизу выдачи.
Поле name — имя секции поисковой выдачи в UI. Поле sourceId — ID источника поиска. По нему мы будем делать запрос. Например, для sourceId:users будет такой запрос:
"/api/extra-search/users?q=ivan"
Все запросы с фронта будут отправляться параллельно:

Почему не сделать такой BFF, который соединит все эти ручки вместе, дождется их загрузки и отдаст единый ответ? Потому что бэк отдаст ответ только тогда, когда придут данные от всех запрошенных ручек, и результаты выдаст одновременно.
Мы же хотим отдавать результат пользователю как можно раньше, по мере ответа внешних сервисов. Пока самые медленные сервисы грузятся, пользователь уже может увидеть в результатах выдачи то, что искал. И перейти к результату, не дожидаясь, пока весь поиск прогрузится.
Пока ждем ответы от сервера, рисуем скелетоны по каждому из источников:

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

Если какой-то из источников отвалился, скрываем его, будто бы его и не было в выдаче. Например, пользователи (секция Users) успешно загрузились и вывелись в выдачу, а вот поиск по ссылкам отвалился из-за ошибки. В этом случае секция ссылок (Links) совсем пропадет из выдачи.

Валидация поискового запроса
Типичный путь обработки пользовательского запроса от ввода до отображения результатов состоит из нескольких этапов:
ввод пользователем поискового запроса;
валидация запроса;
отправка запросов на бэк;
загрузка данных;
преобразование потоков данных в единый объект поисковой выдачи;
отрисовка выдачи в UI.
Разберем подробнее этап валидации. Шаблон компонента выдачи валидирует запрос пользователя и отрисовывает несколько различных состояний. Это могут быть заглушки с информационными сообщениями или результат поисковой выдачи.
@if (status === 'tooShortQuery') {
<!-- 1) Просьба ввести более 3-х символов -->
} @else if (status === 'error') {
<!-- 2) Сообщение об ошибке -->
} @else if (status === 'pendingTyping') {
<!-- 3) Сообщение об ожидании ввода запроса -->
} @else {
<!-- 4) Отрисовка поисковой выдачи -->
<!-- 5) Или блока популярных запросов и истории поиска-->
}
Разберем подробнее состояния:
pendingTyping: ожидание ввода поискового запроса;
tooShortQuery: просьба ввести более 3 символов;
historyAndPopular: отрисовка блока популярных запросов и истории поиска.
В компоненте обработка этих состояний выглядит так:
const resultState$: Observable<SearchResultState> =
this.control.valueChanges.pipe(
startWith(''),
switchMap(query => {
const state = {
pendingTyping: {
status: 'pendingTyping',
},
// Так работает компонент tui-search-results.
// Если поле ввода пустое, а в results передать data: null
// то отобразится блок с популярными и историей.
historyAndPopular: { status: 'ready', data: null },
tooShortQuery: { status: 'tooShortQuery' },
} as const;
if (!query && this.control.value) {
return of(state.pendingTyping);
}
if (!query) {
// Отрисовка блока популярных запросов и истории поиска
return of(state.historyAndPopular);
}
if (query.length < 3) {
return of(state.tooShortQuery);
}
// Начинаем поиск
return this.searchService.makeSearch$(query);
})
);
Получение и обработка данных
После того как прошли все этапы валидации, начинаем поиск. Он разбивается на основной и внешний:

Для основного поиска отправляем запросы на бэк:
function makeMainSearch$(query: string) {
return this.http
.get(`${this.baseUrl}/main-search?q=${query}`)
.pipe(
map(result =>
Object.entries(result).reduce(
(acc, [key, data]) => ({
...acc,
[key]: data.map(item => ({ data: item })),
}),
{} as SearchResult
)
),
// При старте основного поиска рисуем скелетоны
// для секций постов и статей
startWith<SearchResult>({
Posts: [{ loading: true }],
Articles: [{ loading: true }],
})
);
}
Получаем конфиг для источников внешних поисков:
const extraSearchSources$ = this.http
.get(`${this.baseUrl}/extra-search-sources`)
.pipe(
// сразу отсортируем порядок секций
map(items => items.sort((a, b) => a.priority - b.priority)),
// отключим вообще внешние поиски в случае ошибки
catchError(() => of([])),
// закэшируем результат
shareReplay(1)
);
Согласно конфигу для внешних поисков параллельно отправим пачку запросов на бэк:
function makeExtraSearch$(query: string) {
const initialValue: SearchResult = {};
// берем конфиг внешних поисков
return this.extraSearchSources$
.pipe(
// формируем пачку запросов на бэк
switchMap(sources => from(sources)),
// отправим пачку запросов параллельно
mergeMap(source =>
this.makeSearchFromExtraSource$(query, source)
)
)
.pipe(
// из нескольких потоков
// соберем результаты в единый объект
scan(
(acc, curr) => ({
...acc,
...curr,
}),
initialValue
)
);
}
Для внешних поисков каждый запрос на бэк обработаем так:
function makeSearchFromExtraSource$(
query: string,
source: ExtraSearchSourceDto
) {
return this.http
.get<
SearchItemDto[]
>(`${this.baseUrl}/extra-search/${source.sourceId}?q=${query}`)
.pipe(
map(value => ({
// добавим результат в секцию по ее имени
[source.sectionName]: value.map(data => ({ data })),
})),
// отрисуем скелетон
startWith<SearchResult>({
[source.sectionName]: [{ loading: true }],
}),
// обработаем ошибку
catchError(() => {
return of<SearchResult>({
[source.sectionName]: [],
});
})
);
}
Результаты основного и внешнего поисков соберем в единый объект — результат поисковой выдачи:
function makeSearch$(query: string): Observable<SearchResultState> {
const mainSearch$ = this.makeMainSearch$(query); // основной поиск
const extraSearch$ = this.makeExtraSearch$(query); // внешний
const initialValue: SearchResult = {};
// смержим потоки данных основного и внешних поисков
return merge(mainSearch$, extraSearch$).pipe(
// соберем данные в единый объект — результат поисковой выдачи
scan(
(acc, curr) => ({
...acc,
data: {
...acc.data,
...curr,
},
}),
{
status: 'ready',
data: initialValue,
} as const
),
// обработаем сценарий ошибки основного поиска
catchError(() =>
of({
status: 'error',
} as const)
)
);
}
В результате на каждую смену состояния одного из поисков объект выдачи будет меняться:

Ранжирование секций внешних поисков
Как же выходит, что секции внешних поисков выводятся в нужном порядке? Мы изначально отсортировали источники и отправили запросы на бэк в порядке ранжирования. В порядке ранжирования секции попали в объект выдачи, ведь мы их сразу же эмитили с помощью startWith
:
startWith({ [source.sectionName]: [{ loading: true }] })

Пользователь не заметит первые эмиты с состоянием загрузки, потому что они произойдут мгновенно. А потом, когда объект будет наполняться данными, это уже будет замена по существующим ключам. Поэтому приоритет сохранится, даже если данные для секции с первым приоритетом придут самыми последними. Благодаря этому секции внешних поисков будут выводиться в нужном порядке.
Итоги
Соберем итоговый результат на Stackblitz. Для мокового бэка используем сервис My JSON Server.
В результате мы получили поисковый компонент, который можно дополнять различными внешними поисками, не внося правки в код. Новые источники поиска мы добавляем через специальный конфиг и через него сможем управлять положением секции поиска в выдаче.
Вот так с помощью RxJS-операторов можно работать с потоками данных и сменой состояния с помощью компонентов Taiga UI для построения поиска.
Если у вас остались вопросы или появились идеи — добро пожаловать в комментарии!