玩轉JavaScript OOP[3]——完全理解繼承和原型鏈

概述

上一篇咱們介紹了經過構造函數和原型能夠實現JavaScript中的「類」,因爲構造函數和函數的原型都是對象,因此JavaScript的「類」本質上也是對象。這一篇咱們將介紹JavaScript中的一個重要概念原型鏈,以及如何經原型鏈實現JavaScript中的繼承。javascript

C#的繼承

首先,咱們簡單描述一下繼承的概念:當一個類和另外一個類構成"is a kind of"關係時,這兩個類就構成了繼承關係。繼承關係的雙方分別是子類和基類,子類能夠重用基類中的屬性和方法。java

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()方法。post

image

這是C#的繼承語法,JavaScript則沒有提供這樣的語法,如今咱們來探討如何在JavaScript中實現繼承。性能

JavaScript原型繼承

繼承的目的

在JavaScript中定義兩個構造函數Person()和Employee(),爲了方便理解和講解,咱們能夠將它們理解爲Person類和Employee類。
如下內容提到的Person類、Employee類,和Person()構造函數、Employee()構造函數是一個意思。this

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('keepfool@xxx.com');

目前Person()和Employee()構造函數是兩個彼此獨立的存在,它們沒有任何關係。
因此由Employee()構造函數建立的實例emp,確定是訪問不到Person的name屬性和sayHello()方法的。spa

image

使用instanceof操做符一樣能夠肯定emp是Employee類的實例,而不是Person類的實例。prototype

image

實現繼承的目的是什麼?固然是讓子類可以使用基類的屬性和方法。
在這個示例中,咱們的目的是實現Employee繼承Person,而後讓Employee的實例可以訪問Person的name和sayHello()了。3d

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('keepfool@xxx.com');

如今咱們就能夠訪問emp.name和emp.sayHello()方法了。

image

在Chrome控制檯,使用instanceof操做符,能夠看到emp對象如今已是Person類的實例了。

image

這是如何實現的?

  • 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('keepfool@xxx.com');

下面這幅圖歸納了實現Employee繼承Person的過程:

image

name和sayHello()不是Employee類的自有屬性和方法,它來源於Employee.prototype。
而Employee.prototype指向一個Person的實例,這個實例是可以訪問name和sayHello()的。

image

原型繼承的本質

JavaScript的原型繼承的本質:將構造函數的原型對象指向由另一個構造函數建立的實例。

這行代碼Employee.prototype = new Person()描述的就是這個意思。
如今咱們能夠說Employee()構造函數繼承了Person()構造函數。

用一句話歸納這個繼承實現的過程:

Employee()構造函數的原型引用了一個由Person()構造函數建立的實例,從而創建了Employee()和Person()的繼承關係。

再談constructor

對象的constructor屬性

上一篇文章有提到過,每一個對象都有constructor屬性,constructor屬性應該指向對象的構造函。
例如:Person實例的constructor屬性是指向Person()構造函數的。

var person = new Person();

image

在未設置Employee.prototype時,emp對象的構造函數本來也是指向Employee()構造函數的。
image

當設置了Employee.prototype = new Person();時,emp對象的構造函數卻指向了Person()構造函數。

image

無形之中,emp.constructor被改寫了。
emp對象看起來不像是Employee()構造函數建立的,而是Person()構造函數建立的。

這不是咱們指望的,咱們但願emp對象看起來也是由Employee()構造函數建立的,即emp.constructor應該是指向Employee()構造函數的。
要解決這個問題,咱們先弄清楚對象的constructor屬性是從哪兒來的,知道它是從哪兒來的就知道爲何emp.constructor被改寫了。

constructor屬性的來源

當咱們沒有改寫構造函數的原型對象時,constructor屬性是構造函數原型對象的自有屬性。
例如:Person()構造函數的原型沒有改寫,constructor是Person.prototype的自有屬性。

image

當咱們改寫了構造函數的原型對象後,constructor屬性就不是構造函數原型對象的自有屬性了。
例如:Employee()構造函數的原型被改寫後,constructor就不是Person.prototype的自有屬性了。

image

Employee.prototype的constructor屬性是指向Person()構造函數的。

image

這說明:當對象被建立時,對象自己沒有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('keepfool@xxx.com');

image

若是你仍是不能理解關鍵的這行代碼:

Employee.prototype.constructor = Employee;

你能夠嘗試從C#的角度去理解,在C#中Employee類的實例確定是由Employee類的構造函數建立出來的。

原型鏈

原型鏈是JavaScript中很是重要的概念,理解它有助於理解JavaScript面向對象編程的本質。

__proto__屬性

定義函數時,函數就有了prototype屬性,該屬性指向一個對象。
prototype屬性指向的對象是共享的,這有點像C#中的靜態屬性。
站在C#的角度講,由new建立的對象是不能直接訪問類的靜態屬性的。
那麼在JavaScript中,爲何對象可以訪問到prototype中的屬性和方法的呢?

由於:當對象由new構造函數建立時,對象會自帶一個__proto__屬性,這個屬性是由JavaScript分配的。
這個屬性是一個引用類型,它指向的正是構造函數的原型。

例如:當emp對象被建立時,JavaScript自動地爲emp對象分配了一個__proto__屬性,這個屬性是指向Employee.prototype的。

image

在Chrome的控制檯查看emp.__proto__的內容

image

首先,▼Person {name: "keepfool"}表示emp.__proto__是一個Person對象,由於Employee.prototype確實指向一個Person對象。
其次,咱們把emp.__proto__的屬性分爲3個部分來看。

  1. 第1部分:name屬性和sayHello()方法,它們兩個來源於Person對象。
  2. 第2部分:constructor屬性,由於咱們重寫了Employee()構造函數的原型對象的constructor屬性,即Employee.prototype.constructor = Employee,因此constructor是指向Employee()構造函數的。
  3. 第3部分:__proto__它指向一個Object,Person類是Employee類的父類,那麼誰是Person類的父類呢?——Object類。

對象的__proto__屬性就像一個祕密連接,它指向了建立該對象的構造函數的原型對象。

什麼是原型鏈

咱們注意到第3部分的內容仍然是一個__proto__屬性,咱們展開它看個究竟吧。

再往下看,還有兩層__proto__。

image

emp.__proto__.__proto__:從▶constructor:function Person()能夠看出它是Person()構造函數的原型。

image

Person.prototype包含兩部份內容:

  • Person()構造函數
  • 一個__proto__屬性,即emp__proto__.__proto__.__proto__,這個屬性指向內置的Object對象。

image

咱們將這一系列的__proto__稱之爲原型鏈。

理解原型鏈

下面兩幅圖展現了本文示例的原型鏈,這兩幅圖表示的同一個意思。原型鏈的最頂端是null,由於Object.prototype是沒有__proto__屬性。

image

image

下表清晰地描述了每一層__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 原型鏈的頂端

image

原型鏈查找

如今能夠解釋emp對象可以訪問到name屬性和sayHello()方法了。
以訪問emp.sayHello()爲例,咱們用幾個慢鏡頭來闡述:

  1. emp是由Employee()構造函數建立的,JavaScript先去Employee()構造函數查找sayHello()方法
  2. 在Employee()中沒找到sayHello()方法,但emp有一個__proto__屬性,因而JavaScript就去emp.__proto__中查找
  3. emp.__proto__和Employee.prototype是相等的,而Employee.prototype指向的是一個Person對象
  4. 因而JavaScript就在這個Person對象中查找,結果發現了sayHello()方法
  5. 最終JavaScript調用的是emp.__proto__.sayHello(),也就是Employee.prototype.sayHello()。

JavaScript在背後作的事情

另外,在實現Employee()繼承Person(),以及emp對象訪問name和sayHello()時,JavaScript是幫咱們作了一些事情的,見下圖:

image

將方法提高到原型對象

上一篇有提到過,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('keepfool@xxx.com');

能夠看到sayHello()方法的路徑是:emp.__proto__.__proto__.sayHello(),比直接定義在Person()構造函數中多了一層。

image

這樣看來將方法定義在原型對象中並非絕對的好,會使得JavaScript遍歷較多層數的原型鏈,這也會有一些性能上的損失。

原型鏈示例

爲了增強對原型鏈的理解,咱們來作個簡單的示例吧。

上圖已經說明了toString()方法是屬於內置的Object對象的,咱們以toString()方法來說解這個示例。

在Chrome控制檯輸入emp.toString(),咱們獲得的結果是"[object Object]"
toString()方法是在emp的第3層原型鏈找到的,即emp.__proto__.__proto__.__proto__,它就是Object對象。

image

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('keepfool@xxx.com');

這時toString()方法是在emp對象的第2層原型鏈找到的,即emp.__proto__.__proto__
emp.__proto__.__proto__是Person()構造函數的原型對象,即Person.prototype。

image

這個也是一個簡單的重寫示例,Person.protoype重寫了toString()方法,emp最終調用的是Person.prototype.toString()方法。

總結

  • JavaScript實現原型繼承有兩個關鍵:1.子類構造函數原型指向父類的一個實例 2.重寫子類構造函數原型的constructor屬性,讓其指向子類構造函數自己。
  • 在定義函數時,JavaScript自動地給函數分配了一個prototype屬性;在建立對象時,JavaScript自動的爲對象分配了一個__proto__屬性。
  • __proto__是JavaScript的原型鏈,每一個__proto__都是一個對象,它是子類可以訪問基類屬性和方法的橋樑。
  • 當訪問一個對象的屬性時,首先查找自有屬性,其次逐層地遍歷__proto__原型鏈。
  • JavaScript是基於對象和原型的語言,「類」、「繼承」這些概念都是經過對象和原型實現的。
相關文章
相關標籤/搜索