+8

JavaScript Closures

JavaScript Closures

Closures 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.
Tôi nhận thấy rằng việc hiểu chính xác nội dung giúp cho các lập trình viên nắm vững các công cụ lập trình. Bài viết này sẽ đề cập đến việc Closures là gì và tại sao chúng ta lại cần phải biết nó.

Closures là gì?

Closures là một thuộc tính vô cùng mạnh mẽ của Javascript (và của nhiều ngôn ngữ lập trình khác). Dựa theo định nghĩa từ MDN thì:

Closures là những functions tham chiếu các biến độc lập (Free variables). Nói cách khác, function được định nghĩa trong closures nhớ môi trường mà nó được tạo ra.

Lưu ý: Free variables là các biến không được khai báo local hay được truyền thông qua tham số (parameters).

Hãy xem xét các ví dụ sau:

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 local numcheckNumber() (một hàm in ra num trong console). Hàm checkNumber() không có bất kỳ biến local nào trong nó. Tuy nhiên, nó có quyền truy cập vào các biến bên ngoài function, bởi vì numberGenerator() là một closure. Do đó, nó có thể sử dụng biến num được khai báo trong numberGenerator() để log num trong console sau khi numberGenerator() được trả lại.

Ví dụ 2

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 anonymous function nhưng vẫn có thể truy cập biến hello. Điều này là do biến hello đã được khai báo trong function scope tại thời điểm được tạo ra, làm cho nó có sẵn khi anonymous function được thực thi.

Ngữ cảnh thực thi

Là một khái niệm trừu tượng được sử dụng bởi đặc tả ECMAScript theo dõi và đánh giá thời gian chạy của code. Nó có thể là global context trong đó code của bạn chạy lần đầu tiên hoặc khi luồng thực hiện vào một function body.

execution_context.png

Tại bất kỳ thời điểm nào, chỉ có một Execution Context (ngữ cảnh thực thi) được chạy. Đó là lý do tại sao Javascript là "single threaded" (đơn luồng), nghĩa là một command chỉ có thể được xử lý tại một thời điểm. Thông thường, các trình duyệt duy trì execution context bằng cách sử dụng ngăn xếp. Do đó chúng ta chỉ có thể thêm hoặc xóa các phần tử ở đầu ngăn xếp. Các execution context đang chạy luôn luôn nằm ở mục trên cùng của ngăn xếp. Nó được lấy ra khỏi đầu ngăn xếp khi code của execution context được đánh giá hoàn toàn, cho phép item ở đầu tiên tiếp quản việc chạy execution context.

Hơn nữa, chỉ vì một execution context chạy không có nghĩa là nó phải kết thúc trước khi chạy một execution khác. Trường hợp một execution context bị đình chỉ và một execution context khác được chạy. Execution context đình chỉ có thể sao lưu tại thời điểm nó bị tắt. Một execution context khác được đẩy vào stack và trở thành current execution context (bối cảnh thực thi hiện tại).

execution_context2.png

Hãy xem ví dụ thực tế sau để hiểu thêm về vấn đề nà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
/*
  The function below executes the function bar which was returned
  when we executed the function foo in the line above. The function bar
  invokes boop, at which point bar gets suspended and boop gets push
  onto the top of the call stack (see the screenshot below)
*/
moar(15);

execution_context3.png

Khi boop() được return, nó được lấy ra khỏi stack và bar() được hồi phục. execution_context4.png

Mỗi execution context có các state components khác nhau được sử dụng để theo dõi process của code trong execution context đã làm được. Chúng bao gồm:

  • Code evaluation state: Bất cứ state nào cần thiết để thực hiện, đình chỉ, hay khôi phục đánh giá của code kết hợp với execution context.
  • Function: Các đối tượng chức năng mà execution context được đánh giá (hoặc null nếu bối cảnh đang được đánh giá là một script hay module).
  • Realm: Một tập hợp các đối tượng nội bộ, một môi trường global ECMAScript, tất cả các code ECMAScript được nạp trong phạm vi global, các state liên quan khác và tài nguyên.
  • Lexical Environment: Được dùng để giải quyết xác định các tài liệu tham khảo bởi code trong execution context.
  • Variable Environment: Lexical Environment thứ mà EnvironmentRecord chứa các ràng buộc tạo bởi VariableStatements trong execution context.

Closures

Mỗi một function đều có một execution context, trong đó bao gồm một môi trường đem lại ý nghĩa cho các biến bên trong hàm đó, và một tham chiếu đến môi trường parent của nó. Một tham chiếu đến môi trường parent làm cho các biến trong phạm vi parent khả dụng cho tất cả các functions bên trong, bất kể các functions bên trong được gọi ở bên ngoài hoặc bên trong phạm vi mà nó được tạo ra.

Vì vậy, có vẻ như chức năng nhớ của môi trường này (hoặc phạm vi) bởi vì hàm nghĩa đen đó là một tham chiếu đến môi trường (và các biến được định nghĩa trong môi trường đó).

Trở lại vớ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 sự hiểu biết của chúng tôi về cách thức hoạt động của môi trường, chúng tôi có thể nói rằng môi trường định nghĩa cho ví dụ ở trên trông giống như thế này (lưu ý, đây là purely pseudocode):

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
};
view raw

Khi gọi function để test, chúng ta nhận được kết quả là 45. Giá trị được trả về khi ta gọi hàm bar() (bởi vì hàm foo() trả về hàm bar()). bar() có thể truy cập vào biến y sau khi function foo() trả về bởi vì bar tham chiếu đến y thông qua môi trường bên ngoài. bar() cũng truy cập được biến x bởi vì nó là biến global. Đây gọi là “scope-chain lookup.

Một ví dụ kinh điển về sự nhầm lẫn khi có một vòng lặp for và chúng ta cố gắng liên kết biến counter trong vòng lặp với một function trong nó: Ví dụ 1:

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 với những gì chúng ta vừa học được, chúng ta sẽ dễ dàng nhận ra sự nhầm lẫn ở đây. Đây là những gì mà môi trường trông thấy tại thời điểm vòng lặp kết thúc:

environment: {
  EnvironmentRecord: {
    result: [...],
    i: 5
  },
  outer: null,
}

Các giả định không chính xác ở đây là các scope khác nhau trong 5 funtions bên trong mảng result. Thay vào đó, những gì đang thực sự xảy ra là môi trường (hay context/scope) là giống nhau trong tất cả 5 functions bên trong mảng result. Vì vậy, mỗi khi biến i được tăng nó cập nhật scope - được chia sẻ bởi tất cả các functions. Đó là lý so tại sao bất kỳ functions nào truy cập biến i đều trả về 5 (i có giá trị là 5 khi vòng lặp kết thúc).

Có một cách để sửa lỗi này, đó là thêm một context bao bọc mỗi lần function lấy execution context/scope của chú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

Ngoài ra, có một cách tiếp cận thông minh hơn đó là sử dụng let thay vì var. let là block-scope và nó bắt buộc tạo mới một định danh mỗi vòng lặp trong vòng lặp 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, tôi sẽ giới thiệu cách thức mỗi lần gọi một function tạo mội closure 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 ví dụ này, chúng ta có thể thấy rằng mỗi lần gọi tới function iCantThinkOfAName sẽ tạo mới một closure, cụ thể là foo hoặc bar. Lời gọi tiếp theo tới mỗi closure function cập nhật lại các biến closure trong chính nó. Điều đó chứng minh rằng các biến trong mỗi closure có thể tiếp tục được sử dụng bởi doSomething của iCantThinkOfAName sau khi iCantThinkOfAName’s được trả về.

Tl;dr

  • Execution context là mội khái niệm trừu tượng được sử dụng bởi các đặc tả ECMAScript để đánh giá, theo dõi thời gian chạy của code. Tại bất kỳ thời điểm nào, chỉ có một execution context được chạy.
  • Mỗi execution context có một Lexical Environment. Nó giữ các ràng buộc định danh (ví dụ: biến và sự liên kết giữa chúng với giá trị) và cũng tham chiếu tới chúng nó tại môi trường bên ngoài.
  • Các bộ định danh cho mỗi môi trường có thể truy cập được gọi là scope. Chúng ta có thể xếp lồng những scopes thành một chuỗi phân cấp các môi trường được gọi là scope chain.
  • Mỗi function có một Execution context, trong đó bao gồm một Lexical Environment mang lại ý nghĩa cho các biến bên trong hàm đó và một tham chiếu đến môi trường parent. Và do đó, nó có vẻ như function "nhớ" môi trường này (hoặc scope) vì hàm nghĩa đen có một tham chiếu đến môi trường này. Đây là một closure.
  • Một closure được tạo mỗi lần function bọc bên ngoài được gọi. Nói cách khác, các function bên trong không cần phải return cho một closure được tạo ra.
  • Phạm vi của một closure trong JavaScript là lexical, có nghĩa là nó được định nghĩa tĩnh bởi vị trí của nó trong source code.
  • Closure có nhiều trường hợp được sử dụng thực tế. Một trường hợp sử dụng quan trọng là duy trì một tham chiếu tới một biến trong phạm vi bên ngoài.

Hy vọng qua bài viết này giúp các bạn hiểu thêm về closure trong Javascript.

Bài viết gốc

Let's Learn Javascript Closure


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí