Giao thức iterator
Rất nhiều tính năng của JavaScript như for...of, spread (...), destructuring, Array.from, Promise.all đều dựa trên cùng một cơ chế bên dưới: giao thức iterator (iterator protocol). Khi đã hiểu nó, bạn sẽ thấy tất cả chỉ là các biến thể của cùng một ý tưởng.
Iterator là bất kỳ object nào có phương thức next() trả về { value, done }:
Gọi next() nhiều lần. Mỗi lần gọi trả về giá trị kế tiếp và một cờ done. Khi done là true, dãy giá trị kết thúc. Toàn bộ giao thức chỉ có vậy: một phương thức và một giá trị boolean.
Iterable và iterator
Có một khái niệm thứ hai liên quan. Iterable là bất cứ thứ gì biết cách tạo ra một iterator. Nó làm điều đó thông qua một phương thức được lưu dưới một khóa đặc biệt: Symbol.iterator.
Mảng là iterable. Gọi numbers[Symbol.iterator]() trả về một iterator mới. Chuỗi, Map, Set và arguments cũng đều là iterable, và đó là lý do for...of dùng được với tất cả chúng.
Sự phân tách này quan trọng: iterable là tập hợp dữ liệu, iterator là con trỏ duyệt. Bạn có thể yêu cầu một iterable tạo ra bao nhiêu con trỏ độc lập tùy thích.
Vì sao for...of hoạt động
for...of chỉ là cú pháp rút gọn của giao thức iterator. Bên dưới, nó gọi Symbol.iterator, rồi gọi next() cho đến khi done là true:
Spread và destructuring cũng làm y như vậy: chúng duyệt một iterator cho đến khi hết:
Bất kỳ object nào bạn tạo ra có cài đặt Symbol.iterator đều tự động dùng được với tất cả các tính năng này.
Tự viết một iterable
Hãy tạo một object range sinh ra các số từ start đến end:
Vài điểm cần chú ý:
[Symbol.iterator]()dùng tên phương thức được tính toán (computed method name). Khóa ở đây chính là symbol, không phải chuỗi"Symbol.iterator".- Mỗi lần gọi
[Symbol.iterator]()trả về một iterator hoàn toàn mới vớicurrentriêng. Nhờ vậy bạn có thể lặp quarangehai lần mà nó không bị "dùng hết". - Iterator được trả về chỉ cần có
next(). Vậy là đủ.
Cách này chạy được, nhưng dài dòng. Có một cách tốt hơn nhiều.
Generator xuất hiện
Hàm generator (generator function) được khai báo bằng function* (chú ý dấu sao). Thay vì chạy một mạch đến hết, nó có thể tạm dừng ở biểu thức yield và tiếp tục sau. Gọi hàm này không thực thi thân hàm mà trả về một object generator, vừa là iterator vừa là iterable:
Mỗi lần next() chạy thân hàm cho đến khi gặp yield, tạm dừng và trả về { value, done: false }. Khi hàm chạy xong, bạn nhận được { value: undefined, done: true }.
Và vì generator là iterable, nó dùng được với mọi thứ ở phần trước:
Viết lại range bằng generator
So sánh phiên bản dài dòng ở trên với phiên bản này:
Chỉ vậy thôi. Dấu * phía trước [Symbol.iterator] biến nó thành một phương thức generator. yield i thay thế toàn bộ object iterator viết tay. Không next, không done, không lo lệch một đơn vị: chỉ là một vòng lặp bình thường với yield thay cho push.
Đây là lý do generator tồn tại. Chúng biến việc "viết một iterator" thành "viết một hàm có yield".
yield và return
yield tạm dừng; return kết thúc. Bạn có thể yield bao nhiêu lần tùy ý, generator sẽ tiếp tục từ chỗ đã dừng:
Một return bên trong generator xuất hiện dưới dạng { value: "done", done: true } ở lần gọi kết thúc nó. for...of và spread bỏ qua giá trị trả về đó: chúng chỉ lấy các phần tử có done là false. Vì vậy đừng dùng return value để lén đưa phần tử cuối vào vòng lặp; nó sẽ bị bỏ qua.
Dãy lười và dãy vô hạn
Generator sinh giá trị khi được yêu cầu, mỗi lần một giá trị. Nghĩa là bạn có thể biểu diễn những dãy không thể nào lưu bằng mảng:
Vòng lặp đúng là while (true), vậy mà chương trình vẫn kết thúc, vì generator chỉ chạy tiếp khi có thứ gì đó yêu cầu giá trị kế tiếp. Bạn có thể lấy N phần tử đầu tiên rồi dừng, phần còn lại sẽ không bao giờ chạy:
Bản thân take cũng là một generator bọc lấy một generator khác. Ghép các generator theo cách này là một phần lớn sức hấp dẫn của chúng: những mảnh nhỏ, mỗi mảnh làm một việc.
Ủy quyền với yield*
Nếu một generator cần yield toàn bộ phần tử của một iterable khác, yield* sẽ ủy quyền cho iterable đó:
yield* dùng được với mọi iterable như mảng, set, generator khác, và chuyển tiếp từng phần tử một. Nó là phiên bản iterator của phép spread.
Sơ lược về async generator
Một generator khai báo bằng async function* có thể yield những giá trị cần thời gian để có, rất hữu ích khi stream dữ liệu từ một API hoặc đọc từng phần của một tệp. Bạn dùng nó với for await...of:
async function* paginate(url) {
let next = url;
while (next) {
const res = await fetch(next);
const page = await res.json();
for (const item of page.items) yield item;
next = page.nextUrl;
}
}
for await (const item of paginate("/api/users")) {
console.log(item);
}
Đoạn code này không chạy được ở đây (nó cần một endpoint thật), nhưng bạn nên biết cấu trúc này tồn tại. Khi đã hiểu generator thông thường, async generator chỉ là cùng ý tưởng đó có thêm await.
Khi nào nên dùng generator
Hãy dùng generator khi:
- Dãy giá trị là vô hạn hoặc có thể vô hạn: ID, mốc thời gian, độ trễ giữa các lần thử lại.
- Sinh ra mọi giá trị rất tốn kém và nơi dùng có thể dừng sớm.
- Bạn cài đặt
Symbol.iteratorcho một object tùy chỉnh. Cách này gần như luôn ngắn hơn viết tay object{ next() }. - Bạn muốn ghép các phép biến đổi dạng stream (
take,filter,map) mà không tạo mảng trung gian.
Hãy dùng mảng thường khi dữ liệu đã có sẵn trong bộ nhớ và nhỏ. Generator không miễn phí: cơ chế tạm dừng và tiếp tục một hàm có chi phí riêng, và stack trace đi qua code generator có thể khó đọc hơn.
Tiếp theo: symbol
Symbol.iterator là symbol đầu tiên mà phần lớn mọi người gặp, nhưng còn rất nhiều symbol khác. Symbol là một kiểu nguyên thủy được thiết kế chính cho loại điểm mở rộng này: những khóa duy nhất cho phép ngôn ngữ và code của bạn gắn vào object mà không đụng độ với tên thuộc tính thông thường. Đó là nội dung của trang tiếp theo.
Câu hỏi thường gặp
Iterable và iterator trong JavaScript khác nhau thế nào?
Iterable là bất kỳ object nào có phương thức Symbol.iterator trả về một iterator. Iterator là object thật sự sinh ra các giá trị: nó có phương thức next() trả về { value, done }. Mảng, chuỗi, Map và Set là iterable; gọi phương thức Symbol.iterator của chúng sẽ cho bạn một iterator để duyệt từng bước.
Hàm generator trong JavaScript là gì?
Là hàm được khai báo bằng function*, sinh ra giá trị theo kiểu lười (lazy) bằng yield. Gọi hàm này không chạy thân hàm mà trả về một object generator, vừa là iterator vừa là iterable. Mỗi lần gọi next() sẽ chạy đến yield kế tiếp, tạm dừng và trả về giá trị được yield.
yield và return trong generator khác nhau thế nào?
yield tạm dừng generator và trả về một giá trị, nhưng hàm có thể tiếp tục từ chỗ đã dừng ở lần gọi next() tiếp theo. return kết thúc generator hẳn: nó đặt done: true và không còn giá trị nào được sinh ra nữa. Bạn có thể yield nhiều lần; return chỉ có ý nghĩa một lần.
Khi nào nên dùng generator thay vì mảng?
Khi dãy giá trị là vô hạn, tốn kém để tính, hoặc bạn chỉ cần một phần giá trị. Generator sinh từng phần tử một khi được yêu cầu, nên bạn có thể biểu diễn một dòng ID vô tận hay kết quả API phân trang mà không phải tạo sẵn mọi thứ ngay từ đầu. Nếu bạn đã có sẵn một mảng nhỏ cố định, cứ dùng mảng.