剖析JS的原型鏈和繼承

JavaScript是一門面向對象的設計語言,在JS裏除了nullundefined,其他一切皆爲對象。其中Array/Function/Date/RegExp是Object對象的特殊實例實現,Boolean/Number/String也都有對應的基本包裝類型的對象(具備內置的方法)。傳統語言是依靠class類來完成面向對象的繼承和多態等特性,而JS使用原型鏈和構造器來實現繼承,依靠參數arguments.length來實現多態。而且在ES6裏也引入了class關鍵字來實現類。
接下來咱們來聊一下JS的原型鏈、繼承和類。git

函數與對象的關係

有時咱們會好奇爲何能給一個函數添加屬性,函數難道不該該就是一個執行過程的做用域嗎?es6

var name = 'Leon';
function Person(name) {
    this.name = name;
    this.sayName = function() {
        alert(this.name);
    }
}
Person.age = 10;
console.log(Person.age);    // 10
console.log(Person);
/* 輸出函數體:
ƒ Person(name) {
    this.name = name;
}
*/

咱們可以給函數賦一個屬性值,當咱們輸出這個函數時這個屬性卻無影無蹤了,這究竟是怎麼回事,這個屬性又保存在哪裏了呢?github

其實,在JS裏,函數就是一個對象,這些屬性天然就跟對象的屬性同樣被保存起來,函數名稱指向這個對象的存儲空間。
函數調用過程沒查到資料,我的理解爲:這個對象內部擁有一個內部屬性[[function]]保存有該函數體的字符串形式,當使用()來調用的時候,就會實時對其進行動態解析和執行,如同eval()同樣。編程

圖片描述

上圖是JS的具體內存分配方式,JS中分爲值類型和引用類型,值類型的數據大小固定,咱們將其分配在棧裏,直接保存其數據。而引用類型是對象,會動態的增刪屬性,大小不固定,咱們把它分配到內存堆裏,並用一個指針指向這片地址,也就是Person其實保存的是一個指向這片地址的指針。這裏的Person對象是個函數實例,因此擁有特殊的內部對象[[function]]用於調用。同時它也擁有內部屬性arguments/this/name,由於不相關,這裏咱們沒有繪出,而展現了咱們爲其添加的屬性age。瀏覽器

函數與原型的關係

同時在JS裏,咱們建立的每個函數都有一個prototype(原型)屬性,這個屬性是一個指針,指向一個用於包含該對象全部實例的共享屬性和方法的對象。而這個對象同時包含一個指針指向這個這個函數,這個指針就是constructor,這個函數也被成爲構造函數。這樣咱們就完成了構造函數和原型對象的雙向引用。函數

而上面的代碼實質也就是當咱們建立了Person構造函數以後,同步開闢了一片空間建立了一個對象做爲Person的原型對象,能夠經過Person.prototype來訪問這個對象,也能夠經過Person.prototype.constructor來訪問Person該構造函數。經過構造函數咱們能夠往實例對象裏添加屬性,如上面的例子裏的name屬性和sayName()方法。咱們也能夠經過prototype來添加原型屬性,如:性能

Person.prototype.name = 'Nicholas';
Person.prototype.age = 24;
Person.prototype.sayAge = function () {
    alert(this.age);
};

這些原型對象爲實例賦予了默認值,如今咱們能夠看到它們的關係是:
圖片描述優化

要注意屬性和原型屬性不是同一個東西,也並不保存在同一個空間裏:this

Person.age; // 10
Person.prototype.age; // 24

原型和實例的關係

如今有了構造函數和原型對象,那咱們接下來new一個實例出來,這樣才能真正體現面向對象編程的思想,也就是繼承spa

var person1 = new Person('Lee');
var person2 = new Person('Lucy');

咱們新建了兩個實例person1和person2,這些實例的內部都會包含一個指向其構造函數的原型對象的指針(內部屬性),這個指針叫[[Prototype]],在ES5的標準上沒有規定訪問這個屬性,可是大部分瀏覽器實現了__proto__的屬性來訪問它,成爲了實際的通用屬性,因而在ES6的附錄裏寫進了該屬性。__proto__先後的雙下劃線說明其本質上是一個內部屬性,而不是對外訪問的API,所以官方建議新的代碼應當避免使用該屬性,轉而使用Object.setPrototypeOf()(寫操做)、Object.getPrototypeOf()(讀操做)、Object.create()(生成操做)代替。

這裏的prototype咱們稱爲顯示原型,__proto__咱們稱爲隱式原型

同時因爲現代 JavaScript 引擎優化屬性訪問所帶來的特性的關係,更改對象的 [[Prototype]]在各個瀏覽器和 JavaScript 引擎上都是一個很慢的操做。其在更改繼承的性能上的影響是微妙而又普遍的,這不只僅限於 obj.__proto__ = ... 語句上的時間花費,並且可能會延伸到任何代碼,那些能夠訪問任何[[Prototype]]已被更改的對象的代碼。若是你關心性能,你應該避免設置一個對象的 [[Prototype]]。相反,你應該使用 Object.create()來建立帶有你想要的[[Prototype]]的新對象。

此時它們的關係是(爲了清晰,忽略函數屬性的指向,用(function)代指):
圖片描述

在這裏咱們能夠看到兩個實例指向了同一個原型對象,而在new的過程當中調用了Person()方法,對每一個實例分別初始化了name屬性和sayName方法,屬性值分別被保存,而方法做爲引用對象也指向了不一樣的內存空間。

咱們能夠用幾種方法來驗證明例的原型指針到底指向的是否是構造函數的原型對象:

person1.__proto__ === Person.prototype // true
Person.prototype.isPrototypeOf(person1); // true
Object.getPrototypeOf(person2) === Person.prototype; // true
person1 instanceof Person; // true

原型鏈

如今咱們訪問實例person1的屬性和方法了:

person1.name; // Lee
person1.age; // 24
person1.toString(); // [object Object]

想下這個問題,咱們的name值來自於person1的屬性,那麼age值來自於哪?toString( )方法又在哪定義的呢?

這就是咱們要說的原型鏈,原型鏈是實現繼承的主要方法,其思想是利用原型讓一個引用類型繼承另外一個引用類型的屬性和方法。若是咱們讓一個原型對象等於另外一個類型的實例,那麼該原型對象就會包含一個指向另外一個原型的指針,而若是另外一個原型對象又是另外一個原型的實例,那麼上述關係依然成立,層層遞進,就構成了實例與原型的鏈條,這就是原型鏈的概念

上面代碼的name來自於自身屬性,age來自於原型屬性,toString( )方法來自於Person原型對象的原型Object。當咱們訪問一個實例屬性的時候,若是沒有找到,咱們就會繼續搜索實例的原型,若是尚未找到,就遞歸搜索原型鏈直到原型鏈末端。咱們能夠來驗證一下原型鏈的關係:

Person.prototype.__proto__ === Object.prototype // true

同時讓咱們更加深刻的驗證一些東西:

Person.__proto__ === Function.prototype // true
Function.prototype.__proto__ === Object.prototype // true

咱們會發現Person是Function對象的實例,Function是Object對象的實例,Person原型是Object對象的實例。這證實了咱們開篇的觀點:JavaScript是一門面向對象的設計語言,在JS裏除了null和undefined,其他一切皆爲對象

下面祭出咱們的原型鏈圖:
圖片描述

根據咱們上面講述的關於prototype/constructor/__proto__的內容,我相信你能夠徹底看懂這張圖的內容。須要注意兩點:

  1. 構造函數和對象原型一一對應,他們與實例一塊兒做爲三要素構成了三面這幅圖。最左側是實例,中間是構造函數,最右側是對象原型。
  2. 最最右側的null告訴咱們:Object.prototype.__proto__ = null,也就是Object.prototype是JS中一切對象的根源。其他的對象繼承於它,並擁有本身的方法和屬性。

繼承

原型鏈繼承

經過原型鏈咱們已經實現了對象的繼承,咱們具體的實現下:

function Super(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
};
function Sub(age) {
    this.age = age;
}
Sub.prototype = new Super('Lee');
var instance = new Sub(20);

instance.name; // Lee
instance.age; // 20

咱們經過讓Sub類的原型指向Super類的實例,實現了繼承,能夠在instance上訪問name和colors屬性。可是,其最大的問題來自於共享數據,若是實例1修改了colors屬性,那麼實例2的colors屬性也會變化。另外,此時咱們在子類上並不能傳遞父類的參數,限制性很大。

構造函數繼承

爲了解決對象引用的問題,咱們調用構造函數來實現繼承,保證每一個實例擁有相同的父類屬性,但值之間互不影響。實質

function Super(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
    this.sayName = function() {
        return this.name;
    }
}
function Sub() {
    Super.call(this, 'Nicholas');
}
var instance1 = new Sub();
var instance2 = new Sub();
instance1.colors.push('black');

instance1.colors; // ['red', 'blue', 'black']
instance2.colors; // ['red', 'blue']

此時咱們經過改變父類構造函數的做用域就解決了引用對象的問題,同時咱們也能夠向父類傳遞參數了。可是,只用構造函數就很難在定義方法時複用,如今咱們建立全部實例時都要聲明一個sayName()的方法,並且此時,子類中看不到父類的方法。

組合繼承

爲了複用方法,咱們使用組合繼承的方式,即利用構造函數繼承屬性,利用原型鏈繼承方法,融合它們的優勢,避免缺陷,成爲JS中最經常使用的繼承。

function Super(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
};
function Sub(name, age) {
    // 第二次調用
    Super.call(this, name);
    this.age = age;
}
Super.prototype.sayName = function () {
    return this.name;
};
// 第一次調用
Sub.prototype = new Super();
Sub.prototype.constructor = Sub;
Sub.prototype.sayAge = function () {
    return this.age;
}

var instance = new Sub('lee', 40);
instance.sayName(); // lee
instance.sayAge(); // 40

這時咱們全局只有一個函數,不用再給每個實例新建一個,而且每一個實例擁有相同的屬性,達到了咱們想要的繼承。此時instanceof和isPrototypeOf()也可以識別繼承建立的對象。
可是依然有一個不理想的地方是,咱們會調用兩次父類的構造函數,第一次在Sub的原型上設置了name和colors屬性,此時name的值是undefined;第二次調用在Sub的實例上新建了name和colors屬性,而這個實例屬性會屏蔽原型的同名屬性。因此這種繼承會出現兩組屬性,這並非理想的方式,咱們試圖來解決這個問題。

原型式繼承

咱們先來看一個後面會用到的繼承,它根據已有的對象建立一個新對象。

function create(obj) {
    function F(){};
    F.prototype = obj;
    return new F();
}

var person = {
    name: 'Nicholas',
    friends: ['Lee', 'Luvy']
};
var anotherPerson = create(person);

anotherPerson.name; // Nicholas
anotherPerson.friends.push('Rob');
person.friends; // ['Lee', 'Luvy', 'Rob']

也就是說咱們根據一個對象做爲原型,直接生成了一個新的對象,其中的引用對象依然共用,但你同時也能夠給其賦予新的屬性。

ES5規範化了這個原型繼承,新增了Object.create()方法,接收兩個參數,第一個爲原型對象,第二個爲要混合進新對象的屬性,格式與Object.defineProperties()相同。

Object.create(null, {name: {value: 'Greg', enumerable: true}});

寄生組合式繼承

function Super(name) {
    this.name = name;
    this.colors = ['red', 'blue'];
};
function Sub(name, age) {
    Super.call(this, name);
    this.age = age;
}
Super.prototype.sayName = function () {
    return this.name;
};

// 咱們封裝其繼承過程
function inheritPrototype(Sub, Super) {
    // 以該對象爲原型建立一個新對象
    var prototype = Object.create(Super.prototype);
    prototype.constructor = Sub;
    Sub.prototype = prototype;
}

inheritPrototype(Sub, Super);

Sub.prototype.sayAge = function () {
    return this.age;
}

var instance = new Sub('lee', 40);
instance.sayName(); // lee
instance.sayAge(); // 40

這種方式只調用了一次父類構造函數,只在子類上建立一次對象,同時保持原型鏈,還可使用instanceof和isPrototypeOf()來判斷原型,是咱們最理想的繼承方式。

Class類

ES6引進了class關鍵字,用於建立類,這裏的類是做爲ES5構造函數和原型對象的語法糖存在的,其功能大部分均可以被ES5實現,不過在語言層面上ES6也提供了部分支持。新的寫法不過讓對象原型看起來更加清晰,更像面向對象的語法而已。
咱們先看一個具體的class寫法:

//定義類
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}

var point = new Point(10, 10);

咱們看到其中的constructor方法就是以前的構造函數,this就是以前的原型對象,toString()就是定義在原型上的方法,只能使用new關鍵字來新建實例。語法差異在於咱們不須要function關鍵字和逗號分割符。其中,全部的方法都直接定義在原型上,注意全部的方法都不可枚舉。類的內部使用嚴格模式,而且不存在變量提高,其中的this指向類的實例。

new是從構造函數生成實例的命令。ES6 爲new命令引入了一個new.target屬性,該屬性通常用在構造函數之中,返回new命令做用於的那個構造函數。若是構造函數不是經過new命令調用的,new.target會返回undefined,所以這個屬性能夠用來肯定構造函數是怎麼調用的。

類存在靜態方法,使用static關鍵字表示,其只能類和繼承的子類來進行調用,不能被實例調用,也就是不能被實例繼承,因此咱們稱它爲靜態方法。類不存在內部方法和內部屬性。

class Foo {
  static classMethod() {
    return 'hello';
  }
}

Foo.classMethod() // 'hello'

var foo = new Foo();
foo.classMethod()
// TypeError: foo.classMethod is not a function

類經過extends關鍵字來實現繼承,在繼承的子類的構造函數裏咱們使用super關鍵字來表示對父類構造函數的引用;在靜態方法裏,super指向父類;在其它函數體內,super表示對父類原型屬性的引用。其中super必須在子類的構造函數體內調用一次,由於咱們須要調用時來綁定子類的元素對象,不然會報錯。

class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y); // 調用父類的constructor(x, y)
    this.color = color;
  }

  toString() {
    return this.color + ' ' + super.toString(); // 調用父類的toString()
  }
}

參考資料

  1. 阮一峯 ES6 - class: http://es6.ruanyifeng.com/#do...
  2. MDN文檔 - Object.create(): https://developer.mozilla.org...
  3. 深刻理解原型對象和繼承: https://github.com/norfish/bl...
  4. 知乎 prototype和__proto__的區別: https://www.zhihu.com/questio...
  5. Javascript高級程序設計: 第四章(變量、做用域和內存問題)、第五章(引用類型)、第六章(面向對象的程序設計)
相關文章
相關標籤/搜索