Object Prototypes trong JavaScript

Javascript nâng cao | by Học Javascript

Trong JavaScript, Prototype là một khái niệm cốt lõi giúp hiện thực hóa tính kế thừa mà không cần đến các lớp (class) như trong các ngôn ngữ lập trình hướng đối tượng truyền thống. Mọi object trong JavaScript đều có một Prototype, đóng vai trò như một khuôn mẫu chứa các thuộc tính và phương thức mà object có thể kế thừa.

Cơ chế Prototype-based Inheritance giúp JavaScript trở nên linh hoạt và mạnh mẽ, cho phép các object chia sẻ phương thức một cách hiệu quả mà không cần sao chép chúng. Việc hiểu rõ về Prototype, Prototype Chain, và cách mở rộng Prototype sẽ giúp lập trình viên tối ưu hóa bộ nhớ, cải thiện hiệu suất, và tổ chức mã nguồn một cách khoa học hơn.

Trong bài viết này, mình sẽ tìm hiểu về khái niệm Prototype, cách hoạt động của nó, cũng như so sánh với mô hình kế thừa dựa trên class trong JavaScript ES6.

Prototype là gì?

Trong JavaScript, Prototype là một cơ chế cho phép các object kế thừa thuộc tính và phương thức từ một object khác. Mỗi object trong JavaScript đều có một Prototype (ngoại trừ null), và nó đóng vai trò như một khuôn mẫu chứa các phương thức hoặc thuộc tính mà object có thể truy cập.

Ví dụ, khi bạn tạo một object từ một constructor function hoặc một class, JavaScript tự động liên kết object đó với một Prototype. Nếu object không tìm thấy thuộc tính hoặc phương thức nào đó, JavaScript sẽ tìm kiếm trong Prototype của nó.

Mô hình Prototype-based trong JavaScript

JavaScript sử dụng mô hình Prototype-based inheritance thay vì class-based inheritance như các ngôn ngữ OOP truyền thống (Java, C++). Trong mô hình này, thay vì tạo lớp và định nghĩa phương thức trong đó, các object có thể kế thừa trực tiếp từ một object khác thông qua Prototype.

Ví dụ:

const person = {
  greet() {
    return "Hello!";
  }
};

const user = Object.create(person); // Tạo object user kế thừa từ person

console.log(user.greet()); // "Hello!"

Ở đây, user không có phương thức greet(), nhưng vì nó kế thừa từ person, JavaScript sẽ tìm greet() trong Prototype của user.

Mối quan hệ giữa Object và Prototype

  • Mọi object trong JavaScript đều có một Prototype, trừ null.

  • Khi một object được tạo ra từ một Constructor Function, Prototype của Constructor Function sẽ được liên kết với object đó.

  • Các phương thức hoặc thuộc tính có thể được chia sẻ giữa nhiều object thông qua Prototype mà không cần sao chép dữ liệu.

Ví dụ về mối quan hệ giữa Object và Prototype:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  return `Hello, my name is ${this.name}`;
};

const john = new Person("John");
console.log(john.sayHello()); // "Hello, my name is John"

Trong đoạn mã trên, phương thức sayHello() không tồn tại trực tiếp trên object john, mà nằm trong Prototype của Person. Khi gọi john.sayHello(), JavaScript sẽ tìm phương thức này trong Prototype của john.

Prototype Chain (Chuỗi nguyên mẫu)

Prototype Chain là cơ chế mà JavaScript sử dụng để tìm kiếm thuộc tính hoặc phương thức trong object và các Prototype của nó. Nếu một object không chứa thuộc tính hoặc phương thức được gọi, JavaScript sẽ tiếp tục tìm trong Prototype của object đó, rồi đến Prototype của Prototype đó, cho đến khi gặp null.

Ví dụ về Prototype Chain:

function Animal(name) {
  this.name = name;
}

Animal.prototype.makeSound = function () {
  return "Some sound";
};

const dog = new Animal("Dog");

console.log(dog.makeSound()); // "Some sound"
console.log(dog.toString());  // "[object Object]" (được kế thừa từ Object.prototype)
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null

Trong ví dụ trên:

  1. dog không có phương thức makeSound(), nhưng nó tìm thấy trong Animal.prototype.

  2. Animal.prototype không có phương thức toString(), nhưng Object.prototype có, nên JavaScript lấy phương thức này từ Object.prototype.

  3. Nếu JavaScript không tìm thấy thuộc tính hoặc phương thức trong toàn bộ Prototype Chain, nó sẽ trả về undefined.

Truy cập và sử dụng Prototype trong JavaScript

__proto__ vs Object.getPrototypeOf()

Có hai cách để truy cập Prototype của một object:

__proto__ (deprecated - không nên dùng trong sản xuất)

  • Đây là một thuộc tính không chính thức nhưng vẫn được nhiều trình duyệt hỗ trợ.

  • Cho phép truy cập trực tiếp vào Prototype của một object.

const obj = {};
console.log(obj.__proto__ === Object.prototype); // true

Object.getPrototypeOf(obj) (cách chính thống, an toàn hơn)

Đây là phương thức chính thức để lấy Prototype của một object.

const obj = {};
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true

Cách kiểm tra Prototype của một Object

Dùng Object.getPrototypeOf()

function Person() {}
const john = new Person();

console.log(Object.getPrototypeOf(john) === Person.prototype); // true

Dùng instanceof để kiểm tra kế thừa Prototype

console.log(john instanceof Person); // true
console.log(john instanceof Object); // true (vì Person kế thừa từ Object)

Dùng isPrototypeOf() để kiểm tra một Prototype có phải là Prototype của Object không

console.log(Person.prototype.isPrototypeOf(john)); // true
console.log(Object.prototype.isPrototypeOf(john)); // true

Kế thừa bằng Prototype trong JavaScript

Cách Object kế thừa thuộc tính và phương thức thông qua Prototype

Trong JavaScript, khi một object được tạo ra từ một constructor function, nó sẽ tự động kế thừa các thuộc tính và phương thức từ Prototype của constructor đó. Nếu một thuộc tính hoặc phương thức không được tìm thấy trong object, JavaScript sẽ tìm kiếm nó trong Prototype Chain cho đến khi gặp null.

Ví dụ về kế thừa trong JavaScript bằng Prototype

Ví dụ 1: Kế thừa phương thức thông qua Prototype

function Animal(name) {
  this.name = name;
}

// Thêm phương thức vào prototype của Animal
Animal.prototype.makeSound = function () {
  return `${this.name} is making a sound`;
};

const dog = new Animal("Dog");

console.log(dog.makeSound()); // "Dog is making a sound"

// Kiểm tra kế thừa
console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.__proto__ === Object.prototype); // true
  • dog không có phương thức makeSound(), nhưng vì dog kế thừa từ Animal.prototype, JavaScript sẽ tìm kiếm makeSound() trong Prototype Chain.

  • Animal.prototype không có phương thức như toString(), nên JavaScript tiếp tục tìm trong Object.prototype.

Ví dụ 2: Kế thừa nhiều cấp

Chúng ta có thể mở rộng kế thừa bằng cách tạo một prototype từ một constructor function khác.

function Animal(name) {
  this.name = name;
}

Animal.prototype.makeSound = function () {
  return `${this.name} is making a sound`;
};

// Tạo một constructor function kế thừa từ Animal
function Dog(name, breed) {
  Animal.call(this, name); // Gọi constructor Animal
  this.breed = breed;
}

// Kế thừa prototype từ Animal
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog; // Khôi phục constructor về Dog

Dog.prototype.bark = function () {
  return `${this.name} is barking!`;
};

const myDog = new Dog("Buddy", "Golden Retriever");

console.log(myDog.makeSound()); // "Buddy is making a sound"
console.log(myDog.bark()); // "Buddy is barking!"

// Kiểm tra kế thừa
console.log(myDog instanceof Dog); // true
console.log(myDog instanceof Animal); // true
console.log(myDog instanceof Object); // true
  • Dog kế thừa Animal, nên Dog.prototype sẽ được liên kết với Animal.prototype.

  • Dog vẫn có thể có các phương thức riêng như bark(), ngoài các phương thức kế thừa từ Animal.

Sự khác biệt giữa Prototype và Object instance

Tiêu chí Prototype Object Instance
Lưu trữ ở đâu? Trong Function.prototype Được tạo từ constructor function
Có thể sửa đổi không? Có thể mở rộng bằng cách thêm phương thức Chỉ có thể thay đổi thuộc tính nội tại
Dùng để làm gì? Chia sẻ phương thức và thuộc tính chung Lưu trữ dữ liệu cụ thể của từng object
Ví dụ Animal.prototype.makeSound = function() {...} const dog = new Animal("Dog");

Thêm và mở rộng phương thức trong Prototype trong JavaScript

Thêm phương thức vào Prototype

Chúng ta có thể mở rộng prototype bằng cách thêm phương thức vào prototype của constructor function.

function Car(brand) {
  this.brand = brand;
}

// Thêm phương thức vào Prototype
Car.prototype.getBrand = function () {
  return `This car is a ${this.brand}`;
};

const myCar = new Car("Toyota");
console.log(myCar.getBrand()); // "This car is a Toyota"
  • Phương thức getBrand() không được định nghĩa trực tiếp trong myCar mà nằm trong Car.prototype.

  • Mọi object được tạo từ Car sẽ có thể sử dụng getBrand() mà không cần sao chép phương thức vào từng object.

Sửa đổi phương thức của Prototype

Chúng ta có thể ghi đè một phương thức đã có trong Prototype nếu muốn thay đổi hành vi của nó.

Car.prototype.getBrand = function () {
  return `Car brand: ${this.brand}`;
};

console.log(myCar.getBrand()); // "Car brand: Toyota"
  • Phương thức getBrand() mới sẽ thay thế phương thức cũ trong Car.prototype.

  • Mọi object kế thừa từ Car sẽ sử dụng phương thức mới.

Khi nào nên mở rộng Prototype, khi nào không?

Nên mở rộng Prototype khi:

  • Tạo phương thức dùng chung cho nhiều object để tiết kiệm bộ nhớ.

  • Cải thiện hiệu suất bằng cách giữ các phương thức trong Prototype thay vì tạo bản sao trong từng instance.

  • Thêm phương thức vào constructor function mà không ảnh hưởng đến các object hiện có.

Ví dụ tốt:

Array.prototype.first = function () {
  return this[0];
};

const numbers = [10, 20, 30];
console.log(numbers.first()); // 10

Không nên mở rộng Prototype khi:

Thay đổi Prototype của built-in objects (Array, Object, String, v.v.) có thể gây xung đột với thư viện khác.

Array.prototype.last = function () {
  return this[this.length - 1];
};
  • Nếu một thư viện khác cũng định nghĩa Array.prototype.last(), hành vi của mã có thể trở nên không nhất quán.

Ghi đè phương thức mặc định của JavaScript (như toString(), valueOf()) có thể gây ra lỗi khó dự đoán.

Object.prototype.toString = function () {
  return "Custom toString method";
};

Sử dụng quá nhiều phương thức trong Prototype có thể làm mã nguồn khó hiểu và bảo trì khó khăn.

Constructor Function và Prototype trong JavaScript

Mối quan hệ giữa Constructor Function và Prototype

  • Mỗi Constructor Function trong JavaScript có một thuộc tính đặc biệt gọi là prototype, chứa các phương thức và thuộc tính mà các object được tạo từ constructor này sẽ kế thừa.

  • Khi một object được tạo bằng new, nó sẽ được liên kết với prototype của constructor function.

Sơ đồ mối quan hệ:

Constructor Function → Prototype → Object instance

Tạo Object bằng Constructor Function và Prototype

Ví dụ về tạo object bằng Constructor Function:

function Person(name, age) {
  this.name = name;
  this.age = age;
}

// Thêm phương thức vào Prototype
Person.prototype.greet = function () {
  return `Hello, my name is ${this.name}`;
};

const john = new Person("John", 30);
console.log(john.greet()); // "Hello, my name is John"

Điểm quan trọng:

  • greet() không được tạo trong từng object mà nằm trong Person.prototype, giúp tiết kiệm bộ nhớ.

  • Khi gọi john.greet(), JavaScript sẽ tìm greet trong john, nếu không có, nó sẽ tìm trong Person.prototype.

So sánh Constructor Function với Object Literal

Tiêu chí Constructor Function Object Literal ({})
Cách tạo object Dùng từ khóa new Dùng {}
Khả năng tái sử dụng Có thể tạo nhiều object giống nhau Phải sao chép thủ công
Kế thừa từ Prototype Không
Hiệu suất bộ nhớ Tốt hơn vì chia sẻ phương thức qua Prototype Không tối ưu do mỗi object chứa phương thức riêng
Dễ sử dụng Cần hiểu về thisprototype Đơn giản, dễ dùng cho object nhỏ
  • Dùng Object Literal ({}) khi chỉ cần tạo một object đơn giản, không cần kế thừa.

  • Dùng Constructor Function khi cần tạo nhiều object có cùng cấu trúc và phương thức chung.

Prototype Chain (Chuỗi nguyên mẫu) trong JavaScript

Cách hoạt động của Prototype Chain

  • Khi truy cập một thuộc tính hoặc phương thức của object, JavaScript sẽ tìm trong chính object đó trước.

  • Nếu không tìm thấy, nó m trong prototype của object.

  • Quá trình này tiếp tục cho đến khi gặp null (đỉnh của Prototype Chain).

Ví dụ minh họa Prototype Chain:

function Animal(name) {
  this.name = name;
}

Animal.prototype.makeSound = function () {
  return `${this.name} is making a sound`;
};

const cat = new Animal("Kitty");

console.log(cat.makeSound()); // "Kitty is making a sound"

// JavaScript tìm `makeSound` trong cat → Không có, nên tìm trong `Animal.prototype` → Tìm thấy!
console.log(cat.toString()); // "[object Object]"

// JavaScript tìm `toString` trong cat → Không có, nên tìm trong `Animal.prototype` → Không có
// Tiếp tục tìm trong `Object.prototype` → Tìm thấy `toString`

Khi nào JavaScript tìm thấy hoặc không tìm thấy thuộc tính trong Prototype Chain?

Trường hợp Kết quả Giải thích
Thuộc tính có trong object Tìm thấy JavaScript trả về giá trị từ object
Thuộc tính có trong Prototype Tìm thấy JavaScript tìm thấy trong prototype của object
Thuộc tính có trong Prototype của Prototype Tìm thấy JavaScript tiếp tục tìm lên chuỗi prototype
Không có trong Prototype Chain undefined JavaScript không tìm thấy trong chuỗi nguyên mẫu

So sánh Prototype với Class (ES6) trong JavaScript

JavaScript ES6 giới thiệu class, nhưng nó vẫn dựa trên Prototype

  • class trong JavaScript chỉ là cách viết khác của Prototype-based inheritance.

  • Khi khai báo một class, JavaScript vẫn tạo một prototype để lưu các phương thức.

Ví dụ tương đương giữa Prototype và Class:

Dùng Prototype:

function Person(name) {
  this.name = name;
}

Person.prototype.greet = function () {
  return `Hello, I'm ${this.name}`;
};

const john = new Person("John");
console.log(john.greet());
Dùng Class:
class Person {
  constructor(name) {
    this.name = name;
  }

  greet() {
    return `Hello, I'm ${this.name}`;
  }
}

const john = new Person("John");
console.log(john.greet());

Về bản chất, cả hai cách đều sử dụng Prototype Chain.

So sánh Prototype-based inheritance và Class-based inheritance

Tiêu chí Prototype-based Class-based (ES6)
Cách viết Sử dụng functionprototype Dùng từ khóa class
Tính dễ đọc Khó đọc hơn Dễ đọc và trực quan hơn
Cách kế thừa Sử dụng Object.create() hoặc Prototype Chain Dùng extendssuper
Hỗ trợ cú pháp ES6+ Không
Dễ sử dụng cho lập trình viên mới Khó hơn Dễ dàng hơn

Ví dụ về kế thừa với Class:

class Animal {
  constructor(name) {
    this.name = name;
  }

  makeSound() {
    return `${this.name} is making a sound`;
  }
}

class Dog extends Animal {
  bark() {
    return `${this.name} is barking!`;
  }
}

const myDog = new Dog("Buddy");
console.log(myDog.makeSound()); // "Buddy is making a sound"
console.log(myDog.bark()); // "Buddy is barking!"

Khi nào nên sử dụng Prototype, khi nào nên sử dụng Class?

Sử dụng Prototype khi:

  • Cần tối ưu bộ nhớ và không cần cú pháp ES6+.

  • Cần kế thừa linh hoạt, ví dụ như tạo các object prototype trực tiếp mà không cần class.

  • Muốn mở rộng các built-in objects như Array.prototype, Object.prototype.

Sử dụng Class khi:

  • Viết code dễ hiểu, dễ bảo trì hơn nhờ cú pháp ES6.

  • Dùng mô hình hướng đối tượng (OOP) hiện đại, kế thừa bằng extends.

  • Làm việc với ES6 modules và frameworks như React, Vue.

Kết bài

Prototype là một trong những khái niệm quan trọng nhất trong JavaScript, giúp hiện thực hóa tính kế thừa mà không cần đến class như trong các ngôn ngữ lập trình hướng đối tượng truyền thống. Hiểu rõ về Prototype, Prototype Chain, Constructor Function sẽ giúp lập trình viên tối ưu hóa bộ nhớ và viết mã hiệu quả hơn.

Mặc dù ES6 đã giới thiệu class, nhưng bản chất của nó vẫn dựa trên Prototype. Tùy vào từng trường hợp, bạn có thể sử dụng Prototype-based inheritance để tối ưu bộ nhớ hoặc Class-based inheritance để có mã nguồn dễ đọc và bảo trì hơn.

Nắm vững kiến thức về Prototype không chỉ giúp bạn hiểu sâu hơn về JavaScript mà còn giúp bạn trở thành một lập trình viên chuyên nghiệp, có khả năng viết mã tối ưu, hiệu suất cao và dễ mở rộng!

Bài viết liên quan