Định nghĩa lớp hoặc đối tượng ECMAScript

Việc sử dụng đối tượng được định nghĩa sẵn chỉ là một phần của khả năng hướng đối tượng của ngôn ngữ lập trình, nhưng điểm mạnh thực sự của nó là khả năng tạo ra các lớp và đối tượng tùy chỉnh riêng.

ECMAScript có rất nhiều phương pháp để tạo đối tượng hoặc lớp.

Cách nhà máy

Cách ban đầu

Vì thuộc tính của đối tượng có thể được định nghĩa động sau khi đối tượng được tạo, vì vậy nhiều nhà phát triển đã viết mã tương tự như sau khi JavaScript được giới thiệu lần đầu tiên:

var oCar = new Object;
oCar.color = "blue";
oCar.doors = 4;
oCar.mpg = 25;
oCar.showColor = function() {
  alert(this.color);
};

TIY

Trong đoạn mã trên, tạo đối tượng car. Sau đó, thiết lập một số thuộc tính cho nó: màu sắc của nó là xanh da trời, có bốn cửa, mỗi gallon dầu có thể chạy 25 dặm. Thuộc tính cuối cùng thực chất là con trỏ đến hàm, có nghĩa là thuộc tính này là phương pháp. Sau khi thực hiện đoạn mã này, bạn có thể sử dụng đối tượng car.

Tuy nhiên, ở đây có một vấn đề, đó là có thể cần tạo nhiều instance của car.

Giải pháp: Cách nhà máy

Để giải quyết vấn đề này, các nhà phát triển đã tạo ra các hàm nhà máy (factory function) có thể tạo và trả về đối tượng của loại cụ thể.

Ví dụ, hàm createCar() có thể được sử dụng để封装 các thao tác tạo đối tượng car được liệt kê trước đó:

function createCar() {
  var oTempCar = new Object;
  oTempCar.color = "blue";
  oTempCar.doors = 4;
  oTempCar.mpg = 25;
  oTempCar.showColor = function() {
    alert(this.color);
  };
  return oTempCar;
}
var oCar1 = createCar();
var oCar2 = createCar();

TIY

Ở đây, tất cả mã trong ví dụ đầu tiên đều được bao gồm trong hàm createCar(). Ngoài ra, còn một dòng mã bổ sung, trả về đối tượng car (oTempCar) làm giá trị của hàm. Khi gọi hàm này, sẽ tạo ra đối tượng mới và gán cho nó tất cả các thuộc tính cần thiết, sao chép ra đối tượng car mà chúng ta đã giải thích trước đó. Do đó, bằng cách này, chúng ta có thể dễ dàng tạo ra hai phiên bản của đối tượng car (oCar1 và oCar2), có cùng các thuộc tính.

Truyền tham số cho hàm

Chúng ta có thể修改 hàm createCar() để truyền các giá trị mặc định của các thuộc tính, thay vì chỉ gán giá trị mặc định cho thuộc tính:

function createCar(sColor,iDoors,iMpg) {
  var oTempCar = new Object;
  oTempCar.color = sColor;
  oTempCar.doors = iDoors;
  oTempCar.mpg = iMpg;
  oTempCar.showColor = function() {
    alert(this.color);
  };
  return oTempCar;
}
var oCar1 = createCar("red",4,23);
var oCar2 = createCar("blue",3,25);
oCar1.showColor();        //输出 "red"
oCar2.showColor();        //输出 "blue"

TIY

Bằng cách thêm tham số vào hàm createCar(), bạn có thể gán giá trị cho các thuộc tính color, doors và mpg của đối tượng car cần tạo. Điều này làm cho hai đối tượng có cùng thuộc tính nhưng giá trị thuộc tính khác nhau.

Định nghĩa phương pháp đối tượng ngoài hàm nhà máy

Mặc dù ECMAScript ngày càng trở nên chính thức hóa, nhưng phương pháp tạo đối tượng lại bị bỏ qua, và việc quy chuẩn hóa đến nay vẫn bị phản đối. Một phần là do nguyên nhân ngữ nghĩa (nó trông không chính thức như việc sử dụng toán tử new của hàm xây dựng), một phần là do nguyên nhân chức năng. Nguyên nhân chức năng nằm ở việc phải tạo đối tượng bằng cách này. Trong ví dụ trước, mỗi lần gọi hàm createCar(), phải tạo hàm mới showColor(), có nghĩa là mỗi đối tượng có phiên bản showColor() riêng. Tuy nhiên, thực tế, mỗi đối tượng đều chia sẻ cùng một hàm.

Một số nhà phát triển định nghĩa phương pháp của đối tượng ngoài hàm nhà máy, sau đó chỉ phương pháp này thông qua thuộc tính để tránh vấn đề này: }}

function showColor() {
  alert(this.color);
}
function createCar(sColor,iDoors,iMpg) {
  var oTempCar = new Object;
  oTempCar.color = sColor;
  oTempCar.doors = iDoors;
  oTempCar.mpg = iMpg;
  oTempCar.showColor = showColor;
  return oTempCar;
}
var oCar1 = createCar("red",4,23);
var oCar2 = createCar("blue",3,25);
oCar1.showColor();        //输出 "red"
oCar2.showColor();        //输出 "blue"

TIY

Trong đoạn mã đã được viết lại trên đây, hàm showColor() được định nghĩa trước hàm createCar(). Trong hàm createCar(), gán cho đối tượng một con trỏ đến hàm showColor() đã tồn tại. Về chức năng, điều này giải quyết vấn đề tạo lại đối tượng hàm; nhưng về mặt ngữ nghĩa, hàm này không quá giống với phương pháp của đối tượng.

Tất cả những vấn đề này đều gây rado người phát triển định nghĩaxuất hiện của hàm构造函数.

Cách thức của hàm构造函数

Tạo hàm构造函数 giống như tạo hàm nhà máy rất dễ dàng. Bước đầu tiên là chọn tên lớp, tức là tên của hàm构造函数. Theo quy ước, tên này có chữ cái đầu viết hoa để phân biệt với các tên biến thường viết thường. Ngoài ra, hàm构造函数 trông rất giống với hàm nhà máy. Hãy xem ví dụ sau:

function Car(sColor,iDoors,iMpg) {
  this.color = sColor;
  this.doors = iDoors;
  this.mpg = iMpg;
  this.showColor = function() {
    alert(this.color);
  };
}
var oCar1 = new Car("red",4,23);
var oCar2 = new Car("blue",3,25);

TIY

Dưới đây tôi sẽ giải thích sự khác biệt giữa mã trên và cách thức của nhà máy. Đầu tiên, trong hàm构造函数, không tạo đối tượng mà sử dụng từ khóa this. Khi sử dụng toán tử new để gọi hàm构造函数, trước khi thực hiện dòng mã đầu tiên, trước tiên tạo một đối tượng, chỉ có thể truy cập đối tượng thông qua this. Sau đó có thể trực tiếp gán thuộc tính này, theo mặc định là giá trị trả về của hàm构造函数 (không cần sử dụng toán tử return rõ ràng).

Hiện tại, việc tạo đối tượng bằng toán tử new và tên lớp Car sẽ giống như cách tạo đối tượng của đối tượng thông thường trong ECMAScript.

Bạn có thể hỏi, cách này có tồn tại vấn đề quản lý hàm như cách tiếp cận trước đó không? Đúng vậy.

Như một hàm nhà máy, hàm tạo sẽ tạo lại hàm, tạo ra một phiên bản độc lập của hàm cho mỗi đối tượng. Tuy nhiên, giống như hàm nhà máy, bạn cũng có thể viết lại hàm tạo bằng hàm bên ngoài, điều này không có ý nghĩa về mặt ngữ nghĩa. Đó chính là ưu điểm của cách nguyên mẫu mà chúng ta sẽ nói đến sau này.

Cách nguyên mẫu

Cách này sử dụng thuộc tính prototype của đối tượng, có thể coi là nguyên mẫu mà đối tượng mới phụ thuộc vào để tạo ra.

Ở đây, trước tiên sử dụng hàm tạo trống để đặt tên lớp. Sau đó, tất cả các thuộc tính và phương thức đều được gán trực tiếp vào thuộc tính prototype. Chúng ta đã viết lại ví dụ trước, mã như sau:

function Car() {
}
Car.prototype.color = "blue";
Car.prototype.doors = 4;
Car.prototype.mpg = 25;
Car.prototype.showColor = function() {
  alert(this.color);
};
var oCar1 = new Car();
var oCar2 = new Car();

TIY

Trong đoạn mã này, trước tiên định nghĩa hàm tạo (Car), không có bất kỳ mã nào. Các dòng tiếp theo, thông qua việc thêm thuộc tính vào thuộc tính prototype của Car để định nghĩa thuộc tính của đối tượng Car. Khi gọi new Car(), tất cả các thuộc tính của nguyên mẫu sẽ được gán ngay lập tức cho đối tượng được tạo ra, có nghĩa là tất cả các bản sao của Car đều chứa chỉ mục đến hàm showColor(). Trên thực tế, tất cả các thuộc tính dường như thuộc về một đối tượng, do đó giải quyết được vấn đề của hai cách tiếp cận trước đó.

Ngoài ra, với cách này, bạn còn có thể sử dụng toán tử instanceof để kiểm tra loại đối tượng mà biến chỉ đến. Do đó, mã sau sẽ in ra TRUE:

alert(oCar1 instanceof Car);	//In ra "true"

Vấn đề của cách nguyên mẫu

Cách nguyên mẫu trông như là một giải pháp tốt. Tuy nhiên, nó không thực sự làm hài lòng.

Đầu tiên, hàm tạo này không có tham số. Sử dụng cách nguyên mẫu, không thể truyền tham số vào hàm tạo để khởi tạo giá trị thuộc tính vì thuộc tính color của Car1 và Car2 đều bằng "blue", thuộc tính doors đều bằng 4, thuộc tính mpg đều bằng 25. Điều này có nghĩa là phải thay đổi giá trị mặc định của thuộc tính sau khi đối tượng được tạo ra, điều này rất khó chịu, nhưng chưa hết. Vấn đề thực sự xuất hiện khi thuộc tính chỉ đến đối tượng,而不是 hàm. Việc chia sẻ hàm không gây ra vấn đề, nhưng đối tượng lại hiếm khi được chia sẻ giữa nhiều bản sao. Hãy suy nghĩ về ví dụ sau:

function Car() {
}
Car.prototype.color = "blue";
Car.prototype.doors = 4;
Car.prototype.mpg = 25;
Car.prototype.drivers = new Array("Mike","John");
Car.prototype.showColor = function() {
  alert(this.color);
};
var oCar1 = new Car();
var oCar2 = new Car();
oCar1.drivers.push("Bill");
alert(oCar1.drivers);	//Xuất "Mike,John,Bill"
alert(oCar2.drivers);	//Xuất "Mike,John,Bill"

TIY

Trong mã trên, thuộc tính drivers là con trỏ đến đối tượng Array, chứa hai tên "Mike" và "John". Do drivers là giá trị tham chiếu, hai thực thể của Car đều chỉ đến cùng một mảng. Điều này có nghĩa là khi thêm giá trị "Bill" vào oCar1.drivers, bạn cũng có thể thấy nó trong oCar2.drivers. Xuất bất kỳ con trỏ nào trong hai con trỏ này, kết quả đều là hiển thị chuỗi "Mike,John,Bill".

Do có nhiều vấn đề khi tạo đối tượng như vậy, bạn sẽ nghĩ rằng có phải có một phương pháp hợp lý để tạo đối tượng không? Câu trả lời là có, cần kết hợp sử dụng hàm构造function và phương thức nguyên mẫu.

Phương thức kết hợp hàm构造function/원 mẫu

Kết hợp sử dụng hàm构造函数 và phương thức nguyên mẫu để tạo đối tượng như trong ngôn ngữ lập trình khác. Khái niệm này rất đơn giản, đó là sử dụng hàm构造function để định nghĩa tất cả các thuộc tính không phải là hàm của đối tượng, và sử dụng phương thức nguyên mẫu để định nghĩa các thuộc tính hàm (phương thức) của đối tượng. Kết quả là, tất cả các hàm chỉ được tạo một lần, và mỗi đối tượng đều có một thực thể thuộc tính riêng của mình.

Chúng tôi đã viết lại ví dụ trước, mã như sau:

function Car(sColor,iDoors,iMpg) {
  this.color = sColor;
  this.doors = iDoors;
  this.mpg = iMpg;
  this.drivers = new Array("Mike","John");
}
Car.prototype.showColor = function() {
  alert(this.color);
};
var oCar1 = new Car("red",4,23);
var oCar2 = new Car("blue",3,25);
oCar1.drivers.push("Bill");
alert(oCar1.drivers);	//Xuất "Mike,John,Bill"
alert(oCar2.drivers);	//Xuất "Mike,John"

TIY

Bây giờ nó gần giống như việc tạo đối tượng thông thường. Tất cả các thuộc tính không phải là hàm đều được tạo trong hàm构造函数, có nghĩa là bạn có thể gán giá trị mặc định cho các thuộc tính thông qua các tham số của hàm构造函数. Bởi vì chỉ tạo ra một thực thể của hàm showColor(), vì vậy không có sự lãng phí bộ nhớ. Ngoài ra, thêm giá trị "Bill" vào mảng drivers của oCar1 sẽ không ảnh hưởng đến mảng của oCar2, vì vậy khi xuất giá trị của các mảng này, oCar1.drivers hiển thị là "Mike,John,Bill", trong khi oCar2.drivers hiển thị là "Mike,John". Bởi vì sử dụng phương thức nguyên mẫu, vì vậy vẫn có thể sử dụng toán tử instanceof để xác định loại đối tượng.

Cách này là phương pháp chính mà ECMAScript sử dụng, nó có tính năng của các phương pháp khác, nhưng không có tác dụng phụ của chúng. Tuy nhiên, vẫn có một số nhà phát triển cho rằng phương pháp này không hoàn hảo lắm.

Phương pháp原型 động

Đối với những nhà phát triển quen thuộc với ngôn ngữ lập trình khác, việc sử dụng phương pháp kết hợp hàm xây dựng/原型 có cảm giác không hợp lý. Bởi vì khi định nghĩa lớp, hầu hết các ngôn ngữ hướng đối tượng đều封装 thuộc tính và phương pháp một cách trực quan. Hãy xem xét lớp Java sau:

class Car {
  public String color = "blue";
  public int doors = 4;
  public int mpg = 25;
  public Car(String color, int doors, int mpg) {
    this.color = color;
    this.doors = doors;
    this.mpg = mpg;
  }
  public void showColor() {
    System.out.println(color);
  }
}

Java đã gói gọn tất cả các thuộc tính và phương pháp của lớp Car một cách tốt, vì vậy khi nhìn thấy đoạn mã này, bạn sẽ biết nó sẽ thực hiện chức năng gì, nó định nghĩa thông tin của đối tượng. Những người phê bình phương pháp kết hợp hàm xây dựng/原型 cho rằng việc tìm thuộc tính trong hàm xây dựng và phương pháp ở bên ngoài không hợp lý. Do đó, họ đã thiết kế phương pháp原型 động để cung cấp phong cách mã hóa thân thiện hơn.

Ý tưởng cơ bản của phương pháp原型 động là tương tự như cách kết hợp giữa hàm xây dựng và nguyên mẫu, tức là định nghĩa các thuộc tính không phải là hàm trong hàm xây dựng, trong khi các thuộc tính là hàm được định nghĩa bằng thuộc tính nguyên mẫu. Điểm khác biệt duy nhất là vị trí gán phương pháp cho đối tượng. Dưới đây là lớp Car được viết lại bằng phương pháp原型 động:

function Car(sColor,iDoors,iMpg) {
  this.color = sColor;
  this.doors = iDoors;
  this.mpg = iMpg;
  this.drivers = new Array("Mike","John");
  if (typeof Car._initialized == "undefined") {
    Car.prototype.showColor = function() {
      alert(this.color);
    };
    Car._initialized = true;
  }
}

TIY

Hàm này không thay đổi cho đến khi kiểm tra typeof Car._initialized có bằng "undefined" hay không. Đây là phần quan trọng nhất trong phương pháp nguyên mẫu động. Nếu giá trị này chưa được định nghĩa, hàm sẽ tiếp tục định nghĩa phương pháp đối tượng bằng cách sử dụng phương pháp prototype, sau đó đặt Car._initialized thành true. Nếu giá trị này đã được định nghĩa (giá trị của nó là true, typeof của nó là Boolean), thì sẽ không tạo phương pháp này. Tóm lại, phương pháp này sử dụng dấu hiệu (_initialized) để xác định xem đã赋予原型 bất kỳ phương pháp nào hay chưa. Phương pháp này chỉ được tạo và gán giá trị một lần, và các nhà phát triển OOP truyền thống sẽ rất vui lòng khi thấy rằng đoạn mã này trông giống như định nghĩa lớp trong các ngôn ngữ khác.

Phương pháp nhà máy kết hợp

Phương pháp này thường là phương pháp thay thế khi không thể áp dụng phương pháp trước đó. Mục đích của nó là tạo ra hàm构造函数 giả, chỉ trả về một mới instance của đối tượng khác.

Mã này trông rất giống với hàm nhà máy:

function Car() {
  var oTempCar = new Object;
  oTempCar.color = "blue";
  oTempCar.doors = 4;
  oTempCar.mpg = 25;
  oTempCar.showColor = function() {
    alert(this.color);
  };
  return oTempCar;
}

TIY

Khác với phương pháp kinh điển, phương pháp này sử dụng toán tử new, làm cho nó trông giống như một hàm构造函数 thực sự:

var car = new Car();

Do trong hàm构造函数 Car() đã gọi toán tử new, vì vậy toán tử new thứ hai (nằm ngoài hàm构造函数) sẽ bị bỏ qua, đối tượng được tạo trong hàm构造函数 được truyền trở lại biến car.

Phương pháp này có cùng vấn đề trong việc quản lý nội bộ phương pháp đối tượng như phương pháp kinh điển. Tôi khuyến nghị mạnh mẽ: trừ khi không có cách nào khác, hãy tránh sử dụng phương pháp này.

Chọn phương pháp nào

Như đã đề cập, phương pháp kết hợp hàm构造函数 và prototype được sử dụng phổ biến nhất hiện nay. Ngoài ra, phương pháp nguyên mẫu động cũng rất phổ biến, tương đương về chức năng với phương pháp hàm构造函数 và prototype. Bạn có thể sử dụng bất kỳ phương pháp nào trong hai phương pháp này. Tuy nhiên, không nên sử dụng riêng lẻ phương pháp hàm构造函数 hoặc prototype kinh điển, vì điều này có thể gây ra vấn đề trong mã của bạn.

Mẫu

Điểm thú vị của các đối tượng là cách chúng giải quyết vấn đề. Một trong những vấn đề phổ biến nhất trong ECMAScript là hiệu suất của việc nối chuỗi. Tương tự như các ngôn ngữ khác, các chuỗi trong ECMAScript là không thay đổi, nghĩa là giá trị của chúng không thể thay đổi. Hãy xem đoạn mã sau:

var str = "hello ";
str += "world";

Thực tế, các bước thực hiện của mã này dưới表层 như sau:

  1. Tạo chuỗi lưu trữ "hello ";
  2. Tạo chuỗi lưu trữ "world".
  3. Tạo chuỗi lưu trữ kết quả kết nối.
  4. Sao chép nội dung hiện tại của str vào kết quả.
  5. Sao chép "world" vào kết quả.
  6. Cập nhật str để nó trỏ đến kết quả.

Mỗi khi hoàn thành việc kết nối chuỗi, bước 2 đến 6 sẽ được thực hiện, làm cho hoạt động này rất tốn tài nguyên. Nếu lặp lại quá trình này hàng trăm hoặc hàng nghìn lần, sẽ gây ra vấn đề về hiệu suất. Giải pháp là sử dụng đối tượng Array để lưu trữ chuỗi, sau đó tạo chuỗi cuối cùng bằng phương thức join() (tham số là chuỗi rỗng). Hãy tưởng tượng thay thế mã trước đó bằng mã sau:

var arr = new Array();
arr[0] = "hello ";
arr[1] = "world";
var str = arr.join(" ");

Như vậy, bất kể có bao nhiêu chuỗi được thêm vào mảng thì cũng không thành vấn đề, vì chỉ khi gọi phương thức join() mới xảy ra việc kết nối. Lúc này, các bước thực hiện như sau:

  1. Tạo chuỗi lưu trữ kết quả
  2. Sao chép mỗi chuỗi vào vị trí phù hợp trong kết quả

Mặc dù giải pháp này rất tốt, nhưng vẫn có phương pháp tốt hơn. Vấn đề là mã này không thể phản ánh chính xác ý định của nó. Để dễ hiểu hơn, bạn có thể gói chức năng này bằng lớp StringBuffer:

function StringBuffer () {
  this._strings_ = new Array();
}
StringBuffer.prototype.append = function(str) {
  this._strings_.push(str);
};
StringBuffer.prototype.toString = function() {
  return this._strings_.join(" ");
};

Mã nguồn này cần chú ý đầu tiên là thuộc tính strings, có nghĩa là thuộc tính riêng. Nó chỉ có hai phương thức, đó là phương thức append() và phương thức toString(). Phương thức append() có một tham số, nó thêm tham số này vào mảng chuỗi, phương thức toString() gọi phương thức join() của mảng, trả về chuỗi thực sự được kết nối. Để kết nối một nhóm chuỗi bằng đối tượng StringBuffer, bạn có thể sử dụng mã sau:

var buffer = new StringBuffer ();
buffer.append("hello ");
buffer.append("world");
var result = buffer.toString();

TIY

Bạn có thể kiểm tra hiệu suất của đối tượng StringBuffer và phương pháp nối chuỗi truyền thống bằng mã sau:

var d1 = new Date();
var str = "";
for (var i=0; i < 10000; i++) {
    str += "text";
}
var d2 = new Date();
document.write("Concatenation with plus: ")
 + (d2.getTime() - d1.getTime()) + " milliseconds");
var buffer = new StringBuffer();
d1 = new Date();
for (var i=0; i < 10000; i++) {
    buffer.append("text");
}
var result = buffer.toString();
d2 = new Date();
document.write("<br />Concatenation with StringBuffer: ")
 + (d2.getTime() - d1.getTime()) + " milliseconds");

TIY

Mã này thực hiện hai kiểm tra về việc nối chuỗi, một là sử dụng dấu cộng, hai là sử dụng lớp StringBuffer. Mỗi thao tác nối 10000 chuỗi. Giá trị ngày tháng d1 và d2 được sử dụng để đánh giá thời gian cần thiết để hoàn thành thao tác. Lưu ý rằng khi tạo đối tượng Date mà không có tham số, đối tượng được赋予 là ngày và giờ hiện tại. Để tính toán thời gian thực hiện thao tác nối, chỉ cần trừ đi giá trị miliseconds của ngày tháng (sử dụng phương thức getTime() trả về) là được. Đây là phương pháp phổ biến để đo lường hiệu suất của JavaScript. Kết quả của bài kiểm tra này sẽ giúp bạn so sánh hiệu suất giữa việc sử dụng lớp StringBuffer và việc sử dụng dấu cộng.