Tính kế thừa của lớp là một cách để một lớp mở rộng từ một lớp khác.

Vì vậy, chúng ta có thể tạo phương thức mới trên các đối tượng hiện có.

1. Từ khóa “extends”

Chúng ta có lớp Animal:

/*
Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
@author cafedevn
Contact: cafedevn@gmail.com
Fanpage: https://www.facebook.com/cafedevn
Instagram: https://instagram.com/cafedevn
Twitter: https://twitter.com/CafedeVn
Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  run(speed) {
    this.speed = speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }
  stop() {
    this.speed = 0;
    alert(`${this.name} stands still.`);
  }
}

let animal = new Animal("My animal");

Đây là cách chúng ta có thể biểu diễn đối tượng animal và Sơ đồ của lớp Animal:

Và tôi muốn tạo ra một cái khác class Rabbit.

Vì thỏ là động vật, lớpRabbitnên dựa vào Animal, có quyền truy cập vào các phương thức của động vật, để thỏ có thể làm những gì mà động vật có tên chung chung có thể làm.

Cú pháp để mở rộng một lớp khác là : class Child extends Parent.

Hãy tạo ra class Rabbitnhững kế thừa từ Animal:

class Rabbit extends Animal {
  hide() {
    alert(`${this.name} hides!`);
  }
}

let rabbit = new Rabbit("White Rabbit");

rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.hide(); // White Rabbit hides!

Đối tượng của lớpRabbit có quyền truy cập vào cả hai phương thức của Rabbit, chẳng hạn như rabbit.hide()và cả các phương thứcAnimal, chẳng hạn như rabbit.run().

Trong nội bộ, từ khóa extends hoạt động bằng cách sử dụng các cơ chế của nguyên mẫu(prototype). Nó đặt Rabbit.prototype.[[Prototype]]thành Animal.prototype. Vì vậy, nếu không tìm thấy một phương thức Rabbit.prototype, JavaScript sẽ lấy nó từ đó Animal.prototype.

Chẳng hạn, để tìm phương thức rabbit.run, động cơ sẽ kiểm tra (từ dưới lên trên hình):

  1. Đối tượng rabbit (không có run).
  2. Nguyên mẫu của nó, đó là Rabbit.prototype(có hide, nhưng không run).
  3. Nguyên mẫu của nó, nghĩa là (do extends) Animal.prototype, cuối cùng cũng có phương thức run.

Như chúng ta có thể nhớ lại từ chương Native prototypes, JavaScript tự sử dụng tính kế thừa nguyên mẫu cho các đối tượng. Ví dụ như Date.prototype.[[Prototype]]Object.prototype. Đó là lý do tại sao ngày có quyền truy cập vào các phương thức đối tượng chung.

Bất kỳ biểu thức nào cũng được cho phép extends

Cú pháp class cho phép chỉ định không chỉ một lớp, mà bất kỳ biểu thức nào cũng đi sau từ khoáextends.

Ví dụ, một lệnh gọi hàm tạo lớp cha:

function f(phrase) {
  return class {
    sayHi() { alert(phrase) }
  }
}

class User extends f("Hello") {}

new User().sayHi(); // Hello

Ở đây class Userkế thừa từ kết quả của f("Hello").

Điều đó có thể hữu ích cho các mẫu lập trình nâng cao khi chúng ta sử dụng các hàm để tạo các lớp tùy thuộc vào nhiều điều kiện và có thể kế thừa từ chúng.

2. Ghi đè(Overriding) một phương thức

Bây giờ chúng ta hãy tiến lên và ghi đè(Overriding) một phương thức. Theo mặc định, tất cả các phương thức không được chỉ định trong class Rabbitđều được thực hiện trực tiếp như là từ class Animal.

Nhưng nếu chúng ta chỉ định phương thức riêng của mình trong Rabbit, chẳng hạn như stop()nó sẽ được sử dụng thay thế:

class Rabbit extends Animal {
  stop() {
    // ...now this will be used for rabbit.stop()
    // instead of stop() from class Animal
  }
}

Thông thường chúng ta không muốn thay thế hoàn toàn một phương thức cha, mà là xây dựng lại phương thức cha để điều chỉnh hoặc mở rộng hàm của nó. Chúng ta làm một cái gì đó trong phương thức của chúng ta, nhưng gọi phương thức cha trước / sau nó hoặc trong quy trình.

Các lớp cung cấp từ khóa "super" để làm điều đó.

  • super.method(...) để gọi một phương thức cha.
  • super(...) để gọi một hàm tạo của cha (chỉ bên trong hàm tạo của chúng ta).

Ví dụ, hãy để thỏ của chúng ta tự động khi dừng lại:

/*
Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
@author cafedevn
Contact: cafedevn@gmail.com
Fanpage: https://www.facebook.com/cafedevn
Instagram: https://instagram.com/cafedevn
Twitter: https://twitter.com/CafedeVn
Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  run(speed) {
    this.speed = speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }

  stop() {
    this.speed = 0;
    alert(`${this.name} stands still.`);
  }

}

class Rabbit extends Animal {
  hide() {
    alert(`${this.name} hides!`);
  }

  stop() {
    super.stop(); // call parent stop
    this.hide(); // and then hide
  }
}

let rabbit = new Rabbit("White Rabbit");

rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.stop(); // White Rabbit stands still. White rabbit hides!

Bây giờ Rabbitcó phương thức stop và nó gọi phương thức cha super.stop()trong quá trình.

Hàm mũi tên không có super

Các hàm mũi tên không có super.

Nếu được truy cập, nó được lấy từ hàm bên ngoài. Ví dụ:

class Rabbit extends Animal {
  stop() {
    setTimeout(() => super.stop(), 1000); // call parent stop after 1sec
  }
}

Hàm supertrong mũi tên giống như trong stop(), vì vậy nó hoạt động như dự định. Nếu chúng ta đã chỉ định một hàm thông thường của người dùng ở đây, thì sẽ xảy ra lỗi:

// Unexpected super
setTimeout(function() { super.stop() }, 1000);

3. Overriding constructor

Với các constructor, nó có một chút khó khăn.

Cho đến bây giờ, Rabbitkhông có constructorriêng của nó .

Theo đặc tả , nếu một lớp mở rộng một lớp khác và không có constructor, thì lớp trống rỗng sau đây được tạo ra một constructor:

class Rabbit extends Animal {
  // generated for extending classes without own constructors
  constructor(...args) {
    super(...args);
  }
}

Như chúng ta có thể thấy, về cơ bản nó gọi constructorcha truyền cho nó tất cả các đối số. Điều đó xảy ra nếu chúng ta không viết một constructor của riêng lớp con.

Bây giờ hãy thêm một hàm tạo tùy chỉnh của mình vào Rabbit. Nó sẽ tạo thêm earLengthngoài thuộc tínhname:

/*
Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
@author cafedevn
Contact: cafedevn@gmail.com
Fanpage: https://www.facebook.com/cafedevn
Instagram: https://instagram.com/cafedevn
Twitter: https://twitter.com/CafedeVn
Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/


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

class Rabbit extends Animal {

  constructor(name, earLength) {
    this.speed = 0;
    this.name = name;
    this.earLength = earLength;
  }

  // ...
}

// Doesn't work!
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.

Rất tiếc! Chúng ta đã có một lỗi. Bây giờ chúng ta không thể tạo ra thỏ. Có chuyện gì?

Câu trả lời ngắn gọn là: các hàm xây dựng(constructors) trong kế thừa các lớp phải gọi super(...)và phải Làm điều đó trước khi sử dụng this.

…Nhưng tại sao? Những gì đang xảy ra ở đây? Thật vậy, yêu cầu có vẻ lạ.

Tất nhiên, có một lời giải thích. Hãy đi vào chi tiết, vì vậy bạn sẽ thực sự hiểu những gì đang diễn ra.

Trong JavaScript, có một sự khác biệt giữa hàm xây dựng(constructors) của một lớp kế thừa (cái gọi là hàm tạo của lớp dẫn xuất hay hàm tạo của lớp con) và các hàm khác. Một constructor dẫn xuất có một thuộc tính nội bộ đặc biệt [[ConstructorKind]]:"derived". Đó là một nhãn nội bộ đặc biệt.

Nhãn đó ảnh hưởng đến hành vi của nó với new.

  • Khi một hàm thông thường được thực thi new, nó sẽ tạo một đối tượng trống và gán nó cho this.
  • Nhưng khi một hàm tạo dẫn xuất chạy, nó không làm điều này. Nó hy vọng các hàm xây dựng(constructors) cha sẽ làm công việc này.

Vì vậy, một hàm tạo có nguồn gốc phải gọi superđể thực thi hàm tạo (không dẫn xuất) của nó, nếu không thì đối tượng thissẽ không được tạo. Và chúng ta sẽ nhận được một lỗi.

Để hàm tạo Rabbit hoạt động, nó cần gọi super()trước khi sử dụng this, như ở đây:

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  // ...
}

class Rabbit extends Animal {

  constructor(name, earLength) {
    super(name);
    this.earLength = earLength;
  }

  // ...
}

// now fine
let rabbit = new Rabbit("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10

4. Super: nội bộ(internals), [[HomeObject]]

Thông tin nâng cao

Nếu bạn đang học bài hướng dẫn này lần đầu tiên – phần này có thể bị bỏ qua.

Đó là về các cơ chế nội bộ đằng sau sự kế thừa và super.

Chúng ta hãy đi sâu hơn một chút` của super. Chúng ta sẽ thấy một số điều thú vị trên đường đi.

Đầu tiên phải nói, từ tất cả những gì chúng ta đã học cho đến bây giờ, nó không thể làm chosuper làm việc được!

Vâng, thực sự, chúng ta hãy tự hỏi, làm thế nào nó nên hoạt động về mặt kỹ thuật? Khi một phương thức đối tượng chạy, nó nhận được đối tượng hiện tại là this. Nếu chúng ta gọi super.method()thì Công cụ của Javascript cần lấy methodtừ nguyên mẫu của đối tượng hiện tại. Nhưng bằng cách nào?

Nhiệm vụ có vẻ đơn giản, nhưng không phải. Công cụ biết đối tượng this hiện tại , vì vậy nó có thể lấy methodcha là this.__proto__.method. Thật không may, một giải pháp này không hoạt động.

Hãy chứng minh vấn đề. Không có các lớp, sử dụng các đối tượng đơn giản vì mục đích đơn giản.

Bạn có thể bỏ qua phần này và đi đến phần phụ[[HomeObject]] nếu bạn không muốn biết chi tiết. Điều đó sẽ không gây hại. Hoặc đọc tiếp nếu bạn muốn tìm hiểu sâu về những điều sâu sắc.

Trong ví dụ dưới đây , rabbit.__proto__ = animal. Bây giờ hãy thử: rabbit.eat()chúng ta sẽ gọi animal.eat(), sử dụng this.__proto__:

let animal = {
  name: "Animal",
  eat() {
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {
    // that's how super.eat() could presumably work
    this.__proto__.eat.call(this); // (*)
  }
};

rabbit.eat(); // Rabbit eats.

Tại dòng (*)chúng ta lấy eattừ nguyên mẫu ( animal) và gọi nó trong ngữ cảnh của đối tượng hiện tại. Xin lưu ý rằng điều này rất quan trọng ở đây .call(this), bởi vì đơn giản this.__proto__.eat()sẽ thực thi eat cha trong ngữ cảnh của nguyên mẫu, không phải đối tượng hiện tại.

Và trong đoạn code trên nó thực sự hoạt động như dự định: chúng ta có chính xác alert.

Bây giờ hãy thêm một đối tượng vào chuỗi. Chúng ta sẽ thấy mọi thứ vỡ như thế nào:

let animal = {
  name: "Animal",
  eat() {
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  eat() {
    // ...bounce around rabbit-style and call parent (animal) method
    this.__proto__.eat.call(this); // (*)
  }
};

let longEar = {
  __proto__: rabbit,
  eat() {
    // ...do something with long ears and call parent (rabbit) method
    this.__proto__.eat.call(this); // (**)
  }
};

longEar.eat(); // Error: Maximum call stack size exceeded

Code không hoạt động nữa! Chúng ta có thể thấy lỗi khi cố gắng gọi longEar.eat().

Nó có thể không rõ ràng, nhưng nếu chúng ta theo dõi lệnh gọilongEar.eat(), thì chúng ta có thể thấy lý do tại sao. Trong cả hai dòng (*)(**)giá trị của thislà đối tượng hiện tại ( longEar). Điều đó rất cần thiết: tất cả các phương thức đối tượng đều lấy đối tượng hiện tại this, không phải là nguyên mẫu hay thứ gì đó.

Vì vậy, trong cả hai dòng (*)(**)giá trị của this.__proto__là hoàn toàn giống nhau : rabbit. Cả hai gọi rabbit.eatmà không đi lên chuỗi xử lý trong vòng lặp vô tận.

Đây là bức tranh về những gì xảy ra:

  1. Bên trong longEar.eat(), các (**) gọi rabbit.eatcung cấp cho nó với this=longEar.
// inside longEar.eat() we have this = longEar
this.__proto__.eat.call(this) // (**)
// becomes
longEar.__proto__.eat.call(this)
// that is
rabbit.eat.call(this);

2. Sau đó, trong dòng (*)của rabbit.eat, chúng ta muốn vượt qua lệnh gọi thậm chí cao hơn trong chuỗi xử lý, nhưng this=longEar, vì vậy this.__proto__.eatlà một lần nữa rabbit.eat!

// inside rabbit.eat() we also have this = longEar
this.__proto__.eat.call(this) // (*)
// becomes
longEar.__proto__.eat.call(this)
// or (again)
rabbit.eat.call(this);

3. Vì vậy, bản thân rabbit.eatgọi nó trong vòng lặp vô tận, bởi vì nó không thể tăng thêm nữa.

Vấn đề không thể được giải quyết bằng cách sử dụng thismột mình.

[[HomeObject]]

Để cung cấp giải pháp, JavaScript thêm một thuộc tính nội bộ đặc biệt cho các hàm : [[HomeObject]].

Khi một hàm được chỉ định là một phương thức lớp hoặc đối tượng, thuộc tính của nó [[HomeObject]]trở thành đối tượng đó.

Sau đó supersử dụng nó để giải quyết nguyên mẫu cha và các phương thức của nó.

Chúng ta hãy xem cách nó hoạt động, đầu tiên với các đối tượng đơn giản:

/*
Cafedev.vn - Kênh thông tin IT hàng đầu Việt Nam
@author cafedevn
Contact: cafedevn@gmail.com
Fanpage: https://www.facebook.com/cafedevn
Instagram: https://instagram.com/cafedevn
Twitter: https://twitter.com/CafedeVn
Linkedin: https://www.linkedin.com/in/cafe-dev-407054199/
*/

let animal = {
  name: "Animal",
  eat() {         // animal.eat.[[HomeObject]] == animal
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {         // rabbit.eat.[[HomeObject]] == rabbit
    super.eat();
  }
};

let longEar = {
  __proto__: rabbit,
  name: "Long Ear",
  eat() {         // longEar.eat.[[HomeObject]] == longEar
    super.eat();
  }
};

// works correctly
longEar.eat();  // Long Ear eats.

Nó hoạt động như dự định, do [[HomeObject]]cơ học. Một phương thức, chẳng hạn như longEar.eat, biết phương thức của nó [[HomeObject]]và lấy phương thức cha từ nguyên mẫu của nó. Mà không sử dụng this.

4.1. Các phương thức không phải là “miễn phí” để muốn làm gì thì làm

Như chúng ta đã biết trước đây, các phương thức nói chung là miễn phí, không bị ràng buộc với các đối tượng trong JavaScript. Vì vậy, chúng có thể được sao chép giữa các đối tượng và được gọi với một đối tượng khác this.

Chính sự tồn tại của [[HomeObject]]vi phạm nguyên tắc đó, bởi vì các phương thức ghi nhớ các đối tượng của chúng. [[HomeObject]]không thể thay đổi.

Nơi duy nhất trong ngôn ngữ [[HomeObject]]được sử dụng – là super. Vì vậy, nếu một phương thức không sử dụng super, thì chúng ta vẫn có thể xem xét nó miễn phí và sao chép giữa các đối tượng. Nhưng với supernhững điều có thể đi sai.

Đây là bản demo của một superkết quả sai sau khi sao chép:

let animal = {
  sayHi() {
    console.log(`I'm an animal`);
  }
};

// rabbit inherits from animal
let rabbit = {
  __proto__: animal,
  sayHi() {
    super.sayHi();
  }
};

let plant = {
  sayHi() {
    console.log("I'm a plant");
  }
};

// tree inherits from plant
let tree = {
  __proto__: plant,
  sayHi: rabbit.sayHi // (*)
};

tree.sayHi();  // I'm an animal (?!?)

Một lệnh gọi cho tree.sayHi()thấy tôi là một động vật. Chắc chắn là sai.

Lý do rất đơn giản:

  • Trong dòng (*), phương thức tree.sayHiđược sao chép từ rabbit. Có lẽ chúng ta chỉ muốn tránh trùng lặp code?
  • [[HomeObject]]rabbit, như nó đã được tạo ra trong rabbit. Không có cách nào để thay đổi [[HomeObject]].
  • Code của tree.sayHi()super.sayHi()bên trong. Nó đi lên từ rabbitvà lấy phương thức từ animal.

Đây là sơ đồ của những gì xảy ra:

4.2. Phương thức, không phải là thuộc tính hàm

[[HomeObject]]được định nghĩa cho các phương thức cả trong các lớp và trong các đối tượng đơn giản. Nhưng đối với các đối tượng, các phương thức phải được chỉ định chính xác method(), không phải là "method: function()".

Sự khác biệt có thể không cần thiết đối với chúng ta, nhưng nó quan trọng đối với JavaScript.

Trong ví dụ dưới đây, một cú pháp phi phương thức được sử dụng để so sánh. [[HomeObject]]thuộc tính không được đặt và thừa kế không hoạt động:


let animal = {
  eat: function() { // intentionally writing like this instead of eat() {...
    // ...
  }
};

let rabbit = {
  __proto__: animal,
  eat: function() {
    super.eat();
  }
};

rabbit.eat();  // Error calling super (because there's no [[HomeObject]])

5. Tóm lược

  1. Để mở rộng một lớp class Child extends Parent::
    • Điều đó có nghĩa Child.prototype.__proto__Parent.prototype, vì vậy các phương thức được kế thừa.
  2. Khi ghi đè một hàm tạo:
    • Chúng ta phải gọi hàm tạo cha như super()trong hàm Childtạo trước khi sử dụng this.
  3. Khi ghi đè phương thức khác:
    • Chúng ta có thể sử dụng super.method()trong một Childphương thức để gọi Parentphương thức.
  4. Nội bộ:
    • Các phương thức ghi nhớ lớp / đối tượng của chúng trong thuộc tính bên trong [[HomeObject]]. Đó là cách supergiải quyết các phương thức cha.
    • Vì vậy, không an toàn khi sao chép một phương thức supertừ đối tượng này sang đối tượng khác.

Cũng như vậy:

  • Các hàm mũi tên không có riêng thishoặc super, vì vậy chúng hoàn toàn phù hợp với bối cảnh hiện tại.

Full series tự học Javascript từ cơ bản tới nâng cao tại đây nha.

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của cafedev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!