JS原型鏈與繼承別再被問倒了

路易斯發表於2017-04-21

我面試過很多同學,其中能把原型繼承講明白的寥寥無幾,能把new操作符講明白的就更少了。希望這篇文章能夠解決你的疑惑,帶你面試飛起來。 原文:詳解JS原型鏈與繼承

摘自JavaScript高階程式設計:

繼承是OO語言中的一個最為人津津樂道的概念.許多OO語言都支援兩種繼承方式: 介面繼承實現繼承 .介面繼承只繼承方法簽名,而實現繼承則繼承實際的方法.由於js中方法沒有簽名,在ECMAScript中無法實現介面繼承.ECMAScript只支援實現繼承,而且其 實現繼承 主要是依靠原型鏈來實現的.

概念

簡單回顧下建構函式,原型和例項的關係:

每個建構函式(constructor)都有一個原型物件(prototype),原型物件都包含一個指向建構函式的指標,而例項(instance)都包含一個指向原型物件的內部指標.

JS物件的圈子裡有這麼個遊戲規則:

如果試圖引用物件(例項instance)的某個屬性,會首先在物件內部尋找該屬性,直至找不到,然後才在該物件的原型(instance.prototype)裡去找這個屬性.

如果讓原型物件指向另一個型別的例項.....有趣的事情便發生了.

即: constructor1.prototype = instance2

鑑於上述遊戲規則生效,如果試圖引用constructor1構造的例項instance1的某個屬性p1:

1).首先會在instance1內部屬性中找一遍;

2).接著會在instance1.__proto__(constructor1.prototype)中找一遍,而constructor1.prototype 實際上是instance2, 也就是說在instance2中尋找該屬性p1;

3).如果instance2中還是沒有,此時程式不會灰心,它會繼續在instance2.__proto__(constructor2.prototype)中尋找...直至Object的原型物件

搜尋軌跡: instance1--> instance2 --> constructor2.prototype…-->Object.prototype

這種搜尋的軌跡,形似一條長鏈, 又因prototype在這個遊戲規則中充當連結的作用,於是我們把這種例項與原型的鏈條稱作 原型鏈 . 下面有個例子

function Father(){
	this.property = true;
}
Father.prototype.getFatherValue = function(){
	return this.property;
}
function Son(){
	this.sonProperty = false;
}
//繼承 Father
Son.prototype = new Father();//Son.prototype被重寫,導致Son.prototype.constructor也一同被重寫
Son.prototype.getSonVaule = function(){
	return this.sonProperty;
}
var instance = new Son();
alert(instance.getFatherValue());//true
複製程式碼

instance例項通過原型鏈找到了Father原型中的getFatherValue方法.

注意: 此時instance.constructor指向的是Father,這是因為Son.prototype中的constructor被重寫的緣故.

以上我們弄清楚了何為原型鏈,如有不清楚請儘量在下方給我留言


確定原型和例項的關係

使用原型鏈後, 我們怎麼去判斷原型和例項的這種繼承關係呢? 方法一般有兩種.

第一種是使用 instanceof 操作符, 只要用這個操作符來測試例項(instance)與原型鏈中出現過的建構函式,結果就會返回true. 以下幾行程式碼就說明了這點.

alert(instance instanceof Object);//true
alert(instance instanceof Father);//true
alert(instance instanceof Son);//true
複製程式碼

由於原型鏈的關係, 我們可以說instance 是 Object, Father 或 Son中任何一個型別的例項. 因此, 這三個建構函式的結果都返回了true.

第二種是使用 isPrototypeOf() 方法, 同樣只要是原型鏈中出現過的原型,isPrototypeOf() 方法就會返回true, 如下所示.

alert(Object.prototype.isPrototypeOf(instance));//true
alert(Father.prototype.isPrototypeOf(instance));//true
alert(Son.prototype.isPrototypeOf(instance));//true
複製程式碼

原理同上.

原型鏈的問題

原型鏈並非十分完美, 它包含如下兩個問題.

問題一: 當原型鏈中包含引用型別值的原型時,該引用型別值會被所有例項共享;

問題二: 在建立子型別(例如建立Son的例項)時,不能向超型別(例如Father)的建構函式中傳遞引數.

有鑑於此, 實踐中很少會單獨使用原型鏈.

為此,下面將有一些嘗試以彌補原型鏈的不足.

借用建構函式

為解決原型鏈中上述兩個問題, 我們開始使用一種叫做借用建構函式(constructor stealing)的技術(也叫經典繼承).

基本思想:即在子型別建構函式的內部呼叫超型別建構函式.

function Father(){
	this.colors = ["red","blue","green"];
}
function Son(){
	Father.call(this);//繼承了Father,且向父型別傳遞引數
}
var instance1 = new Son();
instance1.colors.push("black");
console.log(instance1.colors);//"red,blue,green,black"

var instance2 = new Son();
console.log(instance2.colors);//"red,blue,green" 可見引用型別值是獨立的
複製程式碼

很明顯,借用建構函式一舉解決了原型鏈的兩大問題:

其一, 保證了原型鏈中引用型別值的獨立,不再被所有例項共享;

其二, 子型別建立時也能夠向父型別傳遞引數.

隨之而來的是, 如果僅僅借用建構函式,那麼將無法避免建構函式模式存在的問題--方法都在建構函式中定義, 因此函式複用也就不可用了.而且超型別(如Father)中定義的方法,對子型別而言也是不可見的. 考慮此,借用建構函式的技術也很少單獨使用.

組合繼承

組合繼承, 有時候也叫做偽經典繼承,指的是將原型鏈和借用建構函式的技術組合到一塊,從而發揮兩者之長的一種繼承模式.

基本思路: 使用原型鏈實現對原型屬性和方法的繼承,通過借用建構函式來實現對例項屬性的繼承.

這樣,既通過在原型上定義方法實現了函式複用,又能保證每個例項都有它自己的屬性. 如下所示.

function Father(name){
	this.name = name;
	this.colors = ["red","blue","green"];
}
Father.prototype.sayName = function(){
	alert(this.name);
};
function Son(name,age){
	Father.call(this,name);//繼承例項屬性,第一次呼叫Father()
	this.age = age;
}
Son.prototype = new Father();//繼承父類方法,第二次呼叫Father()
Son.prototype.sayAge = function(){
	alert(this.age);
}
var instance1 = new Son("louis",5);
instance1.colors.push("black");
console.log(instance1.colors);//"red,blue,green,black"
instance1.sayName();//louis
instance1.sayAge();//5

var instance1 = new Son("zhai",10);
console.log(instance1.colors);//"red,blue,green"
instance1.sayName();//zhai
instance1.sayAge();//10
複製程式碼

組合繼承避免了原型鏈和借用建構函式的缺陷,融合了它們的優點,成為 JavaScript 中最常用的繼承模式. 而且, instanceof 和 isPrototypeOf( )也能用於識別基於組合繼承建立的物件.

同時我們還注意到組合繼承其實呼叫了兩次父類建構函式, 造成了不必要的消耗, 那麼怎樣才能避免這種不必要的消耗呢, 這個我們將在後面講到.

原型繼承

該方法最初由道格拉斯·克羅克福德於2006年在一篇題為 《Prototypal Inheritance in JavaScript》(JavaScript中的原型式繼承) 的文章中提出. 他的想法是藉助原型可以基於已有的物件建立新物件, 同時還不必因此建立自定義型別. 大意如下:

在object()函式內部, 先建立一個臨時性的建構函式, 然後將傳入的物件作為這個建構函式的原型,最後返回了這個臨時型別的一個新例項.

function object(o){
	function F(){}
	F.prototype = o;
	return new F();
}
複製程式碼

從本質上講, object() 對傳入其中的物件執行了一次淺複製. 下面我們來看看為什麼是淺複製.

var person = {
	friends : ["Van","Louis","Nick"]
};
var anotherPerson = object(person);
anotherPerson.friends.push("Rob");
var yetAnotherPerson = object(person);
yetAnotherPerson.friends.push("Style");
alert(person.friends);//"Van,Louis,Nick,Rob,Style"
複製程式碼

在這個例子中,可以作為另一個物件基礎的是person物件,於是我們把它傳入到object()函式中,然後該函式就會返回一個新物件. 這個新物件將person作為原型,因此它的原型中就包含引用型別值屬性. 這意味著person.friends不僅屬於person所有,而且也會被anotherPerson以及yetAnotherPerson共享.

在 ECMAScript5 中,通過新增 object.create() 方法規範化了上面的原型式繼承.

object.create() 接收兩個引數:

  • 一個用作新物件原型的物件
  • (可選的)一個為新物件定義額外屬性的物件
var person = {
	friends : ["Van","Louis","Nick"]
};
var anotherPerson = Object.create(person);
anotherPerson.friends.push("Rob");
var yetAnotherPerson = Object.create(person);
yetAnotherPerson.friends.push("Style");
alert(person.friends);//"Van,Louis,Nick,Rob,Style"
複製程式碼

object.create() 只有一個引數時功能與上述object方法相同, 它的第二個引數與Object.defineProperties()方法的第二個引數格式相同: 每個屬性都是通過自己的描述符定義的.以這種方式指定的任何屬性都會覆蓋原型物件上的同名屬性.例如:

var person = {
	name : "Van"
};
var anotherPerson = Object.create(person, {
	name : {
		value : "Louis"
	}
});
alert(anotherPerson.name);//"Louis"
複製程式碼

目前支援 Object.create() 的瀏覽器有 IE9+, Firefox 4+, Safari 5+, Opera 12+ 和 Chrome.

提醒: 原型式繼承中, 包含引用型別值的屬性始終都會共享相應的值, 就像使用原型模式一樣.

寄生式繼承

寄生式繼承是與原型式繼承緊密相關的一種思路, 同樣是克羅克福德推而廣之.

寄生式繼承的思路與(寄生)建構函式和工廠模式類似, 即建立一個僅用於封裝繼承過程的函式,該函式在內部以某種方式來增強物件,最後再像真的是它做了所有工作一樣返回物件. 如下.

function createAnother(original){
	var clone = object(original);//通過呼叫object函式建立一個新物件
	clone.sayHi = function(){//以某種方式來增強這個物件
		alert("hi");
	};
	return clone;//返回這個物件
}
複製程式碼

這個例子中的程式碼基於person返回了一個新物件--anotherPerson. 新物件不僅具有 person 的所有屬性和方法, 而且還被增強了, 擁有了sayH()方法.

注意: 使用寄生式繼承來為物件新增函式, 會由於不能做到函式複用而降低效率;這一點與建構函式模式類似.

寄生組合式繼承

前面講過,組合繼承是 JavaScript 最常用的繼承模式; 不過, 它也有自己的不足. 組合繼承最大的問題就是無論什麼情況下,都會呼叫兩次父類建構函式: 一次是在建立子型別原型的時候, 另一次是在子型別建構函式內部. 寄生組合式繼承就是為了降低呼叫父類建構函式的開銷而出現的 .

其背後的基本思路是: 不必為了指定子型別的原型而呼叫超型別的建構函式

function extend(subClass,superClass){
	var prototype = object(superClass.prototype);//建立物件
	prototype.constructor = subClass;//增強物件
	subClass.prototype = prototype;//指定物件
}
複製程式碼

extend的高效率體現在它沒有呼叫superClass建構函式,因此避免了在subClass.prototype上面建立不必要,多餘的屬性. 於此同時,原型鏈還能保持不變; 因此還能正常使用 instanceof 和 isPrototypeOf() 方法.

以上,寄生組合式繼承,集寄生式繼承和組合繼承的優點於一身,是實現基於型別繼承的最有效方法.


下面我們來看下extend的另一種更為有效的擴充套件.

function extend(subClass, superClass) {
  var F = function() {};
  F.prototype = superClass.prototype;
  subClass.prototype = new F(); 
  subClass.prototype.constructor = subClass;

  subClass.superclass = superClass.prototype;
  if(superClass.prototype.constructor == Object.prototype.constructor) {
    superClass.prototype.constructor = superClass;
  }
}
複製程式碼

我一直不太明白的是為什麼要 "new F()", 既然extend的目的是將子型別的 prototype 指向超型別的 prototype,為什麼不直接做如下操作呢?

subClass.prototype = superClass.prototype;//直接指向超型別prototype
複製程式碼

顯然, 基於如上操作, 子型別原型將與超型別原型共用, 根本就沒有繼承關係.

new 運算子

為了追本溯源, 我順便研究了new運算子具體幹了什麼?發現其實很簡單,就幹了三件事情.

var obj  = {};
obj.__proto__ = F.prototype;
F.call(obj);
複製程式碼

第一行,我們建立了一個空物件obj;

第二行,我們將這個空物件的__proto__成員指向了F函式物件prototype成員物件;

第三行,我們將F函式物件的this指標替換成obj,然後再呼叫F函式.

我們可以這麼理解: 以 new 操作符呼叫建構函式的時候,函式內部實際上發生以下變化:

1、建立一個空物件,並且 this 變數引用該物件,同時還繼承了該函式的原型。

2、屬性和方法被加入到 this 引用的物件中。

3、新建立的物件由 this 所引用,並且最後隱式的返回 this.

__proto__ 屬性是指定原型的關鍵

以上, 通過設定 __proto__ 屬性繼承了父類, 如果去掉new 操作, 直接參考如下寫法

subClass.prototype = superClass.prototype;//直接指向超型別prototype
複製程式碼

那麼, 使用 instanceof 方法判斷物件是否是構造器的例項時, 將會出現紊亂.

假如參考如上寫法, 那麼extend程式碼應該為

function extend(subClass, superClass) {
  subClass.prototype = superClass.prototype;

  subClass.superclass = superClass.prototype;
  if(superClass.prototype.constructor == Object.prototype.constructor) {
    superClass.prototype.constructor = superClass;
  }
}
複製程式碼

此時, 請看如下測試:

function a(){}
function b(){}
extend(b,a);
var c = new a(){};
console.log(c instanceof a);//true
console.log(c instanceof b);//true
複製程式碼

c被認為是a的例項可以理解, 也是對的; 但c卻被認為也是b的例項, 這就不對了. 究其原因, instanceof 操作符比較的應該是 c.__proto__ 與 構造器.prototype(即 b.prototype 或 a.prototype) 這兩者是否相等, 又extend(b,a); 則b.prototype === a.prototype, 故這才列印出上述不合理的輸出.


那麼最終,原型鏈繼承可以這麼實現,例如:

function Father(name){
	this.name = name;
	this.colors = ["red","blue","green"];
}
Father.prototype.sayName = function(){
	alert(this.name);
};
function Son(name,age){
	Father.call(this,name);//繼承例項屬性,第一次呼叫Father()
	this.age = age;
}
extend(Son,Father)//繼承父類方法,此處並不會第二次呼叫Father()
Son.prototype.sayAge = function(){
	alert(this.age);
}
var instance1 = new Son("louis",5);
instance1.colors.push("black");
console.log(instance1.colors);//"red,blue,green,black"
instance1.sayName();//louis
instance1.sayAge();//5

var instance1 = new Son("zhai",10);
console.log(instance1.colors);//"red,blue,green"
instance1.sayName();//zhai
instance1.sayAge();//10
複製程式碼

擴充套件:

屬性查詢

​ 使用了原型鏈後, 當查詢一個物件的屬性時,JavaScript 會向上遍歷原型鏈,直到找到給定名稱的屬性為止,到查詢到達原型鏈的頂部 - 也就是 Object.prototype - 但是仍然沒有找到指定的屬性,就會返回 undefined. 此時若想避免原型鏈查詢, 建議使用 hasOwnProperty 方法. 因為 hasOwnProperty 是 JavaScript 中唯一一個處理屬性但是不查詢原型鏈的函式. 如:

console.log(instance1.hasOwnProperty('age'));//true
複製程式碼

對比: isPrototypeOf 則是用來判斷該方法所屬的物件是不是引數的原型物件,是則返回true,否則返回false。如:

console.log(Father.prototype.isPrototypeOf(instance1));//true
複製程式碼

instanceof && typeof

上面提到幾次提到 instanceof 運算子. 那麼到底它是怎麼玩的呢? 下面讓我們來趴一趴它的使用場景.

instanceof 運算子是用來在執行時指出物件是否是構造器的一個例項, 例如漏寫了new運算子去呼叫某個構造器, 此時構造器內部可以通過 instanceof 來判斷.(java中功能類似)

function f(){
  if(this instanceof arguments.callee)
    console.log('此處作為建構函式被呼叫');
  else
    console.log('此處作為普通函式被呼叫');
}
f();//此處作為普通函式被呼叫
new f();//此處作為建構函式被呼叫
複製程式碼

以上, this instanceof arguments.callee 的值如果為 true 表示是作為建構函式被呼叫的,如果為 false 則表示是作為普通函式被呼叫的。

對比: typeof 則用以獲取一個變數或者表示式的型別, 一般只能返回如下幾個結果:

number,boolean,string,function(函式),object(NULL,陣列,物件),undefined。

new運算子

此處引用 艾倫的 JS 物件機制深剖——new 運算子

接著上述對new運算子的研究, 我們來考察 ECMAScript 語言規範中 new 運算子的定義:

The new Operator

The production NewExpression : new NewExpression is evaluated as follows:Evaluate NewExpression.Call GetValue(Result(1)).If Type(Result(2)) is not Object, throw a TypeError exception.If Result(2) does not implement the internal [[Construc]] method, throw a TypeError exception.Call the [[Construct]] method on Result(2), providing no arguments (that is, an empty list of arguments).Return Result(5).

其大意是,new 後必須跟一個物件並且此物件必須有一個名為 [[Construct]] 的內部方法(其實這種物件就是構造器),否則會丟擲異常

根據這些內容,我們完全可以構造一個偽 [[Construct]] 方法來模擬此流程

function MyObject(age) {
    this.age = age;
}

MyObject.construct = function() {
    var o = {}, Constructor = MyObject;
    o.__proto__ = Constructor.prototype;
    // FF 支援使用者引用內部屬性 [[Prototype]]
    Constructor.apply(o, arguments);
    return o;
};

var obj1 = new MyObject(10);
var obj2 = MyObject.construct(10);
alert(obj2 instanceof MyObject);// true
複製程式碼

不知不覺本文已經寫了3天, 其實還有很多引申的東西沒有講出來, 大家有什麼問題或好的想法歡迎在下方參與留言和評論.

本文作者: louis

本文連結: louiszhai.github.io/2015/12/15/…

參考:

相關文章