Bài 6 · Nâng cao · 24 phút
Generator & async iterator
Biên soạn bởi Nguyễn Anh Tuấn
Iterator protocol & generator (function*, yield) trong JavaScript; async iterator & for await...of - nền tảng để hiểu Stream và xử lý dữ liệu lớn.
Ở khoá Cơ bản (bài Rẽ nhánh & vòng lặp), for...of duyệt mảng, chuỗi, Map… như một tiện nghi có sẵn. Bài này mở nắp: tất cả chạy trên một giao kèo nhỏ cả ngôn ngữ cùng theo - iterator protocol. Một iterator là object có next(), mỗi lần gọi trả về { value, done } - giá trị kế tiếp và cờ "hết chưa"; một iterable ("duyệt được") là object có method ở key đặc biệt [Symbol.iterator]() trả về một iterator - object của mèo con cũng tự viết được:
dem-toi-ba.mjs - iterator viết tay, rồi cho for...of ăn
const demToiBa = {
[Symbol.iterator]() {
let i = 0;
return {
next() {
i += 1;
return i <= 3 ? { value: i, done: false } : { value: undefined, done: true };
}
};
}
};
// Tự quay bằng tay - đây chính là việc for...of làm hộ bạn
const it = demToiBa[Symbol.iterator]();
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());
for (const so of demToiBa) console.log("for...of nhận:", so); Kết quả khi chạy
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }
for...of nhận: 1
for...of nhận: 2
for...of nhận: 3- ▸Iterator protocol: object có next() trả { value, done } - done: true nghĩa là hết.
- ▸Iterable: object có method [Symbol.iterator]() trả về một iterator.
- ▸for...of (và spread) chỉ làm hộ bạn đúng việc đó: xin iterator, gọi next() tới khi done.
- ▸Mảng, chuỗi, Map, Set có sẵn [Symbol.iterator] - đã kiểm: typeof [10, 20][Symbol.iterator] ra "function".
Viết iterator bằng tay khá lắt nhắt: nuôi biến đếm, tự trả done đúng lúc. Generator để máy lo phần đó: khai báo hàm với dấu sao - function* - chỗ nào muốn phát giá trị thì yield; bản viết tay ở trên rút còn function* demToiBa() { yield 1; yield 2; yield 3; }, đã chạy kiểm: in y hệt từng dòng. Mô hình chạy mới là thứ phải nhìn tận mắt: hàm chạy-tạm dừng - mỗi yield một điểm dừng, next() chạy tiếp từ đó, biến cục bộ giữ nguyên. Điểm hay nhầm nhất: gọi phatCa() chưa chạy dòng nào - thân chỉ động đậy ở next() đầu tiên. Generator phát cá 🐟 này chứng minh bằng log:
thu-tu-chay.mjs - đoán thứ tự in trước khi đọc phần kết quả
function* phatCa() {
console.log(" thân bắt đầu chạy");
yield "cá thứ nhất 🐟";
console.log(" chạy tiếp sau lần dừng 1");
yield "cá thứ hai 🐟";
console.log(" thân chạy xong");
}
console.log("1. gọi phatCa()");
const g = phatCa();
console.log("2. có generator rồi - thân vẫn CHƯA chạy dòng nào");
console.log("3.", g.next().value);
console.log("4.", g.next().value);
console.log("5. done =", g.next().done); Kết quả khi chạy
1. gọi phatCa() 2. có generator rồi - thân vẫn CHƯA chạy dòng nào thân bắt đầu chạy 3. cá thứ nhất 🐟 chạy tiếp sau lần dừng 1 4. cá thứ hai 🐟 thân chạy xong 5. done = true
- ▸function* tạo generator function; gọi nó KHÔNG chạy thân, chỉ tạo generator object.
- ▸Mỗi yield là một điểm tạm dừng: next() chạy thân tới yield kế rồi đứng im, biến cục bộ giữ nguyên.
- ▸Thân chỉ bắt đầu chạy ở lần next() ĐẦU TIÊN - quên điều này là đoán sai thứ tự log.
Vì chỉ tính khi được hỏi - tính chất lazy ("lười": chưa ai gọi next() thì chưa tính gì) - generator viết được thứ mảng không bao giờ chứa nổi: một dãy vô hạn. Chỉ cấm hỏi hết ([...soTuNhien()] là treo máy) nên đi kèm van chặn take(n); và vì generator nhận được generator khác làm nguồn, chúng ghép thành đường ống (pipeline):
vo-han.mjs - dãy vô hạn + van chặn take(n), nối thành đường ống
function* soTuNhien() {
let n = 1;
while (true) yield n++; // vô hạn - nhưng lazy: chỉ đếm khi được hỏi
}
function* take(n, nguon) {
if (n <= 0) return;
for (const x of nguon) {
yield x;
if (--n === 0) return; // đủ n phần tử thì thôi, không hỏi nguồn nữa
}
}
// Đường ống: generator nhận generator làm nguồn, kéo TỪNG phần tử một
function* loc(nguon, giu) {
for (const x of nguon) if (giu(x)) yield x;
}
function* nhanDoi(nguon) {
for (const x of nguon) yield x * 2;
}
// Số tự nhiên → giữ số lẻ → nhân đôi. Dòng này CHƯA tính gì cả!
const duongOng = nhanDoi(loc(soTuNhien(), (x) => x % 2 === 1));
console.log([...take(4, duongOng)]); // giờ mới tính - chạy tức thì, không treo Kết quả khi chạy
[ 2, 6, 10, 14 ]
Mỗi tầng chỉ kéo phần tử từ tầng trước khi tầng sau hỏi - không có mảng trung gian. Làm bằng mảng, mỗi tầng đẻ một mảng mới: nguồn một triệu phần tử thành vài mảng triệu phần tử nằm trên heap chờ GC dọn (đã mổ xẻ ở bài Bộ nhớ & garbage collector). Nhắc ngắn: yield* uỷ quyền - phát hộ toàn bộ phần tử của một iterable khác, vd yield* ["Mun", "Xám"] tương đương hai lần yield.
- ▸Lazy: không tính trước, chỉ tính khi next() hỏi - dãy vô hạn vẫn vô hại, miễn đừng duyệt hết.
- ▸take(n) biến nguồn vô hạn thành hữu hạn; mỗi tầng chỉ kéo đúng số phần tử cần.
- ▸Đường ống generator không tạo mảng trung gian - dữ liệu chảy qua từng phần tử một.
Mọi next() ở trên đều trả giá trị NGAY - nhưng dữ liệu thật hay bắt chờ: từng trang API, từng khối từ đĩa. Ghép công cụ chờ của khoá Cơ bản (bài Bất đồng bộ - Promise, async/await) vào protocol là xong: async iterator có next() trả về Promise của { value, done }, method ở key Symbol.asyncIterator; viết gọn bằng async function* (vừa await vừa yield được), duyệt bằng for await...of - tự await từng next() hộ bạn:
tung-trang.mjs - for await ở cấp ngoài cùng cần module ESM (đuôi .mjs); mỗi dòng hiện cách nhau ~0,3 giây
const cho = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function* taiTungTrang() {
const trang = [["Mun", "Xám"], ["Vện", "Gấu"], ["Tom"]];
for (let i = 0; i < trang.length; i++) {
await cho(300); // giả lập chờ mạng 0,3 giây mỗi trang
yield { so: i + 1, meo: trang[i] };
}
}
for await (const goi of taiTungTrang()) {
console.log(`trang ${goi.so}:`, goi.meo.join(", "));
} Kết quả khi chạy
trang 1: Mun, Xám trang 2: Vện, Gấu trang 3: Tom
Nhịp chạy là điểm chính: nhận tới đâu, xử lý tới đó - và trong 0,3 giây chờ mỗi trang, luồng không bị chặn: event loop (Bài 1) vẫn rảnh phục vụ việc khác.
- ▸Async iterator: next() trả về Promise của { value, done } - mỗi miếng dữ liệu là một lời hứa.
- ▸async function* = await (chờ) + yield (phát) trong cùng một hàm.
- ▸for await...of tự await từng next(): xử lý dần từng miếng, luồng vẫn không bị chặn.
Async iterable không phải đồ trang trí trong spec - Node dùng nó khắp nơi. Gặp ngay một chỗ: node:readline/promises cắt nguồn dữ liệu thành từng dòng - và thứ nó trả về là một async iterable:
doc-tung-dong.mjs - tự dựng file mẫu rồi đọc lại theo từng dòng
import { writeFileSync, createReadStream } from "node:fs";
import { createInterface } from "node:readline/promises";
writeFileSync("diem-danh.txt", "Mun 9\nXám 8\nVện 10\n"); // dựng file mẫu 3 dòng
const rl = createInterface({ input: createReadStream("diem-danh.txt") });
let dong = 0;
for await (const line of rl) { // rl là async iterable - phát ra TỪNG DÒNG
dong += 1;
console.log(`dòng ${dong}: ${line}`);
}
console.log("tổng cộng", dong, "dòng"); Kết quả khi chạy
dòng 1: Mun 9 dòng 2: Xám 8 dòng 3: Vện 10 tổng cộng 3 dòng
Hé mở thêm, đã kiểm bằng Node thật: Readable stream (thứ createReadStream trả về) cũng có Symbol.asyncIterator - for await chạy thẳng trên stream được.
Tiếp theo
- ▸readline từ node:readline/promises là async iterable - đọc file theo từng dòng bằng for await.
- ▸Mỗi lúc chỉ một phần nhỏ của file nằm trong bộ nhớ - file lớn cỡ nào cũng xử lý được.
- ▸Readable stream cũng là async iterable: nền tảng trực tiếp của bài Buffer & Stream.
Câu hỏi thường gặp
Iterable là "thứ duyệt được" - object có method [Symbol.iterator](). Iterator là "con trỏ đang duyệt" - object có next() trả { value, done }. Gọi [Symbol.iterator]() của một iterable, bạn nhận một iterator mới tinh. Generator object thì kiêm cả hai vai: nó có next(), và [Symbol.iterator]() của nó trả về chính nó - nên vừa đưa cho for...of được, vừa tự bấm next() được.
Symbol là một kiểu dữ liệu của JavaScript: mỗi symbol là một giá trị duy nhất, thường dùng làm KEY không bao giờ đụng hàng. Symbol.iterator là một symbol "well-known" cả ngôn ngữ thống nhất dùng chung: object nào có method ở key này thì duyệt được. Dùng symbol thay cho chuỗi "iterator" để chắc chắn không giẫm lên property cùng tên mà code của bạn tình cờ đặt.
return kết thúc generator: lần next() chạm tới nó trả { value: <giá trị return>, done: true }. Nhưng for...of và spread chỉ gom các giá trị có done: false - tức giá trị return bị BỎ QUA. Đã kiểm: với function* g() { yield 1; return 99; } thì [...g()] ra [ 1 ], còn next() lần hai trả { value: 99, done: true }.
Không phải lúc nào. Generator duyệt MỘT lần là cạn (spread lần hai ra mảng rỗng - đã kiểm), không có length, không truy cập được "phần tử thứ i". Dữ liệu nhỏ, cần xem đi xem lại, cần sắp xếp → mảng vẫn đúng. Generator thắng khi dữ liệu LỚN, VÔ HẠN hoặc ĐẾN DẦN - những thứ không nhét nổi (hay không đáng nhét) vào một mảng.
Giống đến bất ngờ: Python dùng __iter__/__next__ thay cho Symbol.iterator/next(), cũng có yield, có yield from (vai của yield*), và cũng lazy y như vậy. Học chắc một bên là bên kia gần như miễn phí; khác biệt nằm ở chi tiết - Python báo hết bằng exception StopIteration, JavaScript báo bằng done: true.
Không như mong đợi: forEach không quan tâm callback trả về Promise - nó "chạy qua" hết mảng ngay, các await bên trong tự chạy nền, không ai chờ ai. Muốn xử lý tuần tự có chờ: dùng for...of + await bên trong hàm async; còn khi nguồn là async iterable thì for await...of. Đây là một lý do vòng for cổ điển vẫn sống khoẻ thời async.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Gọi const g = phatCa() với phatCa là một function* - điều gì xảy ra?
- 1
Đếm ngược bằng tay
Viết hàm
demNguoc(n)trả về một object iterable (tự viết[Symbol.iterator]+next(), KHÔNG dùng generator) đếm từnvề 1. Duyệt bằngfor...ofvà spread.Hoàn thành khi:
[...demNguoc(5)]ra[ 5, 4, 3, 2, 1 ]- và spread HAI lần liên tiếp vẫn đúng, vì mỗi lần gọi[Symbol.iterator]()phải trả một iterator MỚI. - 2
Viết lại bằng generator
Làm lại bài 1 bằng
function*với một vòng lặp vàyield. So số dòng hai phiên bản.Hoàn thành khi: Output y hệt bài 1; thân generator chỉ còn cỡ 2-3 dòng.
- 3
Đoán thứ tự log
Không chạy vội: với generator
phatCa()ở Bước 2, mèo con tự ghi ra giấy thứ tự 8 dòng in ra, rồi mới chạy kiểm.Hoàn thành khi: Đoán đúng cả 8 dòng - đặc biệt là KHÔNG dòng nào của thân chạy trước lần
next()đầu tiên. - 4
Đường ống của mèo con
Viết generator
binhPhuong(nguon)rồi ghép đường ống:soTuNhien()→locgiữ số chẵn →binhPhuong→take(5).Hoàn thành khi: Kết quả
[ 4, 16, 36, 64, 100 ]; đổitake(5)thànhtake(3)thì chỉ còn 3 phần tử đầu - không tính thừa phần tử nào. - 5
Trang dữ liệu trễ ngẫu nhiên
Sửa
taiTungTrang()ở Bước 4: mỗi trang trễ ngẫu nhiên 100-500ms (dùngMath.random()), thêm trang thứ 4 tuỳ ý, chạy vài lần.Hoàn thành khi: Các trang LUÔN in đúng thứ tự 1 → 4 dù độ trễ mỗi lần mỗi khác -
for awaitchờ xong từng promise rồi mới hỏi miếng kế. - 6
Đếm dòng một file thật
Dùng mẫu
readlineở Bước 5 đếm số dòng của một file thật trên máy mèo con (vd chính file script đang viết), in ra tổng.Hoàn thành khi: Con số khớp với số dòng hiển thị trong editor khi mở file đó.