構造函數、原型、原型鏈、繼承

JS里一切皆對象,對象是「無序屬性的集合,其屬性值可以是數據或函數」。

事實上,所有的對象都是由函數創建的,而常見的對象字面量則只是一種語法糖:

// let user = {name: 'paykan', age: 29} ,等同於:
let user = new Object(); user.name = 'paykan'; user.age = 29;

//let list = [1, 'hi', true],等同於:
let list = new Array(); list[0] = 1; list[1] = 'hi'; list[2] = true;

對象的特性

  • 每個對象都有constructor,用來表明是誰創建了它。

  • 每個對象都有一個__proto__屬性,該屬性是一個對象,被稱為原型對象,原型對象有一個constructor屬性,指向創建對象的那個函數(obj.constructor === obj.__proto__.constructor

  • 在對象上訪問一個屬性或方法時,會先從該對象查找,若找不到就去原型對象上找。

    所以一個簡單的字元串也有若干屬性和方法,因為它們來自原型對象:

    let str = '123-456-789';
    str.split === str.__proto__.split;	//true
    
  • 每個函數只要被創建就會有一個prototype屬性,它的值就是原型對象(所以訪問原型對象有兩條途徑:函數的prototype、實例對象的__proto__)。

  • 原型對象可以被修改,而對原型對象的修改可以立即反映到實例對象上。

創建對象

工廠模式

function Person(name){ return {name: name} };	
let paykan = Person('paykan')

這裡的paykan對象其實並非Person函數創建的,因為該函數只是使用了對象字面量——調用了Object()。這種方式只是封裝了使用對象字面量的過程,但並非完全無用。

構造函數模式

function Person(name){ 
  this.name = name; 
  this.say = function(){
    return this.name
  }
};	
let man = new Person('paykan');
  • 這裡有三個特點:

    1. 函數內部沒有創建對象;

    2. 屬性和方法直接傳遞給了this對象;

    3. 使用new關鍵字來調用。任何一個函數,只要使用了new關鍵字,它就成了構造函數

  • 使用new關鍵字調用函數時發生了以下事情:

    1. 創建新對象

    2. 將函數的作用域賦給新對象,從而使得this指向了該對象

    3. 執行函數程式碼(為新對象添加屬性和方法)

    4. 返回新對象

這裡的man對象才算是真正由Person函數創建的了:

man.constructor;	//ƒ Person(name){ this.name = name ... }
man.__proto__.constructor;	//ƒ Person(name){ this.name = name ... }
man.__proto__.constructor === man.constructor;	//true

構造-原型組合模式

根據對象的特性,對象上沒有的屬性會在原型對象中尋找,所以可以把公共的屬性和方法給到原型對象上去。

可以通過函數的prototype或者對象的__proto__來實現:

function Person(name){ this.name = name };	
let man = new Person('paykan');
Person.prototype.nation = 'Chinese';
man.nation;	//Chinese

man.__proto__.greeting = function(){
  return 'Hi, there';
}
man.greeting();	//Hi, there

動態原型模式

這種模式把給對象添加屬性以及給原型添加屬性的動作都放到了構造函數里,原型的屬性只在創建第一個對象實例時添加,以後就會被跳過。

function Person(name){ 
    this.name = name;
    if(!this.nation){ Person.prototype.nation = 'Chinese' };
};	

原型鏈

函數被創建後prototype指向了默認的原型對象,如果使用new調用該函數來生成一個對象,就會形成函數、對象、原型之間的三角關係:

此時如果讓實例對象指向另一個構造函數的實例對象,這個關係就變成了這樣:

實例對象A和實例對象B被一個__proto__屬性鏈接起來了,這已經是一個具有兩個節點的鏈條了,稱為原型鏈只需要修改函數的prototype的指向或者實例對象的__proto__的指向,就可以產生原型鏈。

實際上,由於原型對象B是由Object()函數創建的,而Object()函數的prototype的__proto指向的是null,所以一條原型鏈的起點是實例對象,終點是null,中間由__proto__鏈接。

如果在實例對象A上訪問某個屬性或方法,JS會從實例對象A開始沿著原型鏈層層查找,直到遇見null

繼承

有了原型鏈的概念就可以開始實現繼承了,最基本的模式就是修改原型對象:

function Father(){
  this.say = function(){return this.name}
}
function Child(name){
  this.name = name;
}
Child.prototype = new Father();	
let man = new Child('jack');
man.say();	//'jack'

由於對原型的修改會立即反映到所有實例上,實例對象會互相影響,而且在調用Child函數時無法給Father函數傳參,所以我們需要更加實用的繼承方式。

省略分析推導過程,這裡只介紹最實用和可靠的實現繼承的方式:組合繼承,為了方便描述,引入「父類函數」和「子類函數」這兩個概念:

//父類函數
function Father(name, age){
  this.name = name;
  this.age = age;
}
//在父類函數的prototype上定義方法
Father.prototype.say = function(){ 
  return `name: ${this.name}, age: ${this.age}, intrest: ${this.intrest}`
}
//子類函數
function Child(name, age, intrest){
  this.intrest = intrest;
  Father.call(this, name, age);	//在子類對象上調用父類構造函數,並為之傳參
}
//設置子類函數的prototype為父類的實例
Child.prototype = new Father();	
//修改constructor屬性,使之指向子類,此非必需,但可以讓實例對象知道是誰創建了它
Child.prototype.constructor = Child;	

let man = new Child('paykan', 29, 'coding');
man.say();	//"name: paykan, age: 29, intrest: coding"

這種繼承方式有以下幾個特點:

  • 子類繼承了父類所設定的屬性,但每個實例對象都可以有自己的屬性值,不會互相影響
  • 子類共享了父類定義的方法,因為方法是在父類的prototype上的,所以不會在每個實例對象上創建一遍
  • 如果有哪個屬性是可以被所有實例對象共享的,可以設置到父類的prototype上去。

總之利用原型鏈實現可靠繼承的步驟是:

  1. 在父類函數內設置通用的屬性
  2. 在子類函數內調用父類函數,並設置特有的屬性
  3. 修改子類函數的prototype,以繼承父類
  4. 修改子類函數的prototype.constructor,糾正對象識別問題
  5. 使用new關鍵字調用子類函數,傳遞所有必需的參數