JS進階(3):人人都能懂的繼承

李等等扣丁發表於2018-05-09

在上一篇文章中,我們主要介紹了 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__ 屬性指向另外一個物件。聽起來比較繞?那麼我們用控制檯來列印一下試試。

JS進階(3):人人都能懂的繼承

通過上圖我們發現,在建構函式的原型物件 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;
...
複製程式碼

用圖來表示一下這個過程。

JS進階(3):人人都能懂的繼承

實際上在 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 的年齡,但是 obj4name 也被覆蓋了。有時候,我們僅僅想繼承自身沒有的屬性,而保留自身已有的屬性。這個時候,混入繼承是做不到了,但原型式繼承卻可以。

原型式繼承的大致思路就是讓 obj3obj4 之間產生繼承關係,比如:

// 讓 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
複製程式碼

最後我們來總結一下原型式繼承的功能:建立一個新的物件,讓新的物件可以繼承自指定的物件,從而新的物件可以訪問到自己的屬性和方法,也可以訪問到指定物件的屬性和方法。

其實,除了上面三種繼承方法,還有比較經典的借用建構函式實現繼承的方式,我會把它放在之後的文章中講解。而基於原型鏈的繼承,可能是我們會比較常用的繼承方式,混入繼承與經典繼承,大家多理解就好,也許哪天面試就遇到了呢?

我說的不一定都對,你一定要自己試試。

相關文章