19/10/2019, 10:41

Closures của JavaScript hoạt động như thế nào?

Bài viết được dịch từ blog của tác giả Dmitry Frank. Tôi đã dùng closures vài lần rồi. Tôi học cách dùng chúng, nhưng không hiểu rõ closures thực sự hoạt động như thế nào, thực chất điều gì xảy ra khi tôi sử dụng chúng. Mà clousre là cái gì cơ chứ? Wikipedia cũng không giúp ích gì ...

Bài viết được dịch từ blog của tác giả Dmitry Frank.

Tôi đã dùng closures vài lần rồi. Tôi học cách dùng chúng, nhưng không hiểu rõ closures thực sự hoạt động như thế nào, thực chất điều gì xảy ra khi tôi sử dụng chúng. Mà clousre là cái gì cơ chứ? Wikipedia cũng không giúp ích gì lắm. Khi nào thì closure được tạo ra và khi nào nó bị xóa bỏ? Implement của nó trông như thế nào?

Và khi cuối cùng cũng hiểu được, tôi cảm thấy cực kì thích thú và quyết định sẽ giải thích nó: ít nhất, giờ đây tôi sẽ không quên nó nữa. Bạn biết đấy, có câu nói như thé này:

Bảo tôi, tôi quên. Dạy tôi, tôi nhớ. Để tôi làm và tôi sẽ học được.

Tell me and I forget. Teach me and I remember. Involve me and I learn.

© Benjamin Franklin

Và khi đọc các giải thích về closures, tôi cố hình dung một cách trực quan xem mọi thứ liên quan đến nhau như thế nào: object nào reference đến các object khác, cái nào kế thừa từ cái nào, vân vân… Tôi tìm các hình minh họa như vậy mà không có, vậy nên tôi quyết định tự mình vẽ luôn.

Tôi xem như rằng độc giả của bài viết này đã quen với JavaScript, biết Global Object là cái gì, biết rằng hàm trong JavaScript là “first-class objects”, vân vân và vân vân…

Scope chain

Khi bất kì một đoạn JavaScript nào được thực thi thì nó cần một chỗ nào đó để chứa các biến địa phương của mình. Ta hãy gọi nó là scope object đi (nhiều người sẽ gọi nó là LexicalEnvironment). Ví dụ, khi bạn gọi một hàm, và hàm đó định nghĩa ra vài biến địa phương, thì những biến địa phương này được lưu vào trong scope object. Bạn có thể xem nó như một object của JavaScript bình thường, chỉ có một điểm khác biệt là bạn không thể tham chiếu đến toàn bộ object này một cách trực tiếp.

Khái niệm về scope object này rất khác với nhiều ngôn ngữ khác, như C hay C++; ở các ngôn ngữ này thì biến địa phương được lưu trong stack. Còn trong JavaScript, scope object được cấp phát bộ nhớ trong heap (hay ít nhất là “hành vi” của nó giống như vậy), do đó nó có thể tồn tại ngay cả khi hàm đã được trả về. Tôi sẽ giải thích thêm sau.

Đúng như bạn nghĩ, scope object có thể có parent. Khi một đoạn code thử truy cập tới một biến, thì trình thông dịch sẽ tìm property này trong scope object hiện tại. Nếu property không tồn tại, trình thông dịch chuyển sang tìm ở parent scope object. Và tiếp tục lần lượt đến khi thấy giá trị cần tìm, hoặc nếu như không còn parent nữa. Ta gọi chuỗi các scope object này là scope chain.

Việc phân giải một biến trên scope chain rất giống với kế thừa prototype (protototypal inheritance) với một điểm khác biệt: nếu bạn truy cập một thuộc tính không tồn tại của object bình thường, và prototype chain cũng không chứa property này thì sẽ không có lỗi xảy ra: undefined sẽ được trả về. Nhưng nếu bạn truy cập một property không tồn tại trên scope chain (truy cập một biến không tồn tại), thì lỗi ReferenceError sẽ xảy ra.

Phần tử cuối cùng trong scope chain luôn luôn là Global Object. Ở code JavaScript top-level thì scope chain chỉ bao gồm một phần tử duy nhất: Global Object. Vì vậy, nếu bạn định nghĩa biến ở code top-level, thì chúng được định nghĩa ở Global Object. Khi một hàm được gọi, scope chain chứa nhiều hơn một object. Bạn có thể nghĩ rằng nếu hàm được gọi từ code top-level, thì scope chain đảm bảo chỉ chứa đúng 2 scope object, tuy nhiên điều này không đúng! Có thể có 2 hoặc nhiều hơn scope object, tùy thuộc vào hàm. Phần sau tôi sẽ phân tích kĩ hơn.

Top-level code

Ok, lý thuyết thế là đủ rồi, hãy thử một ví dụ cụ thể. Sau đây là một ví dụ rất đơn giản:

my_script.js

Chúng ta mới tạo hai biến ở code top-level. Như đã nói bên trên, đối với code top-level, scope object chính là Global Object:

Trong hình trên, ta có execution context (chính là code top-level my_script.js) tham chiếu tới scope object. Đương nhiên, trong thực tế thì Global Object bao gồm rất nhiều property chuẩn cũng như property đối với từng host, nhưng không được biểu diễn trong hình trên.

Non-nested functions

Giờ, xét đoạn script sau:

my_script.js

Khi hàm myFunc được định nghĩa, định danh myFunc được thêm vào scope object hiện thời (trong ví dụ này là Global Object), và định danh này tham chiếu tới function object. Function object chứa code của hàm cũng như các property khác của nó. Một property mà ta quan tâm là internal property [[scope]], tham chiếu tới scope object hiện tại, hay nói cách khác chính là scope object đang active khi hàm được định nghĩa (trong trường hợp này, là Global Object).

Vậy nên lúc mà console.log("outside"); được thực thi thì ta có dạng như sau:

Nhắc lại một lần nữa: function object được tham chiếu bởi myFunc không chỉ giữ code của hàm mà còn tham chiếu tới scope object đang hoạt động lúc hàm được định nghĩa. Đây là một điểm rất quan trọng.

Và khi hàm được gọi, một scope object mới được tạo ra lưu giữ các biến địa phương cho myFunc (và cả các tham số của nó nữa), và scope object mới này kế thừa từ scope object được tham chiếu bởi hàm được gọi.

Do vậy, lúc myFunc thực sự được gọi, thì ta có sơ đồ như sau:

Cái chúng ta có ở đây là một scope chain: nếu ta thử truy cập một biến bên trong myFunc, JavaScript sẽ thử tìm nó trong scope object đầu tiên: myFunc() scope. Nếu tìm kiếm thất bại thì tiếp tục tới scope object tiếp theo (ở đây là Global object) để tìm tiếp. Nếu property được yêu cầu không nằm trong bất cứ scope object nào thì ReferenceError sẽ xảy ra.

Ví dụ, nếu ta truy cập a từ myFunc, ta sẽ thu được giá trị 1 từ scope object đầu tiên myFunc() scope. Nếu ta truy cập foo, ta sẽ có giá trị 3 cũng từ myFunc() scope: nó che đi property foo của Global object. Nếu ta truy cập bar, ta thu được 2 từ Global object. Nó hoạt động gần như là kế thừa prototype.

Một điểm quan trọng cần chỉ ra ở đây là những scope object này còn tồn tại khi mà có tham chiếu đến chúng. Khi tham chiếu cuối cùng tới một scope object biến mất thì scope object này sẽ bị garbage-collect.

Vậy, khi myFunc() trả về, không có gì tham chiếu đến myFunc() scope nữa, và nó bị garbage-collect, ta thu được sơ đồ giống như lúc trước:

Từ giờ, tôi sẽ không cho function object vào đồ hình nữa, nếu không đồ hình sẽ trở nên cực kì rối rắm. Bạn hãy luôn nhớ rằng: bất cứ tham chiếu tới một hàm nào trong JavaScript đều tham chiếu đến function object, và function object này lại tham chiếu tới scope object.

Nested functions

Như đã thấy ở phần trước, khi hàm trả về, không có gì tham chiếu tới scope object của nó nữa, và vì vậy nó bị garbage-collect. Nhưng nếu ta định nghĩa một nested function (hàm lồng nhau) và trả nó như kết quả (hoặc lưu nó vào đâu đó bên ngoài) thì sao? Bạn đã biết rằng: function object luôn luôn tham chiếu tới scope object mà tại đó nó được tạo ra. Do đó, khi ta định nghĩa nested function, nó tham chiếu tới scope object của hàm bên ngoài. Và nếu ta lưu nested function đâu đó bên ngoài, thì scope object không bị garbage collect kể cả khi hàm số bên ngoài trả về: vì vẫn còn tham chiếu tới nó! Xem đoạn code sau đây:

my_script.js

Khi ta gọi createCounter(100);, ta có sơ đồ như sau:

Để ý rằng createCounter(100) scope được tham chiếu bới nested function increment và get. Nếu createCounter() không trả về gì cả, thì đương nhiên, việc tự tham chiếu này không tính và scope vẫn bị garbage collect. Nhưng do createCounter() trả về object chứa tham chiếu tới những hàm này, nên ta có:

Bỏ chút thời gian để ngẫm nghĩ về nó nào: hàm createCounter(100); đã trả về, nhưng scope của nó vẫn còn, có thể truy cập được thông qua các hàm bên trong, và chỉ thông qua những hàm này. Thực sự là không thể truy cập object createCounter(100) scope trực tiếp, ta chỉ có thể gọi myCounter.increment() hoặc myCounter.get() mà thôi. Những hàm này có quyền truy cập private tới scope của createCounter.

Giờ ta hãy thử gọi, ví dụ như myCounter.get() xem sao. Nhớ lại rằng khi bất cứ hàm nào được gọi, thì một scope object được tạo ra, và được thêm vào scope chain tham chiếu bởi hàm. Do vậy, khi myCounter.get() được gọi, ta có:

Scope object đầu tiên trong chuỗi của hàm get() là object rỗng get() scope. Vậy nên, khi get() truy cập biến counter, JavaScript không thể tìm nó trong object đầu tiên của scope object chain, chuyển đến scope object tiếp theo, và dùng biến counter ở createCounter(100) scope. Rồi hàm get() chỉ đơn thuần trả về kết quả đó.

Bạn có thể đã để ý thấy rằng object myCounter được thêm vào hàm myCounter.get() như là this (biểu thị trong hình bằng mũi tên đỏ). Bởi vì this không bao giờ nằm trong scope chain, bạn cần lưu ý điều này. Tôi sẽ giải thích thêm ở phần sau.

Gọi increment(5) thì thú vị hơn một chút, do hàm này còn có tham số đầu vào:

Bạn thấy đấy, tham số value được lưu tại scope object mới được tạo cho lần gọi increment(5). Khi hàm truy cập đến biến value, JavaScript ngay lập tức xác định được nó ở object đầu tiên trong scope chain. Tuy nhiên khi hàm truy cập counter, JavaScript không tìm thấy nó ở object đầu tiên trong scope chain, chuyển tiếp tới scope object tiếp theo, và tìm thấy nó ở đây. Vì vậy increment() thay đối biến counter ở createCounter(100) scope. Và gần như không ai khác có thể thay đổi được giá trị của biến này. Đó là lí do tại sao closure lại mạnh đến như vậy: object myCounter không thể bị tấn công. Closure cực kì phù hợp để lưu những thứ riêng tư.

Để ý rằng tham số initial cũng được lưu ở scope object của createCounter(), dù rằng nó không được sử dụng. Vậy nên ta có thể tiết kiệm được một xíu bộ nhớ nếu ta bỏ đi var counter = initial; đổi tên initial thành counter và sử dụng trực tiếp. Nhưng để rõ ràng, ta dùng initial và var counter;

Cần nói rõ thêm rằng các scope này vẫn “sống”. Khi hàm được gọi, scope chain hiện thời không được copy cho hàm này: chỉ là scope object mới được thêm vào scope chain mà thôi, và khi bất kì scope object nào đó trong chuỗi này bị thay đổi bời một hàm nào đó, thì thay đổi này ngay lập tức có thể được quan sát thấy bởi tất cả các hàm có scope object này trong scope chain của chúng. Khi increment() thay đổi giá trị counter , lần gọi get() tiếp theo sẽ trả về giá trị đã được cập nhật này.

Đó cũng chính là lí do vì sao ví dụ nổi tiếng sau chạy không đúng:

Trong vòng lặp trên có rất nhiều hàm được tạo ra, và tất cả chúng đều tham chiếu đến cùng một scope object trong scope chain của mình. Chính vì vậy, chúng sử dụng cùng một biến i, chứ không phải một bản sao riêng của nó. Để xem thêm giải thích rõ hơn cho ví dụ này, xem link sau: Don’t make functions within a loop.

Function object giống nhau, scope object khác nhau

Giờ ta mở rộng ví dụ về couter cho vui nhé. Nếu ta tạo ra nhiều hơn một object counter thì sao? Đơn giản:

my_script.js

Khi cả myCounter1 và myCounter2 được tạo, ta có sơ đồ sau:

Hãy nhớ lại rằng từng funciton object có tham chiếu tới scope object. Vậy nên ở ví dụ bên trên, myCounter1.increment và myCounter2.increment tham chiếu đến function object có code giống hệt nhau và các giá trị property như nhau(name, length, và nhiều nữa), nhưng [[scope]] của chúng lại tham chiếu đến scope object khác nhau.

Đồ hình không vẽ các function object riêng biệt để đơn giản, nhưng chúng vẫn có ở đó.

Một vài ví dụ khác:

Đó chính là cách nó hoạt động, Khái niệm closure quả thật rất mạnh.

Scope chain và “this”

Dù thích hay không thì this không được lưu như một phần của scope chain. Thay vào đó, giá trị của this phụ thuộc vào cách hàm được gọi: bạn có thể gọi cùng một hàm với nhiều giá trị this khác nhau.

Invocation patterns

Về chủ đề này thì có thể viết được hẳn một bài viết riêng, nên tôi không đi sâu nhưng nhìn chung có bốn cách gọi hàm:

Method invocation pattern

Nếu invocation expression chứa refinement (dấu chấm, hoặc [subscript]), thì hàm được gọi như là một phương thức. Vì vậy, trong ví dụ bên trên, this được cho trong myFunc là tham chiếu tới myObj.

Function invocation pattern

Khi không có refinement, thì nó lại phụ thuộc xem code có được chạy trong strict mode hay không:

  • trong strict mode, thì this là undefined
  • trong non-strict mode, this trỏ tới Global Object

Do đoạn code trên có "use strict"; nên được chạy trong strict mode, myFunc() trả về undefined.

Constructor invocation pattern

Khi hàm được gọi với new ở đằng trước, JavaScripts cấp phát object mới kế thừa từ property prototype của hàm, và object mới được cấp phát này chính là this của hàm.

Apply invocation pattern

Ta cũng có thể truyền một giá trị tùy ý vào làm this. Trong v

0