JavaScript - The Core - Object & Prototype

Overview

Câu hỏi kinh điển khi phỏng vấn web developer:

"JS có phải là một ngôn ngữ lập trình OOP không? Tại sao?".

Thực sự mà nói thì đây không phải là câu hỏi dễ, tất nhiên đáp án là "Đúng" nhưng mọi người vẫn hay có sự nhầm lẫn giữa OOP trong JS với các ngôn ngữ OOP Class-based như C++, Java, PHP,.. Đặc biệt với sự xuất hiện của các bản specification ES 6, tool Babel, rồi Complier Typescript, các từ khóa class, constructor đều đã có trong JS nhưng có phải nó giống như những gì bạn nghĩ không? Tìm hiểu sâu hơn 1 chút càng thấy mọi thứ mông lung như 1 trò đùa =)) Vậy nên bài này mình sẽ đi sâu vào cách JS vận hành hệ thống đối tượng của mình để mọi người có hiểu rõ hơn, OOP trong JS là gì ?

Object & Prototype

Tạm thời nếu đã lỡ học ES6, Typescript thì hãy xóa ký ức đi, chúng ta sẽ đi vào những gì thuộc về bản chất và sơ khai của JS

Object

JavaScript được coi là 1 ngôn ngữ hướng đối tượng dạng prototype-base phân biệt với class-base như các ngôn ngữ OOP thông dụng. JS có những kiểu primitives( hay còn gọi là built-in type như: number, string , boolean..) nhưng thậm chí cả những kiểu này cũng có thể chuyển sang dạng Object. Nói cách khác Object là cốt lõi của JS, mọi thứ đều có thể trở thành Object.

Mỗi Object là 1 tập hợp các thuộc tính (property) và trong số đó có duy nhất 1 thuộc tính được gọi là [[Prototype]]. Thuộc tính [[Prototype]] có thể có giá trị là tham chiếu đến 1 Object hoặc null.

Thông thường một khi đối tượng được khởi tạo thì [[Prototype]] tham chiếu đến 1 Object khác. Sẽ rất ít khi bạn thấy 1 [[Prototype]] mang giá trị null.

1 ví dụ đơn giản như sau:

var foo = {
  x: 10,
  y: 20
};

Tuy nhiên khi gõ foo lên trình duyệt, bạn không thể tìm thấy thuộc tính [[Prototype]] ở đâu thay vào đó lại là thuộc tính có tên __proto__ khá lạ lẫm.

Thuộc tính __proto__ thực ra là 1 getter function, getter function này được định nghĩa trên native built-in Object của JavaScript (cũng chính là tổ tiên của tất cả đối tượng trong gia phả nhà JS - hãy coi nó như 1 đối tượng root). Vì vậy, đây có thể coi như 1 cách trình duyệt public ra để bạn truy cập vào [[Prototype]] của Object.

Diagram bên dưới cho thấy sự liên hệ giữa __proto__ và [[Prototype]]

A Prototype Chain

Đối tượng mà [[Prototype]] tham chiếu tới cũng chỉ là những Object bình thường và bản thân [[Prototype]] cũng có những thuộc tính của riêng mình. Nếu 1 [[Prototype]] tham chiếu đến 1 [[Prototype]] khác ta gọi đó là 1 chuỗi [[Prototype]] hay [[Prototype]] chain. Theo định nghĩa của Mozilla thì [[Prototype]] chain là 1 chuỗi hữu hạn các Objects - các Object này được sử dụng để triển khai "kế thừa" và "chia sẻ thuộc tính".

Note: Khác với các ngôn ngữ OOP Class-Base, JS không có khái niệm Class. Điều này đúng ngay cả đối với các phiên bản ECMAScript mới nhất cho đến giờ ES2017.

Cách triển khai "Kế Thừa" trong JS cũng khác biệt với các ngôn ngữ OOP class-base truyền thống, "Kế Thừa" trong JS được gọi là Delegation Based Inheritance hay gần gũi hơn Prototype Base Inheritance

Ta cùng xem ví dụ sau:

var a = {
  x: 10,
  calculate: function (z) {
    return this.x + this.y + z;
  }
};
 
var b = {
  y: 20,
  __proto__: a
};
 
var c = {
  y: 30,
  __proto__: a
};
 
// Gọi ra phương thức được kế thừa
b.calculate(30); // 60
c.calculate(40); // 80

Nguyên tắc kế thừa rất đơn giản, nêu 1 thuộc tính/ phương thức được gọi không có trong bản thân Object, JS sẽ tìm thuộc tính/ phương thức đó trong chuỗi [[Prototype]]. Có thể thấy sự tương quan khi các ngôn ngữ Class-based dò tìm từ dưới lên trên trong Class Chain. Nếu 1 thuộc tính/ phương thức không được tìm thấy trong chuỗi [[Prototype]], thì giá trị undefined sẽ được trả về.

Khi tạo ra 1 Object nếu [[Prototype]] không được chỉ định cụ thể tham chiếu tới Object nào thì mặc định nó sẽ tham chiếu tới Object.prototype . Tại sao lại là Object.prototype mà không phải là Object.[[Prototype]], tôi sẽ giải thích kỹ hơn ở phần sau nhưng tạm thời bạn hãy nhớ Object.[[Prototype]] và Object.prototype là 2 khái niệm khác nhau. Object.prototype cũng có [[Prototype]] của mình và đó cũng chính là là kết thúc của mọi [[Prototype]] chain với giá trị là null.

ES5 đã chuẩn hóa cách prototype-base inheritance bằng hàm Object.create() Mặc dù vậy __proto__ vẫn có thể được sử dụng để khởi tạo Object bình thường như ta đã làm trong ví dụ trên

Constructor

Thông thường sử dụng OOP giúp ta tạo ra các đối tượng giống nhau về mặt cấu trúc properties, nhưng lại có sự khác biệt về mặt trạng thái state. Để đạt được điều này trong JS ta cần đến Constructor Function

Constructor function được sử dụng để tạo đối tượng mới. Sau khi khai báo 1 function ta có 1 đối tượng prototype lưu trữ trong ConstructorFunction.prototype. Và khi khởi tạo đối tượng bằng từ khóa new thì constructor function sẽ tự động thiết lập [[Prototype]] cho đối tượng vừa được khởi tạo. Và tất nhiên đó chính là đối tượng được lưu trữ trong ConstructorFunction.prototype. Ví dụ như sau:

// constructor function
function Foo(y) {
  // Định nghĩa cấu trúc
  this.y = y;
}
 
//Ngay lập tức Foo.prototype được khởi tạo, nó chứa tham chiếu tới [[Prototype]] của các Object sẽ được tạo ra bằng từ khóa new
//Tại đây chúng ta sẽ khai báo các thuộc tính/ đối tượng muốn chia sẻ
//Inherited property "x"
Foo.prototype.x = 10;
 
//Inherited method "calculate"
Foo.prototype.calculate = function (z) {
  return this.x + this.y + z;
};
 
// Tạo ra 2 đối tượng b và c
var b = new Foo(20);
var c = new Foo(30);
 
// Gọi đến phương thức có trong [[Prototype]]
b.calculate(30); // 60
c.calculate(40); // 80
 
// [[Prototype]] của b và c đều tham chiếu tới Foo.prototype
console.log(
  b.__proto__ === Foo.prototype, // true
  c.__proto__ === Foo.prototype, // true
  
  // Bên trong Foo.prototype , 1 thuộc tính đặc biệt là *constructor* cũng được khởi tạo, và nó tham chiếu tới chính Constructor Function, các đối tượng được tạo ra từ Constructor Function sử dụng thuộc tính này để tham chiếu tới Constructor Function
 
  b.constructor === Foo, // true
  c.constructor === Foo, // true
  Foo.prototype.constructor === Foo, // true
 
  b.calculate === b.__proto__.calculate, // true
  b.__proto__.calculate === Foo.prototype.calculate // true
 

Dưới đây là 1 biểu đồ cho thấy tất cả mọi đối tượng trong JS đều có [[Prototype]]. Constructor Function cũng có [[Prototype]] (tham chiếu tới Function.prototype và theo [[Prototype]] chain nó lại tham chiếu tới Object.prototype).

Như vậy có thể kết luận X.prototype là 1 đối tượng mà được JS sử dụng để tạo thành [[Prototype]] cho đối tượng con của nó trong quan hệ kế thừa. Theo như ví dụ bên trên thì: a & b là con của Foo nên [[Prototype]] của nó chính là Foo.prototype. Đây cũng chính là sự khác nhau giữa Object.prototypeObject.[[Prototype]] mà tôi đã nói ở phần Object. Thậm chí X.prototype cũng có [[Prototype]] của mình, thường thì đối với mỗi kiểu built-in đối tượng này sẽ được quy định bởi native code của JS. Khi Inspect lên những gì bạn nhận được là chữ native code =))

Trong ES6, khái niệm class đã được chuẩn hóa, tuy nhiên bạn cần hiểu rõ, nó chính xác là cách để giúp những LTV quen thuộc với Class-Based dễ tiếp cận hơn với JS bằng việc tạo ra 1 lớp giao diện sử dụng bên trên Constructor Function còn về bản chất JS vẫn hoàn toàn là Prototype-Base.

How to exactly inherit in JS

Sự thực là ngày nay ít người còn sử dụng cách này bởi có quá nhiều lớp interface bên trên Vanilla JS - JS thuần (như ES 6, Babel, Typescript ...) (nguyên gốc bản dịch sugar on the top - ngụ ý thêm đường vào cho đỡ chua), nhưng chính xác đây là cách JS thực sự làm việc ở tầng bên dưới. Chúng ta sẽ xem qua 1 ví dụ và sau đó sẽ phân tích nó.

// Định nghĩa Constructor Function
// Định hình cấu trúc của đối tượng muốn khởi tạo
function Person(first, last, age, gender, interests) {
  this.name = {
    first,
    last
  };
  this.age = age;
  this.gender = gender;
  this.interests = interests;
};

// Như đã biết, ngay lập tức Person.prototype cũng được khởi tạo
// Định nghĩa phương thức trên Person.prototype
Person.prototype.greeting = function() {
  alert('Hi! I\'m ' + this.name.first + '.');
};

// Định nghĩa Constructor Function sẽ kế thừa từ Person
function Teacher(first, last, age, gender, interests, subject) {
  // Hàm call gọi đến constructor function Person ở ngữ cảnh hiện tại Teacher, *this* lúc này tham chiếu tới Teacher
  Person.call(this, first, last, age, gender, interests);
  
  // Định nghĩa thêm thuộc tính private với Person, thuộc tính này chỉ có ý nghĩa với Teacher
  this.subject = subject;
}

Những thuộc tính có khả năng nắm giữ state của đối tượng ta cần định nghĩa nó trên bản thân constructor, điều này đảm bảo tính đóng gói cho mỗi đối tượng. Mặt khác, các phương thức được định nghĩa trên FunctionConstructor.prototype đảm bảo tất cả các instance được tạo ra đều tham chiếu đến 1 phương thức duy nhất. Điểm này mang lại hai ích lợi :

  • Mỗi instance không cần tạo ra 1 phương thức mới trong bộ nhớ (mặc dù vậy hãy nhớ JS là 1 High-Level Language và vấn đề PerformanceMemory Management không phải luôn luôn ở ưu tiên hàng đầu)
  • Thay đổi method trong FunctionConstructor.prototype sẽ khiến tất cả thể hiện của nó thay đổi theo

2 mặt của 1 vấn đề, đó là ta thấy trong JS, các public methods được định nghĩa trong FunctionConstructor.prototype, nó có thể truy cập vào các property được định nghĩa trên Constructor Function. Đối với OOP Class-Base, điều này là không được phép bởi vì các phương thức kế thừa không được truy cập đến private properties ở tầng trên nó

Quay trở lại ví dụ của chúng ta, Teacher lúc này có đầy đủ thuộc tính theo như cấu trúc nhưng phương thức thì chưa. Những gì bạn trông chờ tới giờ là Teacher.prototype phải tham chiếu đến Person.prototype. Hãy sử dụng hàm Object.create(), hàm này tạo ra 1 đối tượng dựa trên đối số truyền vào.

Teacher.prototype = Object.create(Person.prototype);

Tuy nhiên lúc này Teacher.prototype.constructor trở thành Person, đó là do ta đã tham chiếu prototype của Teacher tới 1 đối tượng khởi tạo bằng Person.

Hãy sửa lại bằng cách:

Teacher.prototype.constructor = Teacher

Bây giờ thử nghiệm 1 chút

//Ghi đè phương thức greeting
Teacher.prototype.greeting = function() {
  var prefix;

  if (this.gender === 'male' || this.gender === 'Male' || this.gender === 'm' || this.gender === 'M') {
    prefix = 'Mr.';
  } else if (this.gender === 'female' || this.gender === 'Female' || this.gender === 'f' || this.gender === 'F') {
    prefix = 'Mrs.';
  } else {
    prefix = 'Mx.';
  }

  alert('Hello. My name is ' + prefix + ' ' + this.name.last + ', and I teach ' + this.subject + '.');
};

// Tạo ra instance
var teacher1 = new Teacher('Dave', 'Griffiths', 31, 'male', ['football', 'cookery'], 'mathematics');

// Gọi phương thức
teacher1.greeting() //Hello. My name is Mr. Griffiths, and I teach mathematics.
//Kết quả nhận được là phương thức đã được ghi đè

Conclusion

Qua bài này, tôi mong các bạn nắm rõ hơn về bản chất của JS, những gì thực sự đang chạy bên dưới đoạn mã Typescript hay ES6 của bạn. Tất nhiên đã là năm 2017, nên chắc sẽ không ai còn code kiểu này, nhưng hiểu thì vẫn tốt hơn là cứ coi nó như 1 "phép màu" phải không =))

Bài viết có tham khảo từ: You Dont Know Javascript: this & Object Prototypes - tác giả Getify - Chapter 5: Prototypes ECMA-262-3 in detail. Chapter 7.1 - Dmitry Soshinikov https://developer.mozilla.org/en-US/docs/Learn/JavaScript/ và 1 vài kiến thức cóp nhặt từ StackOverFlow

All Rights Reserved