JS專題之繼承
前言
眾所周知,JavaScript 中,沒有 JAVA 等主流語言“類”的概念,更沒有“父子類繼承”的概念,而是通過原型物件和原型鏈的方式實現繼承。
於是,我們這一篇講一講 JS 中的繼承(委託)。
一、為什麼要有繼承?
JavaScript 是面向物件程式設計的語言,裡面全是物件,而如果不通過繼承的機制將物件聯絡起來,勢必會造成程式程式碼的冗餘,不方便書寫。
二、為什麼又是原型鏈繼承?
好,既然是 OO 語言,那麼就加繼承屬性吧。但是 JS 創造者並不打算引用 class,不然 JS 就是一個完整的 OOP 語言了,而創造者 JS 更容易讓新手開發。
後來,JS 創造者就將 new 關鍵字建立物件後面不接 class,改成建構函式,又考慮到繼承,於是在建構函式上加一個原型物件,最後讓所有通過 new 建構函式
創建出來的物件,就繼承構造函函式的原型物件的屬性。
function Person() { // 建構函式 this.name = "jay"; } Person.prototype = { sex: "male" } var person1 = new Person(); console.log(person1.name);// jay console.log(person1.sex);// male
所以,就有了 JavaScript 畸形的繼承方式:原型鏈繼承~
三、原型鏈繼承
function Parent() { this.names = ["aa", "bb", "cc"]; this.age = 18; } function Child() { // ... } Child.prototype = new Parent();// 改變建構函式的原型物件 var child1 = new Child(); // 繼承了 names 屬性 console.log(child1.names);// ["aa", "bb", "cc"] console.log(child1.age);// 18 child1.names.push("dd"); child1.age = 20; var child2 = new Child(); console.log(child2.names);// ["aa", "bb", "cc", "dd"] console.log(child2.age);// 18
以上例子中,暴露出原型鏈繼承的兩個問題:
- 包含引用型別資料的原型屬性,會被所有例項共享,基本資料型別則不會。
- 在建立子型別例項時,無法向父型別的建構函式中傳遞引數。
四、call 或 apply 繼承
function Parent(age) { this.names = ["aa", "bb", "cc"] this.age = age; } function Child() { Parent.call(this, 18); } var child1 = new Child(); // 繼承了 names 屬性 console.log(child1.names);// ["aa", "bb", "cc"] child1.names.push("dd"); console.log(child1.age);// 18 var child2 = new Child(); console.log(child2.names);// ["aa", "bb", "cc"] console.log(child2.age);// 18
call 或 apply 的原理是在子型別的建構函式中,“借調”父型別的建構函式,最終實現子型別中擁有父型別中屬性的副本了。
call 或 apply 這種繼承方式在《JavaScript 高階程式設計》中叫作“借用建構函式(constructor stealing)”,解決了原型鏈繼承中,引用資料型別被所有子例項共享的問題,也能夠實現傳遞引數到建構函式中,但唯一的問題在於業務程式碼也寫在了建構函式中,函式得不到複用。
五、組合繼承
組合繼承(combination inheritance)也叫作偽經典繼承,指的是,前面兩種方法:原型鏈繼承和 call 或 apply 繼承 組合起來,保證了例項都有自己的屬性,同時也能夠實現函式複用:
function Parent(age) { this.names = ["aa", "bb", "cc"] this.age = age; } Parent.prototype.sayName = function () { console.log(this.names); } function Child() { Parent.call(this, 18);// 第一次呼叫 } Child.prototype = new Parent();// 第二次呼叫:通過原型鏈繼承 sayName 方法 Child.prototype.constructor = Child;// 改變 constructor 為子型別建構函式 var child1 = new Child(); child1.sayName();// ["aa", "bb", "cc"] child1.names.push("dd"); console.log(child1.age);// 18 var child2 = new Child(); console.log(child2.names);// ["aa", "bb", "cc"] console.log(child2.age); child2.sayName();// ["aa", "bb", "cc"]
組合繼承將繼承分為兩步,一次是建立子型別關聯父型別原型物件的時候,另一次是在子型別建構函式的內部。是 JS 最常用的繼承方式。
六、原型式繼承
原型式繼承說白了,就是將父型別作為一個物件,直接變成子型別的原型物件。
function object(o){ function F(){} F.prototype = o; return new F(); } var parent = { age: 18, names: ["aa", "bb", "cc"] }; var child1 = object(parent); // 繼承了 names 屬性 console.log(child1.names);// ["aa", "bb", "cc"] child1.names.push("dd"); console.log(child1.age);// 18 var child2 = object(parent); console.log(child2.names);// ["aa", "bb", "cc", "dd"] console.log(child2.age); // 18
原型式繼承其實就是對原型鏈繼承的一種封裝,它要求你有一個已有的物件作為基礎,但是原型式繼承也有共享父類引用屬性,無法傳遞引數的缺點。
這個方法後來有了正式的 API: Object.create({...})
所以當有一個物件,想讓子例項繼承的時候,可以直接用 Object.create() 方法。
七、寄生式繼承
寄生式繼承是把原型式 + 工廠模式結合起來,目的是為了封裝建立的過程。
function createAnother(original){ var clone= object(original);//通過呼叫函式建立一個新物件 clone.sayHi = function(){//以某種方式來增強這個物件 console.log("hi"); }; return clone;//返回這個物件 } var person = { age: 18, names: ["aa", "bb", "cc"] }; var anotherPerson = createAnother(person); anotherPerson.sayHi(); // "hi"
八、 寄生組合式繼承
剛才說到組合繼承有一個會兩次呼叫父類的建構函式造成浪費的缺點,寄生組合繼承就可以解決這個問題。
function inheritPrototype(subType, superType){ var prototype = object(superType.prototype); // 建立了父類原型的淺複製 prototype.constructor = subType;// 修正原型的建構函式 subType.prototype = prototype;// 將子類的原型替換為這個原型 } function SuperType(age){ this.age = age; this.names = ["aa", "bb", "cc"]; } SuperType.prototype.sayName = function(){ console.log(this.names); }; function SubType(age){ SuperType.call(this, age); this.age = age; } // 核心:因為是對父類原型的複製,所以不包含父類的建構函式,也就不會呼叫兩次父類的建構函式造成浪費 inheritPrototype(SubType, SuperType); SubType.prototype.sayAge = function(){ console.log(this.age); } var child1 = new SubType(22) child1.sayAge()// 22 child1.sayName()// ["aa", "bb", "cc"]
九、ES6 class extends
class Parent { constructor(name) { this.name = name; } doSomething() { console.log('parent do something!'); } sayName() { console.log('parent name:', this.name); } } class Child extends Parent { constructor(name, parentName) { super(parentName); this.name = name; } sayName() { console.log('child name:', this.name); } } const child = new Child('son', 'father'); child.sayName();// child name: son child.doSomething();// parent do something! const parent = new Parent('father'); parent.sayName();// parent name: father
ES6 的 class extends 本質上是 ES5 的語法糖。
ES6實現繼承的具體原理:
class Parent { } class Child { } Object.setPrototypeOf = function (obj, proto) { obj.__proto__ = proto; return obj; } // B 的例項繼承 A 的例項 Object.setPrototypeOf(Child.prototype, parent.prototype); // B 繼承 A 的靜態屬性 Object.setPrototypeOf(Child, Parent);
總結
javascript 由於歷史發展原因,繼承方式實際上是通過原型鏈屬性查詢的方式,但正規的叫法不叫繼承而叫“委託”,ES6 的 class extends 關鍵字也不過是 ES5 的語法糖。所以,瞭解 JS 的原型和原型鏈非常重要,詳情請翻看我之前的文章《JavaScript原型與原型鏈》
參考:
《JavaScript 高階程式設計》
2019/02/10 @Starbucks
歡迎關注我的個人公眾號“謝南波”,專注分享原創文章。