Học bằng cách đọc theo thứ tự

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
Biến mà hàm được trả về ghi nhớ
Tầng ngoài cùng (không nằm trong ngoặc nhọn nào)
  • const countDownload = makeDownloadCounter() — chứa hàm được trả về
  • console.log(total) — không thấy tên này nên báo ReferenceError
Ngoặc nhọn của makeDownloadCounter
  • let total = 0 — gọi countDownload() không đưa nó về 0
  • return countUp — trả về chính hàm, không phải một giá trị
Ngoặc nhọn của countUp
  • 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ị đếmGiá trị giữa các lần gọiDòng bên ngoài ghi đè được không?
Khai báo let bên trong hàm đếmMất khi lần gọi kết thúc; lần sau bắt đầu lại từ 0Không, vì không thấy tên
Khai báo let ở tầng ngoài cùngĐược giữ lạiCó, 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 trongMỗi lần gọi hàm được trả về, tiếp nối từ giá trị trướcChỉ thay đổi qua hàm được trả về

Gói xem thử video miễn phí cho phép phát tối đa 3 lượt. Bạn tự khai báo các biến.

① Viết makeTrialCounter, hàm trả về một hàm bắt đầu với 3 lượt còn lại, mỗi lần gọi trừ đi 1 và trả về số lượt còn lại. Khi còn 0 lượt thì không trừ nữa.

② Tạo một hàm xem thử bằng makeTrialCounter.

③ Gọi hàm ở ② bốn lần và hiển thị từng kết quả theo dạng "Còn N lượt".

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Ba hàm từ một định nghĩa
Một định nghĩa:makePointCalculatorTruyền (0.01)→ regularPointTruyền (0.05)→ goldPointTruyền (0.1)→ platinumPointGhi nhớrate là 0.01Ghi nhớrate là 0.05Ghi nhớrate là 0.13000 yêntrả về 303000 yêntrả về 1503000 yêntrả về 300
Thân hàm nào cũng là cùng một biểu thức; khác biệt duy nhất là 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.

Cửa hàng chính và chi nhánh có mức miễn phí vận chuyển và phí vận chuyển khác nhau. cartTotals đã được khai báo sẵn.

① Viết makeShippingFee, hàm nhận mức miễn phí vận chuyển và phí vận chuyển, rồi trả về một hàm nhận tổng tiền giỏ hàng và trả về phí vận chuyển.

② Tạo hàm cho cửa hàng chính (miễn phí từ 4000 yên trở lên, nếu không thì 500 yên) và cho chi nhánh (miễn phí từ 3000 yên trở lên, nếu không thì 350 yên).

③ Cho từng số tiền trong cartTotals chạy qua cả hai hàm, và hiển thị "N yên → Cửa hàng chính N yên / Chi nhánh N yên".

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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
Mỗi lần gọi tạo ra một biến mới
makeStockReserver(10) chạyTạo một biếnrest = 10makeStockReserver(4) chạyTạo thêm mộtrest = 4 riêngreserveShirt(3) vàreserveCap(1)Trả về 7 và 3,độc lập nhau
Dù từ cùng một định nghĩa, mỗi hàm có 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 đó.

Cấp ID theo số thứ tự cho đơn hàng và đơn trả hàng, mỗi loại đánh số riêng. Bạn tự khai báo các biến.

① Viết makeIdIssuer, hàm nhận một tiền tố và trả về một hàm mà mỗi lần gọi sẽ trả về ID tiếp theo — "tiền tố-1", "tiền tố-2", v.v.

② Tạo một hàm cấp ID cho ORD và một hàm cho RTN.

③ Cấp 2 ID đơn hàng và 1 ID trả hàng, hiển thị từng ID.

④ Cấp thêm một ID đơn hàng, hiển thị nó, và kiểm tra xem số thứ tự có tiếp nối từ lần trước không.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

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]
Hàm ghi nhớ biến vòng lặp rốt cuộc đọc giá trị nào
var trong ngoặctròn của forMột biến chungcho mọi vòngCả 3 hàm đọccùng một iTrả về[undefined]let trong ngoặctròn của forMỗi vòng mộtbiến mớiMỗi hàm ghi nhớ0, 1 hoặc 2Trả về[info]
Ở phía 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).

Tạo cho mỗi phương thức thanh toán một hàm hiển thị thông báo xác nhận. methods, labelsnotifiers đã được khai báo sẵn.

① Dùng let trong ngoặc tròn của for để lặp qua methods.

② Ở mỗi vòng, lấy tên phương thức thanh toán làm key và đưa vào notifiers một hàm nhận mã đơn hàng rồi hiển thị thông báo kiểu "[Chuyển khoản] Đã nhận đơn A-1002".

③ Sau vòng lặp, truyền A-1002 cho bank và A-1003 cho cod.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra
QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

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?

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ì?