JavaScript là một ngôn ngữ khá đặc biệt khi nói về lập trình hướng đối tượng (Object-Oriented Programming - OOP). Khác với Java hay C# vốn được xây dựng dựa trên class ngay từ đầu, JavaScript nguyên bản sử dụng cơ chế prototype - một mô hình kế thừa hoàn toàn khác biệt. Bài viết này sẽ giúp bạn hiểu bản chất OOP trong JavaScript, từ prototype nguyên thủy cho đến cú pháp class hiện đại.

1. Bốn trụ cột của OOP - áp dụng vào JavaScript

OOP thường được xây dựng trên bốn nguyên lý: Encapsulation (đóng gói), Inheritance (kế thừa), Polymorphism (đa hình), và Abstraction (trừu tượng hóa). Hãy xem JavaScript thể hiện từng nguyên lý này như thế nào.

2. Object - nền tảng của mọi thứ

Trong JavaScript, gần như mọi thứ đều là object (ngoại trừ các kiểu nguyên thủy). Cách đơn giản nhất để tạo object là dùng object literal:

const user = {
  name: "Nguyen",
  age: 25,
  greet() {
    console.log(`Xin chào, tôi là ${this.name}`);
  }
};

user.greet(); // "Xin chào, tôi là Nguyen"

Nhưng nếu cần tạo nhiều object có cùng cấu trúc (ví dụ: nhiều user), viết lặp lại object literal là không hiệu quả. Đây là lúc ta cần đến khuôn mẫu (constructor/class).

3. Constructor Function - cách làm OOP truyền thống trong JS

Trước khi ES6 ra đời, JavaScript dùng constructor function kết hợp với new để tạo object theo khuôn mẫu:

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

User.prototype.greet = function () {
  console.log(`Xin chào, tôi là ${this.name}`);
};

const user1 = new User("Nguyen", 25);
const user2 = new User("Linh", 22);

user1.greet(); // "Xin chào, tôi là Nguyen"

Điều quan trọng cần hiểu: khi gọi new User(...), JavaScript thực hiện 4 bước:

  1. Tạo một object rỗng mới.
  2. Gán this bên trong hàm trỏ tới object đó.
  3. Gán __proto__ của object trỏ tới User.prototype.
  4. Trả về object đó (trừ khi hàm tự return một object khác).

4. Prototype - cơ chế kế thừa cốt lõi của JavaScript

Đây là phần khác biệt nhất so với các ngôn ngữ OOP truyền thống. Mọi object trong JavaScript đều có một liên kết ẩn tới một object khác gọi là prototype. Khi bạn truy cập một thuộc tính không tồn tại trên object, JavaScript sẽ tự động tìm lên chuỗi prototype (gọi là prototype chain) cho đến khi tìm thấy hoặc đến null.

const animal = {
  eat() {
    console.log("Đang ăn...");
  }
};

const dog = Object.create(animal); // dog kế thừa từ animal
dog.bark = function () {
  console.log("Gâu gâu!");
};

dog.eat();  // "Đang ăn..." - lấy từ prototype animal
dog.bark(); // "Gâu gâu!" - thuộc tính riêng của dog

Đây chính là lý do tại sao ở ví dụ User phía trên, ta gắn greet vào User.prototype thay vì gắn trực tiếp trong constructor: tất cả instance được tạo ra sẽ dùng chung một hàm greet trong bộ nhớ, thay vì mỗi object tự tạo một bản sao riêng - giúp tiết kiệm bộ nhớ đáng kể khi có nhiều instance.

5. Class - cú pháp hiện đại (ES6+)

Từ ES6 (2015), JavaScript giới thiệu từ khóa class. Cần lưu ý: đây không phải là class thật sự như trong Java - nó chỉ là cú pháp đường (syntactic sugar) bọc bên ngoài cơ chế prototype đã có từ trước. Bên dưới, mọi thứ vẫn hoạt động dựa trên prototype chain.

class User {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    console.log(`Xin chào, tôi là ${this.name}`);
  }
}

const user1 = new User("Nguyen", 25);
user1.greet(); // "Xin chào, tôi là Nguyen"

Cú pháp class giúp code dễ đọc và gần gũi hơn với lập trình viên đến từ các ngôn ngữ OOP khác, dù bản chất vận hành phía dưới vẫn là prototype.

6. Inheritance (Kế thừa) với extends và super

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

  eat() {
    console.log(`${this.name} đang ăn`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name); // gọi constructor của lớp cha
    this.breed = breed;
  }

  bark() {
    console.log(`${this.name} (${this.breed}) sủa: Gâu gâu!`);
  }
}

const dog = new Dog("Lucky", "Poodle");
dog.eat();  // "Lucky đang ăn" - kế thừa từ Animal
dog.bark(); // "Lucky (Poodle) sủa: Gâu gâu!"

extends thiết lập kế thừa giữa hai class, còn super() bắt buộc phải được gọi trong constructor của lớp con trước khi dùng this, vì nó chịu trách nhiệm khởi tạo phần thuộc tính kế thừa từ lớp cha.

7. Encapsulation (Đóng gói) với thuộc tính private

Trước đây, JavaScript không có cách nào thực sự ẩn thuộc tính (chỉ có quy ước đặt tên _tenBien để ngầm hiểu là "private"). Từ ES2022, JavaScript hỗ trợ private field thực sự bằng dấu #:

class BankAccount {
  #balance = 0; // thuộc tính private thực sự

  constructor(owner) {
    this.owner = owner;
  }

  deposit(amount) {
    if (amount <= 0) {
      console.log("Số tiền không hợp lệ");
      return;
    }
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

const acc = new BankAccount("Nguyen");
acc.deposit(1000000);
console.log(acc.getBalance()); // 1000000
console.log(acc.#balance); // SyntaxError: thuộc tính private không thể truy cập từ bên ngoài

Nhờ đó, #balance chỉ có thể được đọc/ghi thông qua các phương thức công khai (deposit, getBalance) mà class cung cấp - đúng tinh thần đóng gói của OOP.

8. Polymorphism (Đa hình) - cùng phương thức, hành vi khác nhau

Đa hình cho phép các lớp con override (ghi đè) phương thức của lớp cha để có hành vi riêng, trong khi vẫn được gọi thông qua cùng một interface.

class Shape {
  area() {
    return 0;
  }
}

class Circle extends Shape {
  constructor(radius) {
    super();
    this.radius = radius;
  }

  area() {
    return Math.PI * this.radius ** 2;
  }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }

  area() {
    return this.side ** 2;
  }
}

const shapes = [new Circle(5), new Square(4)];

shapes.forEach((shape) => {
  console.log(shape.area().toFixed(2));
});
// 78.54
// 16.00

Dù cùng gọi shape.area(), mỗi object trả về kết quả khác nhau tùy theo class thực sự của nó - đó chính là đa hình.

9. Getter, Setter và Static Method

Class trong JavaScript còn hỗ trợ getter/setter để kiểm soát cách đọc/ghi thuộc tính, và static method dùng để định nghĩa hàm thuộc về class chứ không thuộc về từng instance:

class Circle {
  constructor(radius) {
    this._radius = radius;
  }

  get diameter() {
    return this._radius * 2;
  }

  set radius(value) {
    if (value < 0) throw new Error("Bán kính không thể âm");
    this._radius = value;
  }

  static describe() {
    return "Circle là một hình học cơ bản";
  }
}

const c = new Circle(5);
console.log(c.diameter);      // 10 (gọi như thuộc tính, không cần dấu ngoặc)
c.radius = 10;                // dùng setter để gán giá trị mới
console.log(Circle.describe()); // gọi trực tiếp trên class, không qua instance

10. Kết luận

OOP trong JavaScript có một hành trình khá thú vị: xuất phát từ prototype linh hoạt nhưng khó tiếp cận, đến cú pháp class hiện đại dễ đọc hơn nhưng bản chất vẫn dựa trên prototype chain phía dưới. Việc hiểu rõ cả hai lớp - cú pháp class bề mặt và cơ chế prototype cốt lõi - sẽ giúp bạn không chỉ viết code OOP đúng cách mà còn debug được những lỗi liên quan đến this và kế thừa một cách tự tin hơn.