Co to jest closure w JavaScript?
| |
makeCounter działa raz, zwraca funkcję i na tym koniec: jej stack frame już nie istnieje. Mimo to każde wywołanie counter() wciąż odczytuje i aktualizuje count — zmienną zadeklarowaną wewnątrz funkcji, która już się zakończyła. count nie wraca do 0 i nie jest kopiowana. To wciąż ta sama zmienna, żywa, trzy wywołania później.
To jest closure: funkcja powiązana z referencją do zmiennych, które były w jej zakresie w momencie definicji. Nie kopia tych wartości z danego momentu — żywa referencja. Jeśli coś innego zmieni count, counter zobaczy nową wartość.
Czemu zmienna przetrwa po zakończeniu funkcji?
JavaScript rozwiązuje nazwy zmiennych leksykalnie, według miejsca zapisu w kodzie, a nie według tego, kto wywołuje funkcję. Dla count += 1 wewnątrz funkcji wewnętrznej silnik szuka na zewnątrz, przez otaczające zakresy, aż znajdzie deklarację — i znajduje count w makeCounter. To połączenie jest ustalone w miejscu, gdzie funkcja wewnętrzna jest zdefiniowana, nie w miejscu, gdzie jest wywoływana.
Normalnie, gdy funkcja się kończy, nic nie może już dosięgnąć jej zmiennych lokalnych i garbage collector je zwalnia. Closure jest wyjątkiem. Zwrócona funkcja utrzymuje referencję do zakresu, w którym powstała, więc ten zakres pozostaje żywy tak długo, jak sama funkcja. Wywołaj makeCounter() jeszcze raz i otrzymasz drugi counter z własnym count we własnym zakresie:
| |
Każda funkcja, którą tworzysz w JavaScript, zamyka się nad otaczającym zakresem, niezależnie od tego, czy to wykorzystujesz. Zwykle nic nie przetrwa wywołania, które ją stworzyło, więc nigdy tego nie zauważasz. Ma to znaczenie, gdy zwracasz funkcję wewnętrzną, przekazujesz ją dalej albo przechowujesz: wtedy działa dalej, nawet gdy zakres funkcji nadrzędnej inaczej by już zniknął.
Jak tworzy się prywatne zmienne za pomocą closure?
Counter już pokazuje główne zastosowanie: dane, do których dostęp ma tylko zwrócona funkcja. Nic poza makeCounter nie może odczytać ani ustawić count. Nie ma do niej żadnej property i nic do podglądania z konsoli. Otrzymujesz takie samo ukrycie danych jak w prywatnym polu klasy, zbudowane z jednego wywołania funkcji:
| |
Wszystkie trzy metody zamykają się nad tym samym balance, więc go współdzielą. Kod zewnętrzny może dostać się do niego tylko przez metody, które zdecydowałeś udostępnić. To jest module pattern: zamknięcie prywatnego stanu i publicznego interfejsu w jednym wywołaniu funkcji, które zwraca tylko interfejs.
Jak memoizować funkcję za pomocą closure?
Zamknij się nad Map i masz wrapper memoizujący, z cache współdzielonym przez każde wywołanie zwróconej funkcji:
| |
Oba przykłady mają ten sam kształt: wywołujesz funkcję, która przygotowuje jakiś stan, a w zamian otrzymujesz jedną lub więcej funkcji, które dalej go wykorzystują. Ten cache nigdy niczego nie usuwa, więc nie owijaj w ten sposób funkcji, która dostaje nieograniczoną liczbę różnych wejść. Dekoratory w Pythonie opierają się na tym samym mechanizmie: dekorator zamyka się nad funkcją, którą owija, i zwraca zamiennik, który wciąż może ją wywołać.
Czemu var w pętli łamie closure?
To błąd z closure, na który prawie każdy trafia przynajmniej raz:
| |
Chciałeś 0 1 2. Dostałeś 3 3 3. var ma zakres funkcji, nie bloku, więc istnieje dokładnie jedno i dla całej pętli i wszystkie trzy callbacki zamykają się nad nim. Kiedy którykolwiek z nich się wykona, pętla już się zakończyła i i wynosi 3.
Zadeklaruj je z let i błąd znika:
| |
let ma zakres bloku, a pętla for z let w nagłówku tworzy osobne powiązanie dla każdej iteracji. Trzy osobne i. Każdy callback zamyka się nad tym ze swojej iteracji.
Zanim pojawił się let, rozwiązaniem była IIFE, która wymuszała nowy zakres na iterację, przekazując i jako parametr:
| |
Nadal znajdziesz to w starszym kodzie. Nie pisz tego dzisiaj — umieść let w nagłówku pętli.
Czy closure może powodować wycieki pamięci?
Tak. Closure utrzymuje rzeczywiste środowisko leksykalne, w którym żyją jej zmienne, tak długo, jak sama closure jest dostępna. Podczep ją do czegoś długo żyjącego, jak event listener DOM, tablica na poziomie modułu albo cache, z którego nic nie jest usuwane, i wszystko, co to środowisko niesie, też zostaje w pamięci.
Częsty przypadek: listener na węźle DOM zamyka się nad dużym obiektem, z którego handler niewiele korzysta. Węzeł zostaje usunięty ze strony, listener nigdy nie zostaje odłączony, i zarówno węzeł, jak i obiekt pozostają żywe, bo closure wciąż na nie wskazuje. Wywołaj removeEventListener, gdy element zniknie. I trzymaj closure wąsko: jeśli callback potrzebuje jednego pola, zamknij się nad tym polem, nie nad całym obiektem.
Drugi koszt jest cichszy. Metoda zdefiniowana wewnątrz konstruktora, zamiast na prototypie, jest tworzona na nowo z własną closure dla każdej instancji, zamiast być współdzielona między wszystkimi. Jeśli funkcja wewnętrzna nic nie przechwytuje z zewnętrznej, nie zagnieżdżaj jej.
Kiedy warto użyć closure?
Pewnie już z nich korzystałeś. Debounce lub throttle w JavaScript zwraca wrapper, który zamyka się nad timeoutId trwającym między wywołaniami, dzięki czemu może odwołać poprzednie, wciąż oczekujące wywołanie. Każdy callback, który przekazujesz do setTimeout albo handlera promise i który odczytuje zmienną z funkcji planującej, działa tak samo: event loop wykonuje go długo po zakończeniu tej funkcji.
Używaj closure świadomie, gdy potrzebujesz stanu, który przetrwa między wywołaniami, ale nie powinien być ujawniony jako property: counter, cache, timer debounce, funkcja skonfigurowana raz i wywoływana później. Dla bezstanowego narzędzia, które tylko przekształca swoje argumenty, pomiń wrapper. Nie ma nad czym się zamykać, a zagnieżdżenie dodaje zakres, o którym musisz myśleć, bez żadnej korzyści.