Tìm hiểu kế thừa trong JavaScript

Javascript nâng cao | by Học Javascript

Trong lập trình hướng đối tượng, kế thừa (Inheritance) là một khái niệm quan trọng giúp tái sử dụng mã nguồn và mở rộng chức năng của các lớp (class). Trước khi có ES6, JavaScript sử dụng mô hình prototype-based inheritance, nhưng kể từ ES6, việc kế thừa trở nên đơn giản và dễ hiểu hơn với từ khóa extends.

Kế thừa trong JavaScript cho phép một lớp con (subclass) kế thừa các thuộc tính và phương thức từ một lớp cha (parent class), giúp lập trình viên viết mã linh hoạt, gọn gàng và dễ bảo trì hơn. Ngoài ra, JavaScript cũng cung cấp từ khóa super() để gọi constructor hoặc phương thức của lớp cha trong lớp con, giúp việc kế thừa trở nên mạnh mẽ hơn.

Bài viết này sẽ đi sâu vào cách hoạt động của kế thừa trong JavaScript, từ cú pháp cơ bản đến các kỹ thuật nâng cao như method overriding, multilevel inheritance, và kế thừa phương thức tĩnh.

Kế thừa trong JavaScript là gì?

Kế thừa (Inheritance) là một trong những tính chất quan trọng của lập trình hướng đối tượng (OOP - Object-Oriented Programming). Nó cho phép một lớp con (subclass) kế thừa các thuộc tính và phương thức từ một lớp cha (parent class), giúp tái sử dụng mã nguồn và mở rộng chức năng của lớp cha mà không cần viết lại từ đầu.

Ví dụ: Nếu bạn có một lớp Animal chứa các phương thức chung như eat() hoặc sleep(), bạn có thể tạo một lớp Dog kế thừa từ Animal để có thể sử dụng các phương thức này mà không cần định nghĩa lại chúng.

Lợi ích của kế thừa trong JavaScript

Kế thừa mang lại nhiều lợi ích trong lập trình, bao gồm:

  • Tái sử dụng mã nguồn: Lớp con có thể sử dụng lại các phương thức và thuộc tính của lớp cha, giúp giảm thiểu việc lặp lại mã.

  • Dễ bảo trì và mở rộng: Nếu có thay đổi trong lớp cha, tất cả các lớp con kế thừa nó cũng sẽ nhận được cập nhật mà không cần chỉnh sửa từng lớp riêng lẻ.

  • Cấu trúc rõ ràng, dễ hiểu: Việc kế thừa giúp tổ chức mã nguồn có hệ thống hơn, dễ đọc và dễ quản lý.

  • Hỗ trợ tính đa hình (Polymorphism): Lớp con có thể ghi đè (override) phương thức của lớp cha để thay đổi hành vi theo nhu cầu.

Sự khác biệt giữa kế thừa bằng prototype (trước ES6) và kế thừa bằng class (ES6+)

Trước ES6, JavaScript dựa vào prototype-based inheritance để thực hiện kế thừa, sử dụng Object.create() hoặc thiết lập prototype thủ công. Tuy nhiên, từ ES6 trở đi, JavaScript cung cấp cú pháp classextends, giúp kế thừa dễ hiểu và trực quan hơn.

Cách kế thừa Trước ES6 (Prototype-based) Từ ES6+ (Class-based)
Cách khai báo Dùng Object.create() hoặc thiết lập prototype thủ công Dùng từ khóa classextends
Cách gọi constructor Không có cú pháp super(), phải gọi thủ công Dùng super() để gọi constructor của lớp cha
Dễ đọc & bảo trì Khó hiểu, cú pháp phức tạp hơn Dễ đọc, gần giống các ngôn ngữ OOP truyền thống
Hỗ trợ OOP tốt hơn Ít hỗ trợ, chủ yếu là mô phỏng Hỗ trợ mạnh mẽ hơn với class, extends, super

Ví dụ minh họa

Trước ES6 (Prototype-based Inheritance)

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

Animal.prototype.eat = function() {
    console.log(`${this.name} is eating.`);
};

function Dog(name, breed) {
    Animal.call(this, name); // Kế thừa thuộc tính từ Animal
    this.breed = breed;
}

Dog.prototype = Object.create(Animal.prototype); // Kế thừa phương thức từ Animal
Dog.prototype.constructor = Dog;

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

const myDog = new Dog("Buddy", "Golden Retriever");
myDog.eat();  // Output: Buddy is eating.
myDog.bark(); // Output: Buddy is barking.

Từ ES6 (Class-based Inheritance)

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

    eat() {
        console.log(`${this.name} is eating.`);
    }
}

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} is barking.`);
    }
}

const myDog = new Dog("Buddy", "Golden Retriever");
myDog.eat();  // Output: Buddy is eating.
myDog.bark(); // Output: Buddy is barking.

Cách sử dụng từ khóa extends để kế thừa class trong JavaScript

Cú pháp kế thừa một class bằng extends

Trong JavaScript ES6+, từ khóa extends được sử dụng để tạo một lớp con (subclass) kế thừa từ lớp cha (parent class). Khi một lớp con kế thừa từ lớp cha, nó sẽ có quyền truy cập vào tất cả các thuộc tính và phương thức của lớp cha mà không cần định nghĩa lại.

Cú pháp kế thừa bằng extends:

class ParentClass {
    // Định nghĩa thuộc tính và phương thức của lớp cha
}

class ChildClass extends ParentClass {
    // Định nghĩa thuộc tính và phương thức của lớp con
}

Ví dụ minh họa về kế thừa class cơ bản

Giả sử chúng ta có một lớp Animal, và muốn tạo một lớp Dog kế thừa từ Animal.

// Lớp cha
class Animal {
    constructor(name) {
        this.name = name;
    }

    eat() {
        console.log(`${this.name} is eating.`);
    }
}

// Lớp con kế thừa từ Animal
class Dog extends Animal {
    bark() {
        console.log(`${this.name} is barking.`);
    }
}

// Tạo một đối tượng từ lớp Dog
const myDog = new Dog("Buddy");

myDog.eat();  // Output: Buddy is eating.
myDog.bark(); // Output: Buddy is barking.

Giải thích:

  • class Dog extends Animal giúp Dog kế thừa toàn bộ thuộc tính và phương thức của Animal.

  • Đối tượng myDog có thể sử dụng phương thức eat() của lớp Animal mà không cần khai báo lại trong Dog.

Sử dụng super() để gọi constructor của lớp cha trong JavaScript

Vai trò của super() trong constructor của lớp con

Trong JavaScript, khi một lớp con kế thừa từ một lớp cha, nếu lớp con có constructor, thì bắt buộc phải gọi super() trước khi sử dụng this.

Tại sao cần super()?

  • super() gọi constructor của lớp cha và kế thừa các thuộc tính từ lớp cha.

  • Nếu không gọi super(), JavaScript sẽ báo lỗi khi truy cập this trong constructor của lớp con.

Ví dụ minh họa về cách sử dụng super()

// Lớp cha
class Animal {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    eat() {
        console.log(`${this.name} is eating.`);
    }
}

// Lớp con kế thừa từ Animal
class Dog extends Animal {
    constructor(name, age, breed) {
        super(name, age); // Gọi constructor của lớp cha
        this.breed = breed;
    }

    bark() {
        console.log(`${this.name}, a ${this.age}-year-old ${this.breed}, is barking.`);
    }
}

// Tạo đối tượng từ lớp Dog
const myDog = new Dog("Buddy", 3, "Golden Retriever");

myDog.eat();  // Output: Buddy is eating.
myDog.bark(); // Output: Buddy, a 3-year-old Golden Retriever, is barking.

Giải thích:

  1. class Dog extends Animal giúp Dog kế thừa từ Animal.

  2. Constructor của Dog nhận ba tham số: name, age, breed.

  3. super(name, age) gọi constructor của Animal để thiết lập các thuộc tính nameage.

  4. Nếu không có super(name, age), JavaScript sẽ báo lỗi khi truy cập this.

Ghi đè phương thức (Method Overriding) trong kế thừa trong JavaScript

Ghi đè phương thức (Method Overriding) là kỹ thuật trong lập trình hướng đối tượng, trong đó một lớp con định nghĩa lại một phương thức có cùng tên, tham số và hành vi giống với một phương thức của lớp cha.

Lợi ích của method overriding:

  • Giúp lớp con có thể điều chỉnh hoặc mở rộng hành vi của lớp cha.

  • Tạo sự linh hoạt trong thiết kế mã nguồn.

  • Giúp tổ chức mã nguồn dễ đọc và dễ bảo trì hơn.

Cách sử dụng phương thức cùng tên để thay đổi hành vi của lớp cha

Khi lớp con định nghĩa lại một phương thức có cùng tên với lớp cha, phương thức của lớp con sẽ được ưu tiên sử dụng thay vì phương thức của lớp cha.

Tuy nhiên, nếu vẫn muốn sử dụng phương thức của lớp cha bên trong lớp con, ta có thể sử dụng super.methodName() để gọi phương thức gốc từ lớp cha.

Ví dụ minh họa về method overriding

// Lớp cha
class Animal {
    constructor(name) {
        this.name = name;
    }

    makeSound() {
        console.log(`${this.name} makes a sound.`);
    }
}

// Lớp con kế thừa từ Animal
class Dog extends Animal {
    makeSound() {
        console.log(`${this.name} barks.`);
    }
}

// Lớp con khác kế thừa từ Animal
class Cat extends Animal {
    makeSound() {
        console.log(`${this.name} meows.`);
    }
}

// Tạo đối tượng từ lớp Dog và Cat
const myDog = new Dog("Buddy");
const myCat = new Cat("Whiskers");

myDog.makeSound(); // Output: Buddy barks.
myCat.makeSound(); // Output: Whiskers meows.

Giải thích:

  • makeSound() trong DogCat đã ghi đè phương thức makeSound() từ Animal.

  • Khi gọi makeSound() trên một đối tượng Dog hoặc Cat, nó sẽ thực thi phương thức mới thay vì phương thức của Animal.

Sử dụng super() để gọi phương thức của lớp cha

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

    makeSound() {
        console.log(`${this.name} makes a generic sound.`);
    }
}

class Dog extends Animal {
    makeSound() {
        super.makeSound(); // Gọi phương thức từ lớp cha
        console.log(`${this.name} barks loudly.`);
    }
}

const myDog = new Dog("Rex");
myDog.makeSound();  
// Output: 
// Rex makes a generic sound.
// Rex barks loudly.

Giải thích:

  • super.makeSound() gọi phương thức makeSound() từ lớp Animal.

  • Sau đó, phương thức mới trong Dog tiếp tục thực thi.

Kế thừa nhiều cấp (Multilevel Inheritance) trong JavaScript

Kế thừa nhiều cấp xảy ra khi một lớp con kế thừa từ một lớp cha, và lớp cha lại kế thừa từ một lớp cha khác.

Mô hình kế thừa nhiều cấp:

Lớp A (Grandparent Class)
   ↑
Lớp B (Parent Class) kế thừa từ A
   ↑
Lớp C (Child Class) kế thừa từ B

Lợi ích của kế thừa nhiều cấp:

  • Tái sử dụng mã nguồn một cách tối đa.

  • Dễ mở rộng chức năng mà không cần viết lại logic cũ.

  • Tạo mối quan hệ rõ ràng giữa các lớp trong hệ thống.

Ví dụ minh họa về multilevel inheritance

// Lớp ông
class LivingBeing {
    constructor() {
        this.alive = true;
    }

    breathe() {
        console.log("Breathing...");
    }
}

// Lớp cha kế thừa từ LivingBeing
class Animal extends LivingBeing {
    constructor(name) {
        super(); // Gọi constructor của LivingBeing
        this.name = name;
    }

    eat() {
        console.log(`${this.name} is eating.`);
    }
}

// Lớp con kế thừa từ Animal
class Dog extends Animal {
    constructor(name, breed) {
        super(name); // Gọi constructor của Animal
        this.breed = breed;
    }

    bark() {
        console.log(`${this.name} (${this.breed}) is barking.`);
    }
}

// Tạo đối tượng từ lớp Dog
const myDog = new Dog("Buddy", "Golden Retriever");

myDog.breathe(); // Output: Breathing... (Từ LivingBeing)
myDog.eat();     // Output: Buddy is eating. (Từ Animal)
myDog.bark();    // Output: Buddy (Golden Retriever) is barking. (Từ Dog)

Giải thích:

  • LivingBeing có phương thức breathe().

  • Animal kế thừa LivingBeing, có thêm eat().

  • Dog kế thừa Animal, có thêm bark().

  • myDog có thể sử dụng tất cả các phương thức từ cả ba lớp.

Sử dụng phương thức của lớp cha trong lớp con trong JavaScript

Cách gọi phương thức của lớp cha trong lớp con bằng super.methodName()

Trong JavaScript, từ khóa super.methodName() được sử dụng để gọi phương thức của lớp cha từ lớp con.

Cách hoạt động:

  • Nếu lớp con định nghĩa lại một phương thức đã có trong lớp cha, phương thức trong lớp con sẽ được ưu tiên.

  • Tuy nhiên, nếu muốn sử dụng lại logic của lớp cha trong phương thức bị ghi đè, ta có thể gọi super.methodName() bên trong lớp con.

Ví dụ minh họa về gọi phương thức lớp cha trong lớp con

class Parent {
    greet() {
        console.log("Hello from Parent class.");
    }
}

class Child extends Parent {
    greet() {
        super.greet(); // Gọi phương thức từ lớp cha
        console.log("Hello from Child class.");
    }
}

const obj = new Child();
obj.greet();

/*
Output:
Hello from Parent class.
Hello from Child class.
*/

Giải thích:

  • Child kế thừa từ Parent.

  • Child ghi đè phương thức greet(), nhưng vẫn gọi super.greet() để chạy phương thức gốc từ Parent.

  • Kết quả: Cả thông điệp từ ParentChild đều được in ra.


Ví dụ khác khi lớp cha có tham số:

class Vehicle {
    constructor(brand) {
        this.brand = brand;
    }

    showInfo() {
        console.log(`This vehicle is a ${this.brand}.`);
    }
}

class Car extends Vehicle {
    constructor(brand, model) {
        super(brand); // Gọi constructor của lớp cha
        this.model = model;
    }

    showInfo() {
        super.showInfo(); // Gọi phương thức từ lớp cha
        console.log(`Model: ${this.model}`);
    }
}

const myCar = new Car("Toyota", "Camry");
myCar.showInfo();

/*
Output:
This vehicle is a Toyota.
Model: Camry
*/

Giải thích:

  • Car kế thừa từ Vehicle.

  • super(brand) được gọi trong constructor của Car để sử dụng lại logic khởi tạo của Vehicle.

  • super.showInfo() được gọi trong showInfo() để hiển thị thông tin từ Vehicle trước khi thêm thông tin của Car.

Thuộc tính và phương thức tĩnh trong kế thừa (Static Inheritance) trong JavaScript

Giới thiệu về thuộc tính và phương thức tĩnh (static)

  • Trong JavaScript, thuộc tính và phương thức tĩnh (static) là những thành phần thuộc về lớp (class) thay vì đối tượng (instance).

  • Các phương thức hoặc thuộc tính tĩnh không thể được gọi bằng đối tượng, mà phải gọi trực tiếp từ lớp.

class Example {
    static staticMethod() {
        console.log("This is a static method.");
    }
}

Example.staticMethod(); //  Gọi từ lớp
// new Example().staticMethod();  Lỗi: Không thể gọi từ đối tượng

Cách kế thừa phương thức tĩnh từ lớp cha

  • Khi một lớp con kế thừa từ lớp cha (extends), các phương thức static của lớp cha cũng được kế thừa.

  • Lớp con có thể gọi phương thức static của lớp cha bằng super.methodName().

Ví dụ minh họa về kế thừa phương thức tĩnh

class Parent {
    static parentMethod() {
        console.log("Static method in Parent class.");
    }
}

class Child extends Parent {
    static childMethod() {
        console.log("Static method in Child class.");
    }
}

Child.parentMethod(); //  Kế thừa từ lớp cha
Child.childMethod();  //  Phương thức riêng của lớp con

/*
Output:
Static method in Parent class.
Static method in Child class.
*/

Giải thích:

  • Child kế thừa Parent, do đó có thể gọi Parent.parentMethod() mà không cần định nghĩa lại.

  • Child.childMethod() là phương thức tĩnh riêng của Child.

Gọi phương thức tĩnh của lớp cha bằng super trong lớp con

class Vehicle {
    static type() {
        return "General Vehicle";
    }

    static showType() {
        console.log(`Vehicle type: ${this.type()}`);
    }
}

class Car extends Vehicle {
    static type() {
        return "Car";
    }

    static showType() {
        super.showType(); // Gọi phương thức của lớp cha
        console.log(`Specific type: ${this.type()}`);
    }
}

Car.showType();

/*
Output:
Vehicle type: Car
Specific type: Car
*/

Giải thích:

  • Car kế thừa từ Vehicle và ghi đè phương thức type().

  • Khi gọi super.showType(), nó sử dụng logic của Vehicle.showType() nhưng áp dụng Car.type()this tham chiếu đến Car.

Khi nào nên sử dụng kế thừa trong JavaScript?

Kế thừa (Inheritance) là một tính năng quan trọng trong lập trình hướng đối tượng, giúp tái sử dụng mã nguồn và tổ chức chương trình một cách rõ ràng hơn. Tuy nhiên, việc sử dụng kế thừa không phải lúc nào cũng là lựa chọn tốt. Dưới đây là các trường hợp nên và không nên sử dụng kế thừa trong JavaScript.

Những trường hợp nên sử dụng kế thừa

Khi có quan hệ "is-a" (là một loại của...)

  • Nếu một lớp con thực sự là một phiên bản cụ thể của lớp cha, kế thừa là hợp lý.

  • Ví dụ: Car là một loại Vehicle, Dog là một loại Animal.

Ví dụ minh họa:

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

    makeSound() {
        console.log("Some generic sound...");
    }
}

class Dog extends Animal {
    makeSound() {
        console.log("Woof! Woof!");
    }
}

const myDog = new Dog("Buddy");
myDog.makeSound(); // Output: Woof! Woof!

Lý do hợp lý:

  • Dog có quan hệ "is-a" với Animal.

  • Dog có thể mở rộng Animal bằng cách ghi đè phương thức makeSound().

Khi có nhiều lớp chia sẻ chung hành vi

  • Nếu nhiều lớp có cùng thuộc tính và phương thức, kế thừa giúp tránh lặp lại mã nguồn.

  • Ví dụ: Car, Bike, và Bus đều có thể kế thừa từ Vehicle thay vì viết lại cùng một logic.

Ví dụ minh họa:

class Vehicle {
    constructor(brand) {
        this.brand = brand;
    }

    start() {
        console.log(`${this.brand} is starting...`);
    }
}

class Car extends Vehicle {
    drive() {
        console.log(`${this.brand} is driving.`);
    }
}

class Bike extends Vehicle {
    ride() {
        console.log(`${this.brand} is riding.`);
    }
}

const myCar = new Car("Toyota");
myCar.start(); // Output: Toyota is starting...
myCar.drive(); // Output: Toyota is driving.

const myBike = new Bike("Yamaha");
myBike.start(); // Output: Yamaha is starting...
myBike.ride();  // Output: Yamaha is riding.

Lý do hợp lý:

  • Tránh lặp lại phương thức start() ở cả CarBike.

  • Giúp mã nguồn gọn gàng và dễ bảo trì hơn.

Khi muốn mở rộng một lớp mà không làm thay đổi lớp gốc

  • Nếu cần thêm tính năng mới cho một lớp hiện có mà không muốn sửa đổi nó, kế thừa là một giải pháp tốt.

Ví dụ minh họa:

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

    login() {
        console.log(`${this.name} logged in.`);
    }
}

class Admin extends User {
    deleteUser(user) {
        console.log(`${this.name} deleted ${user.name}`);
    }
}

const admin = new Admin("Alice");
const user = new User("Bob");

admin.login();      // Output: Alice logged in.
admin.deleteUser(user); // Output: Alice deleted Bob

Lý do hợp lý:

  • Admin có tất cả chức năng của User nhưng có thêm khả năng deleteUser().

  • Không cần sửa đổi User, giúp mã nguồn linh hoạt hơn.

Những trường hợp không nên sử dụng kế thừa

Khi quan hệ không thực sự là "is-a"

  • Nếu mối quan hệ giữa các lớp không thực sự phản ánh "is-a", không nên dùng kế thừa mà nên dùng composition.

  • Ví dụ: Một Printer không phải là một Computer, nhưng nó có thể được sử dụng bởi một Computer.

Giải pháp thay thế: Dùng composition thay vì kế thừa.

class Printer {
    print(document) {
        console.log(`Printing: ${document}`);
    }
}

class Computer {
    constructor(brand) {
        this.brand = brand;
        this.printer = new Printer(); // Sử dụng composition thay vì kế thừa
    }

    printDocument(document) {
        this.printer.print(document);
    }
}

const myComputer = new Computer("Dell");
myComputer.printDocument("Report.pdf");

/*
Output:
Printing: Report.pdf
*/

Sai lầm phổ biến:

  • Nếu Computer kế thừa Printer, thì Computer sẽ có tất cả các phương thức của Printer, ngay cả khi nó không cần.

  • Điều này làm tăng sự phụ thuộc không cần thiết.

Khi kế thừa làm tăng độ phức tạp và khó bảo trì

  • Nếu hệ thống có quá nhiều lớp con kế thừa từ một lớp cha, nó có thể trở nên quá phức tạpkhó mở rộng.

  • Nếu thay đổi lớp cha, tất cả các lớp con cũng có thể bị ảnh hưởng.

Giải pháp thay thế: Dùng composition hoặc interfaces (trong TypeScript) để tránh quan hệ cha-con cứng nhắc.

class Logger {
    log(message) {
        console.log(`Log: ${message}`);
    }
}

class Payment {
    constructor() {
        this.logger = new Logger(); // Dùng composition thay vì kế thừa
    }

    processPayment(amount) {
        this.logger.log(`Processing payment of $${amount}`);
    }
}

const payment = new Payment();
payment.processPayment(100);

/*
Output:
Log: Processing payment of $100
*/

Lợi ích của composition thay vì kế thừa:

  • Payment có thể sử dụng Logger mà không cần kế thừa.

  • Nếu sau này cần thay đổi hệ thống log, chỉ cần thay đổi Logger, không cần sửa Payment.

Khi kế thừa chỉ để tái sử dụng mã (Code Reuse Trap)

  • Nếu kế thừa chỉ để tái sử dụng phương thức, thì có thể không cần thiết.

  • Kế thừa nên dựa trên quan hệ thực sự, không phải chỉ để tránh viết lại mã.

Sai lầm phổ biến:

class Utility {
    log(message) {
        console.log(message);
    }
}

class User extends Utility {} //  Không hợp lý!

const user = new User();
user.log("Hello!"); // Output: Hello!

Giải pháp thay thế: Dùng helper functions thay vì kế thừa.

const Utility = {
    log: (message) => console.log(message),
};

Utility.log("Hello!"); // Output: Hello!

Kết bài

Kế thừa (Inheritance) là một tính năng mạnh mẽ trong lập trình hướng đối tượng, giúp tái sử dụng mã nguồn, tổ chức chương trình tốt hơn và giảm sự trùng lặp. JavaScript cung cấp cơ chế kế thừa thông qua từ khóa extendssuper(), giúp mở rộng các lớp một cách linh hoạt và hiệu quả.

Tuy nhiên, kế thừa không phải lúc nào cũng là giải pháp tối ưu. Nếu sử dụng không hợp lý, nó có thể làm tăng độ phức tạp, gây khó khăn trong việc bảo trì và mở rộng hệ thống. Trong một số trường hợp, composition có thể là một lựa chọn tốt hơn, giúp mã nguồn linh hoạt và dễ quản lý hơn.

Vì vậy, khi thiết kế một hệ thống trong JavaScript, bạn nên cân nhắc kỹ lưỡng giữa kế thừacomposition, đảm bảo rằng bạn chọn được mô hình phù hợp nhất để phát triển phần mềm một cách hiệu quả

Bài viết liên quan