概述 上一篇我們介紹了通過構造函數和原型可以實現JavaScript中的“類”,由於構造函數和函數的原型都是對象,所以JavaScript的“類”本質上也是對象。這一篇我們將介紹JavaScript中的一個重要概念原型鏈,以及如何經原型鏈實現JavaScript中的繼承。 C#的繼承 首先,我們簡單... ...
概述
上一篇我們介紹了通過構造函數和原型可以實現JavaScript中的“類”,由於構造函數和函數的原型都是對象,所以JavaScript的“類”本質上也是對象。這一篇我們將介紹JavaScript中的一個重要概念原型鏈,以及如何經原型鏈實現JavaScript中的繼承。
C#的繼承
首先,我們簡單描述一下繼承的概念:當一個類和另一個類構成"is a kind of"關係時,這兩個類就構成了繼承關係。繼承關係的雙方分別是子類和基類,子類可以重用基類中的屬性和方法。
C#可以顯式地定義class,也可以讓一個class直接繼承另外一個class,下麵這段代碼就是一個簡單的繼承。
public class Person { public string Name { get { return "keepfool"; } } public string SayHello() { return "Hello, I am " + this.Name; } } public class Employee : Person { public string Email { get; set; } }
由於Employee類是繼承Person類的,所以Employee類的實例能夠使用Person類的屬性和方法。
Employee emp = new Employee(); Console.WriteLine(emp.Name); Console.WriteLine(emp.SayHello()); Console.WriteLine("emp{0}是Person類的實例", emp is Person ? "" : "不");
emp是Employee類的一個實例,同時也是Person類的實例,它可以訪問定義在Person類的Name屬性和SayHello()方法。
這是C#的繼承語法,JavaScript則沒有提供這樣的語法,現在我們來探討如何在JavaScript中實現繼承。
JavaScript原型繼承
繼承的目的
在JavaScript中定義兩個構造函數Person()和Employee(),為了方便理解和講解,我們可以將它們理解為Person類和Employee類。
以下內容提到的Person類、Employee類,和Person()構造函數、Employee()構造函數是一個意思。
function Person() { this.name = 'keefool'; this.sayHello = function() { return 'Hello, I am ' + this.name; } } function Employee(email) { this.email = email; } var person = new Person(); var emp = new Employee('[email protected]');
目前Person()和Employee()構造函數是兩個彼此獨立的存在,它們沒有任何關係。
所以由Employee()構造函數創建的實例emp,肯定是訪問不到Person的name屬性和sayHello()方法的。
使用instanceof操作符同樣可以確定emp是Employee類的實例,而不是Person類的實例。
實現繼承的目的是什麼?當然是讓子類能夠使用基類的屬性和方法。
在這個示例中,我們的目的是實現Employee繼承Person,然後讓Employee的實例能夠訪問Person的name和sayHello()了。
JavaScript是如何實現繼承的呢?
這個答案有很多種,這裡我先只介紹比較常見的一種——通過原型實現繼承。
實現繼承
當我們定義函數時,JavaScript會自動的為函數分配一個prototype屬性。
Person()也是一個函數,那麼Person()函數也會有prototype屬性,即Person.prototype。
function Person() { this.name = 'keefool'; this.sayHello = function() { return 'Hello, I am ' + this.name; } } // 定義了函數後,JavaScript自動地為Person()函數分配了一個prototype屬性 // Person.prototype = {};
我們可以在Person.prototype上定義一些屬性和方法,這些屬性和方法是可以被Person的實例使用的。
function Person() { this.name = 'keefool'; this.sayHello = function() { return 'Hello, I am ' + this.name; } } Person.prototype.height = 176; var person = new Person(); // 訪問Person.prototype上定義的屬性 person.height; // 輸出176
同理在Employee.prototype上定義的屬性和方法,也可以被Employee類的實例使用。
咱們的目的是讓Employee的實例能夠訪問name屬性和sayHello()方法,如果沒有Person()構造函數,咱們是這麼做的:
function Employee(email) { this.email = email; } Employee.prototype = { name : 'keefool', sayHello = function() { return 'Hello, I am ' + this.name; } }
既然Person()構造函數已經定義了name和sayHello(),我們就不必這麼做了。
怎麼做呢?讓Employee.prototype指向一個Person類的實例。
function Person() { this.name = 'keefool'; this.sayHello = function() { return 'Hello, I am ' + this.name; } } function Employee(email) { this.email = email; } var person = new Person(); Employee.prototype = person; var emp = new Employee('[email protected]');
現在我們就可以訪問emp.name和emp.sayHello()方法了。
在Chrome控制台,使用instanceof操作符,可以看到emp對象現在已經是Person類的實例了。
這是如何實現的?
- Employee.prototype是一個引用類型,它指向一個Person類的一個實例person。
- person對象恰恰是有name屬性和sayHello()方法的,訪問Employee.prototype就像訪問person對象一樣。
- 訪問emp.name和emp.sayHello()時,實際訪問的是Employee.prototype.name和Employee.prototype.sayHello(),最終訪問的是person.name和person.sayHello()。
如果你對這段代碼還是有所疑惑,你可以這麼理解:
var person = new Person(); Employee.prototype.name = person.name; Employee.prototype.sayHello = person.sayHello;
由於person對象在後面完全沒有用到,以上這兩行代碼可以合併為一行。
function Person() { this.name = 'keefool'; this.sayHello = function() { return 'Hello, I am ' + this.name; } } function Employee(email) { this.email = email; } Employee.prototype = new Person(); var emp = new Employee('[email protected]');
下麵這幅圖概括了實現Employee繼承Person的過程:
name和sayHello()不是Employee類的自有屬性和方法,它來源於Employee.prototype。
而Employee.prototype指向一個Person的實例,這個實例是能夠訪問name和sayHello()的。
原型繼承的本質
JavaScript的原型繼承的本質:將構造函數的原型對象指向由另外一個構造函數創建的實例。
這行代碼Employee.prototype = new Person()
描述的就是這個意思。
現在我們可以說Employee()構造函數繼承了Person()構造函數。
用一句話概括這個繼承實現的過程:
Employee()構造函數的原型引用了一個由Person()構造函數創建的實例,從而建立了Employee()和Person()的繼承關係。再談constructor
對象的constructor屬性
上一篇文章有提到過,每個對象都有constructor屬性,constructor屬性應該指向對象的構造函。
例如:Person實例的constructor屬性是指向Person()構造函數的。
var person = new Person();
在未設置Employee.prototype時,emp對象的構造函數原本也是指向Employee()構造函數的。
當設置了Employee.prototype = new Person();
時,emp對象的構造函數卻指向了Person()構造函數。
無形之中,emp.constructor被改寫了。
emp對象看起來不像是Employee()構造函數創建的,而是Person()構造函數創建的。
這不是我們期望的,我們希望emp對象看起來也是由Employee()構造函數創建的,即emp.constructor應該是指向Employee()構造函數的。
要解決這個問題,我們先弄清楚對象的constructor屬性是從哪兒來的,知道它是從哪兒來的就知道為什麼emp.constructor被改寫了。
constructor屬性的來源
當我們沒有改寫構造函數的原型對象時,constructor屬性是構造函數原型對象的自有屬性。
例如:Person()構造函數的原型沒有改寫,constructor是Person.prototype的自有屬性。
當我們改寫了構造函數的原型對象後,constructor屬性就不是構造函數原型對象的自有屬性了。
例如:Employee()構造函數的原型被改寫後,constructor就不是Person.prototype的自有屬性了。
Employee.prototype的constructor屬性是指向Person()構造函數的。
這說明:當對象被創建時,對象本身沒有constructor屬性,而是來源於創建對象的構造函數的原型對象。
即當我們訪問emp.constructor時,實際訪問的是Employee.prototype.constructor,Employee.prototype.constructor實際引用的是Person()構造函數,person.constructor引用是Person()構造函數,Person()構造函數實際上是Person.prototype.constructor。
這個關係有點亂,我們可以用以下式子來表示這個關係:
emp.constructor = person.constructor = Employee.prototype.constructor = Person = Person.prototype.constructor
它們最終都指向Person.prototype.constructor!
改寫原型對象的constructor
弄清楚了對象的constructor屬性的來弄去脈,上述問題就好解決了。
解決辦法就是讓Employee.prototype.constructor指向Employee()構造函數。
var o = {}; function Person() { this.name = 'keefool'; this.sayHello = function() { return 'Hello, I am ' + this.name; } } function Employee(email) { this.email = email; } Employee.prototype = new Person(); Employee.prototype.constructor = Employee; var emp = new Employee('[email protected]');
如果你還是不能理解關鍵的這行代碼:
Employee.prototype.constructor = Employee;
你可以嘗試從C#的角度去理解,在C#中Employee類的實例肯定是由Employee類的構造函數創建出來的。
原型鏈
原型鏈是JavaScript中非常重要的概念,理解它有助於理解JavaScript面向對象編程的本質。
__proto__屬性
定義函數時,函數就有了prototype屬性,該屬性指向一個對象。
prototype屬性指向的對象是共用的,這有點像C#中的靜態屬性。
站在C#的角度講,由new創建的對象是不能直接訪問類的靜態屬性的。
那麼在JavaScript中,為什麼對象能夠訪問到prototype中的屬性和方法的呢?
這個屬性是一個引用類型,它指向的正是構造函數的原型。
例如:當emp對象被創建時,JavaScript自動地為emp對象分配了一個__proto__屬性,這個屬性是指向Employee.prototype的。
在Chrome的控制台查看emp.__proto__
的內容
首先,▼Person {name: "keepfool"}
表示emp.__proto__是一個Person對象,因為Employee.prototype確實指向一個Person對象。
其次,我們把emp.__proto__的屬性分為3個部分來看。
- 第1部分:name屬性和sayHello()方法,它們兩個來源於Person對象。
- 第2部分:constructor屬性,因為我們重寫了Employee()構造函數的原型對象的constructor屬性,即
Employee.prototype.constructor = Employee
,所以constructor是指向Employee()構造函數的。 - 第3部分:__proto__它指向一個Object,Person類是Employee類的父類,那麼誰是Person類的父類呢?——Object類。
對象的__proto__屬性就像一個秘密鏈接,它指向了創建該對象的構造函數的原型對象。
什麼是原型鏈
我們註意到第3部分的內容仍然是一個__proto__屬性,我們展開它看個究竟吧。
再往下看,還有兩層__proto__。
emp.__proto__.__proto__:從▶constructor:function Person()
可以看出它是Person()構造函數的原型。
Person.prototype包含兩部分內容:
- Person()構造函數
- 一個__proto__屬性,即emp__proto__.__proto__.__proto__,這個屬性指向內置的Object對象。
我們將這一系列的__proto__
稱之為原型鏈。
理解原型鏈
下麵兩幅圖展示了本文示例的原型鏈,這兩幅圖表示的同一個意思。原型鏈的最頂端是null,因為Object.prototype是沒有__proto__屬性。
下表清晰地描述了每一層__proto__表示的內容:
編號 | 原型鏈 | 原型鏈指向的對象 | 描述 |
---|---|---|---|
1 | emp.__proto__ | Employee.prototype | Employee()構造函數的原型對象 |
2 | emp.__proto__.__proto__ | Person.prototype | Person()構造函數的原型對象 |
3 | emp.__proto__.__proto__.__proto__ | Object.prototype | Object()構造函數的原型對象 |
4 | emp.__proto__.__proto__.__proto__.__proto__ | null | 原型鏈的頂端 |
原型鏈查找
現在可以解釋emp對象能夠訪問到name屬性和sayHello()方法了。
以訪問emp.sayHello()為例,我們用幾個慢鏡頭來闡述:
- emp是由Employee()構造函數創建的,JavaScript先去Employee()構造函數查找sayHello()方法
- 在Employee()中沒找到sayHello()方法,但emp有一個__proto__屬性,於是JavaScript就去emp.__proto__中查找
- emp.__proto__和Employee.prototype是相等的,而Employee.prototype指向的是一個Person對象
- 於是JavaScript就在這個Person對象中查找,結果發現了sayHello()方法
- 最終JavaScript調用的是emp.__proto__.sayHello(),也就是Employee.prototype.sayHello()。
JavaScript在背後做的事情
另外,在實現Employee()繼承Person(),以及emp對象訪問name和sayHello()時,JavaScript是幫我們做了一些事情的,見下圖:
將方法提升到原型對象
上一篇有提到過,Person類的sayHello()方法放到它的原型對象中更合適,這樣所有的Person實例共用一個sayHelo()方法副本,如果我們把這個方法提到原型對象會發生什麼?
var o = {}; function Person() { this.name = 'keefool'; } Person.prototype.sayHello = function(){ return 'Hello, I am ' + this.name; } function Employee(email) { this.email = email; } Employee.prototype = new Person(); Employee.prototype.constructor = Employee; var emp = new Employee('[email protected]');
可以看到sayHello()方法的路徑是:emp.__proto__.__proto__.sayHello()
,比直接定義在Person()構造函數中多了一層。
這樣看來將方法定義在原型對象中並不是絕對的好,會使得JavaScript遍歷較多層數的原型鏈,這也會有一些性能上的損失。
原型鏈示例
為了加強對原型鏈的理解,我們來做個簡單的示例吧。
上圖已經說明瞭toString()方法是屬於內置的Object對象的,我們以toString()方法來講解這個示例。
在Chrome控制台輸入emp.toString(),我們得到的結果是"[object Object]"
。
toString()方法是在emp的第3層原型鏈找到的,即emp.__proto__.__proto__.__proto__
,它就是Object對象。
emp.toString()輸出"[object Object]"
沒有什麼意義,現在我們在Person.prototype上定義一個toString()方法。
var o = {}; function Person() { this.name = 'keefool'; } Person.prototype.sayHello = function(){ return 'Hello, I am ' + this.name; } Person.prototype.toString = function() { return '[' + this.name + ']'; } function Employee(email) { this.email = email; } Employee.prototype = new Person(); Employee.prototype.constructor = Employee; var emp = new Employee('[email protected]');
這時toString()方法是在emp對象的第2層原型鏈找到的,即emp.__proto__.__proto__
。emp.__proto__.__proto__
是Person()構造函數的原型對象,即Person.prototype。
這個也是一個簡單的重寫示例,Person.protoype重寫了toString()方法,emp最終調用的是Person.prototype.toString()方法。
總結
- JavaScript實現原型繼承有兩個關鍵:1.子類構造函數原型指向父類的一個實例 2.重寫子類構造函數原型的constructor屬性,讓其指向子類構造函數本身。
- 在定義函數時,JavaScript自動地給函數分配了一個prototype屬性;在創建對象時,JavaScript自動的為對象分配了一個__proto__屬性。
- __proto__是JavaScript的原型鏈,每個__proto__都是一個對象,它是子類能夠訪問基類屬性和方法的橋梁。
- 當訪問一個對象的屬性時,首先查找自有屬性,其次逐層地遍歷__proto__原型鏈。
- JavaScript是基於對象和原型的語言,“類”、“繼承”這些概念都是通過對象和原型實現的。
隱藏文章目錄 顯示右邊欄 關註keepfool