深入理解JavaScript原型鏈與繼承

小小前端真可怕發表於2018-12-06

原型鏈

原型鏈一直都是一個在JS中比較讓人費解的知識點,但是在面試中經常會被問到,這裡我來做一個總結吧,首先引入一個關係圖:

深入理解JavaScript原型鏈與繼承

一.要理解原型鏈,首先可以從上圖開始入手,圖中有三個概念:

1.建構函式: JS中所有函式都可以作為建構函式,前提是被new操作符操作;

function Parent(){
    this.name = 'parent';
}
//這是一個JS函式

var parent1 = new Parent()
//這裡函式被new操作符操作了,所以我們稱Parent為一個建構函式;
複製程式碼

2.例項: parent1 接收了new Parent(),parent1可以稱之為例項;

3.原型物件: 建構函式有一個prototype屬性,這個屬性會初始化一個原型物件;

二.弄清楚了這三個概念,下面我們來說說這三個概念的關係(參考上圖):

1.通過new操作符作用於JS函式,那麼就得到了一個例項;

2.建構函式會初始化一個prototype,這個prototype會初始化一個原型物件,那麼原型物件是怎麼知道自己是被哪個函式初始化的呢?原來原型物件會有一個constructor屬性,這個屬性指向了建構函式;

3.那麼關鍵來了例項物件是怎麼和原型物件關聯起來的呢?原來例項物件會有一個__proto__屬性,這個屬性指向了該例項物件的建構函式對應的原型物件;

4.假如我們從一個物件中去找一個屬性name,如果在當前物件中沒有找到,那麼會通過__proto__屬性一直往上找,直到找到Object物件還沒有找到name屬性,才證明這個屬性name是不存在,否則只要找到了,那麼這個屬性就是存在的,從這裡可以看出JS物件和上級的關係就像一條鏈條一樣,這個稱之為原型鏈;

5.如果看到這裡還沒理解原型鏈,可以從下面我要說到繼承來理解,因為原型繼承就是基於原型鏈;

三.new操作符的工作原理

廢話不多說,直接上程式碼
var newObj = function(func){
    var t = {}
    t.prototype = func.prototype
    var o = t
    var k =func.call(o);
    if(typeof k === 'object'){
        return k;
    }else{
        return o;
    }
}
var parent1 = newObj(Parent)等價於new操作

1.一個新物件被建立,它繼承自func.prototype。
2.建構函式func 被執行,執行的時候,相應的引數會被傳入,同時上下文(this) 會被指定為這個新例項。
3.如果建構函式返回了一個新物件,那麼這個物件會取代整個new出來的結果,如果建構函式沒有返回物件,
那麼new出來的結果為步驟1建立的物件。
複製程式碼

繼承

一.建構函式實現繼承(構造繼承)

function Parent(){
    this.name = 'parent1'
    this.play = [1,2,3]
}
    
function Child{
    Parent.call(this);//apply
    this.type = 'parent2';
}
Parent.prototype.id = '1'
var child1 = new Child()
console.log(child1.name)//parent1
console.log(child1.id)//undefined

//以下程式碼看完繼承方式2,再回過頭來看
var child2 = new Child();
child1.play[0] = 2;
console.log(child2.play)//[1,2,3]

從上面構造繼承的程式碼可以看出,構造繼承實現了繼承,
列印出來父級的name屬性,但是例項物件並沒有訪問到父級原型上面到屬性;
複製程式碼

二.原型鏈實現繼承

function Parent(){
    this.name = 'parent'
    this.play = [1,2,3]
}
function Child(){
    this.type = 'child';
}
Child.prototype = new Parent();
Parent.prototype.id = '1';
var child1 = new Child();    
console.log(child1.name)//parent1
console.log(child1.id)//1

從這裡可以看出,原型繼承彌補了構造繼承到缺點,繼承了原型上到屬性;
但是下面再做一個操作:
var child2 = new Child();
child1.play[0] = 2;
console.log(child2.play)//[2,2,3]
這裡我只是改變了例項物件child1到play陣列,但是例項列印例項物件child2到paly陣列,發現也跟著變化
了,所以可以得出結論,原型鏈繼承引用型別到屬性,在所有例項物件上面改變該屬性,所有例項物件該屬性都會
變化,這樣肯定就存在問題,現在我們回到繼承方式1(構造繼承),會發現構造繼承不會存在這個問題,所以
其實構造繼承和原型鏈繼承完全可以互補,由此我們引入第三種繼承方式;

額外解釋:這裡通過一個原型鏈繼承,我們再來回顧一下對原型鏈的理解,上面程式碼,我們進行了一個操作:
Child.prototype = new Parent();
這個操作把父類的例項賦值給子類的原型,然後結合上面原型鏈的關係圖,我們再來理一下(為了閱讀方便,復
制上圖到此處):
複製程式碼

深入理解JavaScript原型鏈與繼承
現在我們可以把圖中到例項看成child1,首先如果要找child1例項物件中的name屬性,那麼我首先到Child本身去找,發現沒有找到name屬性,因為Child函式裡面只有一個type屬性,那麼通過__proto__找到Child的原型物件,而剛才我們做了一個操作:

Child.prototype = new Parent(); 這個操作把父類的例項給了Child的原型,所以通過這個我們就可以找到父級的name,這就是原型鏈,一層一層的,像一個鏈條;

三.組合繼承

function Parent(){
    this.name = 'parent1'
    this.play = [1,2,3]
}
    
function Child{
    Parent.call(this);//apply
    this.type = 'parent2';
}
Child.prototype = new Parent();
Parent.prototype.id = '1'
var child1 = new Child()
console.log(child1.name)//parent1
console.log(child1.id)//1
var child2 = new Child();
child1.play[0] = 2;
console.log(child2.play)//[1,2,3]

從上面程式碼可以看出,組合繼承就是把構造繼承和原型鏈繼承組合在一起,把他們的優勢互補,從而彌補了各自的
缺點;那麼組合繼承就完美了嗎?我們繼續思考,從程式碼中可以發現,我們呼叫了兩次Parent函式,一次是
new Parent(),一次是Parent.call(this),是否可以優化呢?我們引入第四種繼承方式;
複製程式碼

四.組合繼承(優化1)

function Parent(){
    this.name = 'parent1'
    this.play = [1,2,3]
}
    
function Child{
    Parent.call(this);//apply
    this.type = 'parent2';
}
Child.prototype = Parent.prototype;//這裡改變了
Parent.prototype.id = '1'
var child1 = new Child()
console.log(child1.name)//parent1
console.log(child1.id)//1
var child2 = new Child();
child1.play[0] = 2;
console.log(child2.play)//[1,2,3]

我們改成Child.prototype = Parent.prototype,這樣就只呼叫一次Parent了,解決了繼承方式3的問題,
好吧,我們繼續思考,這樣就沒有問題了嗎,我們做如下操作:
console.log(Child.prototype.constructor)//Parent
這裡我們列印發現Child的原型的構造器成了Parent,按照我們的理解應該是Child,這就造成了構造器紊亂,
所以我們引入第五種繼承優化
複製程式碼

五.組合繼承(優化2)

function Parent(){
    this.name = 'parent1'
    this.play = [1,2,3]
}
    
function Child{
    Parent.call(this);//apply
    this.type = 'parent2';
}
Child.prototype = Parent.prototype;
Child.prototype.constructor = Child//這裡改變了
Parent.prototype.id = '1'
var child1 = new Child()
console.log(child1.name)//parent1
console.log(child1.id)//1
var child2 = new Child();
child1.play[0] = 2;
console.log(child2.play)//[1,2,3]

現在我們列印
console.log(Child.prototype.constructor)//Child
這裡就解決了問題,但是我們繼續列印
console.log(Parent.prototype.constructor)//Child
發現父類的構造器也出現了紊亂,所有我們通過一箇中間值來解決這個問題,最終版本為:

function Parent(){
    this.name = 'parent1'
    this.play = [1,2,3]
}
    
function Child{
    Parent.call(this);//apply
    this.type = 'parent2';
}

var obj = {};
obj.prototype = Parent.prototype;
Child.prototype = obj;
//上面三行程式碼也可以簡化成Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child

console.log(Child.prototype.constructor)//Child
console.log(Parent.prototype.constructor)//Parent
用一箇中間obj,完美解決了這個問題複製程式碼

相關文章