Strona główna / Artykuły / Dwadzieścia pięć scenariuszy rozmów kwalifikacyjnych z JavaScript dotyczących błędów w produkcji

Dwadzieścia pięć scenariuszy rozmów kwalifikacyjnych z JavaScript dotyczących błędów w produkcji

Wyścigi, thrash, płatności idempotentne, wycieki danych, nawodnienie, pamięci cache WeakMap, niestabilne testy oraz wspólne warstwy pobierania danych – wraz z odpowiedziami, których faktycznie szukają pracodawcy podczas rozmów kwalifikacyjnych.

4513 słów

Pochodzi od AI

Dwadzieścia pięć pytań z JavaScriptu w formacie praktycznym

Karty do testów na rozmowę wciąż dotyczą tego, co zwraca typeof null, jak działa mechanizm hoistingu lub jak zastąpić funkcję bind. Takie pytania są łatwe do oceny – a także łatwe do sfałszowania po weekendowym memorowaniu. Następnie ten sam kandydat dostarcza pole wyszukiwania, które pokazuje wyniki dla zapytania, które użytkownik już usunął.

Zespoły pracujące z rzeczywistym ruchem zmieniły format. Zamiast prosić o wykład na temat pętli zdarzeń, po zmianie zakresu dat pokazują zamrożony ekran analityczny, przekazują fragment kodu i pytają, co idzie nie tak. To ta sama podstawowa wiedza; inny sposób jej sprawdzenia. Jeden testuje pamięć, drugi sprawdza, czy ktoś potrafi rozumować nieznany system pod obserwacją.

Różnica ta jest najbardziej widoczna w trzech obszarach: zachowanie asynchroniczne w rzeczywistych sieciach (odpowiedzi w niewłaściwej kolejności, obietnice, które się realizują, ale już nie mają znaczenia), pamięć (nic się nie psuje, gdy laptop pozostaje otwarty przez sześć minut) oraz błędy (dostawca zwraca HTTP 200 z treścią błędu w formacie HTML, a response.json() wywołuje błąd o 2 w nocy).

Następnie przedstawiono dwadzieścia pięć scenariuszy zaczerpniętych z błędów, które rzeczywiście trafiają do produkcji: powolne panele kontrolne, wielokrotne żądania, rosnąca ilość pamięci przy zmianach tras, przestarzałe wyniki wyszukiwania, podwójne opłaty, błędne założenia dotyczące autoryzacji, tabele z dwudziestoma tysiącami wierszy oraz API, które są dostępne w 96% czasu. Każdy punkt obejmuje opis sytuacji, odpowiedź, której szukają rozmówcy podczas wywiadu, krótki zarys kodu, powszechnie stosowany błędny podejście, możliwe dalsze kroki oraz to, co jest mierzone.

Przykłady znajdują się najpierw w JavaScript, z adnotacjami TypeScript tylko tam, gdzie typy zmieniają odpowiedź. Poziomy: początkujący – przygotowanie do pracy na poziomie średnim, średni – dla większości zaawansowanych interfejsów, zaawansowany – do prowadzenia rozmów z pracownikami i kierownictwem.

Początkujący – podstawy produkcji

Rozróżniają one osoby, które już wdrożyły rozwiązania, od tych, które tylko ukończyły tutoriale. Nic skomplikowanego; wszystko to może sprawiać problemy w rzeczywistych aplikacjach.

Debounce vs throttle w przypadku wyszukiwania i przewijania

Sytuacja. Pole wyszukiwania wysyła żądanie przy każdym naciśnięciu klawisza. Produkt wymaga mniej wywołań, nie sprawiając przy tym wrażenia zamarznięcia podczas pisania. Obsługa przewijania w innych miejscach wymaga ciągłych, ale ograniczonych aktualizacji.

Pytanie. Kiedy debounce jest lepszym narzędziem niż throttle i jak bezpiecznie go zaimplementować w React?

Odpowiedź. Mechanizm throttlinggwarantuje wykonywanie żądań w stałym tempie – co jest przydatne podczas przewijania i zmiany rozmiaru. Mechanizm debounce czeka, aż przerwie się pisanie, co odpowiada intencji wyszukiwania. Zacznij od wartości w przedziale od jednej czwartej sekundy do trzystu milisekund i dostosowuj ją na podstawie pomiarów. Połącz debounce z minimalną długością zapytania oraz możliwością anulowania żądania; sam debounce nie rozwiązuje problemu nieuporządkowanych odpowiedzi.

function debounce(fn, wait = 250) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

const search = debounce((q) => {
  if (q.length < 2) return;
  fetchResults(q);
});

Niewłaściwe podejście. Tworzenie nowego obłoku debounce przy każdym renderowaniu. Timery są resetowane dla każdej nowej funkcji zamkniętej, więc debounce tak naprawdę nigdy nie zostaje aktywowany. Ustabilizuj sytuację za pomocą useMemo/useRef i usuń te elementy przy dezmontowaniu komponentu.

Kwestie dodatkowe. Użytkownik szybko pisze, a następnie usuwa treść pola. Żądanie z użyciem debounce i tak zostanie wysłane z ostatnią niepustą wartością. Jak współdziałają mechanizmy anulowania przy dezmontowaniu i przy usunięciu treści?

Zmierzone. Wybór narzędzia oparty na intencjach UX oraz wygodzie korzystania z mechanizmów zamknięcia, które zachowują się podczas różnych renderowań.

Dwadzieścia tysięcy słuchaczy kliknięć

Sytuacja. Tabela danych przypisuje metodę onClick do każdej akcji wiersza. Przy 20 000 wierszach strona potrzebuje kilku sekund, aby stać się interaktywna, a zużycie pamięci rośnie podczas przewijania.

Pytanie. Jak należy przebudować obsługę zdarzeń?

Odpowiedź. Należy użyć delegacji zdarzeń. Jeden słuchacz w kontenerze odczytuje cel, gdy zdarzenia docierają w górę – jedna funkcja w pamięci, brak ponownego przypinania przy zmianie wierszy, co działa również przy dodawaniu nowych wierszy później. Identyfikuj wiersze i akcje za pomocą atrybutów danych oraz metody closest, ponieważ kliknięcia często trafiają w ikonę wewnątrz przycisku, a nie samego przycisku.

grid.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action]');
  if (!button || !grid.contains(button)) return;

  const { action } = button.dataset;
  const rowId = button.closest('tr')?.dataset.rowId;
  handleAction(action, rowId);
});

Niewłaściwe podejście. Nadal konfiguruje się tysiące słuchaczy wierszy i ma się nadzieję, że pętla czyszczenia je usunie. Koszt konfiguracji pozostaje niezmieniony, a niezgodne odniesienia do funkcji w tle powodują bezgłośną nieudaną dekonfigurację.

Kontynuacja. W React 17+ gdzie biblioteka rejestruje swój główny słuchacz i jak powinny współistnieć delegowane natywne obsługi z syntetycznym rozprzestrzenianiem się zdarzeń?

Ocena. Czy kandydat traktuje ilość słuchaczy jako rzeczywisty budżet wydajności, a nie tylko jako kwestię stylu.

Obietnica, która się spełniła, to nie to samo co obietnica, która nadal ma znaczenie.

Poziom średni — asynchroniczność, pamięć i wydajność

Tutaj decyduje się o większości rozmów rekrutacyjnych na wyższe stanowiska. Pytania przypominają sesje debugowania, ponieważ właśnie to jest ich celem.

Panel sterowania, który zamarza na dwie sekundy

Sytuacja. Zmiana zakresu dat zamraża kartę. Kliki nie mają żadnego efektu, animacje się zatrzymują, a element spinający się nie obraca. Sam wywołanie sieciowe trwa 180 ms.

Pytanie. Dlaczego element spinający się nie animuje i gdzie podziało się czas?

Odpowiedź. Główny wątek obsługuje jednocześnie skrypt, układ, rysowanie i dane wejściowe. Jeśli wywołania trwają zbyt długo, nawet element spinający się nie może zostać narysowany, ponieważ klatka, która miałaby go pokazać, nigdy się nie uruchamia. 180 ms to czas potrzebny na połączenie sieciowe; zamrożenie wynika z późniejszej pracy synchronicznej — parsowania ogromnego pliku JSON, a następnie przetwarzania dziesiątek tysięcy wierszy za pomocą funkcji map/filter, przy czym dla każdego wiersza tworzony jest nowy tabliczka.

Zmieść intensywne operacje transformacyjne do Web Workera lub podziel pracę na części i robić przerwy pomiędzy nimi, a jeśli to możliwe, poproś serwer o dane zsumowane.

// Yield to the event loop between chunks so input and paint can run
async function processInChunks(items, fn, chunkSize = 500) {
  const out = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    for (const item of items.slice(i, i + chunkSize)) out.push(fn(item));
    await new Promise((r) => setTimeout(r, 0));
  }
  return out;
}

Niewłaściwe podejście. Rozrzucanie instrukcji async/await wewnątrz obiegu obciążającego procesor oraz określanie go jako nieblokującego. Nie powstaje żaden dodatkowy wątek; praca synchroniczna nadal zamyka kartę.

Kontynuacja. Wyjaśnij różnicę między mikrozadaniami a makrozadaniami w tym przypadku zamykania karty oraz dlaczego użycie await Promise.resolve() nadal powoduje długie odcinki pracy synchronicznej.

Pomiar. Postrzeganie równoległości w JS jako planowania zadań oraz znajomość momentu, gdy potrzebne są dodatkowe wątki.

Z AI

Wyniki wyszukiwania dla zapytania, które użytkownik już usunął

Sytuacja. Po wpisaniu „sam” a następnie doprecyzowaniu do „samantha” czasami pojawiają się wyniki dla „sam” nawet po tym, jak już pokazały się wyniki dla „samantha”. Trudno to odtworzyć przy szybkim połączeniu.

Pytanie. Co się dzieje i jaka jest właściwa poprawka?

Odpowiedź. Wyścigi odpowiedzi niewykonanych w kolejności. Wysyłane są dwa żądania; to wolniejsze należy do starszego zapytania; ten, który zostanie sfinalizowany jako ostatni, wygrywa aktualizację stanu. Należy stosować obie metody łagodzące: anulować poprzednie żądanie za pomocą AbortController oraz zabezpieczyć zapis stanu tak, aby tylko odpowiedź na bieżące dane mogła zostać przyjęta.

const controllerRef = useRef(null);

async function search(query) {
  controllerRef.current?.abort();
  const controller = new AbortController();
  controllerRef.current = controller;

  try {
    const res = await fetch(`/api/customers?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    setResults(await res.json());
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

Nieprawidłowe podejście. Wydłużanie czasu oczekiwania do pełnej sekundy i ogłaszanie zwycięstwa. Wyścigi stają się coraz rzadsze, jakość interfejsu pogarsza się, a wolne sieci nadal przestawiają odpowiedzi w kolejności.

Kontynuacja. Kiedy identyfikator żądania rosnący monotonicznie mógłby przewyższyć AbortController w usuwaniu przestarzałych danych z wyszukiwania?

Pomiar. Prawidłowe nazywanie wyścigów oraz unikanie pokazywania informacji o przerwaniach w panelach błędów.

To samo żądanie, pięć razy

Sytuacja. Pięć komponentów pobiera po kolei /api/current-user przy załadunku. Karta sieci pokazuje pięć identycznych żądań; czasami jedna odpowiedź staje się przestarzała po aktualizacji profilu.

Pytanie. Jak usunąć duplikaty żądań w trakcie przetwarzania, nie przerabiając całej warstwy danych?

Odpowiedź. Zapisz w pamięci podręcznej promise, a nie wynik. Użyj identyfikatora żądania jako klucza w mapie, zwróć ten wspólny promise wszystkim użytkownikom, a gdy się on rozstrzygnie, usuń klucz, aby można było ponownie spróbować bezpowodowych prób i później pobrać aktualne dane. Biblioteki takie jak TanStack Query i SWR budują na tej idei zasady unieważniania danych i wykrywania ich przestarzałości.

const inFlight = new Map();

export function dedupedFetch(key, fetcher) {
  if (inFlight.has(key)) return inFlight.get(key);

  const promise = fetcher().finally(() => inFlight.delete(key));
  inFlight.set(key, promise);
  return promise;
}

// All five callers receive the same promise
const user = await dedupedFetch('/api/me', () => fetch('/api/me').then((r) => r.json()));

Niewłaściwe podejście. Trzymanie rozstrzygniętego treści żądania w zmiennej na poziomie modułu na zawsze. Duplikaty żądań GET znikają, ale interfejs pokazuje dane z wczorajszego dnia, dopóki ktoś nie wykonuje ręcznego odświeżenia.

Kontynuacja. Jeśli dwóch użytkowników do wspólnej obietnicy w trakcie realizacji doda osobne sygnały anulowania, która z polityk anulowania zapewni spójność obu?

Rozwiązanie. Rozsądne udostępnianie obietnic w trakcie realizacji oraz uprzednie planowanie ich unieważnienia.

Jeden niestabilny dostawca powoduje awarię strony

Sytuacja. Profil, system fakturowania oraz widget z rekomendacjami od third party ładowają się jednocześnie. Dostawca jest dostępny w ~96% przypadków. Gdy wystąpi awaria, cała strona pokazuje błędy, a sekcja fakturowania staje się niewidoczna.

Pytanie. Jak należy przeprojektować operację fetch, i w czym różnią się tutaj Promise.all oraz Promise.allSettled?

Odpowiedź. Promise.all odrzuca wynik, gdy którykolwiek z argumentów zawiedzie, ignorując te, które odniosły sukces. Promise.allSettled zawsze rozwiązuje się, podając status każdego elementu – wyświetl to, co się udało, a resztę obsłuż w sposób uproszczony. Zachowaj faktury i profil na kluczowej ścieżce; umieść rekomendacje w krótkim terminie, aby dostawca nie mógł zablokować procesu, nawet jeśli później zgłosi sukces.

const [profile, billing, recs] = await Promise.allSettled([
  getProfile(),
  getBilling(),
  withTimeout(getRecommendations(), 2000),
]);

if (profile.status === 'rejected' || billing.status === 'rejected') {
  return renderError();
}
render({
  profile: profile.value,
  billing: billing.value,
  recs: recs.status === 'fulfilled' ? recs.value : [],
});

Niewłaściwe podejście. Przekształcanie błędów w null, aby Promise.all nadal funkcjonował poprawnie. Telemetria traci informację o przyczynie odrzucenia, a wszystkie błędy wyglądają identycznie.

Kontynuacja. Wybierz przypadek użycia Promise.any w porównaniu z Promise.race i wyjaśnij, co dzieje się z obietnicami, które nie odnoszą sukcesu.

Pomiar. Biegłość w obsłudze kombinatorów obietnic plus intuicja dotycząca skutków ich działania.

Opłata naliczona dwa razy

Sytuacja. Proces płatności wygaśnia w bramce płatniczej. Interfejs użytkownika próbuje ponownie. Klient jest obciążony kosztem dwukrotnie.

Pytanie. Jaka strategia ponawiania jest bezpieczna dla punktu końcowego obsługi płatności?

Odpowiedź. Ponawianie jest bezpieczne tylko w przypadku operacji idempotentnych. Zapytanie POST tworzące opłatę domyślnie nie jest idempotentne — należy to zmienić za pomocą klucza idempotencji generowanego przez klienta, na podstawie którego serwer eliminuje duplikaty. Ponawiaj próby wyłącznie w przypadku błędów transmisji oraz odpowiedzi 5xx/429 — nigdy w przypadku odpowiedzi 4xx. Rozdzielać próby przy użyciu eksponencjalnego opóźnienia plus jitter, aby serwer nie został przeciążony. Przestrzegać nagłówków Retry-After oraz ustalić maksymalną liczbę prób.

async function postWithRetry(url, body, key, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status < 500 && res.status !== 429) throw new HttpError(res);

    const backoff = 2 ** i * 300 + Math.random() * 300; // jitter
    await new Promise((r) => setTimeout(r, backoff));
  }
  throw new Error('Payment could not be confirmed');
}

Nieprawidłowe podejście. Ślepe ponawianie wszystkiego trzy razy w ustalonym czasie. Wygaśnięcia mogą doprowadzić do podwójnego naliczenia opłat, pętle z odpowiedziami 4xx nigdy nie przynoszą skutku, a awarie mogą się nasilić do ogólnego załamania.

Kontynuacja. Brauzer wykonał timeout, ale opłata została zaksięgowana po stronie serwera — opisz drogę naprawczą widoczną dla użytkownika.

Zmierzone. Projekt idempotentny w warunkach niepewności po stronie klienta.

API, które się zawiesza zamiast awarii

Sytuacja. Dostawca przestaje odpowiadać, nie zamykając połączeń. Funkcja fetch nigdy nie kończy się pomyślnie; obsługi zadań gromadzą się; ikony spinują się przez minuty.

Pytanie. Jak to ograniczyć i co należy uwzględnić poza timeoutem?

Odpowiedź. Przeglądarki nie przypisują funkcji fetch żadnego domyślnego czasu wygaśnięcia — trzeba go określić ręcznie. AbortSignal.timeout to nowoczesne rozwiązanie; AbortSignal.any łączy czas wygaśnięcia z możliwością anulowania przez użytkownika. Należy dodać mechanizm ochronny: po serii nieudanych prób należy przerwać wywołania na czas chłodzenia i natychmiast użyć alternatywy, chroniąc zarówno aplikację, jak i zależności, które się odbudowują.

const signal = AbortSignal.any([
  AbortSignal.timeout(3000),
  userController.signal,
]);

try {
  const res = await fetch(url, { signal });
  breaker.recordSuccess();
  return res.json();
} catch (err) {
  breaker.recordFailure();
  if (err.name === 'TimeoutError') return cachedFallback();
  throw err;
}

Nieprawidłowe podejście. Konkurencja między funkcją fetch a zegarem oraz udawanie, że to strona przegrana przerwała działanie. Wywołanie HTTP nadal trwa, zajmuje zasoby sieciowe i może nadal zmieniać stan nawet po jego zakończeniu.

Kontynuacja. Należy ustalać czasy wygaśnięcia zarówno w przeglądarce, jak i w bramie API oraz we wywołaniach do dostawców w Node, aby uniknąć nierozwiązanych zadań.

Pomiar. Domyślna nieufność wobec zależności oraz prawdziwe anulowanie w porównaniu z nierozwiązanymi zadaniami.

Pamięć rośnie przy każdej zmianie trasy

Sytuacja. Narzędzie wsparcia pozostawione otwarte przez cały dzień osiąga 1,4 GB. Zdjęcia pamięci typu heap pokazują, że oddzielone węzły DOM rosną z każdym przeglądaniem ticketa.

Pytanie. Jakie są najczęstsze przyczyny i jak to sprawdzić?

Odpowiedź. Oddzielone węzły pozostają aktywne, ponieważ coś nadal do nich odwołuje się: słuchacze window/document nigdy nie zostały usunięte, setInterval nadal działa, IntersectionObserver/ResizeObserver nigdy nie zostały odłączone, a słuchacze globalnego magazynu oraz zamykania w długowiecznych buforach przechowują informacje o DOM. Aby to potwierdzić, należy zrobić zdjęcie pamięci typu heap, przeglądać treść, wymusić zbieranie śmieci, zrobić kolejne zdjęcie, a następnie przejść przez tablicę retentorów, aż zostanie wyłoniony właściciel danych.

useEffect(() => {
  const onResize = () => recalcLayout();
  const observer = new ResizeObserver(onResize);
  const id = setInterval(pollTicket, 5000);

  window.addEventListener('resize', onResize);
  observer.observe(panelRef.current);

  return () => {
    window.removeEventListener('resize', onResize);
    observer.disconnect();
    clearInterval(id);
  };
}, []);

Niewłaściwe podejście. Ustawianie zmiennych lokalnych na null podczas czyszczenia i oczekiwanie, że pamięć się zmniejszy. Dostępność danych nadal jest utrzymywana przez słuchacze i timerzy, które pracują z tymi samymi danymi.

Kontynuacja. Nazwijmy ten wzorzec wycieku sytuacją, w której WeakMap skutecznie rozwiązuje problem, a słabe referencje są niewłaściwym narzędziem do jego naprawy.

Pomiar. Bezpośrednia debugowanie stosu pamięci oraz zrozumienie koncepcji dostępności danych.

Licznik, który zawsze loguje zero

Sytuacja. Widget sprawdza dane co pięć sekund i dodaje powiadomienia. Zawsze wyświetla jedno powiadomienie; log generowany w trakcie tego interwału wiecznie pokazuje początkowy stan.

Pytanie. Dlaczego interwał widzi przestarzały stan i jak to naprawić?

Odpowiedź. Efekt został uruchomiony raz za pomocą [], więc funkcja zwrotna odwoływała się do stanu z pierwszego renderowania. Aktualizacje tworzą nowe wartości; stara funkcja zwrotna nadal wskazuje na tę starszą. Lepiej używać funkcjonalnych aktualizatorów, aby React dostarczał najnowszą wartość z kolejki. Gdy funkcja zwrotna potrzebuje danych, których aktualizator nie może wyrazić, należy je odzwierciedlić w refie przy każdym renderowaniu.

// Broken: `alerts` is frozen at the first render
useEffect(() => {
  const id = setInterval(() => setAlerts([...alerts, poll()]), 5000);
  return () => clearInterval(id);
}, []);

// Fixed: functional update, no stale capture
useEffect(() => {
  const id = setInterval(() => setAlerts((prev) => [...prev, poll()]), 5000);
  return () => clearInterval(id);
}, []);

Nieprawidłowy podejście. Wymienienie alerts jako zależności efektu. Stare funkcje zwrotne znikają, ale interwał restartuje się przy każdym dodaniu elementu, przez co częstotliwość zapytań spada do pięciu sekund.

Kontynuacja. Jak custom hook mógłby utrzymać częstotliwość zapytań pięciu sekund, jednocześnie zawsze odczytując aktualny stan?

Pomiar. Funkcje zwrotne podróżujące w czasie — klasyczny problem średniego poziomu w React.

Dwadzieścia tysięcy wierszy w DOM

Sytuacja. Tabela z inventarzem wyświetla każdą rekord z API. Rysowanie pierwszego elementu trwa sześć sekund; filtrowanie powoduje opóźnienia; karta zużywa setki megabajtów.

Pytanie. Jak uczynić tę tabelę użyteczną i co mierzyć najpierw?

Odpowiedź. Najpierw przeprowadź profilowanie: skrypt, styl, układ i proces rysowania. W przypadku tabel tej wielkości zazwyczaj decydująca jest liczba węzłów DOM. Wirtualizuj listę, aby w DOM znajdował się tylko obszar widoczny na ekranie plus niewielki bufor. Połącz to ze stabilnymi identyfikatorami wierszy, staranną memoizacją oraz filtrowaniem/sortowaniem po stronie serwera, gdy zbiory danych po stronie klienta staną się zbyt duże.

// Row identity matters as much as row count
{visibleRows.map((row) => (
  <Row key={row.id} data={row} />   // stable id, not the array index
))}

Niewłaściwe podejście. Opakowywanie wierszy funkcją React.memo i poprzestawanie na tym. Nowe właściwości inline unieważniają efekt memoizacji, a układ i proces rysowania nadal są przytłoczone dziesiątkami tysięcy węzłów.

Kontynuacja. Co idzie nie tak z fokusem i kontrolowanymi wprowadzaniami danych, gdy klucze wierszy to indeksy tablicy, a jeden z wierszy zostaje usunięty?

Pomiar. Zwyczaj pomiaru przed działaniem oraz realistyczne ograniczenia memoizacji.

Z AI

Nagłe zmiany układu pod wpływem pętli filtrowania i zmieniania rozmiaru

Sytuacja. Po załadowaniu danych panel sterowania zmienia rozmiar kontenerów z wykresami. Profile pokazują długie, fioletowe bloki stylu/układu powtarzające się setki razy w jednym kadrze.

Pytanie. Jaki wzorzec powoduje to zjawisko i jak go naprawić?

Odpowiedź. Problemy z układem elementów na ekranie. Korzystanie z API zajmujących się geometrią, takich jak odchylenia wysokości, prostokąty ograniczające obszar lub pozycje przewijania, zmusza do synchronicznego aktualizowania stylów i układu, aby silnik mógł udzielić dokładnej odpowiedzi. Alternowanie tych odczytów z zapisami stylów wewnątrz pętli powoduje konieczność takiej aktualizacji przy każdej iteracji. Najpierw zbierz wymiary, potem zmień style i umów zapisy za pomocą requestAnimationFrame. W przypadku logiki pokazywania/ukrywania elementów skorzystaj z IntersectionObserver, aby widoczność była dostarczana asynchronicznie, bez konieczności aktualizacji układu.

// Broken: read, write, read, write
panels.forEach((p) => { p.style.height = p.offsetHeight * 1.2 + 'px'; });

// Fixed: batch reads, then batch writes
const heights = panels.map((p) => p.offsetHeight);
requestAnimationFrame(() => {
  panels.forEach((p, i) => { p.style.height = heights[i] * 1.2 + 'px'; });
});

Niewłaściwe podejście. Umawianie każdego zapisu na osobną kadru animacji, podczas gdy odczyty nadal są mieszane. Koszt operacji rozpraszany jest między kadrami, co powoduje dłuższe opóźnienia w działaniu aplikacji.

Kontynuacja. Które właściwości CSS pozostają w komponentorze i kiedy will-change powoduje większe obciążenie niż przynosi oszczędności?

Zmierzone. Koszty procesu przeglądarki przedstawione jako sekwencja, a nie tajemnicza skrzynka.

Oczekiwanie na synchroniczną obliczanie nie uwalnia pętli zdarzeń.

Rozwinięte tematy — bezpieczeństwo, Node, testowanie i projektowanie

Osoby na szczeblu kierowniczym mniej dbają o jedyną prawidłową poprawkę, a bardziej o kompromisy, zakres wpływu i odpowiedzialność.

Pole CMS, które uruchomiło skrypt

Sytuacja. Dział marketingu przechowuje tekst zformatowany w bezgłowym CMS. Przegląd bezpieczeństwa wykrywa, że na każdej stronie produktu uruchamia się kod <img onerror=...>.

Pytanie. Jak to się stało i jak to naprawić w całym systemie?

Odpowiedź. Wartość trafia do innerHTML lub funkcji Reacta dangerouslySetInnerHTML bez żadnej sanitacji. Warstwy ochronne: domyślnie tagi są escapeowane ({value} w React, textContent w DOM). Gdy wymagany jest format HTML, należy użyć sprawdzonej biblioteki z listą dozwolonych elementów, takiej jak DOMPurify — również na serwerze, ponieważ klient nie stanowi bezpiecznej granicy. Należy dodać politykę bezpieczeństwa treści, aby nieautoryzowany kod nie mógł uruchomić skryptu wstrzykniętego bezpośrednio w treść.

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(cmsHtml, {
  ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'li'],
  ALLOWED_ATTR: ['href', 'title'],
});
<div dangerouslySetInnerHTML={{ __html: clean }} />

Niewłaściwe podejście. Usuwanie tagów <script> za pomocą regularnych wyrażeń. Atrybuty obsługujące zdarzenia, adresy URL typu javascript:, wektory SVG oraz zagnieżdżone kodowania przechodzą bez żadnej kontroli.

Kontynuacja. Narzędzia analityczne wstrzykują skrypty bezpośrednio do treści, a polityka CSP je blokuje — należy przywrócić te tagi, nie aktywując opcji unsafe-inline.

Zmierzone. Warstwowe zasoby ochrony przed XSS wraz z czyszczeniem danych w momencie renderowania.

Tokener w localStorage

Sytuacja. Po ataku typu XSS notatki dotyczące bezpieczeństwa odnotowują tokeny sesji w localStorage, które są dodawane za pomocą interfejsu Axios. Potrzebny jest plan działania.

Pytanie. Jakie są zalety i wady używania localStorage oraz plików cookie do przechowywania tokenów autoryzacyjnych, i jaka jest rekomendacja?

Odpowiedź. Skrypty na serwerze mogą odczytywać localStorage, więc jeden incydent XSS może spowodować utratę całej sesji. Ciasteczka HttpOnly Secure z ustawieniem SameSite=Lax pozostają niewidoczne dla JavaScriptu, ale przeglądarki dodają je automatycznie, więc modyfikacje żądań nadal wymagają zabezpieczeń CSRF. Powszechny schemat: krótkotrwały token dostępu w pamięci, token odnowienia w ciasteczku HttpOnly, tokeny CSRF lub ustawienie SameSite przy modyfikacjach oraz rotacja tokenów po odnowieniu. Nic nie pozostaje nietknięte przez XSS — zapobieganie XSS pozostaje najważniejsze.

// Server side, Express
res.cookie('refresh_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/auth/refresh',
  maxAge: 1000 * 60 * 60 * 24 * 7,
});

Niewłaściwe podejście. Szyfrowanie tokenów w localStorage za pomocą klucza, który również znajduje się w JavaScriptu strony. Każdy, kto może odczytać dane przechowywane w pamięci, może odczytać również klucz — to czysta pozorna ochrona.

Kontynuacja. Jeśli tokeny dostępu znajdują się tylko w pamięci, jak nowo otwarta karta może uzyskać dostęp do sesji?

Zmierzone. Wybory dotyczące przechowywania tokenów oparte na modelowaniu zagrożeń zamiast hasł.

Koniec węzła, który spowalnia wszystkie inne konce

Sytuacja. Obsługa raportów PDF w Express, po jej wywołaniu, powoduje timeouty niepowiązanych z nią sprawdzeń stanu oraz gwałtowny wzrost wartości p99 w całym serwisie.

Pytanie. Dlaczego jeden koniec wpływa na wszystkie pozostałe i jak to naprawić?

Odpowiedź. Node uruchamia JavaScript aplikacji na jednym wątku. Prace obciążające CPU blokują pętlę zdarzeń – nie mogą przebiegać inne żądania, timerzy ani callbacki I/O. Przenieś pracę wymagającą dużo mocy obliczeniowej na worker_threads lub zewnętrzną kolejkę, aby obsługa HTTP zajmowała się tylko dodawaniem do kolejki i odpowiadaniem. Przesyłaj duże dane w formie strumieni zamiast je buforować. Wolij async API kryptograficzne od wersji Sync; nigdy nie używaj readFileSync na ścieżce żądania.

import { Worker } from 'node:worker_threads';

app.post('/reports', async (req, res) => {
  const job = await queue.add('generate-report', req.body); // returns immediately
  res.status(202).json({ jobId: job.id, status: 'queued' });
});

// Or for in process CPU work
const worker = new Worker('./report-worker.js', { workerData: params });

Niewłaściwe podejście. Czekanie na prace obciążające procesor w nadziei, że pętla zdarzeń będzie mogła funkcjonować. Rozszerzanie skalowania polega na mnożeniu instancji, z których każda nadal utyka przy tym samym typie żądania.

Kontynuacja. Które sygnały z produkcji wskazują na zablokowaną pętlę zdarzeń Node przed tym, jak klienci zaczną się skarżyć?

Pomiar. Rozdzielenie zadań obciążających wejście/wyjście od tych obciążających procesor, z odpowiadającymi im sygnałami z produkcji.

Niewłaściwy treść po załadowaniu (hydration)

Sytuacja. Aplikacja Next.js wyświetla na serwerze tekst „Zaloguj się”, a po załadowaniu zmienia go na imię użytkownika. Pojawiają się ostrzeżenia o niezgodności podczas procesu hydratacji; czasami Treść tekstowa nie odpowiada HTML wygenerowanemu na serwerze.

Pytanie. Co powoduje niezgodności podczas hydratacji i jak to naprawić?

Odpowiedź. Serwer nie posiada stanu dostępnego wyłącznie w przeglądarce: plików cookie czytanych przez klienta, localStorage, window.matchMedia oraz Date.now(). Proces hydratacji wymaga, aby pierwsze renderowanie po stronie klienta odpowiadało strukturze na serwerze. W przypadku niezgodności React odrzuca markup serwera dla danej podstruktury i wyświetla ostrzeżenie. Aby umożliwić renderowanie sesji podczas pracy serwera, należy odczytać pliki cookie na serwerze i przekazać ich wartości do struktury. Dla wartości, które istnieją rzeczywiście tylko w przeglądarce, należy wyświetlić stabilny zamiennik i zaktualizować go po montowaniu.

// Server component reads the session, so no mismatch
export default async function Layout({ children }) {
  const session = await getSession(cookies());
  return <AuthProvider value={session}>{children}</AuthProvider>;
}

Niewłaściwe podejście. Tłumienie ostrzeżeń związanych z hydratacją w elementach otaczających, lub dynamiczne zmienianie nagłówka w celu uniknięcia SSR. Niezgodność pozostaje; korzyści płynące z SSR znikają.

Kontynuacja. Jak można renderować względne daty czasowe na serwerze bez konfliktu między mechanizmem hydratacji a zegarem klienta?

Zmierzono. Należy poprawnie zarządzać nawodnieniem pamięci, zamiast tłumić ostrzeżenia.

Cache, który nigdy nie rezygnuje

Sytuacja. Biblioteka do tworzenia wykresów przechowuje obrazy w pamięci cache, używając jako kluczy elementów DOM. Pamięć rośnie po tym, jak wykresy opuszczają stronę. Zrzuty ekranu pokazują bufory przechowywane przez Map.

Pytanie. Dlaczego wpisy nie są usuwane i w jaki sposób WeakMap zmienia ten wynik?

Odpowiedź. Proces czyszczenia pamięci sprawdza dostępność elementów od korzeni drzewa DOM. Zwykłe wpisy Map przymocowują zarówno klucz, jak i wartość, więc oddzielony węzeł DOM użyty jako klucz utrzymuje dostęp do swojego bufora obrazu. WeakMap przechowuje klucze w sposób słabszy — gdy nic innego nie odwołuje się do elementu, wpis może razem z nim zniknąć. WeakMap nie jest możliwy do przeliczenia i nie ma określonej wielkości; ta kompromisowa cecha zapewnia bezpieczeństwo.

// Leaks: the Map keeps removed nodes alive
const cache = new Map();

// Collectable: entry dies with the element
const cache = new WeakMap();
cache.set(chartEl, renderedCanvas);

Niewłaściwe podejście. Procedury przypominające Cron, które usuwają wpisy cache, których węzły opuściły dokument. Wszystko w porządku, dopóki o tych procedurach nie zapomina się; WeakMap eliminuje ten problem.

Kontynuacja. Kiedy FinalizationRegistry jest odpowiedni i dlaczego poprawność nie może nigdy zależeć od momentu jego uruchomienia?

Pomiar. GC jako sprawdzanie dostępności, bez udawania, że czas zbierania jest kontrolowalny.

Test, który zawodzi raz na dwadzieścia uruchomień

Sytuacja. Test komponentu wyszukiwania przechodzi lokalnie, ale zawodzi w około 5% przypadków w CI przy wyszukiwaniu tekstu „Samantha”. Ktoś już dodał waitFor(3000), więc CI próbuje ponownie.

Pytanie. Jak uczynić test niezawodnym i co mówi nam jego niestabilność?

Odpowiedź. Testy typu flaky async zazwyczaj sprawdzają czas wykonywania operacji, a nie ich zachowanie. Użyj fałszywych zegarów do obsługi mechanizmu debounce, zastąp żądania HTTP czymś takim jak MSW w celu uzyskania stabilnych danych, a także używaj zapytań, które czekają na aktualizacje DOM-u, zamiast oczekiwać określonej liczby milisekund. Jeśli do przejścia testu konieczne jest arbitralne oczekiwanie, często oznacza to prawdziwy konflikt w komponencie — test działa poprawnie.

test('shows results for the final query', async () => {
  vi.useFakeTimers();
  render(<Search />);

await userEvent.type(screen.getByRole('searchbox'), 'samantha');
  await vi.advanceTimersByTimeAsync(300); // debounce window
  expect(await screen.findByText('Samantha')).toBeInTheDocument();
});

Niewłaściwe podejście. Powtarzane próby w CI oraz coraz dłuższe limity czasowe. Sygnały zamieniają się w szum, a seria trzech nieudanych prób może przykryć prawdziwy konflikt, który później pojawi się w środowisku produkcyjnym.

Kontynuacja. Jak test mógłby zmusić starszą odpowiedź z wyszukiwarki do przyjścia po nowszej?

Pomiar. Traktowanie problemu flaky jako sygnału oraz uczynienie testów async deterministycznymi.

Sześćdziesiąt miejsc, które wywołują fetch

Sytuacja. Czteroletnia baza kodu wykorzystuje funkcję fetch w sześćdziesięciu komponentach. Obsługa błędów jest niespójna; odświeżanie autoryzacji zostało skopiowane jedenastokrotnie; nikt nie zna wartości codziennych limitów czasu.

Pytanie. Jak zaprojektować wspólną warstwę dostępu do danych i jak przeprowadzić migrację bez zatrzymania działania aplikacji?

Odpowiedź. Zgromadź wszystkie wspólne elementy w jednym kliencie: podstawową adresację URL i nagłówki, mechanizm odświeżania autoryzacji działający tylko raz, aby seria błędów 401 powodowała jedno odświeżenie, ustawienia limitów czasu, próby ponowne wykonywane tylko w sposób idempotentny, normalizację błędów z uwzględnieniem typów oraz elementy do przesyłania danych telemetrycznych. Utrzymuj prostotę struktury, aby zastosowanie tego rozwiązania przewyższało tendencję do omijania go. Przeprowadzaj migrację stopniowo — wprowadź kliencka warstwę, najpierw przenieś najbardziej ruchliwe i narażone na błędy ścieżki, a w nowym kodzie zabraniaj użycia surowej funkcji fetch za pomocą narzędzi do sprawdzania kodu, aby granica zmian nie przesuwała się dalej.

type ApiError =
  | { kind: 'network' }
  | { kind: 'timeout' }
  | { kind: 'http'; status: number; body: unknown }
  | { kind: 'parse' };

export async function apiRequest<T>(
  path: string,
  init: RequestInit & { timeoutMs?: number } = {},
): Promise<{ ok: true; data: T } | { ok: false; error: ApiError }> {
  // timeout, auth, retry, telemetry all live here
}

Niewłaściwe podejście. Proponowanie migracji do nowego frameworka lub zbyt ścisłe opakowywanie odpowiedzi, przez co nietypowe przypadki trafiają do surowego fetch. Obie te drogi prowadzą do istnienia dwóch konkurencyjnych warstw danych.

Kontynuacja. Po sześciu miesiącach – w jaki sposób zasady lintingu oraz przeglądy kodu zapobiegają powrotowi użycia surowego fetch?

Mierzone efekty. Projektowanie API na poziomie całego zespołu oraz stopniowa migracja pod presją terminów realizacji.

Zakończenie – przygotuj się bez memorowania

Przygotowanie do testów wiedzy ma swoje ograniczenia: memorowanie tabel przymusu, nierozwiązane problemy z dashboardem. Przygotowanie do scenariuszy ma inny sposób awarii: poznanie fabuły bez zrozumienia mechanizmów. Uniknij obu tych sytuacji, pracując na rzeczywistym kodzie.

Wytwarzaj awarie celowo. Wypuść pole wyszukiwania, które pracuje zbyt szybko przy ograniczeniach sieciowych, a następnie otwórz zakładki „Wydajność” i „Sieć”, aż stanie się oczywiste, że dane są przestarzałe. Zostaw jakiś interwał niezakreślony, przenieś się gdzie indziej i poszukaj w zakładce „Pamięć” elementów, które zostały odłączone. Praktyczne umiejętności korzystania z narzędzi DevTools są bardziej przydatne niż jakiekolwiek przewodniki.

Nawykuj się do pomiarów, zanim będziesz szukać gotowych odpowiedzi. Zakładki „Wydajność”, „Pamięć” i „Sieć” w Chrome, w połączeniu z narzędziem React Profiler, przekształcają wiele „zaawansowanych” pytań w zwykłe kwestie związane z pomiarami.

Ćwicz na głos określanie kompromisów. Prawie każda sytuacja wymaga więcej niż jednego rozwiązania, z którym można się bronić, a każde z nich ma inne konsekwencje. Dobrzy przesłuchujący zauważą, czy wybór był celowy.

Czytaj informacje o incydentach w produkcji. Każdy, kto już coś wypuścił na rynek, zna pięć takich sytuacji. Takie historie są lepsze od tych zapożyczonych, ponieważ dalsze analizy mogą trwać nieograniczenie długo.

Zachowaj krótką listę awarii wraz z przyczynami i rozwiązaniami — to notatki, a nie portfolio. Gdy ktoś pyta o trudny błąd, konkretowa diagnoza zawsze jest lepsza od ogólnej wykładni.

Czy to początkujący, czy zaawansowani użytkownicy, wzorzec się powtarza: określ sposób wystąpienia awarii, zaproponuj rozwiązanie odpowiadające zagrożeniu i wiedz, co sprawi, że błędne rozwiązanie będzie wydawać się atrakcyjne pod presją. To właśnie umiejętność jest poszukiwana podczas rozmów rekrutacyjnych — nie doskonała tabela metod przymusu, lecz zdolność do utrzymania uczciwości systemów, gdy sieci kłamią, pamięć się zmniejsza, a dostawcy nie dotrzymują obietnic.

Zespoły, które przeprowadzają rozmowy w ten sposób, zazwyczaj prowadzą też lepsze analizy postmortem: ta sama terminologia (races, thrash, idempotency, blast radius) pojawia się zarówno podczas procesów rekrutacyjnych, jak i przy przeglądaniu incydentów. Dlatego studiowanie takich scenariuszy przynosi podwójną korzyść — raz w sali rozmów, a ponownie w momencie, gdy panel sterowania zamarza na dwie sekundy, a ikona spinacza pozostaje nieruchoma.

Rozmowy rekrutacyjne oparte na rzeczywistych historiach z produkcji ujawniają również umiejętności komunikacyjne. Opowiadanie o sytuacji typu race bez nadmiaru żargonu, narysowanie szybkiego diagramu sekwencyjnego dla AbortController lub wyjaśnienie, dlaczego allSettled zmniejsza zasięg skutków incydentu, pokazują, jak dana osoba będzie się zachowywać w kanale obsługi incydentów. Odpowiedzi na pytania o fakty powszechne rzadko potwierdzają takie umiejętności, natomiast odpowiedzi na scenariusze prawie zawsze je pokazują, dlatego ten format stale się rozprzestrzenia wśród pracodawców szukających specjalistów średniego i wyższego szczebla.

Literatura pokrewna