Một trong những từ khóa "buzzword" lớn nhất trong ngôn ngữ JavaScript là closure (đóng gói). Nó là chủ đề của nhiều câu hỏi phỏng vấn tại các công ty FAANG. Trong bài viết này, chúng ta sẽ nói về closure và scope, minh họa các khái niệm với các ví dụ đơn giản, và kết thúc với một câu hỏi mẫu từ phỏng vấn của một trong những hãng công nghệ lớn.
Scope (Phạm vi)
Khi ai đó nói cho bạn biết điều gì đó là hoặc không nằm trong phạm vi (scope) của một dự án, điều đó có nghĩa là gì?
Tôi thích nghĩ đến ống nhìn xa (periscope) hoặc kính thiên văn (telescope) khi nghĩ về câu trả lời cho câu hỏi này. Các dụng cụ này cho chúng ta thấy mọi thứ trong giới hạn của ống kính: nó ở trong phạm vi. Nếu nó ở ngoài phạm vi, bạn không thể nhìn thấy qua đường kính của ống kính. Và chiếu ánh sáng vào thứ gì đó bên ngoài đường kính là không thể. Bạn nên nghĩ về điều này khi chúng ta nói về ba loại scope rất quan trọng và riêng biệt trong JavaScript: local (cục bộ), global (toàn cục) và lexical (từ vựng/ngữ cảnh).
Local Scope (Phạm vi cục bộ)
Local Scope là nhỏ nhất trong ba scopes chúng ta sẽ nói hôm nay. Khi chúng ta khai báo một hàm, bất cứ thứ gì bên trong dấu ngoặc nhọn ({}) được coi là cục bộ đối với hàm đó. Khi JavaScript engine đọc hàm, nó sẽ khai báo các biến; khi nó kết thúc, nó sẽ hủy các biến.
function greeting() {
var websiteName = 'Career Karma';
return `Hello ${websiteName}`;
}
console.log(greeting()); // Hello Career Karma
console.log(websiteName); // ReferenceError: websiteName is not defined
Như bạn thấy, khi chúng ta "console.log()" kết quả của hàm greeting được gọi, chúng ta có thể truy cập websiteName sau khi hàm đã thực thi. Điều này cho chúng ta chuỗi 'Hello Career Karma' mà chúng ta đang tìm. console.log() của biến được khai báo bên trong hàm ném ra lỗi vì nó không được định nghĩa.
Như đã đề cập, lý do websiteName là undefined là vì các biến được tạo bên trong hàm khi chúng được gọi và sau đó bị hủy khi câu lệnh terminal chạy. Bất cứ thứ gì bên ngoài hàm không có quyền truy cập vào thứ gì đó bên trong hàm trừ khi nó có thiết lập đặc biệt.
Global Scope (Phạm vi toàn cục)
Scope tiếp theo này về cơ bản là bản dịch đen của cụm từ. Một global scope lấy các mục được khai báo bên ngoài hàm và dành chỗ cho chúng trong một không gian nơi tất cả các scripts, methods và functions có thể truy cập và sử dụng chúng cho logic của chúng.
let counter = 0; // global -- khai báo bên ngoài hàm
const add = () => {
// khai báo hàm
let counter = 0; // local -- khai báo bên trong hàm
counter += 1; // counter tăng 1 -- biến counter nào tăng?
return counter;
}
add(); // gọi add()
add(); // ba lần
add();
console.log(counter) // này là 3 hay 0? Tại sao?
Mã trên làm gì nếu chúng ta console.log() counter ở cuối mã? Bạn kỳ vọng điều gì sẽ xảy ra?
Hãy đi qua mã:
- Biến counter được khai báo và khởi tạo trong môi trường global.
- Hàm add được khai báo trong môi trường global.
- Add được gọi.
- Biến counter được khai báo và khởi tạo trong môi trường local.
- Counter local tăng 1 ← tại sao là local chứ không phải global?
- Counter được trả về. Hàm kết thúc.
- Add được gọi lại
- Lặp lại bước 4 đến 6.
- Lặp lại bước 3 đến 6.
console.log(counter); ← Điều gì được trả về?
Vì hàm kết thúc khi counter ở 1 mỗi lần, biến counter local của chúng ta được khai báo lại và khởi tạo lại ở 0 mỗi lần hàm chạy. Dù xảy ra gì, counter sẽ luôn dừng ở 1 ở mức local.
Nếu một hàm tìm thấy một biến trong phạm vi của nó, nó không nhìn ra global scope để tìm biến – vì vậy biến global không bao giờ thay đổi. Vì vậy, console.log() của chúng ta sẽ xuất ra 0 như biến được định nghĩa gần nhất trong môi trường của câu lệnh đó là trong môi trường global.
Lexical Scope (Phạm vi từ vựng/ngữ cảnh)
Lexical scope là một trong những khái niệm cơ bản nhất trong JavaScript. Nó là ý tưởng rằng việc tạo một hàm hoặc một biến sẽ có thể truy cập được đối với các phần nhất định của mã và sau đó không thể truy cập được đối với các phần khác. Tất cả phụ thuộc vào vị trí khai báo của từng biến và hàm.
Hãy xem khối mã này:
const init = () => {
// <== Đây là hàm bên ngoài (outer function)
const var1 = 'Career'; // outer scope
const second = () => {
// <== Đây là hàm bên trong (inner function)
const var2 = 'Karma'; // inner scope
console.log(var1); // Career
console.log(var2); // Karma
return var1 + " " + var2;
};
// console.log(var2); // undefined
return second();
};
init();
Ở đây chúng ta có một tập hợp các hàm lồng nhau (nested functions). Hàm init() khai báo một biến tên var1, khai báo một hàm tên second và gọi second().
Khi compiler đi qua mã lần đầu, nó nhìn tổng quan:
- Hàm
init() - Gọi
init()
Lúc này, chúng ta không thể thấy bất cứ thứ gì khác bên trong hàm init() – chúng ta chỉ biết hàm tồn tại. Khi hàm init() được gọi, compiler nhìn tổng quan thứ hai vào bên trong hàm:
var1- Hàm
second() - Gọi
second()
Hàm init() không biết bất cứ điều gì về những gì đang xảy ra bên trong khối second(). Nó chỉ có thể thấy những gì trong lexical environment (môi trường từ vựng) của nó – trạng thái bao quanh của nó.
Mỗi hàm lồng nhau nằm trong một container nhỏ hơn, giống như những bộ búp bê Matryoshka lồng nhau của Nga (xem đầu trang nếu bạn không chắc chúng là gì). Các búp bê chỉ biết về những gì đang xảy ra bên trong container của chúng và những gì đã xảy ra hoặc được khai báo/đọc trong parent. Búp bê lớn nhất ví dụ chỉ biết búp bê tiếp theo trong container của nó tồn tại. Nó không biết về bất kỳ búp bê nào khác trong bộ, chỉ những gì trong lexical environment (trạng thái của nó) và những gì đã xảy ra (outer scope).
Về cơ bản, chúng ta biết hai điều:
- Outer scope (phạm vi ngoài) không thể thấy inner scope (phạm vi trong).
- Inner scope có quyền truy cập vào outer scope.
Vì outer scope không thể thấy những gì đang diễn ra trong inner scope, chúng ta có thể an toàn nói đây là mối quan hệ một chiều. Inner có thể nhìn thấy và sử dụng biến từ outer scope, nhưng outer không thể thấy inner. Điều này được gọi là lexical scope.
Vẻ đẹp của lexical scoping là giá trị của một biến được xác định bởi vị trí của nó trong mã. Các hàm tìm kiếm ý nghĩa của một biến bên trong môi trường local của nó trước – nếu không tìm thấy, nó chuyển đến hàm đã định nghĩa hàm đó. Nếu không tìm thấy ở đó, nó di chuyển lên chuỗi đến hàm được định nghĩa tiếp theo.
Đây trở thành một khái niệm rất quan trọng trong JavaScript sẽ xuất hiện lại và lại khi bạn tìm hiểu thêm về các JavaScript frameworks và cách chúng hoạt động. Bạn có thể truyền xuống từ outer, nhưng bạn không bao giờ có thể truyền "lên" theo hướng ngược lại. Điều này rất quan trọng khi chúng ta đến chủ đề chính: closure.
Closure (Đóng gói)
Định nghĩa của Closure rất giống với lexical scope. Sự khác biệt chính giữa hai cái là closure là một higher-order function (hàm bậc cao) và lexical scoping không phải. Một higher-order function có một đặc điểm cơ bản: nó hoặc trả về một hàm hoặc sử dụng một hàm làm tham số.
Closure là một hàm có thể truy cập lexical scope của nó, ngay cả khi hàm đó được gọi sau này.
Cả closure và lexical scope đều có biến riêng, có thể truy cập biến và tham số của hàm cha, và có thể sử dụng biến global. Hãy đi qua mã sau:
function greeting() {
// outer scope (parent function)
const userName = "CrrKrma1952"; // parent variable
function welcomeGreeting() {
// inner function
console.log("Hello, " + userName); // truy cập biến cha
return "Hello, " + userName; // terminal statement
}
return welcomeGreeting; // trả về một hàm (làm cho nó HOF)
}
// end of greeting()
const greetUser = greeting();
// greetUser(); // Hello, CrrKrma1952
- Hàm
greeting()tồn tại, nhưng chúng ta chưa biết nội dung. greetUsertồn tại, nhưng chưa biết nội dung.greetUser()– điều này gọi dòng trước, lần lượt gọi hàm greeting().- userName được khai báo.
- Hàm
welcomeGreeting()tồn tại, nhưng chưa biết nội dung. - Câu lệnh return bên dưới khối
welcomeGreeting()trả về chính hàm đó. console.log('Hello, ' + userName); console.log ở đây có thể truy cập parent scope để lấy giá trị userName.- Câu lệnh terminal kết thúc hàm và hủy ý nghĩa của các biến bên trong khối mã.
Trong mã này, chúng ta đang truyền thông tin bằng cách lồng các hàm với nhau để parent scope có thể được truy cập sau này.
Kết luận
Trong bài viết này, chúng ta đã nói về một chủ đề JavaScript khá nặng: Scope và Closures. Tôi khuyên bạn nên mở rộng và đọc một vài bài viết khác về chủ đề này. Cách dạy điều này có thể đến từ nhiều góc nhìn – điều đó có nghĩa là, tất nhiên, có nhiều cách để học nó. Tôi hy vọng bài hướng dẫn này hữu ích cho bạn! Chúc may mắn trong việc tiếp tục học về closures!