Closure trong Javascript

Dev Mozilla
Đang cập nhật

Closures là một hàm được viết lồng trong một hàm khác (hàm cha), nó có thể sử dụng biến toàn cục(global variable) hoặc biến cục bộ của hàm cha (outer variable) hoặc biến cục bộ trong nó (local variable).

Lexical scoping

Lexical scope là một quy ước được sử dụng trong nhiều ngôn ngữ lập trình để thiết lập phạm vi của các biến chứa trong một hàm (scope) để nó chỉ có thể được gọi trong khối lệnh đã được hàm đó định nghĩa. Phạm vi này được xác định khi code được thực thi.

Xét VD sau :

function init() {
  var name = 'Mozilla'; // name là biến cục bộ của hàm init
  function displayName() { // displayName() là hàm closure
    alert(name); // sử dụng biến của hàm cha
  }
  displayName();
}
init();

Ở VD trên, init là một hàm được gọi, trong init khai báo một biến cục bộ name và một hàm displayName(). Hàm displayName() được khai báo bên trong hàm init() và chỉ tồn tại bên trong hàm init(). Hàm displayName không chưa bất kỳ biến cục bộ nào. Tuy nhiên, nó lại có thể tiếp cận với biến name được định nghĩa từ hàm cha, init(). Nếu bên trong hàm displayName() có khai báo biến cục bộ cho chính nó, biến đó sẽ được sử dụng.

function init() {
  var name = 'Hello World'; // name là biến cục bộ của hàm init
  function displayName() { // displayName() là hàm closure
    console.log(name); // sử dụng biến của hàm cha
  }
  displayName();
}
init();

Thực thi đoạn code trên sẽ nhận được kết quả từ console.log() bên trong hàm displayName() , giá trị biến name . Đây là một ví dụ của lexical scoping, cách các biến được truy cập như thế nào khi hàm được lồng nhau. Hàm lồng bên trong có thể truy suất đến biến được khai bào từ hàm bên ngoài.

Closure

Trong các ngôn ngữ lập trình, một closure cũng như lexical scope hay hàm closure là một kỹ thuật để thực thi một hàm lồng trong một hàm bậc cao hơn, đặc biệt trong Javascript, khi nhắc đến hàm nó được xem như "một công dân hạng 1". Về nguyên lý hoạt động, closure là một bản ghi lưu trữ một hàm cùng với một môi trường --Wikipedia--

Xét VD sau :

function makeFunc() {
  var name = 'Hello World';
  function displayName() {
    console.log(name);
  }
  return displayName;
}

var myFunc = makeFunc();

Chạy đoạn code trên sẽ nhận kết quả tương tự như ví dụ hàm init() ở trên; sự khác nhau ở đây là gì? khi gọi hàm makeFunc() sẽ return về hàm displayName(), và chưa hề chạy qua đoạn code trong hàm displayName().

Thoạt nhìn, đoạn code này sẽ không dễ nhận ra đoạn code này vẫn chạy bình thường. Trong một số ngôn ngữ lập trình khác, biến cục bộ bên trong một hàm chỉ tồn tại trong quá trình hàm thực thi. Một khi makeFunc() chạy xong, chúng ta sẽ nghĩ rằng biến name sẽ không còn thể truy cập được. Tuy nhiên, đoạn code trên sẽ vẫn cho ra kết quả không khác gì ví dụ ở trên cùng, rõ ràng đây là một tính chất đặc biệt của Javascript.

Trong trường hợp này, myFunc đang tham chiếu đến một instance displayName được tạo ra khi chạy makeFunc. Instance của displayName sẽ duy trì lexical environment, biến name sẽ vẫn tồn tại. Với lý do này, khi gọi hàm myFunc , giá trị biến name vẫn có và chuỗi "Hello World" sẽ được đưa vào hàm console.log().

Một ví dụ thú vị khác — hàm makeAdder:

function makeAdder(x) {
  return function(y) {
    return x + y;
  };
}

var add5 = makeAdder(5);
var add10 = makeAdder(10);

console.log(add5(2));  // 7
console.log(add10(2)); // 12

Trong ví dụ này, chúng ta định nghĩa hàm makeAdder(x), nhận vào 1 argument, x, và trả về một hàm khác. Hàm trả về nhận vào 1 argument, y, và trả về kết của của x + y.

Bản chất, makeAdder là một hàm factory — nó tạo ra một hàm khác nhận một argument. Ví dụ trên chúng ta sử dụng hàm factory để tạo ra 2 functions — cái thứ nhất thêm argument là 5, cái thứ 2 thêm 10.

add5add10 đều là closures. Cùng một xử lý bên trong, nhưng được lưu ở lexical environments khác nhau. Trong lexical environment của add5 , x = 5, trong khi lexical environment của add10, x = 10.

Ứng dụng closures

Closures hữu dụng vì nó cho phép chúng ta gắn một vài dữ liệu (bên trong lexical environment) với một function sẽ tương tác với dữ liệu. Tương tự như trong lập trình hướng đối tượng (object-oriented programming), các object cho phép chúng ta gắn một vài dữ liệu với một hoặc nhiều phương thức bên trong.

Trong lập trình web, hầu hết code được viết bằng JavaScript là hướng sự kiến (event-based) — chúng ta định nghĩa một xử lý, sau đó gắn nó vào event sẽ được gọi bởi user (ví dụ như click hay keypress). Đoạn code của chúng ta sẽ là callback: 1 function chạy khi có một sự kiện xảy ra.

Ví dụ, giả sử chúng ta muốn thêm một cái button để thay đổi kích thước chữ. Một trong những cách làm là set font-size cho thẻ body bằng giá trị pixels, sau đó set kích thước của những phần từ khác (như header) sử dụng đơn vị em :

body {
  font-family: Helvetica, Arial, sans-serif;
  font-size: 12px;
}

h1 {
  font-size: 1.5em;
}

h2 {
  font-size: 1.2em;
}

Khi thay đổi font-size của thẻ body , kích thước font của h1, h2 sẽ tự động được điều chỉnh.

Trong JS:

function makeSizer(size) {
  return function() {
    document.body.style.fontSize = size + 'px';
  };
}

var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);

size12, size14, và size16 là những hàm sẽ thay đổi kích thước font chữ của body qua 12, 14, và 16 pixels. Gắn cho các button tương ứng:

document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>

Giả lập phương thức private với closures

Những ngôn ngữ như Java chúng ta có cách để khai báo các phương thức private, nghĩa là phương thức chỉ được gọi bởi các phương thức khác nằm cùng class.

JavaScript không hỗ trợ cách làm chính quy cho việc này, tuy nhiên có thể giả lập việc này bằng closures. Phương thức Private không chỉ hữu dụng trong việc giới hạn việc truy cập: nó còn là một cách rất tốt để quản lý global namespace, giữ các phương thức không cần thiết có thể làm xáo trộn những phương thức public.

Đoạn code bên dưới diễn giải cách sử dụng closures để khai báo một phương thức public có thể truy cập phương thức private và biến. Sử dụng closures như thế này gọi là module pattern:

var counter = (function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  };
})();

console.log(counter.value()); // logs 0
counter.increment();
counter.increment();
console.log(counter.value()); // logs 2
counter.decrement();
console.log(counter.value()); // logs 1

Mỗi closure có một lexical environment. Ở đây, chúng ta tạo 1 lexical environment cho cả 3 function: counter.increment, counter.decrement, và counter.value.

Lexical environment được tạo bên trong một hàm không tên function, sẽ được tạo ra ngay khi được gán cho một khai báo. Lexical environment chứa 2 private: biến privateCounter và hàm changeBy. Cả 2 đối tượng private đều không thể được truy cập trực tiếp từ bên ngoài. Thay vào đó, nó chỉ có thể tương tác thông qua 3 phương thức public.

Cả 3 phương thức public đều là closures chia sẽ cùng 1 Lexical environment. Cả 3 đều có thể truy cập đến privateCounterchangeBy

Giả sử Chúng ta khai báo một hàm không tên tạo counter, và gọi nó ngay lập tức rồi gắn vào biến counter . Chúng ta lưu hàm này vào một biến khác makeCounter và sử dụng nó để tạo ra nhiều counter khác

var makeCounter = function() {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function() {
      changeBy(1);
    },
    decrement: function() {
      changeBy(-1);
    },
    value: function() {
      return privateCounter;
    }
  }
};

var counter1 = makeCounter();
var counter2 = makeCounter();
alert(counter1.value()); /* Alerts 0 */
counter1.increment();
counter1.increment();
alert(counter1.value()); /* Alerts 2 */
counter1.decrement();
alert(counter1.value()); /* Alerts 1 */
alert(counter2.value()); /* Alerts 0 */

Để ý cách 2 counters, counter1counter2, hoàn toàn độc lập với nhau. Mỗi closure tham chiếu đến các instance khác nhau của privateCounter.

Sử dụng closures bằng cách này cho ta rất nhiều ưu điểm như trong object-oriented programming -- cụ thể, dữ liệu được ẩn đi và đóng gói.

Closure Scope Chain

Mỗi closure chúng ta có 3 scopes:-

  • Scope cục bộ
  • Scope của function chứa closure
  • Scope global

Chúng ta có thể truy cập đến cả 3 scope này trong closure tuy nhiên sẽ ra sau nếu chúng lồng nhiều closure với nhau. Như ví dụ sau:

// global scope
var e = 10;
function sum(a){
  return function(b){
    return function(c){
      // outer functions scope
      return function(d){
        // local scope
        return a + b + c + d + e;
      }
    }
  }
}

console.log(sum(1)(2)(3)(4)); // log 20

// chúng ta có thể không dùng hàm không tên:

// global scope
var e = 10;
function sum(a){
  return function sum2(b){
    return function sum3(c){
      // outer functions scope
      return function sum4(d){
        // local scope
        return a + b + c + d + e;
      }
    }
  }
}

var s = sum(1);
var s1 = s(2);
var s2 = s1(3);
var s3 = s2(4);
console.log(s3) //log 20

Với ví dụ trên, chúng ta có thể nói toàn bộ closure sẽ có cùng scope với function cha.

Tạo closures trong vòng lặp: lỗi thường gặp

Trước khi có từ khóa let được giới thiệu trong ECMAScript 2015, một lỗi thường gặp trong closure khi nó được tạo bên trong vòng lặp. Xem ví dụ sau:

<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email"></p>
<p>Name: <input type="text" id="name" name="name"></p>
<p>Age: <input type="text" id="age" name="age"></p>
function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp();

Mảng helpText khai báo 3 string help, tương ứng cho mỗi ID của input. Vòng lặp chạy qua cả 3 khai báo này, chèn vào sự kiện onfocus để hiển thị đoạn string phù hợp với từng input.

Nếu thử chạy đoạn code này, bạn sẽ thấy kết quả không giống như chúng ta nghĩ. Mặc cho chúng ta đang focus vào input nào, dòng message hiển thị sẽ luôn là "Your age (you must be over 16)".

Lý do là hàm gắn cho sự kiện onfocus là closures; nó sẽ thống nhất các khai báo trong và đưa vào chung scope của hàm setupHelp. Cả 3 closures được tạo trong vòng lặp, nhưng cùng chung lexical environment, tức là dùng chung biến item.help. Giá trị item.help được xác định khi onfocus được gọi. Vì ở đây vòng lặp đã chạy đến giá trị cuối cùng của mảng, biến item sẽ trỏ đến giá trị cuối cùng trong mảng.

Giải pháp trong tình huống này là dùng thêm một closures: như cách chúng ta viết function factory trước đó:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function makeHelpCallback(help) {
  return function() {
    showHelp(help);
  };
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    var item = helpText[i];
    document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
  }
}

setupHelp();

Hàm makeHelpCallback đã tạo ra một lexical environment riêng cho mỗi callback.

Một cách khác là sử dụng closure không tên

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    (function() {
       var item = helpText[i];
       document.getElementById(item.id).onfocus = function() {
         showHelp(item.help);
       }
    })(); // Immediate event listener attachment with the current value of item (preserved until iteration).
  }
}

setupHelp();

Nếu không muốn sử dụng nhiều closure, có thể dùng từ khóa let được giới thiệu trong ES2015 :

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  for (var i = 0; i < helpText.length; i++) {
    let item = helpText[i];
    document.getElementById(item.id).onfocus = function() {
      showHelp(item.help);
    }
  }
}

setupHelp();

Ví dụ này ta sử dụng let thay cho var, như thế mỗi closure được gán cho 1 biến block-scoped.

Một cách khác nữa là dùng forEach() để lặp qua mảng helpText và gắn hàm xử lý <div>, như bên dưới:

function showHelp(help) {
  document.getElementById('help').innerHTML = help;
}

function setupHelp() {
  var helpText = [
      {'id': 'email', 'help': 'Your e-mail address'},
      {'id': 'name', 'help': 'Your full name'},
      {'id': 'age', 'help': 'Your age (you must be over 16)'}
    ];

  helpText.forEach(function(text) {
    document.getElementById(text.id).onfocus = function() {
      showHelp(text.help);
    }
  });
}

setupHelp();

Hiệu suất

Dùng closure trong những trường hợp thực sự không cần thiết có thể ảnh hưởng đến hiệu suất hoạt động khi chương trình thực thi.

Một ví dụ, khi tạo mới một object/class, phương thức thường nên gán vào object mà không nên khai báo bên trong hàm khởi tạo của object. Lý do là mỗi khi hàm constructor được gọi, phương thức sẽ được gán lại một lần nữa trên mỗi một object được tạo ra

Ví dụ cho trường hợp sau:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
  this.getName = function() {
    return this.name;
  };

  this.getMessage = function() {
    return this.message;
  };
}

Bởi vì đoạn code trên không thực sự cần những lợi ích có được từ closure trên mỗi instance, chúng ta có thể viết lại mà không sử dụng closure:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype = {
  getName: function() {
    return this.name;
  },
  getMessage: function() {
    return this.message;
  }
};

Tuy nhiên, khai báo lại prototype không được khuyến khích. Chúng ta mở rộng prototype bằng cách sau:

function MyObject(name, message) {
  this.name = name.toString();
  this.message = message.toString();
}
MyObject.prototype.getName = function() {
  return this.name;
};
MyObject.prototype.getMessage = function() {
  return this.message;
};

Trong 2 ví dụ trên, tất cả object sẽ kế thừa cùng những prototype và khai báo phương thức trên mỗi object không bắt buộc. Xem Details of the Object Model để tìm hiểu thêm

Bài viết đến đây là hết, cám ơn sự theo dõi của các bạn.


Bài viết có liên quan