Câu 1Nếu bạn gọi countDownload() hai lần rồi hiển thị total ở tầng ngoài cùng, điều gì xảy ra?
Closure — Hàm ghi nhớ biến bên ngoài
Closure là hàm ghi nhớ biến của scope bên ngoài. Kèm bộ đếm giữ giá trị qua các lần gọi, function factory và hàm được tạo trong vòng lặp.
Đôi khi bạn muốn một hàm giữ một giá trị tăng lên sau mỗi lần gọi, chẳng hạn số lượt xem của một trang. Nhưng nếu đếm bằng một biến thông thường thì hoặc giá trị không được giữ lại, hoặc dòng nào cũng ghi đè được nó.
Bài này nói về closure — hàm ghi nhớ biến của scope bên ngoài — và function factory — hàm trả về một hàm đã gắn sẵn giá trị cấu hình.
Giữ giá trị qua các lần gọi — Cơ bản về closure
Giả sử bạn muốn đếm số lượt tải một tài liệu. Nếu khai báo let bên trong hàm đếm, mỗi lần gọi giá trị lại quay về 0. Nếu khai báo ở tầng ngoài cùng, giá trị được giữ lại, nhưng lúc đó dòng nào cũng ghi đè được nó.
Với closure (hàm ghi nhớ biến của scope bên ngoài), bạn có thể giữ giá trị qua các lần gọi mà vẫn ngăn các dòng bên ngoài ghi đè nó. Nếu khai báo một biến bên trong một hàm rồi return một hàm đọc và ghi biến đó, hàm được trả về sẽ mang theo biến đó ra bên ngoài.
function makeDownloadCounter() {
let total = 0; // Biến chỉ tồn tại bên trong makeDownloadCounter
const countUp = () => {
total = total + 1; // Cập nhật total ở bên ngoài
return total;
};
return countUp; // Trả về chính hàm đó
}
const countDownload = makeDownloadCounter(); // Tạo một bộ đếm
console.log(countDownload()); // 1
console.log(countDownload()); // 2
console.log(countDownload()); // 3 (tiếp nối từ giá trị trước)
// console.log(total); // ReferenceError: total is not defined
const countDownload = makeDownloadCounter()— chứa hàm được trả vềconsole.log(total)— không thấy tên này nên báoReferenceError
let total = 0— gọicountDownload()không đưa nó về 0return countUp— trả về chính hàm, không phải một giá trị
total = total + 1— cập nhật total ở bên ngoài- Vẫn đọc và ghi được total này sau khi hàm bên ngoài đã chạy xong
countUp được trả về tầng ngoài cùng và vẫn ghi nhớ total ở bên ngoài nó. Dù hàm đã chạy xong, biến được ghi nhớ vẫn còn đó.Hàm có tên countUp ở bên trong được gọi bằng tên countDownload ở bên ngoài. Thứ mà return trả về là chính hàm đó, nên bạn có thể đặt cho biến nhận nó bất kỳ tên nào hợp với chỗ dùng. Bảng dưới đây so sánh các vị trí đặt giá trị đếm: giá trị có được giữ lại giữa các lần gọi không, và bên ngoài có ghi đè được không.
| Chỗ đặt giá trị đếm | Giá trị giữa các lần gọi | Dòng bên ngoài ghi đè được không? |
|---|---|---|
| Khai báo let bên trong hàm đếm | Mất khi lần gọi kết thúc; lần sau bắt đầu lại từ 0 | Không, vì không thấy tên |
| Khai báo let ở tầng ngoài cùng | Được giữ lại | Có, từ bất kỳ dòng nào |
| Khai báo let trong hàm bên ngoài và trả về hàm bên trong | Mỗi lần gọi hàm được trả về, tiếp nối từ giá trị trước | Chỉ thay đổi qua hàm được trả về |
Tạo hàm gắn sẵn cấu hình — Function factory
Giả sử tỷ lệ tích điểm khác nhau theo hạng thành viên. Nếu lần tính nào cũng truyền tỷ lệ qua đối số, bên gọi có thể nhầm hạng, và bạn phải viết đi viết lại cùng một tỷ lệ. Tốt hơn là đặt tỷ lệ một lần từ trước.
Với function factory (hàm nhận một giá trị cấu hình và trả về một hàm ghi nhớ giá trị đó), bạn chỉ cần truyền tỷ lệ một lần. Closure cũng ghi nhớ cả đối số, nên mỗi hàm được trả về luôn đọc đúng rate đã truyền vào lúc tạo ra nó.
function makePointCalculator(rate) {
// rate là đối số. Hàm được trả về tiếp tục ghi nhớ rate này
return (amount) => Math.floor(amount * rate);
}
const regularPoint = makePointCalculator(0.01); // Thành viên thường được 1%
const goldPoint = makePointCalculator(0.05); // Thành viên Gold được 5%
const platinumPoint = makePointCalculator(0.1); // Thành viên Platinum được 10%
console.log(regularPoint(3000)); // 30
console.log(goldPoint(3000)); // 150
console.log(platinumPoint(3000)); // 300
// Không cần biến, tính ngay bằng 2 cặp ngoặc tròn liên tiếp
console.log(makePointCalculator(0.05)(5000)); // 250
rate mà mỗi hàm ghi nhớ. Mỗi hàm luôn đọc đúng rate bạn đã truyền vào, trừ khi có gì đó gán lại nó.Trong makePointCalculator(0.05)(5000), cặp ngoặc thứ nhất tạo ra một hàm ghi nhớ tỷ lệ, còn cặp ngoặc thứ hai truyền số tiền vào và tính ngay tại chỗ. Nếu chỉ có một cặp ngoặc, thứ được trả về là một hàm, không phải con số đã tính.
Mỗi lần tạo là một biến mới — Factory và let
Giả sử bạn muốn trừ dần số tồn kho còn lại của từng sản phẩm. Nếu dùng chung một biến để trừ, đặt trước áo sơ mi cũng sẽ làm giảm luôn số mũ còn lại. Mỗi sản phẩm cần có giá trị riêng.
Giống như makeDownloadCounter ở đầu bài, nếu khai báo let bên trong một hàm trả về hàm, biến đó sẽ được tạo mới mỗi lần bạn gọi hàm bên ngoài. Các hàm được trả về không dùng chung biến, nên dù được tạo từ cùng một định nghĩa, mỗi hàm chỉ đọc và ghi biến của riêng nó.
function makeStockReserver(stock) {
let rest = stock; // Được tạo mới ở mỗi lần gọi
return (count) => {
rest = rest - count;
return rest;
};
}
const reserveShirt = makeStockReserver(10);
const reserveCap = makeStockReserver(4);
console.log(reserveShirt(3)); // 7
console.log(reserveCap(1)); // 3
console.log(reserveShirt(2)); // 5 (chỉ tồn kho áo sơ mi giảm)
console.log(reserveCap(1)); // 2
rest của riêng nó. Giảm bên này không ảnh hưởng tới số tồn kho của bên kia.reserveShirt(2) trong console.log thứ ba chỉ giảm tồn kho áo sơ mi từ 7 xuống 5, còn phía mũ vẫn là 3. Lời gọi reserveCap(1) tiếp theo trả về 2, cho thấy hàm của mũ cũng giữ số đếm của riêng nó.
Gọi factory mỗi lần là bắt đầu lại từ đầu
Nếu viết makeStockReserver(10)(3) hai lần, cả hai đều trả về 7. Đó là vì mỗi lần gọi tạo ra một rest riêng bắt đầu từ 10. Muốn số tồn kho đã giảm được giữ cho lần đặt tiếp theo, hãy lưu hàm được trả về vào một biến như reserveShirt, rồi gọi biến đó.
Tạo hàm trong vòng lặp — Mỗi vòng một biến let riêng
Giả sử bạn muốn chuẩn bị sẵn một loạt hàm ghi log, mỗi hàm cho một mức log. Nếu tạo các hàm trong vòng lặp rồi lưu vào một mảng, bạn sẽ gọi chúng sau khi vòng lặp đã kết thúc. Khi đó, mỗi hàm ghi nhớ giá trị của vòng nào sẽ phụ thuộc vào cách bạn khai báo biến vòng lặp.
Khóa học này không dùng var, nhưng đặt nó cạnh let sẽ thấy rõ sự khác biệt. Khi dùng let trong ngoặc tròn của for, mỗi vòng lặp tạo ra một biến mới, và hàm được tạo trong vòng đó sẽ ghi nhớ biến ấy. var thì dùng chung một biến duy nhất cho mọi vòng, nên hàm nào cũng đọc giá trị còn lại sau vòng lặp.
const levels = ["info", "warn", "error"];
// var dùng chung một biến cho mọi vòng
const varLoggers = [];
for (var i = 0; i < levels.length; i++) {
varLoggers.push(() => `[${levels[i]}]`);
}
console.log(varLoggers[0]()); // [undefined]
// let tạo một biến mới cho mỗi vòng
const letLoggers = [];
for (let j = 0; j < levels.length; j++) {
letLoggers.push(() => `[${levels[j]}]`);
}
console.log(letLoggers[0]()); // [info]
console.log(letLoggers[2]()); // [error]
var, cả ba hàm đọc cùng một biến; ở phía let, mỗi hàm đọc giá trị của vòng lặp của riêng nó. Hàm ghi nhớ gì phụ thuộc vào việc mỗi vòng có tạo biến mới hay không.Phía var cho ra [undefined] vì khi thoát vòng lặp, i là 3, mà levels[3] không tồn tại. Ở phía let, levels[j] cũng được đọc lúc gọi hàm, nhưng j mà hàm đọc là biến được tạo riêng cho vòng đó, nên giá trị của từng vòng vẫn còn nguyên.
Closure không ghi nhớ giá trị tại lúc tạo
Closure ghi nhớ chính cái biến, và đọc giá trị mà biến đang giữ vào lúc hàm được gọi. Nếu bạn tạo một hàm đọc let rate = 0.05; rồi sau đó gán lại rate = 0.1;, hàm đó cũng sẽ tính với 0.1. Với giá trị cấu hình muốn cố định, hãy truyền nó làm đối số cho factory, như makePointCalculator(0.05).
Kiểm tra kiến thức
Hãy trả lời từng câu hỏi một.
Câu 2reserveCap bắt đầu với 4 trong kho. Sau khi reserveShirt trừ đi 3, bạn truyền 1 cho reserveCap. Hàm trả về gì?
Câu 3Nếu gọi một hàm được tạo bên trong for (var i = 0; ...) sau khi vòng lặp đã kết thúc, i sẽ có giá trị gì?