在上一篇文章中,我們主要介紹了 JavaScript 中原型物件的概念。這篇文章我們來聊一聊 JavaScript 中的繼承。
一、繼承的基本概念
相對於 JavaScript 來說,在其他一些物件導向的程式語言中,繼承主要指的是父類和子類的一些關係。而在 JavaScript 中,繼承主要是基於原型鏈來實現的。更簡單地說,在 JavaScript 中,某個物件可以訪問到另一個物件中的屬性和方法,我們就可以認為它們之間存在繼承關係。
通過上篇講到的原型物件的知識來舉個例子:
function Fruit() {
// code...
}
var apple = new Fruit();
apple.__proto__ === Fruit.prototype;
複製程式碼
此時,例項 apple
不僅可以訪問到自身的屬性和方法,同時也可以訪問到 Fruit.prototype
中的屬性和方法,所以我們說 apple
繼承自 Fruit.prototype
。
二、 JavaScript 中主流的繼承方式
1、基於原型鏈實現繼承
要了解基於原型鏈的繼承,先要搞清楚什麼是原型鏈。
原型物件就是某個建構函式的 prototype
屬性所指向的那個物件,也就是建構函式的例項的 __proto__
屬性所指向的那個物件。而原型物件上其實也有一個 __proto__
屬性指向另外一個物件。聽起來比較繞?那麼我們用控制檯來列印一下試試。
通過上圖我們發現,在建構函式的原型物件 Fruit.prototype
上有一個 __proto__
屬性指向另一個物件'A',同時在 Fruit.prototype.__proto__
這個物件'A'上,依然有一個 __proto__
屬性,指向物件'B'......
像這樣,已知一個物件,通過這個物件上的 __proto__
屬性找到建構函式的原型物件,再通過原型物件上的 __proto__
屬性找到這個原型的建構函式的原型,最終找到某個不含有 __proto__
屬性的物件終止(原型鏈的頂端)的鏈式結構,我們稱之為原型鏈(說的比較繞,其實自己理解了就好)。
搞清楚了原型鏈,我們就來說說基於原型鏈的繼承。
基於原型鏈的繼承,實際上就是在原型物件中擴充套件方法。實現方式我們也可以再分成兩種:(1) 擴充套件原型物件 和 (2) 替換原型物件。
(1) 擴充套件原型物件
function Person() {}
var p1 = new Person();
複製程式碼
當一個函式建立好之後,就會有一個預設的原型物件。在給這個原型物件新增屬性和方法時,就用到了擴充套件原型物件實現繼承。
舉例來說:
Person.prototype.run = function() {
console.log("I'm running");
};
console.log(p1.run);
複製程式碼
此時,p1
是可以訪問到 run
方法的,我們就說 p1
繼承自 Person.prototype
。
(2) 替換原型物件
擴充套件原型物件的方法雖然很好,但是它也有一些弊端。比如我們要給原型物件中擴充套件多個屬性和方法時,就會出現以下情形:
Person.prototype.run = function() {
console.log("I'm running");
};
Person.prototype.say = function() {
console.log("I'm saying");
};
Person.prototype.sing = function() {
console.log("I'm singing");
};
Person.prototype.walk = function() {
console.log("I'm walking");
};
複製程式碼
此時,我們發現使用擴充套件原型物件的方式又會出現一些重複的程式碼。而當出現重複程式碼的時,作為程式猿的我們自然會想到將這些重複封裝起來。所以,替換原型物件實現繼承的方式就出現了。
function Person() {}
// 替換原型物件
Person.prototype = {
constructor: Person, // 重點
run: function() {},
say: function() {},
sing: function() {},
walk: function() {}
};
// 例項可以訪問
var p1 = new Person();
p1.run;
p1.say;
...
複製程式碼
用圖來表示一下這個過程。
實際上在 Person
函式建立好以後,會自動建立一個 Person.prototype (old)
。而當我們新建立一個 Person.prototype (new)
物件,並且把其中的 constructor
屬性值設為 Person
後,Person
函式中的 prototype
屬性就會指向我們新建立的這個 Person.prototype (new)
物件。
最後,我們通過一個比較經典的面試題再來理解一下其中的過程:
function Person() {}
Person.prototype.run = function() {
// code...
};
var p1 = new Person(); // p1.__proto__ 指向預設的原型物件
Person.prototype = {
constructor: Person,
say: function() {
// code...
}
};
var p2 = new Person(); // p2.__proto__ 指向新的原型物件
console.log(typeof p1.say); // undefined
console.log(typeof p2.say); // "function"
複製程式碼
2. 混入繼承(又稱拷貝繼承)
在日常工作中,經常遇到給一個函式傳遞多個引數的情況。比如說,我們需要得到使用者的詳細地址資訊。
function getAddress(country, name, city, street, code, province, tel) {
// code ...
}
// 當傳遞引數時,我們需要非常小心
getAddress('China', 'zs', 'Beijing', 'xxx', '102611', 'Beijing', '13888889999');
複製程式碼
在上面的例子中,由於引數非常多,我們在傳遞引數時就必須非常小心,一旦傳錯,整個資訊就會錯亂。於是我們找到了一個很好的解決辦法,可以把這些引數當成一個物件來傳遞。
function getAddress(obj) {
this.country = obj.country;
this.name = obj.name;
this.city = obj.city;
this.street = obj.street;
this.code = obj.code;
this.province = obj.province;
this.tel = obj.tel;
}
getAddress({
street: 'xxx',
country: 'China',
name: 'zs',
city: 'Beijing',
code: '102611',
tel: '13888889999',
province: 'Beijing',
});
複製程式碼
此時我們發現,當函式的引數是一個物件時,出錯的機率大大降低,因為我們的引數可以調整順序。對應的變數接收對應的引數。但是我們又發現,函式內部這一大坨東西依然很噁心,如果資訊再多點,那豈不是......
這個時候,混入繼承出現了。用程式碼表示就是:
function getAddress(obj) {
for (var key in obj) { // key 儲存了 obj 中每一個屬性的屬性名
// 獲取指定屬性的值
this[key] = obj[key]; // this["street"] = obj["street"]
}
}
getAddress({
street: 'xxx',
country: 'China',
name: 'zs',
city: 'Beijing',
code: '102611',
tel: '13888889999',
province: 'Beijing',
});
複製程式碼
這樣下來,程式碼是不是簡化了很多?
有的同學可能會問了,這僅僅是傳遞引數的情況,那我如何把一個物件中的屬性和方法拷貝到另一個物件中呢?其實封裝一下就好。
function mixin(target, source) {
for (var key in source) {
target[key] = source[key];
}
return target;
}
var obj1 = { name: 'zs', age: 18 };
var obj2 = {};
mixin(obj2, obj1);
複製程式碼
簡單來說,就是利用 for...in
迴圈,將源物件中的屬性和方法拷貝到目標物件中,從而實現繼承。
以上,就是關於混入繼承,也稱拷貝繼承的實現方式。
3. 原型式繼承(也叫經典繼承)
混入繼承很牛x,但是它也有一些問題,比如說:
var obj3 = { name:'ls', age: 18 };
var obj4 = { name: 'ww', gender: '女' };
mixin(obj4, obj3);
// obj4 = { name: "ls", gender: "女", age: 18 };
複製程式碼
我們想要讓 obj4
繼承 obj3
的年齡,但是 obj4
的 name
也被覆蓋了。有時候,我們僅僅想繼承自身沒有的屬性,而保留自身已有的屬性。這個時候,混入繼承是做不到了,但原型式繼承卻可以。
原型式繼承的大致思路就是讓 obj3
和 obj4
之間產生繼承關係,比如:
// 讓 obj4 繼承自 obj3
// obj4.__proto__ == obj3;
複製程式碼
但是在這裡我們不能直接使用 obj4.__proto__ == obj3
,因為 __proto__
屬性時非標準屬性,有瀏覽器相容問題。此時,我們可以想到之前提到的繼承方式:
function Person() {}
var p1 = new Person();
p1.__proto__ === Person.prototype;
複製程式碼
為了實現上面的關係,我們可以進行相關轉換,也就是通過某個建構函式,建立一個例項 obj4
,然後讓建構函式的原型物件指向 obj3
。下面我們用程式碼來實現一下:
var obj3 = { name:'ls', age: 18 };
function F() {}
F.prototype = obj3; // 讓 F 的例項可以訪問到 obj3 的屬性和方法
var obj4 = new F();
obj4.name = 'ww';
obj4.gender = '女';
console.log(obj4.name); // obj4 有自己的 name,列印 'ww'
console.log(obj4.gender); // obj4 有自己的 gender,列印 '女'
console.log(obj4.age); // obj4 沒有自己的 age,訪問 obj3 中的 age,列印 18
複製程式碼
最後我們來總結一下原型式繼承的功能:建立一個新的物件,讓新的物件可以繼承自指定的物件,從而新的物件可以訪問到自己的屬性和方法,也可以訪問到指定物件的屬性和方法。
其實,除了上面三種繼承方法,還有比較經典的借用建構函式實現繼承的方式,我會把它放在之後的文章中講解。而基於原型鏈的繼承,可能是我們會比較常用的繼承方式,混入繼承與經典繼承,大家多理解就好,也許哪天面試就遇到了呢?
我說的不一定都對,你一定要自己試試。