- 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ą:
- Serializowane do JSON-a przy każdej odpowiedzi
- Kompresowane w cache'u Redisa
- Przesyłane przez sieć
- Parsowane przez runtime JavaScriptu
- 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:
| Endpoint | Rozmiar 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:
- Jawne wykluczenie: od razu widać, które pola wylatują
- Bezpieczeństwo typów: TypeScript wie, że w
restnie 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
| Endpoint | Przed (gzip) | Po (gzip) | Oszczędność |
|---|---|---|---|
| Landing page | 45-60 KB | 20-25 KB | ~35 KB (67%) |
| Karta produktu | 8 KB | 3-4 KB | ~4-5 KB (50%) |
| Listing produktów | 5 KB | 2-3 KB | ~2-3 KB (50%) |
| Strona treściowa | 3 KB | 2 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:
| Metryka | Bez kompresji | Po 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:
- Szybsze parsowanie JSON-a: mniej pól to szybsze
JSON.parse()po stronie klienta - Mniejsze zużycie pamięci: system reaktywności Vue śledzi mniej właściwości
- Mniejsze wpisy w cache'u: w tym samym limicie pamięci Redisa mieści się więcej odpowiedzi
- Czystszy kod: frontend używa
product.namezamiastproduct.nameCore?.translations[locale]
Najważniejsze wnioski
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.Wyliczone pola nie zastępują automatycznie danych źródłowych. Dodanie pola
namenie usuwanameCore,namePosti reszty, z których zostało policzone.Destructuring to jawne wykluczanie. Zapis
const { unwanted, ...rest } = objectczytelnie dokumentuje, które pola wypadają.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.
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.