Strona główna / Artykuły

Fabryki z gwarancją zamknięcia: prywatny państwo, odbijanie sygnałów, limity szybkości i odwrócenie działań

Zobacz, jak zamknięcia utrzymują prywatny stan aktywny w fabrykach JavaScript, dlaczego powodują przestarzałe wartości w timerach React oraz jak debounce, ograniczanie częstotliwości i funkcja cofania zależą od nich.

Wydawca: REACTAPP.TOP7 min czytania1631 słów

Źródła: Closures in JavaScript and React, Explained Simply

Zamknięcia leżą u podstaw zaskakująco dużej liczby codziennych zastosowań w JavaScript: liczników, funkcji o opóźnionej reakcji, mechanizmów ograniczania liczby żądań, stosów operacji cofania oraz niemal wszystkich hooki w React. Są również przyczyną całej serii błędów, w których funkcja zwrotna zgłasza wartość, która już nie jest prawdziwa. Ten przewodnik przedstawia praktyczną definicję zamknięć, wyjaśnia, jak wpływają one na stan w React, a następnie omawia cztery praktyczne wzory fabryczne, wskazując na ograniczenia, które należy znać przed ich użyciem w środowisku produkcyjnym.

Praktyczna definicja

Zamknięcie to funkcja połączona z zmiennymi, które znajdowały się w jej zakresie definiowania. Funkcja ta zachowuje dostęp do tych zmiennych nawet po tym, jak funkcja zewnętrzna, która je deklarowała, została zakończona. Dobrym przykładem jest funkcja, która „wychodzi z domu” z torbą zawierającą zmienne, do których się odwołuje; gdziekolwiek zostanie później wywołana, korzysta z tej samej torby.

Fabryka liczników

Klasycznym przykładem jest fabryka, która zwraca funkcję, która aktualizuje zmienną, której nie posiada:

function makeCounter() {
  let count = 0; // lives inside makeCounter

  function increment() {
    count = count + 1;
    console.log(count);
  }

  return increment;
}

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

Krok po kroku tak wygląda cała procedura:

  • makeCounter() jest wykonywana raz i deklaruje zmienną count.
  • Zwraca funkcję increment, która odwołuje się do count, po czym makeCounter kończy swoją pracę.
  • Każde wezwanie counter() odczytuje i aktualizuje tę samą zmienną count, dlatego wartość wyjściowa rośnie z 1 do 3.

Zazwyczaj lokalne zmienne funkcji stają się niedostępne po jej zakończeniu, a silnik może je zwolnić. Tutaj jednak increment nadal odnosi się do count, więc zmienna pozostaje aktywna tak długo, jak długo istnieje increment. Jeśli chcesz poznać podstawowe zasady, według których silnik w ogóle znajduje count, artykuł na temat sposobu, w jaki łańcuch zakresów rozwiązuje zmienne, opisuje ten mechanizm.

Dlaczego stan w React staje się przestarzały w timerach

React nie dodaje nic specjalnego do mechanizmów zamknięć; po prostu polega na nich wszędzie, szczególnie w hookach. To właśnie ta zależność tłumaczy jeden z najczęstszych problemów w kodzie komponentów:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      console.log("Count is: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Add</button>
      <button onClick={handleClick}>Log count in 3 seconds</button>
    </div>
  );
}

Zacznij od wartości count równej 0. Kliknij „Zapisz liczbę za 3 sekundy”, a następnie naciśnij „Dodaj” trzy razy, zanim wygaśnie timer. Konsola wyświetli 0, a nie 3.

Powodem jest to, że każde renderowanie komponentu Counter stanowi nowe wywołanie funkcji. Każde takie wywołanie ma własną stałą count oraz własną funkcję handleClick. Funkcja zwrotna setTimeout została utworzona podczas renderowania, w którym wartość count wynosiła 0, i to jest jedyna wartość count, jaką może kiedykolwiek zobaczyć.

Ten model wyjaśnia większość pytań typu „dlaczego mój stan jest przestarzały?”, a celem tablicy zależności w useEffect jest właśnie to: informuje ona React, od których zapisanych wartości efekt zależy, dzięki czemu React może go ponownie uruchomić z aktualnymi wartościami, gdy te ulegną zmianie. Gdy naprawdę potrzebujesz najnowszej wartości w opóźnionym callbacku, zwykłymi rozwiązaniami są aktualizacje funkcyjne, takie jak setCount(c => c + 1) do zapisywania danych, lub ref, który musisz aktualizować podczas odczytu. Korzyści i wady tych opcji są omówione w snapshot versus live values in delayed callbacks.

Cztery wzorce fabryczne, które spotkasz w rzeczywistym kodzie

Zamknięty stan bez klas

Zanim do języka zostały włączone pola z prefiksem #, zamknięcia stanowiły standardowy sposób na ukrywanie danych i nadal są łatwą w użyciu opcją do dziś. Wszystko, co zostanie zadeklarowane w fabryce, jest dostępne tylko poprzez funkcje, które ona zwraca:

function createBankAccount(balance) {
  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    }
  };
}

const account = createBankAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined — can't touch it directly

balance nie jest właściwością zwracanego obiektu, więc account.balance ma wartość undefined. Jedynym sposobem na zmianę tej wartości jest użycie funkcji deposit i withdraw.

Odbijanie sygnałów z pola wyszukiwania

Odbijanie opóźnia wykonanie akcji, dopóki wprowadzanie danych nie zostanie wstrzymane na określony czas. Dzięki temu pole wyszukiwania nie wysyła żądań przy każdym naciśnięciu klawisza:

function debounce(func, delay) {
  let timer; // remembered between calls
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func(...args), delay);
  };
}

const searchInput = document.getElementById("search");
const handleSearch = debounce((e) => {
  fetch(`/api/search?q=${e.target.value}`);
}, 300);

searchInput.addEventListener("input", handleSearch);

Kluczowym elementem jest to, gdzie deklarowany jest timer: raz, wewnątrz funkcji debounce, poza zwracaną funkcją. Każde naciśnięcie klawisza wywołuje zwracaną funkcję, która usuwa bieżący timeout i planuje nowy, dzięki czemu żądanie jest wysyłane dopiero po 300 ms bez żadnego wpisywania tekstu. Gdyby timer był deklarowany wewnątrz zwracanej funkcji, każde wywołanie otrzymywałoby nową zmienną, poprzedni timeout nie mógłby zostać anulowany, a każde naciśnięcie klawisza nadal generowałoby żądanie.

W komponencie React ta sama logika obowiązuje, z jednym dodatkowym krokiem: funkcja pozbawiona efektu opóźnienia musi zostać utworzona tylko raz i zachować swoją stabilność (na przykład za pomocą useMemo lub ref), w przeciwnym razie przy każdym renderowaniu tworzona jest nowa funkcja zamknięta z nowym timer, przez co efekt opóźnienia przestaje działać.

Prosty ogranicznik żądań

Ta sama technika może ograniczyć częstotliwość obsługi punktu końcowego:

function createRateLimiter(maxRequests, windowMs) {
  let requestCount = 0;
  setInterval(() => (requestCount = 0), windowMs);

  return function () {
    requestCount++;
    return requestCount <= maxRequests;
  };
}

const rateLimiter = createRateLimiter(5, 60000); // 5 requests per minute

app.get("/api/data", (req, res) => {
  if (!rateLimiter()) {
    return res.status(429).send("Too many requests");
  }
  res.send("Here's your data");
});

requestCount jest współdzielony prywatnie przez każde wezwanie do rateLimiter, a interwał resetuje go w każdym oknie czasowym. Ta wersja to licznik z ustalonym oknem czasowym przechowywany w pamięci, więc należy mieć na uwadze jego ograniczenia:

  • liczy wszystkich żądań razem, więc jeden klient może wyczerpać limity przeznaczone dla wszystkich; prawdziwe ograniczniki liczą żądania według użytkownika, tokena lub IP
  • stan znajduje się w jednym procesie, więc kilka instancji serwera może każda korzystać z pełnego limitu
  • ustalone okna czasowe pozwalają na nagłe wzrosty liczby żądań na granicy, do dwukrotności limitu w krótkim czasie
  • setInterval nigdy nie jest usuwany, co ma znaczenie podczas testów lub gdy ograniczniki są tworzone dynamicznie

Historia cofania i ponownego wykonania

Menedżer historii przechowuje swoją stek i wskaźnik prywatnie dla każdej instancji:

function createHistoryManager() {
  let history = [];
  let currentIndex = -1;

  return {
    save(state) {
      history = history.slice(0, currentIndex + 1);
      history.push(state);
      currentIndex++;
    },
    undo() {
      if (currentIndex > 0) currentIndex--;
      return history[currentIndex];
    },
    redo() {
      if (currentIndex < history.length - 1) currentIndex++;
      return history[currentIndex];
    }
  };
}

save najpierw usuwa wszystko po aktualnej pozycji, więc zapisanie nowej zmiany po odwołaniu akcji usuwa gałąź powrotu do poprzedniego stanu, co odpowiada zachowaniu większości edytorów. undo i redo jedynie przesuwają currentIndex w obrębie dopuszczalnych wartości i zwracają stan w tym momencie. Należy pamiętać, że przechowywane stany powinny być niezmiennej natury; jeśli zapiszesz obiekt, a następnie go zmienisz, każda pozycja historii będzie wskazywać na te same zmodyfikowane dane.

Szybkie heurystyki do wykrywania zamknięć

  • Wewnętrzne funkcje korzystają z zewnętrznych. Funkcja zdefiniowana wewnątrz innej funkcji, która odwołuje się do zmiennych tej drugiej funkcji, stanowi zamknięcie.
  • Zachowuje ono powiązania, a nie żywy strumień nowych renderów. W React każdy render tworzy nowe powiązania, więc stary callback przechowuje wartości z własnego renderu. Mówiąc ściśle, zamknięcie przechowuje samą zmienną, więc jeśli ta sama zmienna zostanie później przypisana na nowo, jak w przypadku count w fabryce licznika, zamknięcie widzi nową wartość.
  • Funkcja zwracana jest silnym wskazówką. Gdy funkcja zwraca inną funkcję, prawie zawsze chodzi o zamknięcie.
  • Pułapka zmiennych var w pętlach

    Zmienne pętli najwyraźniej pokazują zasadę powiązań:

    for (var i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 3, 3, 3 — var is shared across the whole loop
    
    for (let i = 0; i < 3; i++) {
      setTimeout(() => console.log(i), 1000);
    }
    // Logs: 0, 1, 2 — let creates a new i on every iteration
    

    W przypadku użycia var istnieje tylko jedna zmienna i dla całej funkcji, a gdy aktywują się timeouty, pętla już ustaliła jej wartość na 3, więc wszystkie trzy funkcje callback wyświetlają 3. Z kolei przy użyciu let każda iteracja ma własną wartość zmiennej i, a każda funkcja callback korzysta z innej wartości, dzięki czemu wyświetlają się liczby 0, 1 i 2.

    Podsumowanie

    Zamknięcia stanu są mechanizmem stojącym za prywatnym stanem, opóźnionymi wprowadzeniami danych, ograniczaczami liczby żądań, stosami operacji cofania oraz niestandardowymi hookami, a także za wieloma błędami związanymi ze starym stanem. Dwie pytania rozwiązują większość niejasności: jaką zmienną przechwyciła ta funkcja i czy została ona od tamtej pory zastąpiona nowszą? W zwykłych fabrykach JavaScript zmienne przechwycone są współdzielone i aktualizowane na miejscu. W React każde renderowanie dostarcza nowe zmienne, więc każdy callback, który przetrwa swoje renderowanie, widzi starszy stan, chyba że celowo damy mu możliwość odczytu aktualnego stanu.