Переход на модели Airport/Company и кеширование данных на клиенте

Интерактивная карта маршрутных сетей росла постепенно: сначала прямые ответы API, потом больше экранов, модалка каталога, доп. карточки аэропортов, автокомплит, фильтры и отдельные сценарии навигации.
В какой-то момент стало очевидно: держать всё на «сырых» объектах ответа уже неудобно. Слишком много условных полей, разный формат данных в разных эндпоинтах и дублирование логики по всему фронту.
Поэтому я сделал два шага:
- Перевёл слой данных на модели
AirportиCompany - Добавил клиентское кеширование загруженных сущностей, чтобы не дергать API повторно без необходимости
Ниже — что это дало на практике.
Почему вообще понадобились модели
До перехода логика выглядела примерно так: «взяли ответ API, где-то в компоненте мапнули, где-то проверили null, где-то построили label».
Работает, но со временем появляется эффект «расползания правил»:
- в одном месте имя аэропорта берётся из
municipality, в другом — изname; - где-то IATA обязательный, где-то нет;
- агрегаты авиакомпании (
routes_count,airports_count) хранятся рядом с UI-логикой; - подсветка маршрутов и селектор знают про структуру ответа API больше, чем им нужно.
Модели решают это централизованно: нормализация в одном месте, интерфейсы полей в одном месте, бизнес-методы рядом с данными.
Airport: единая точка правды по аэропортам
Файл: src/models/airport.model.ts
Что вынесено в модель:
- структура API-ответа (
AirportApiResponse); - нормализация в
fromApiResponse()(валидный IATA, координаты, country, translations); - отдельный конструктор для компактных данных карты (
fromMapData()); - дозагрузка/обогащение уже существующего объекта через
enrichFromApi(); - признак полноты данных
isEnrichedи проверка координатhasCoords().
Ключевая идея — один и тот же Airport может сначала прийти «лёгким» (для быстрого рендера), а потом стать «полным» после запроса деталей.
Это особенно полезно для сценария карточки аэропорта: мы не создаём второй объект и не дублируем сущность, а дообогащаем уже кешированный экземпляр.
Пример: создание модели из полного ответа API
ts
static fromApiResponse(raw: AirportApiResponse): Airport | null {
const iata = raw.iata_code;
if (!iata) return null;
const a = new Airport(iata, raw.municipality || raw.name || iata);
a.icao = raw.icao_code ?? null;
a.lat = raw.latitude_deg ?? null;
a.lon = raw.longitude_deg ?? null;
a.municipality = raw.municipality ?? null;
a.countryName = raw.country_name ?? null;
a.translations = raw.translations ?? null;
a.airlinesCount = raw.airlines_count ?? null;
a.isEnriched = true;
return a;
}
Пример: обогащение уже закешированного аэропорта
ts
enrichFromApi(raw: AirportApiResponse): void {
if (raw.icao_code) this.icao = raw.icao_code;
if (raw.latitude_deg != null) this.lat = raw.latitude_deg;
if (raw.longitude_deg != null) this.lon = raw.longitude_deg;
if (raw.municipality) this.municipality = raw.municipality;
if (raw.country_name) this.countryName = raw.country_name;
if (raw.translations) this.translations = raw.translations;
if (raw.airlines_count != null) this.airlinesCount = raw.airlines_count;
this.isEnriched = true;
}
Company: модель авиакомпании + сервисные методы
Файл: src/models/company.model.ts
Что даёт Company:
- типобезопасный разбор ответа API в
fromApiResponse(); - хранение имени сразу в двух языках (
name.en/name.ru); - готовый формат для селектора (
getSelectorLabel()); - единый метод отображения названия с fallback (
getDisplayName()); - вычисление графа связей:
getConnections()— уникальные парыdep↔arr;getDirectedRoutes()— направленные маршрутыdep|arr.
В итоге компонентам карты не нужно знать, как именно хранится «сырая» маршрутная сеть.
Они работают с готовой моделью, у которой уже есть нужные представления данных.
Пример: подпись в селекторе из модели
ts
getSelectorLabel(): string {
const parts: string[] = [];
if (this.name.ru) parts.push(this.name.ru);
if (this.name.en) parts.push(this.name.en);
parts.push(this.iata);
if (this.icao) parts.push(this.icao);
return parts.join(' | ');
}
Пример: вычисление уникальных связей для визуализации
ts
getConnections(): Array<{ dep: string; arr: string }> {
const seen = new Set<string>();
const result: Array<{ dep: string; arr: string }> = [];
for (const p of this.points) {
for (const arr of p.arrival) {
if (!arr) continue;
const key = p.departure < arr ? `${p.departure}|${arr}` : `${arr}|${p.departure}`;
if (!seen.has(key)) {
seen.add(key);
result.push({ dep: p.departure, arr });
}
}
}
return result;
}
Кеширование на клиенте: что именно кешируется
Основная реализация в src/stores/map.store.ts.
1) Кеш аэропортов по IATA
В сторе есть долгоживущий словарь:
airportCache: Record<string, Airport>
Это даёт:
- мгновенный повторный доступ к координатам/названию аэропорта;
- отсутствие повторных запросов для уже встречавшихся
dep_iata/arr_iata; - возможность дозагружать детали только когда реально нужно.
Базовая схема в сторе выглядит так:
ts
const airports = ref<Record<string, Airport>>({});
const airportCache: Record<string, Airport> = {};
Все новые аэропорты (из первичной загрузки или из маршрутов) сначала попадают в airportCache,
а затем одним присваиванием обновляют реактивное состояние:
ts
airports.value = { ...airportCache };
2) Ленивое обогащение (lazy enrich)
Для карточки аэропорта используется паттерн:
- если аэропорт в кеше есть, но
isEnriched === false→ делаем параллельно:/airlines/airports/iata/:code?lang=ru/airlines/routes/airport/:code
- после ответа обновляем тот же объект
AirportчерезenrichFromApi().
Если аэропорт уже обогащён — запрашиваются только маршруты, без повторной загрузки метаданных аэропорта.
Пример: lazy enrich через pipeline
ts
if (!cached?.isEnriched) {
const orchestrator = new PipelineOrchestrator({
config: {
stages: [
{ key: 'airport', request: () => `/airlines/airports/iata/${code}?lang=ru` },
{ key: 'routes', request: () => `/airlines/routes/airport/${code}` },
],
},
httpConfig: { baseURL: API_BASE_URL, timeout: 15000, retry: { attempts: 2, delayMs: 1000 } },
});
const result = await orchestrator.run();
const rawAirport = result.stageResults?.airport?.data?.data;
if (rawAirport && cached) cached.enrichFromApi(rawAirport);
}
3) Кеш после первичной инициализации
На старте приложения грузятся:
- агрегированная статистика АК;
- крупные и средние аэропорты РФ.
Данные сразу превращаются в модели и кладутся в стор/кеш.
Дальше при выборе авиакомпании догружаются только отсутствующие аэропорты.
Пример: первичный прогрев данных
ts
const orchestrator = new PipelineOrchestrator({
config: {
stages: [
{ key: 'airlines', request: () => '/airlines/routes/stats/airlines' },
{ key: 'airports_large', request: () => '/airlines/airports?country=RU&type=large_airport&limit=200' },
{ key: 'airports_medium', request: () => '/airlines/airports?country=RU&type=medium_airport&limit=200' },
],
},
httpConfig: { baseURL: API_BASE_URL, timeout: 15000, retry: { attempts: 2, delayMs: 1000 } },
});
const result = await orchestrator.run();
airlines.value = (result.stageResults?.airlines?.data?.data ?? [])
.map((a: any) => Company.fromApiResponse(a))
.filter((c): c is Company => c !== null);
Мини-паттерн, который хорошо сработал
В итоге получился простой и рабочий цикл:
- Прогреваем «лёгкие» данные для быстрого старта UI
- Храним сущности в моделях, а не в сырых
any - Догружаем детали только по пользовательскому действию
- Обогащаем уже существующий объект в кеше, а не создаём дубликат
- Отдаём в UI только реактивный snapshot (
airports.value = { ...airportCache })
Этот подход хорошо масштабируется:
когда добавляется новый экран (каталог, карточка, режим карты), он не ломает старые правила работы с данными, а переиспользует уже готовые модели и кеш.
Что это даёт пользователю
С пользовательской стороны эффект простой:
- быстрее открывается карта после первых действий;
- меньше «дёрганий» интерфейса при повторном выборе тех же аэропортов/АК;
- меньше спиннеров в сценариях, где данные уже были загружены;
- стабильнее логика отображения названий и подписей.
А с технической стороны — меньше дублирующей логики и проще поддержка новых фич (каталог, карточки, навигация по маршрутам, 2D/3D режимы).
Расширение кеша: каталог и версионирование
Этот шаг уже реализован в map.store: кеширование поднято на уровень запросов каталога.
Что кешируется в каталоге
Добавлены отдельные кеши:
catalogAirlineDetailsCache— раскрытые блоки маршрутной сети авиакомпании;catalogAirportAirlinesCache— список авиакомпаний конкретного аэропорта;catalogAirportLettersCache— список инициалов (букв) для вкладки аэропортов;catalogAirportsByLetterCache— аэропорты для выбранной буквы.
Принцип ключей — version + payload, например:
${version}|SU${version}|SVO${version}|airport_letter|A
Контролируемая инвалидиация
В сторе добавлен метод:
ts
const invalidateCatalogCaches = () => {
catalogAirlineDetailsCache.clear();
catalogAirportAirlinesCache.clear();
catalogAirportLettersCache.clear();
catalogAirportsByLetterCache.clear();
};
Его можно вызывать вручную (например, после административной загрузки маршрутов),
а также он вызывается автоматически при смене версии данных.
Проверка версии данных
Для этого используется /airlines/routes/stats и поле newest_update.
ts
const ensureCatalogCacheVersion = async (force = false) => {
const now = Date.now();
if (!force && now - lastCatalogVersionCheckMs < 60_000) return;
const res = await apiClient.request('/airlines/routes/stats');
const newestUpdate = (res.data as any)?.data?.newest_update;
if (newestUpdate && catalogCacheVersion.value !== newestUpdate) {
catalogCacheVersion.value = newestUpdate;
invalidateCatalogCaches();
}
};
Что это даёт:
- при неизменных данных каталог работает почти полностью из памяти;
- после обновления маршрутов старые ответы автоматически не используются;
- не нужно руками «угадывать», когда кеш стал неактуальным.