Wyjaśnienie generatorów w JavaScript: funkcje możliwe do pauzowania i iteracja opóźniona
Dowiedz się, jak funkcje generatory w JavaScript mogą wstrzymywać i kontynuować wykonywanie, implementować protokół iteratora oraz umożliwiać opóźniony, energooszczędny przepływ danych.
Każda funkcja, którą do tej pory napisałeś, przestrzega jednej zasady: wywołujesz ją, wykonuje się ona od początku do końca, zwraca wartość tylko raz i to jest koniec historii. Generatory w tajemnicy łamią tę zasadę, a to złamanie okazuje się mieć ogromne znaczenie w praktyce: funkcja generatoryczna może zawiesić swoje działanie w połowie, przekazać wartość temu, kto ją wywołał, a później wznowić działanie dokładnie w tym miejscu, jakby od tamtej chwili nie minął żaden czas.
Bazowy mechanizm
Generator deklarujesz za pomocą function*, a zamiast return używa się yield, aby przekazywać wartości po jednej:
function* countUp() {
console.log("starting");
yield 1;
console.log("resumed after first yield");
yield 2;
console.log("resumed after second yield");
yield 3;
console.log("done");
}
const counter = countUp();
console.log(counter.next()); // "starting" logs, then { value: 1, done: false }
console.log(counter.next()); // "resumed after first yield" logs, then { value: 2, done: false }
console.log(counter.next()); // "resumed after second yield" logs, then { value: 3, done: false }
console.log(counter.next()); // "done" logs, then { value: undefined, done: true }
Wywołanie countUp() nie uruchamia żadnej części ciała funkcji. Zwraca ono obiekt generatora, strukturę w stanie spoczynku, która jeszcze nie zaczęła działać. Każde kolejne wywołanie .next() kontynuuje wykonanie tam, gdzie wcześniej się zatrzymało, działa dalej aż do następnego yield, po czym ponownie się zatrzymuje, zwracając wartość, która została wygenerowana. Cały wewnętrzny stan funkcji, jej zmienne, pozycja w pętli – wszystko to pozostaje nietknięte podczas tych przerw, dokładnie tak, jakby funkcja w ogóle nie została przerwana.
Dlaczego to rzeczywiście różni się od zwykłego zwracania tablicy
Oczywistym zarzutem jest: dlaczego po prostu nie stworzyć tablicy i jej nie zwrócić? Właśnie tutaj generatory okazują się przydatne, ponieważ wytwarzają wyniki opieszale, element po elemencie, tylko na żądanie, zamiast obliczać cały zestaw wyników z góry.
function* readLargeFileLineByLine(filePath) {
const fileHandle = fs.openSync(filePath, "r");
let position = 0;
let leftover = "";
while (true) {
const buffer = Buffer.alloc(1024);
const bytesRead = fs.readSync(fileHandle, buffer, 0, 1024, position);
if (bytesRead === 0) break; position += bytesRead;
const lines = (leftover + buffer.toString("utf8", 0, bytesRead)).split("\n");
leftover = lines.pop();
for (const line of lines) yield line;
}
fs.closeSync(fileHandle);
}for (const line of readLargeFileLineByLine("access.log")) {
if (line.includes("500")) console.log(line);
// only reads and holds one small chunk of the file in memory at a time
}
Gdyby kod ten miał na celu zebranie tablicy z każdej linii z góry, plik logu o wielkości kilku gigabajtów musiałby zostać w całości załadowany do pamięci, zanim można by dotknąć choćby jednej linii – to jest ten sam problem, którego mają uniknąć podejścia strumieniowe. Generator unika tego za pomocą innego mechanizmu: każda linia jest obliczana dopiero w momencie, gdy coś o nią prosi za pomocą .next(), a jeśli otaczający ją pętla for...of zakończy się wcześniej, na przykład po znalezieniu poszukiwanego elementu, generator w ogóle nie próbuje obliczyć niczego po tym momencie.
Generatorzy implementują protokół iteratora, dlatego for...of po prostu działa
Struktury takie jak for...of, dekonstrukcja tablic oraz operator rozszerzania działają we wszystkim, co spełnia protokół iteratora, czyli w każdym obiekcie udostępniającym metodę next(), która zwraca { value, done }. Obiekt generatora automatycznie przyjmuje tę formę bez żadnego dodatkowego wysiłku ze strony użytkownika, co tłumaczy, dlaczego można go bezpośrednio iterować bez dodatkowych konfiguracji:
function* pageThroughResults(fetchPage) {
let page = 1;
while (true) {
const results = fetchPageSync(fetchPage, page);
if (results.length === 0) return;
yield* results; // delegates to another iterable, yielding each of its values
page++;
}
}
Syntaksa yield* deleguje zadanie na inny obiekt iterowalny, przekazując kolejno wszystkie jego wartości. W ten sposób generator może przekształcić serię wyników paginowanych w jeden ciągły strumień pojedynczych elementów, przy czym kod używający tego strumienia nie musi wiedzieć o istnieniu paginacji.
Komunikacja dwukierunkowa: .next() może wysyłać wartości do środka, a nie tylko je pobierać
Istnieje mniej znana strona generatorów: yield to nie tylko sposób na wypuszczanie wartości, to wyrażenie, a cokolwiek przekażemy do następnego wywołania .next() staje się wynikiem tego wyrażenia. Oznacza to, że dane mogą wracać do funkcji w stanie pauzy, a nie tylko z niej wychodzić.
function* priceNegotiation() {
const offer1 = yield "What's your offer?";
const offer2 = yield `I can't do ${offer1}, how about a counter?`;
return `Final: ${offer2}`;
}
const negotiation = priceNegotiation();
console.log(negotiation.next().value); // "What's your offer?"
console.log(negotiation.next(50).value); // "I can't do 50, how about a counter?"
console.log(negotiation.next(80).value); // "Final: 80"
Za każdym razem, gdy wywołujesz .next(value), generator się budzi, a wstrzymana linia yield jest wykonywana z użyciem podanego wartości value. Posiadanie funkcji, która może zostać wstrzymana, czekać na nowe dane od wywołującego, a następnie kontynuować pracę z tymi danymi, to nietypowa cecha w standardowym projektowaniu funkcji. To właśnie ten mechanizm był wykorzystywany przez wczesne biblioteki asynchroniczne w JavaScript, aby symulować async/await, zanim język obsługiwał je w sposób natywny: planer rozwiązywał obietnicę, a następnie przekazywał jej wynik do generatora za pomocą .next(), powtarzając to przy każdym yield, aż wszystkie kroki asynchroniczne zostaną zakończone.
Dlaczego to jest rzeczywistą podstawą, którą warto znać
Generatorzy są daleki od bycia syntaktyczną anomalią, którą można bezpiecznie zignorować. Są to mechanizm, na którym ostatecznie zostały zbudowane async/await, ponieważ „wstrzymanie wykonywania tutaj i wznowienie go później z jakąś wartością” to dokładnie zachowanie wymagane przy oczekiwaniu na obietnicę. Wyjaśniają one również, dlaczego for...of, składnia rozszerzania i dekonstrukcja zachowują się spójnie w tablicach, ciągach znaków, instancjach Map oraz obiektach użytkownicznych – protokół iteratora implementowany przez generatory jest bowiem identyczny z tym, od którego już korzystają te wbudowane typy. Gdy już opanujesz prawdziwy model mentalny, funkcja zdolna do pauzowania i wznowiania działania przy wymienianiu wartości w każdym punkcie pauzy, ocena opóźniona, własna logika iteracji oraz wewnętrzne mechanizmy async/await przestają wydawać się trzema niepowiązanymi funkcjami. Okazuje się, że stanowią jedną całość.
Literatura pokrewna
- Node.js 26: Temporal API, Map Upserts i Undici 8 wyjaśnione — Wyjaśnia główne zmiany w Node.js 26 dotyczące warstwy backendowej, w tym stabilną Temporal API, wbudowane metody Map upsert, poprawy wydajności Undici 8 oraz zmiany mogące powodować problemy, które należy sprawdzić przed aktualizacją.
- Wyjaśnienie renderowania w React: aktualizacje stanu do pikseli na ekranie — Dowiedz się, w jaki sposób fazy renderowania, porównywania i zatwierdzania w React łączą się z procesami układu, malowania i kompozycji w przeglądarce w celu tworzenia pikseli.