JavaScript Patterns – Khởi tạo đối tượng

Nội dung bài viết này liên quan đến hai cách tạo ra các đối tượng trong JavaScript: bằng literal và các constructor.

Object Literal

Các đối tượng của JavaScript đơn thuần là một tập hợp các cặp khóa:giá-trị. Giá trị có thể là kiểu nguyên thủy hoặc đối tượng; và trong cả hai trường hợp đều được gọi là thuộc tính. Hàm cũng là đối tượng, do đó thuộc tính cũng có thể là hàm, và trong trường hợp đó chúng được gọi là phương thức.

Các đối tượng (do bạn) tự tạo trong JavaScript có thể được chỉnh sửa vào bất cứ lúc nào. Do đó bạn có thể tạo một đối tượng rỗng và sau đó bổ sung dần các chức năng cho nó. Cách lý tưởng để thực hiện điều này là sử dụng object literal.

Một số thuộc tính trong các đối tượng xây dựng sẵn của JavaScript không có khả năng sửa đổi.

Ví dụ:

// khoi tao doi tuong rong
var dog = {};
// bo sung mot thuoc tinh
dog.name = "Benji";
// bo sung mot phuong thuc
dog.getName = function () {
  return dog.name;
};

Bạn có thể sửa đổi một thuộc tính:

dog.getName = function () {
  // dinh nghia lai phuong thuc, tra ve hardcode
  return "Fido";
};

…xóa bỏ chúng:

delete dog.name;

…hay bổ sung thêm thuộc tính và phương thức khác:

dog.say = function () {
  return "Woof!"; };
dog.fleas = true;

Cũng không nhất thiết phải bắt đầu với đối tượng rỗng, object literal cho phép bạn định nghĩa phương thức ngay từ đầu:

var dog = {
  name: "Benji",
  getName: function () {
    return this.name;
  }
};

Cú pháp của Object Literal

Chỉ có ba luật đơn giản trong cú pháp sử dụng object literal:

  • Bao gói đối tượng bằng cặp ký tự {}
  • Phân cách các thuộc tính của đối tượng bằng ký tự , (ký tự , ở sau thuộc tính cuối cùng không có tác dụng “phân cách”, do đó không cần thiết phải có).
  • Phân cách tên và giá trị của thuộc tính bằng ký tự :

Lưu ý rằng ký tự } ở object literal không có ý nghĩa tương tự như ký tự } để đóng hàm, điều này sẽ ảnh hưởng đến việc bạn có đặt dấu ; đằng sau câu lệnh khai báo đối tượng bằng object literal, tùy theo quy ước viết mã mà bạn đang áp dụng.

Khởi tạo đối tượng bằng constructor

Không có lý do gì để phải sử dụng hàm khởi tạo new Object() nếu có thể sử dụng object literal, nhưng nếu bạn nhận được mã như thế, hãy cẩn thận với một hiệu ứng mà nó mang lại. Đó là hàm khởi tạo Object() là hàm có nhận tham số, và tùy thuộc vào giá trị nhận được, nó có thể ủy quyền khởi tạo cho một số constructor khác được xây dựng sẵn và, hành xử không hề như bạn mong muốn. Hãy theo dõi những ví dụ sau đây khi truyền một số, một chuỗi ký tự, hay một giá trị luận lý vào new Object():

// Luu y ma duoi day khong chinh quy

// mot doi tuong rong
var o = new Object();
console.log(o.constructor === Object); // true

// mot doi tuong so
var o = new Object(1);
console.log(o.constructor === Number); // true
console.log(o.toFixed(2)); // "1.00"

// mot doi tuong chuoi ky tu
var o = new Object("I am a string");
console.log(o.constructor === String); // true
// doi tuong binh thuong khong co phuong thuc
// substring(), nhung doi tuong string thi co
console.log(typeof o.substring); // "function"

// mot doi tuong luan ly
var o = new Object(true);
console.log(o.constructor === Boolean); // true

Hành vi ngầm ẩn này của Object() có thể dẫn tới những kết quả không mong muốn khi bạn truyền những dữ liệu động và khó đoán định trước khi thực thi. Vậy nên nói chung đừng sử dụng new Object(). Hãy cứ dụng object literal.

Constructor (do bạn) tự tạo

Bạn có thể tự tạo ra hàm khởi tạo để phục vụ việc tạo đối tượng theo ý bạn muốn.

var adam = new Person("Adam");
adam.say(); // "To la Adam"

Lưu ý không nên gượng ép suy nghĩ về “lớp” ở đây. JavaScript không có khái niệm về lớp, Person đơn thuần là một hàm.

Dưới đây là cách mà hàm Person được định nghĩa:

var Person = function (name) {
  this.name = name;
  this.say = function () {
    return "I am " + this.name;
  };
};

Khi một hàm được gọi với từ khóa new – tức là hàm được được gọi với tư cách một constructor (chứ không phải với tư cách một hàm), những điều sau đây xảy ra bên trong hàm đó:

  • Ngầm định, một đối tượng rỗng được tạo ra, và được tham chiếu tới bởi biến this, đối tượng này kế thừa nguyên mẫu (prototype) của hàm
  • Các câu lệnh trong hàm được thực thi, thông thường nếu một hàm được viết ra với mục tiêu đóng vai trò constructor thì các câu lệnh này thường là các chỉ lệnh bổ sung phương thức và thuộc tính cho this
  • Nếu hàm khởi tạo không thực hiện return một cách tường minh thì this sẽ được dùng để return một cách ngầm định

Tất cả những điều trên, nếu được thể hiện bằng mã, trông sẽ như sau:

var Person = function (name) {
  // tao doi tuong moi
  // bang object literal
  // var this = {};

  // bo sung phuong thuc va doi tuong
  this.name = name;
  this.say = function () {
    return "I am " + this.name;
  };

  // return this;
};

Trong ví dụ vừa trên, cứ mỗi khi bạn gọi new Person(), một hàm say() mới lại được tạo ra. Điều này dĩ nhiên không hiệu quả, mặc dù không thể hiện rõ ràng trên mã nguồn nhưng tại runtime sẽ có rất nhiều phương thức say() giống nhau. Cách làm tốt hơn là đưa say() vào nguyên mẫu của hàm Person:

Person.prototype.say = function () {
  return "I am " + this.name;
};

Bạn không cần biết quá nhiều về chi tiết của nguyên mẫu hay sự kế thừa ở đây để hiểu được một điều rằng “những thành phần tái sử dụng cần được đặt trong nguyên mẫu”.

Nói thêm, ở trên có nhắc đến việc câu lệnh như sau được thực thi khi hàm khởi tạo mới bắt đầu được gọi:

  // var this = {};

Thật ra điều này không hoàn toàn đúng. Đối tượng “rỗng” không thực sự rỗng, nó kế thừa những thuộc tính và phương thức từ nguyên mẫu của hàm khởi tạo. Nên this trông giống như được tạo ra từ câu lệnh như sau thì đúng hơn:

// var this = Object.create(Person.prototype);

Cách constructor trả về đối tượng

Khi được gọi với từ khóa new, hàm luôn trả về một đối tượng, và một cách mặc định thì đó là đối tượng được tham chiếu bởi this. Nếu bạn không bổ sung bất kỳ thuộc tính nào cho this, nó sẽ không có bất kỳ gì khác ngoài những thứ được kế thừa từ nguyên mẫu của hàm.

Tuy vậy bạn vẫn có thể trả về bất kỳ đối tượng nào khác. Theo dõi ví dụ sau đây:

var Objectmaker = function () {
  // thuoc tinh name se bi bo qua
  // boi vi constructor
  // da quyet dinh tra ve mot doi tuong khac
  this.name = "This na`y";

  // tao va tra ve mot doi tuong moi
  var that = {};
  that.name = "That no.";
  return that;
};

// kiem thu
var o = new Objectmaker();
console.log(o.name); // "That no."

Bạn có thể vô tư để constructor trả về bất kỳ đối tượng nào. Lưu ý phải là đối tượng. Cố gắng trả về một thứ gì đó không phải đối tượng sẽ gây lỗi nhưng sẽ ngay lập tức được bỏ qua, và sau đó đối tượng được tham chiếu bởi this sẽ được sử dụng để trả về. Việc này sẽ có thể gây lỗi khó hiểu cho bạn.

var Concatenate = function (a, b) {
  return "" + a + b;
};

// kiem thu
var o = new Concatenate("foo", "bar");
console.log(o); // Concatenate {}

Ép hàm phải tạo mới đối tượng

Khi được gọi như một hàm bình thường (mà không phải với từ khóa new), đối tượng this bên trong hàm sẽ trỏ tới đối tượng toàn cục (trong trường hợp môi trường thực thi là các trình duyệt thì đối tượng toàn cục chính là window). Nếu bạn nhầm lẫn giữa hai cách dùng, bạn có thể gặp những lỗi logic và hành vi không mong muốn.

Nếu constructor của bạn có thứ gì đó như là this.member, và bạn gọi constructor mà quên mất từ khóa new, bạn sẽ khiến đối tượng toàn cục có thêm thuộc tính member – thứ có thể được truy cập qua window.member hay đơn giản là member. Điều này tuyệt đối không mong muốn, chúng ta luôn muốn giữ không gian toàn cục được sạch sẽ.

function Objectmaker() {
  this.member = "Foo";
}

var o = Objectmaker();
console.log(o); // undefined
console.log(window.member); // "Foo"
console.log(member); // "Foo"

Từ khóa strict của ES5 có thể giúp bạn hạn chế vấn đề này. Trong chế độ “nghiêm ngặt”, this sẽ không trỏ tới đối tượng toàn cục nữa. Tuy vậy bạn vẫn nên làm gì đó để đảm bảo hàm luôn trả về đối tượng mong muốn kể cả khi nó được gọi mà không có từ khóa new.

Quy ước đặt tên

Cách đơn giản nhất nhưng có hiệu quả rất cao đó là tuân thủ quy tắc đặt tên. Hãy đặt tên các constructor theo cú pháp CamelCase và các hàm bình thường theo cú pháp camelCase. Thao tác đó có thể giúp giảm thiểu phần lớn các nhầm lẫn.

Đừng this, hãy that

Tất nhiên, quy ước đặt tên chỉ mang tính hạn chế nhầm lẫn. Muốn giải quyết trọn vẹn vấn đề, bạn cần có một giải pháp mang tính kỹ thuật. Cách làm nhanh nhất là thay vì cấu thành đối tượng thông qua this, hãy tạo lập tường minh một đối tượng that và cấu thành nó.

function Mooncake() {
  var that = {};
  that.tastes = "yummy";
  return that;
}

Với những đối tượng quá sức đơn giản thì có thể trả về ngay đối tượng bằng cách sử dụng literal:

function Mooncake() {
  return {
    tastes: "yummy"
  };
}

Với giải pháp này, cho dù được sử dụng với new hay không thì constructor vẫn hành xử như bạn mong muốn:

var pie = new Mooncake(), sticky = Mooncake();
console.log(pie.tastes); // "yummy"
console.log(sticky.tastes); // "yummy"

Lưu ý rằng that chỉ là một cái tên tùy ý bạn chọn, không phải là một từ khóa. Bạn có thể đặt là self, me… tùy ý.

Cho hàm tự gọi nó

Vấn đề của giải pháp that là đối tượng mà bạn tạo ra không được kế thừa nguyên mẫu của hàm, vậy nên bất kỳ phần tử nào được thêm vào nguyên mẫu của Mooncake sẽ không hiện diện trong đối tượng được tạo mới.

Nếu nguyên mẫu của hàm là một thành phần quan trọng, bạn có thể cân nhắc một giải pháp khác. Hãy cho hàm kiểm tra xem this có phải là một thực thể do chính hàm tạo ra không, nếu không phải, hãy cho hàm gọi đến nó với từ khóa new và trả về đối tượng nhận được.

function Mooncake() {
  if (!(this instanceof Mooncake)) {
    return new Mooncake();
  }
  this.tastes = "yummy";
}
Mooncake.prototype.wantAnother = true;

// testing invocations
var pie = new Mooncake(),
sticky = Mooncake();

console.log(pie.tastes); // "yummy"
console.log(sticky.tastes); // "yummy"

console.log(pie.wantAnother); // true
console.log(sticky.wantAnother); // true

Có một cách kiểm tra tổng quát hơn là so sánh this với thuộc tính callee của đối tượng arguments, như thế bạn sẽ không phải hard-code tên của hàm.

if (!(this instanceof arguments.callee)) {
  return new Mooncake();
}

Mã bên trong mỗi hàm đều có thể truy cập một đối tượng arguments chứa toàn bộ các tham số được truyền vào hàm. Bên cạnh đó, arguments chứa một thuộc tính callee trỏ về chính hàm được gọi. Ở ví dụ trên thì arguments.callee chính là hàm Mooncake.

Lưu ý: callee không khả dụng trong chế độ strict kể từ ES5 trở đi.

Tổng kết

Một số nội dung trong bài viết này có thể không còn hữu ích kể từ ES6 trở đi (với sự xuất hiện của từ khóa class), tuy vậy việc nghiên cứu những nội dung này vẫn sẽ giúp bạn hiểu thêm về cách hành xử của trình thông dịch JavaScript, và qua đó hiểu thêm về chính ngôn ngữ.

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *