Bạn nên biết Closure hoạt động như thế nào
Closure là một khái niệm cơ bản trong javascript mà mọi lập trình viên nên biết. Google search là một nhà thông thái với những lời giải thích tuyệt vời về closure là cái gì, nhưng chỉ một chút đi sâu vào khía cạnh "why" của vấn đề. Tôi nhận ra rằng, sự hiểu biết cặn kẽ vấn đề giúp các developer ...
Closure là một khái niệm cơ bản trong javascript mà mọi lập trình viên nên biết. Google search là một nhà thông thái với những lời giải thích tuyệt vời về closure là cái gì, nhưng chỉ một chút đi sâu vào khía cạnh "why" của vấn đề. Tôi nhận ra rằng, sự hiểu biết cặn kẽ vấn đề giúp các developer làm chủ được các công cụ (tool) của họ một cách tốt hơn. Bài viết này dành riêng cho việc giải thích cách làm việc của closure. Tôi hi vọng sau bài viết này bạn có thể sử dụng những lợi thế của closure trong công việc hàng ngày. Bắt đầu nào !!!
Closure là một thuộc tính cực mạnh của javascipt và hầu hết các ngôn ngữ lập trình khác. Theo định nghĩa trên MDN:
Closure là những function tham chiếu đến các biến tự do (free avariable) tách biệt. Nói cách khác, function được định nghĩa trong closure sẽ ghi nhớ môi trường (environment) trong nó được tạo ra.
Chú ý: Các biến tự do không phải là các biến được khai báo cục bộ (local variable) hoặc được truyền vào như tham số (parameter)
Hãy xem một vài ví dụ sau đây:
Ví dụ 1:
function numberGenerator() { // Local free variable that ends up within the closure var num = 1; function checkNumber() { console.log(num); } num++; return checkNumber; } var number = numberGenerator(); number(); // 2
Trong ví dụ trên, hàm numberGenerator tạo ra một biến tự do cục bộ là num và hàm checkNumber in ra giá trị của biến num bằng console. Hàm checkNumber không có bất kỳ biến cục bộ nào, tuy nhiên nó có thể truy cập vào các biến trong các hàm phía bên ngoài như hàm numberGenerator bởi vì có closure. Vì vậy nó có thể sử dụng biến num được khai báo trong hàm numberGenerator để in ra log thậm chí sau khi hàm numberGenerator đã trả về (return).
Ví dụ 2:
Trong ví dụ này, chúng ta sẽ chứng minh rằng một closure có thể chứa bất kỳ biến cục bộ nào hay tất cả các biến cục bộ được khai báo bên trong các hàm bao quanh bên ngoài.
function sayHello() { var say = function() { console.log(hello); } // Local variable that ends up within the closure var hello = 'Hello, world!'; return say; } var sayHelloClosure = sayHello(); sayHelloClosure(); // ‘Hello, world!’
Chú ý biến hello được khai báo sau khi có hàm anonymous (những hàm không được đặt tên)- nhưng hàm này vẫn có thể truy cập vào biến hello. Bởi vì biến hello đã được định nghĩa trong phạm vi (scope) của hàm sayHello vào thời điểm hàm được tạo ra trước đó, do đó biến hello trở nên sẵn dùng khi hàm anonymous được thực thi ở cuối. Có vẻ khó khiểu, tôi sẽ giải thích phạm vi (scope) là gì trong bài viết này. Muốn biết bạn có thể kéo xuống dưới.
Những ví dụ này cho thấy closure là một cái gì đó trừu tượng. Nhìn chung là: chúng ta có thể truy cập vào các biến được định nghĩa trong các hàm bên ngoài ngay cả khi các biến đã được trả về (return). Rõ ràng, có một cái gì đó xảy ra trong background cho phép các biến này vẫn có thể được truy cập ngay cả khi hàm bên ngoài đã được trả về.
Để có thể hiểu được vấn đề này, chúng ta hãy nghiên cứu một số khái niệm liên quan sau. Hãy bắt đầu với cái nhìn tổng quát trong đó một hàm được thực thi, hay còn được gọi là execution context
Execution context (ngữ cảnh thực thi) là một khái niệm trừu tượng được sử dụng bởi ECMAScript để theo dõi việc đánh giá thời gian chạy của mã (code). Nó có thể là global context (ngữ cảnh toàn cục), khi đó mã của bạn được thực thi đầu tiên hoặc khi luồng thực thi đi vào thân hàm.
Vào bất kỳ thời điểm nào, chỉ có một context được chạy. Đấy là lý do vì sao mà Javascript là single thread (đơn luồng), có nghĩa là chỉ có 1 lệnh có thể chạy ở một thời điểm. Thông thường các trình duyệt đảm bảo thực thi context bằng cách sử dụng một ngăn xếp (stack). Ngăn xếp là cấu trúc dữ liệu Last In First Out (LIFO), có nghĩa là thứ cuối cùng bạn đặt vào ngăn xếp sẽ là thứ đầu tiên bạn lấy ra. Điều này bởi vì chúng ta có thể chèn hoặc xóa các phần tử trên đỉnh của ngăn xếp. Execution context hiện tại hoặc đang chạy luôn là phần tử ở đỉnh ngăn xếp. Nó được đưa ra khỏi ngăn xếp khi chạy execution context hoàn tất và cho phép execution context tiếp theo theo trong ngăn xếp chạy.
Tuy nhiên, một execution context đang chạy không có nghĩa là nó phải kết thúc trước khi execution context khác có thể chạy. Có trường hợp execution context đang chạy bị treo và một execution context khác được chạy. Execution context bị tạm dừng có thể sau đấy sẽ được chọn để chạy lại. Bất kể khi nào một execution context bị thay thế bằng một execution context khác thì execution context thay thế này sẽ được đẩy lên đỉnh ngăn xếp và trở thành execution context hiện tại.
Để hiểu thêm về phần này ta xem ví dụ sau đây
var x = 10; function foo(a) { var b = 20; function bar(c) { var d = 30; return boop(x + a + b + c + d); } function boop(e) { return e * -1; } return bar; } var moar = foo(5); // Closure moar(15);
Khi chạy hàm foo thì hàm bar sẽ được trả về. Hàm bar sẽ gọi đến hàm boop, tại thời điểm này hàm bar sẽ bị treo và hàm boop sẽ bị đẩy lên đầu ngăn xếp (bạn hãy xem ảnh chụp màn hình bên dưới)
Khi mà boop được trả về, nó sẽ được đẩy ra khỏi ngăn xếp và bar sẽ được tiếp tục chạy.
Khi chúng ta có một loạt các execution context đang được chạy lại - thường bị tạm dừng giữa chừng và sau đó được chạy lại. Chúng ta cần một số cách để theo dõi trạng thái để chúng ta có thể quản lý thứ tự và thực thi các context. Trong trường hợp cụ thể, theo như ECMAScript spec, mỗi excution context có các component trạng thái khác nhau được sử dụng để theo dõi tiến trình trong mỗi context được thực hiện. Bao gồm các:
- Code evaluation state: bất kỳ trạng thái (state) nào cũng được thực thi, bị treo và thực thi trở lại trong mối liên quan đến execution context.
- Function: Một đối tượng hàm là execution context có giá trị (hoặc bằng null nếu context là một script hoặc module)
- Realm: Một tập các đối tượng bên trong, một môi trường toàn cục ECMAScript (global environment), tất cả các mã ECMAScript được đưa vào trong phạm vi (scope) của môi trường toàn cục đó và các tài nguyên, nguồn liên quan khác.
- Lexical enviroment: Sử dụng để giải quyết những tham chiếu định danh (identifier references) vào bên trong execution context.
- Variable environment: EnvironmentRecord của Lexical enviroment chứa những ràng buộc được tạo bới VariableStatements trong execution context.
Nếu nghe có vẻ khó hiểu, bạn đừng lo lắng. Trong tất cả các biến này, biến lexical enviroment là một trong những điều thú vị của chúng ta bởi vì nó định nghĩa một cách rõ ràng rằng nó giải quyết các identifier references bởi mã trong execution context. Bạn có thể nghĩ identifier tương tự như variable. Vì mục đích ban đầu chúng ta tìm cách thần kỳ để truy cập đến các variable (biến) ngay cả khi function hoặc context đã được trả về. Lexical enviroment là những thứ chúng ta cần đào sâu nghiên cứu.
Chú ý: Về mặt kỹ thuật, cả variable enviroment và lexical environment đều được sử để cài đặt closure. Nhưng để đơn giản, chúng ta sẽ khái quát nó thành một Environment. Để hiểu một cách chi tiết sự khác biệt giữa lexical environment và variabel evironment thì chúng ta có thể xem bài viết xuất sắc của Dr. Alex Rauschmayer.
Theo định nghĩa, một lexical environment là một kiểu định nghĩa được sử dụng để xác định mối liên hệ giữa các định danh (Identifier) với các biến (variable) cụ thể và các hàm (function) cơ bản dựa trên các cấu trúc lexical nesting của ECMAScript code. Một Lexical Environment bao gồm một Environment Record và một possibly null tham chiếu đến đối tượng lexical environment bên ngoài. Thông thường, một lexical environment được liên kết với một cấu trúc cụ thể của của ECMAScript code như một FunctionDeclaration, một BlockStatement hoặc một Catch clause của một TryStatement và một lexical environment mới được tạo ra mỗi lần code được thực thi.
Hãy phân tích cụ thể
- Được sử dụng để định nghĩa liên kết các định danh: Mục đích của các lexical là để quản lý dữ liệu (tức là các định danh) trong code. Nói cách khác nó mang ý nghĩa cho các đinh danh. Giả sử ta có một đoạn code console.log(x/10), nó là vô nghĩa khi có một biến(hoặc định danh) x mà không có thứ gì cung cấp ý nghĩa cho biến đó. Lexical environment cung cấp ý nghĩa này (hoặc liên kết) thông qua Environment Record, hãy nhìn xuống dưới đây.
- Lexical Environment bao gồm một Environment Record: Một Environment Record là một cách thú vị để nói rằng nó giữ một record của tất cả các định danh và các ràng buộc tồn tại trong Lexical Environment. Mỗi Lexical Environment có Environment Record riêng của nó.
- Cấu trúc lexical nesting: Đây là một phần khá thú vị, về cơ bản thì môi trường bên trong tham chiếu đến môi trường bên ngoài bao quanh nó và môi trường bên ngoài này cũng có môi trường bên ngoài của nó. Kết quả là, một môi trường có thể phục vụ như là môi trường bên ngoài cho nhiều môi trường bên trong. Môi trường toàn cục (global environment) là lexical environment duy nhất không có môi trường bên ngoài. Ngôn ngữ ở đây rất phức tạp, do đó chúng ta hãy sử dụng một phép ẩn dụ và nghĩ đến lexical environment như một lớp hành tây: global environment là lớp ngoài cùng của củ hành tây, mọi lớp tiếp theo bên dưới được lồng bên trong.
Tóm lại, môi trường sẽ giống như thế này trong mã giả:
LexicalEnvironment = { EnvironmentRecord: { // Identifier bindings go here }, // Reference to the outer environment outer: < > };
- Một Lexical Environment mới được tao ra mỗi khi đoạn code được thực thi: Mỗi lần một function bên ngoài bao quanh nó được gọi, một Lexical Environment mới được tạo ra. Điều này rất quan trọng, chúng ta sẽ quay lại ở cuối bài viết. Chú ý rằng: một function không phải là cách duy nhất để tạo một Lexical Environment, ta có thể dùng một khối lệnh hoặc một catch clause. Để đơn giản, trong bài viết này chúng ta chỉ tập trung vào evironment được tạo bởi function.
Nói một cách ngắn gọn, mỗi exection context có một Lexical Environment. Lexical Environment này thường chứa các biến, giá trị liên quan và cũng có giá trị tham chiếu đến giá trị bên ngoài của nó. Lexical Environment có thể là global environment, một module environment (có các ràng buộc cho các khai báo cấp cao nhất của module) hoặc một function environment (environment được tạo ra do sự gọi hàm).
Dựa vào định nghĩa trên, chúng ta biết rằng một environment có quyền truy cập vào parent’s environment (môi trường cha) và parent environment của nó có quyền truy cập vào parent environment, vv.... Tập hợp các định danh mà mỗi environment có quyền truy cập gọi là scope (phạm vi). Chúng ta có thể xếp các scope vào một chuỗi các môi trường được gọi là "scope chain".
Hãy nhìn một ví dụ về cấu trúc lồng:
var x = 10; function foo() { var y = 20; // free variable function bar() { var z = 15; // free variable return x + y + z; } return bar; }
Như bạn thấy, bar nest(lồng) trong foor. Để giúp bạn hình dung, chúng ta có thể xem sơ đồ sau: Scope chain hoặc chain of environments kết hợp với một function được lưu vào đối tượng hàm (function object) ở thời điểm nó được tạo. Nói cách khác, nó được định nghĩa statically theo vị trí bên trong mã nguồn. Điều này còn được gọi là "lexical scope". Chúng ta hãy đi nhanh hơn để hiểu sự khác biệt giữa dynamic scope và static scope, điều này sẽ làm rõ tại sao static scope(hoặc lexical scope) là cần thiết để có được closure.
Các ngôn ngữ có dynamic scope có "stack-based implementations", có nghĩa là các biến cục bộ (local variable) và các đối số (argument) được lưu trên một ngăn xếp. Do đó, trạng thái chạy của chương trình ngăn xếp sẽ xác định đến biến bạn đang đề cập đến là gì. Mặt khác, static scope là khi các biến được tham chiếu trong một ngữ cảnh được ghi vào thời điểm tạo. Nói cách khác, cấu trúc của chương trình xác định những biến bạn đang đề cập đến.
Bây giờ, bạn có thể tự hỏi là dynamic scope và static scope khác nhau như thế nào. Dưới đây là 2 ví dụ để giúp minh họa:
Ví dụ 1:
var x = 10; function foo() { var y = x + 5; return y; } function bar() { var x = 2; return foo(); } function main() { foo(); // Static scope: 15; Dynamic scope: 15 bar(); // Static scope: 15; Dynamic scope: 7 return 0; }
Chúng ta có thể nhìn thấy ở trên rằng static scope và dynamic scope trả về các giá trị khác nhau khi hàm bar được gọi. Với static scope, giá trị trả về của bar dựa trên giá trị của x tại thời điểm tạo foo. Điều này là do cấu trúc static và lexical của chương trình, do đó x là 10 và kết quả là 15. Mặt khác, dynamic scope cho ta một ngăn xếp các biến được định nghĩa theo thời gian chạy. Như vậy, x của chúng ta phụ thuộc vào phạm vi được định nghĩa tự động khi chạy. Khi chạy hàm bar, sẽ đẩy x = 2 vào đỉnh của ngăn xếp, làm foo trả lại giá trị 7.
Ví dụ 2:
var myVar = 100; function foo() { console.log(myVar); } foo(); // Static scope: 100; Dynamic scope: 100 (function () { var myVar = 50; foo(); // Static scope: 100; Dynamic scope: 50 })(); // Higher-order function (function (arg) { var myVar = 1500; arg(); // Static scope: 100; Dynamic scope: 1500 })(foo);
Tương tự, trong ví dụ dynamic scope của biến myVar sử dụng giá trị của tại nơi hàm này được gọi. Mặt khác, static scope của biến myVar được lưu trong phạm vi của 2 hàm lúc tạo ra. Như bạn thấy, dynamic scope thường làm chúng ta mơ hồ. Nhưng từ giờ chúng ta có thể làm rõ được phạm vi của free variable.
Một số điều giải thích ở trên có thể làm cho bạn nghĩ này bài viết này sai chủ đề. Nhưng tôi đang đề cập đến những thứ giúp chúng ta hiểu rõ hơn về closure:
Mọi function đều có một execution context, chứa một environment mang đến ý nghĩa cho các biến và tham chiếu đến environment cha. Một tham chiếu đến enviroment cha làm cho tất cả các biến trong phạm vi cha có thể sẵn dùng cho hàm bên trong, bất kể hàm bên trong gọi ra ngoài hay bên trong phạm vi mà chúng được tạo ra. Do đó, sự xuất hiện của closure cứ thể như là hàm nhớ được environment (hoặc scope) này bởi vì hàm tham có một chiếu đến environment và các biến được định nghĩa trong environment đó.
Trở lại ví dụ cấu trúc lồng nhau:
var x = 10; function foo() { var y = 20; // free variable function bar() { var z = 15; // free variable return x + y + z; } return bar; } var test = foo(); test(); // 45
Dựa trên những hiểu biết cơ bản của chúng ta về hoạt động của environment, chúng ta có thể nói rằng định nghĩa environment sẽ trông giống như thế này(chú ý rằng đây chỉ là mã giả):
GlobalEnvironment = { EnvironmentRecord: { // built-in identifiers Array: '<func>', Object: '<func>', // etc.. // custom identifiers x: 10 }, outer: null }; fooEnvironment = { EnvironmentRecord: { y: 20, bar: '<func>' } outer: GlobalEnvironment }; barEnvironment = { EnvironmentRecord: { z: 15 } outer: fooEnvironment };
Khi chúng ta gọi hàm test, chúng ta nhận được 45, đây là giá trị trả về từ việc gọi hàm bar (bởi vì foo trả về bar). bar có thể truy cập vào free avariable ngay cả khi hàm foo đã return bởi vì bar đã tham chiếu đến biến y thông qua outer environment của nó, đó là environment của foo. bar cũng có thể truy cập vào global variable x bởi vì foo's environment có thể truy cập vào global environment. Đây gọi là scope-chain lookup.
Quay lại cuộc thảo luận của chúng ta về dynamic scope và static scope. Cho việc cài đặt closure, chúng ta không thể sử dụng dynamic scope thông qua dynamic stack để lưu trữ các biến của chúng ta. Lý do là bởi vì khi một hàm return, các biến sẽ bị đưa ra khỏi stack và không còn nữa. Điều này là mâu thuẫn với định nghĩa ban đầu của chúng ta về closure. Điều gì sẽ xảy ra khi chúng ta lưu dữ liệu closure của parent context được lưu trong cái gọi là heap, cho phép dữ liệu tồn tại sau khi chức năng được return (tức là ngay cả sau khi execution context bị đưa ra khỏi stack).
Nghe có vẻ hay, một ý tưởng tốt. Bây giờ để chúng ta hiểu rõ hơn, chúng ta hãy nhìn vào một vài ví dụ:
Ví dụ 1:
Chúng ta có một vòng lặp, sai lầm khi chúng ta cố gắng liên kết biến đếm i trong vòng lặp for với một function trong vòng lặp for
var result = []; for (var i = 0; i < 5; i++) { result[i] = function () { console.log(i); }; } result[0](); // 5, expected 0 result[1](); // 5, expected 1 result[2](); // 5, expected 2 result[3](); // 5, expected 3 result[4](); // 5, expected 4
Trở lại những gì chúng ta vừa học, nó rất dễ dàng đế phát hiện ra sai làm ở đây. Tóm lại, ở đây environment sẽ trông như thế này khi vòng lặp kết thúc
environment: { EnvironmentRecord: { result: [...], i: 5 }, outer: null, }
Giả định sai ở đây là scope khác nhau cho cả 5 function trong mảng kết quả. Thay vào đó những gì thực sự diễn ra là environment (hoặc context/scope) là như nhau cho cả 5 function trong mảng kết quả. Do đó mỗi khi biến i được tăng lên, nó sẽ cập nhật scope - được chia sẻ bởi tất cả các function. Đó là lý do tại sao bất kỳ 1 trong 5 function đang cố truy cập i đều trả về là 5 (i bằng 5 khi thoát ra khỏi vòng lặp)
Một cách để sửa lỗi này là tạo ra một enclosing context thêm vào mỗi function để chúng có được execution context/scope riêng:
var result = []; for (var i = 0; i < 5; i++) { result[i] = (function inner(x) { // additional enclosing context return function() { console.log(x); } })(i); } result[0](); // 0, expected 0 result[1](); // 1, expected 1 result[2](); // 2, expected 2 result[3](); // 3, expected 3 result[4](); // 4, expected 4
Một cách tiếp cận mới khá thông minh là sử dụng let thay vì var, vì let là một block-scoped do đó một ràng buộc định danh mới được tạo ra cho mỗi lần lặp trong vòng for.
var result = []; for (let i = 0; i < 5; i++) { result[i] = function () { console.log(i); }; } result[0](); // 0, expected 0 result[1](); // 1, expected 1 result[2](); // 2, expected 2 result[3](); // 3, expected 3 result[4](); // 4, expected 4
Ví dụ 2:
Trong ví dụ này, mỗi khi chúng ta hiển thị lời gọi đến một function sẽ tạo ra một closure mới riêng biệt.
function iCantThinkOfAName(num, obj) { // This array variable, along with the 2 parameters passed in, // are 'captured' by the nested function 'doSomething' var array = [1, 2, 3]; function doSomething(i) { num += i; array.push(num); console.log('num: ' + num); console.log('array: ' + array); console.log('obj.value: ' + obj.value); } return doSomething; } var referenceObject = { value: 10 }; var foo = iCantThinkOfAName(2, referenceObject); // closure #1 var bar = iCantThinkOfAName(6, referenceObject); // closure #2 foo(2); /* num: 4 array: 1,2,3,4 obj.value: 10 */ bar(2); /* num: 8 array: 1,2,3,8 obj.value: 10 */ referenceObject.value++; foo(4); /* num: 8 array: 1,2,3,4,8 obj.value: 11 */ bar(4); /* num: 12 array: 1,2,3,8,12 obj.value: 11 */
Trong trường hợp này, chúng ta có thể thấy rằng mỗi lần gọi đến function iCantThinkOfAName sẽ tạo ra một closure mới, đó là foo và bar. Các lần gọi closure function tiếp theo sẽ cập nhật closure variable num trong chính bản thân của closure, điều đó có nghĩa là biến num vẫn được sử dụng bởi function doSomething sau khi iCantThinkOfAName đã return.
Ví dụ 3:
function mysteriousCalculator(a, b) { var mysteriousVariable = 3; return { add: function() { var result = a + b + mysteriousVariable; return toFixedTwoPlaces(result); }, subtract: function() { var result = a - b - mysteriousVariable; return toFixedTwoPlaces(result); } } } function toFixedTwoPlaces(value) { return value.toFixed(2); } var myCalculator = mysteriousCalculator(10.01, 2.01); myCalculator.add() // 15.02 myCalculator.subtract() // 5.00
Những gì chúng ta có thể nhìn thấy mysteriousCalculator là global scope, nó trả về 2 function. Tóm lại, environment trong ví dụ trên sẽ trông như thế này:
GlobalEnvironment = { EnvironmentRecord: { // built-in identifiers Array: '<func>', Object: '<func>', // etc... // custom identifiers mysteriousCalculator: '<func>', toFixedTwoPlaces: '<func>', }, outer: null, }; mysteriousCalculatorEnvironment = { EnvironmentRecord: { a: 10.01, b: 2.01, mysteriousVariable: 3, } outer: GlobalEnvironment, }; addEnvironment = { EnvironmentRecord: { result: 15.02 } outer: mysteriousCalculatorEnvironment, }; subtractEnvironment = { EnvironmentRecord: { result: 5.00 } outer: mysteriousCalculatorEnvironment, };
Bởi vì function add và subtract của chúng ta đều tham chiếu đến environment của function mysteriousCalculator. Chúng sử dụng các biến trong environment để tính ra kết quả.
Ví dụ 4:
Ví dụ cuối cùng để chứng minh rằng việc sử dụng closure rất quan trọng: để duy trì một tham chiếu private đến một biến ở phạm vi bên ngoài (outer scope).
function secretPassword() { var password = 'xh38sk'; return { guessPassword: function(guess) { if (guess === password) { return true; } else { return false; } } } } var passwordGame = secretPassword(); passwordGame.guessPassword('heyisthisit?'); // false passwordGame.guessPassword('xh38sk'); // true
Đây là một kỹ thuật hết sức mạnh mẽ, nó cho phép function closure guessPassword có thể truy cập vào biến password và không cho phép truy cập password từ bên ngoài
Tôi hi vọng bài viết này hữu ích và cho bạn một cách nhìn tổng quát về cách closure hoạt động trong Javascript. Như bạn thấy, sự hiểu biết những thành phần cấu thành giúp chúng ta dễ dàng hiểu được closure. Chúng ta sẽ không phải đau đầu trong những lần debug. Tôi cũng là một con người và cũng có thể mắc sai lầm, nếu bạn thấy bất kỳ điểm sai nào trong bài viết của tôi xin hãy cho tôi biết. Bài viết này được dịch lại từ nguồn: https://medium.freecodecamp.org/lets-learn-javascript-closures-66feb44f6a44