Kamil Owczarek
Opublikowano

Odchudziłem odpowiedzi API o 60%: ukryty koszt spreadowania obiektów

Autorzy

Właśnie spędziłeś kilka dni na wdrożeniu czystego rozwiązania po stronie serwera. Twoje API wylicza teraz nazwy i slugi produktów z kilku obiektów tłumaczeń i zwraca frontendowi gotowe stringi. Zespół frontendowy zadowolony. Kod czystszy. Sukces.

Tyle że jest problem: odpowiedzi API są dokładnie tak samo duże jak wcześniej.

Niewidzialne puchnięcie payloadu

Oto wzorzec, który widuję bez przerwy w e-commerce i aplikacjach opartych na treści:

const addNameAndSlug = (product) => ({
  ...product,  // <- The silent killer
  name: buildProductName(product, lang),
  slug: buildProductSlug(product, lang),
});

Wygląda niewinnie, prawda? Dokładamy wyliczone pola name i slug. Frontend może teraz użyć product.name zamiast ręcznie sklejać fragmenty tłumaczeń.

Ale przyjrzyj się temu, co robi spread. Kopiuje wszystko z oryginalnego obiektu produktu, w tym:

{
  nameCore: { translations: { pl: "Zlewozmywak granitowy", en: "Granite sink", de: "Granitspüle", ... } },
  namePost: { translations: { pl: "CORDA", en: "CORDA", de: "CORDA", ... } },
  nameFeature1: { translations: { pl: "z ociekaczem", en: "with drainer", de: "mit Abtropffläche", ... } },
  nameFeature2: { translations: { pl: null, en: null, de: null, ... } },
  // ... plus the new computed fields
  name: "Zlewozmywak granitowy CORDA z ociekaczem",
  slug: "zlewozmywak-granitowy-corda-z-ociekaczem-abc123"
}

Te cztery obiekty tłumaczeń? Są teraz całkowicie zbędne. Frontend używa product.name i product.slug. Do surowych tłumaczeń nikt już nie sięga. A mimo to wciąż są:

  1. Serializowane do JSON-a przy każdej odpowiedzi
  2. Kompresowane w cache'u Redisa
  3. Przesyłane przez sieć
  4. Parsowane przez runtime JavaScriptu
  5. Trzymane w systemie reaktywności Vue

Pomnóż to przez 20 produktów na stronę, 15 grup produktowych na landingu albo ponad 100 pozycji na listingu kategorii, a wychodzą kilobajty marnowane na każdym requeście.

Ile to realnie kosztuje

Zanim cokolwiek naprawiłem, przeanalizowałem kilka endpointów, żeby oszacować skalę problemu:

EndpointRozmiar odpowiedzi (gzip)Zbędne dane
Landing page (100 produktów)45-60 KB~35 KB (67%)
Karta produktu (z wariantami)8 KB~4 KB (50%)
Listing produktów (20 pozycji)5 KB~2.3 KB (46%)
Strona treściowa (produkty powiązane)3 KB~1 KB (33%)

Najgorzej wypadał landing page. Przy 15 grupach produktowych i setkach pozycji zbędne obiekty tłumaczeń odpowiadały za blisko dwie trzecie odpowiedzi.

Rozwiązanie: jawny destructuring

Rozwiązanie jest proste: wyciągnij destructuringiem pola, których nie chcesz, zanim zrobisz spread.

// Before: Spreads everything, including redundant translation objects
const addNameAndSlug = (product) => ({
  ...product,
  name: buildProductName(product, lang),
  slug: buildProductSlug(product, lang),
});

// After: Explicitly removes translation objects before spreading
const addNameAndSlug = (product) => {
  const { nameCore, namePost, nameFeature1, nameFeature2, ...rest } = product;
  return {
    ...rest,
    name: buildProductName(product, lang),
    slug: buildProductSlug(product, lang),
  };
};

Destructuring wyciąga nameCore, namePost, nameFeature1 i nameFeature2 do osobnych zmiennych (które od razu wyrzucamy), a w rest ląduje cała reszta.

Ten wzorzec ma dwie kluczowe zalety:

  1. Jawne wykluczenie: od razu widać, które pola wylatują
  2. Bezpieczeństwo typów: TypeScript wie, że w rest nie ma wyciągniętych pól

Wzorzec na wszystkich endpointach

Poprawkę zastosowałem w każdym endpoincie, który transformuje produkty:

Endpoint pojedynczego produktu:

const addNameAndSlug = <T extends ProductFields>(p: T) => {
  const { nameCore, namePost, nameFeature1, nameFeature2, ...rest } = p;
  return {
    ...rest,
    name: getProductFullNamePrisma(p, lang),
    slug: getProductSlug({
      nameCore: nameCore?.translations[lang],
      namePost: namePost?.translations[lang],
      nameFeature1: nameFeature1?.translations[lang],
      nameFeature2: nameFeature2?.translations[lang],
      collectionName: p.collection?.name,
      code: p.code,
    }),
  };
};

Endpoint landing page'a:

const addNameAndSlug = (product: LandingProduct) => {
  const { nameCore, namePost, nameFeature1, nameFeature2, ...rest } = product;
  return {
    ...rest,
    name: getProductFullNamePrisma(product, lang),
    slug: getProductSlug({
      nameCore: nameCore?.translations[lang],
      namePost: namePost?.translations[lang],
      nameFeature1: nameFeature1?.translations[lang],
      nameFeature2: nameFeature2?.translations[lang],
      collectionName: product.collection?.name,
      code: product.code,
    }),
  };
};

Zagnieżdżone pozycje produktów (w galeriach inspiracji):

productsPositions: asset.productsPositions.map(position => {
  const { nameCore, namePost, nameFeature1, nameFeature2, ...productRest } = position.product;
  return {
    ...position,
    product: {
      ...productRest,
      name: getProductFullNamePrisma(position.product, lang),
      slug: getProductSlug({
        nameCore: nameCore?.translations[lang],
        namePost: namePost?.translations[lang],
        nameFeature1: nameFeature1?.translations[lang],
        nameFeature2: nameFeature2?.translations[lang],
        collectionName: position.product.collection?.name,
        code: position.product.code,
      }),
    },
  };
}),

Porządki na froncie

Po usunięciu obiektów tłumaczeń z odpowiedzi część kodu frontendowego wymagała aktualizacji. Komponenty, które nadal sięgały po product.nameCore?.translations[locale], zaczęły rzucać błędami TypeScriptu:

<!-- Before: Accessing raw translation (now throws error) -->
<p>{{ productPositions.product.nameCore?.translations[locale] }}</p>

<!-- After: Using pre-built name -->
<p>{{ productPositions.product.name }}</p>

To tak naprawdę zaleta. Błędy TypeScriptu zadziałały jak zabezpieczenie — dzięki nim nie zostawiliśmy martwych ścieżek kodu, które wywaliłyby się dopiero w runtimie.

Wyniki po optymalizacji

EndpointPrzed (gzip)Po (gzip)Oszczędność
Landing page45-60 KB20-25 KB~35 KB (67%)
Karta produktu8 KB3-4 KB~4-5 KB (50%)
Listing produktów5 KB2-3 KB~2-3 KB (50%)
Strona treściowa3 KB2 KB~1 KB (33%)

Dla Redisa z ograniczoną pamięcią oznacza to znacznie więcej wpisów, które się w nim zmieszczą. Dla użytkowników na wolnym łączu — szybciej ładujące się strony.

Czy kompresja nie zjada tego zysku?

Można zapytać: skoro i tak kompresujemy odpowiedzi gzipem, to czy usuwanie zbędnych danych w ogóle ma znaczenie?

Ma, choć zysk jest mniejszy:

MetrykaBez kompresjiPo gzipie
Obiekty tłumaczeń (4 na produkt)~1400 bajtów~350 bajtów
Gotowy string z nazwą~100 bajtów~60 bajtów
Oszczędność na produkt~1300 bajtów~290 bajtów

Gzip osiąga na JSON-ie około 75% kompresji (powtarzalne klucze kompresują się świetnie), więc oszczędności są mniejsze, ale wciąż odczuwalne:

  • 20 produktów = ~5.8 KB mniej (gzip)
  • 100 produktów = ~29 KB mniej (gzip)

Poza samym transferem dochodzą inne korzyści:

  1. Szybsze parsowanie JSON-a: mniej pól to szybsze JSON.parse() po stronie klienta
  2. Mniejsze zużycie pamięci: system reaktywności Vue śledzi mniej właściwości
  3. Mniejsze wpisy w cache'u: w tym samym limicie pamięci Redisa mieści się więcej odpowiedzi
  4. Czystszy kod: frontend używa product.name zamiast product.nameCore?.translations[locale]

Najważniejsze wnioski

  1. Spread kopiuje wszystko. Kiedy piszesz { ...object, newField: value }, nie dodajesz po prostu jednego pola — dokładasz każde istniejące pole, także te, których już nie potrzebujesz.

  2. Wyliczone pola nie zastępują automatycznie danych źródłowych. Dodanie pola name nie usuwa nameCore, namePost i reszty, z których zostało policzone.

  3. Destructuring to jawne wykluczanie. Zapis const { unwanted, ...rest } = object czytelnie dokumentuje, które pola wypadają.

  4. TypeScript wyłapuje nieaktualne odwołania. Po usunięciu pól z odpowiedzi każdy fragment frontendu, który wciąż po nie sięga, wysypie się już na etapie kompilacji.

  5. Mierz przed i po. Realny zysk zależy od kształtu twoich danych. Obiekty tłumaczeń z 11 językami są wyjątkowo rozdmuchane; proste pola skalarne dużo mniej.

Szersza zasada: projektowanie odpowiedzi API to część inżynierii wydajności. Nie chodzi wyłącznie o zapytania do bazy i strategie cache'owania — chodzi też o to, co faktycznie wysyłasz po kablu.