對象與原型鏈
基于類和基于原型
我們都知道 JavaScript 是一個面向對象的語言,但是它卻沒有其他諸如 Java、C++ 這些面向對象的語言中都存在類的這個概念。取而代之的是原型的概念。這其實就是兩種不同的編程范式。
-
基于類的面向對象
在這種范式中,類定義了對象的結構和行為以及繼承關系,所有基于該類的對象都有相同的行為和結構,不同的只是他們的狀態。
創建新的對象通過類的構造器來創建。只有少數基于類的面向對象語言允許類在運行時進行修改。
-
基于原型的面向對象
在這種范式中,關注的是一系列對象的行為,將擁有相似行為的對象通過原型鏈串聯起來。
創建新的對象通過拓展原有對象創建。很多的基于原型的語言提倡運行時對原型進行修改。
對比
圖片來自 MDN
總的來說基于原型相對來說更加靈活。這也許是 JavaScript 選擇基于原型構建面向對象的原因之一吧。
對象:無序屬性的集合
ECMA262 把對象定義為:無序屬性的集合,其屬性可以包含基本值、對象或者函數。
var obj = {
a: 5,
b: function() {},
c:{ d: 10 }
}
基本類型 a,函數 b,對象 c 都是對象 obj 的屬性。
實際上 JavaScript 中函數也可以添加屬性。
var fun = function(){}
fun.a = 5
fun.b = function() {}
fun.c = { d: 10 }
因此函數也是屬性的集合,它也是對象。
構造函數
‘面向對象編程’的第一步,就是要生成對象。在基于類的語言中類都有創建對象的構造函數。而在 JavaScript 中沒有類,那么生成對象的工作就由函數來完成。這種函數被稱為構造函數。
所有對象都有一個 constructor 的屬性指向它的構建函數。
你可能會提出反對意見:
var obj = { a: 5, b: 10 }
var fun = function(){}
你會說 obj 和 fun 都是對象,但他們都沒有通過函數生成啊。
其實這是 JavaScript 提供的語法糖,本質上他們會分別調用 Object 和 Function (注意大寫)這兩個函數來生成。
obj.constructor // ? Object() { [native code] }
fun.constructor // ? Function() { [native code] }
等同于
// var obj = { a: 5, b: 10 }
var obj = new Object()
obj.a = 5
obj.b = 10
// var fun = function(){}
var fun = new Function()
除了 Object 和 Function 這兩個函數外,你也可以自定義構造函數。函數要具備下面的特征:
- 為區別于普通函數,通常構造函數名首字母大寫;
- 構造函數必須通過 new 命令調用;
- 構造函數內部使用 this 關鍵字,this 指向當前構造函數生成的對象;
- 構造函數沒有 return,默認返回 this。
一個例子
function Person(name) {
this.name = name
}
var peter = new Person('Peter')
其中 new 運算符都做了以下工作:
- 創建一個空對象,作為將要返回的對象實例;
- 將空對象的原型
__proto__
指向了構造函數的prototype
屬性; - 將空對象賦值給構造函數內部的 this 關鍵字;
- 開始執行構造函數內部的代碼;
- 如果構造器返回的是對象,則返回,否則返回第一步創建的對象。
這里出現了兩個容易混淆的概念:__proto__
和 prototype
。
-
__proto__
是每個對象都有的一個屬性。指向創建該對象的函數的 prototype。用它來產生一個鏈,一個原型鏈,用于尋找方法名或屬性,等等。它是個隱藏屬性,早期低版本的瀏覽器甚至不支持這個屬性。 -
prototype
是每個函數都有的一個屬性。它本身是一個對象,它的 constructor 指向函數本身。這個屬性存在的目的就是在通過 new 來創建對象時構造對象的__proto__
屬性。
只是通過上面的敘述可能理解起來比較困難,我們通過代碼和內存布局來仔細分析。
對于 Person 函數
// Person.prototype 是一個對象,它有兩個屬性 constructor 指向 Person 函數,__proto__ 指向 Object。(先不用理會 Object 這個對象,后面會詳細介紹)
Person.prototype
/*
* {constructor: ?}
* constructor: ? Person()
* __proto__: Object
*/
Person 是函數因此它擁有 constructor
、__proto__
、prototype
屬性。
Person.prototype 是只是個普通對象,因此 Person.prototype 擁有 constructor
、__proto__
屬性。
對于 peter 來說,它是個對象因此具有 constructor
、__proto__
屬性。
// peter 對象的構造函數就是 Person()
peter.constructor // ? Person() {...}
// peter 對象的 __proto__ 屬性指向 Person.prototype
peter.__proto__ == Person.prototype // true
可以清楚的看到對象和它的構造函數之間的聯系,總結一下:
- 每個對象都是由其構造函數生成,并且對象有個
constructor
屬性指向構造函數; - 每個對象都有個原型屬性
__proto__
,指向其構造函數的prototype
屬性; - 每個函數都有一個
prototype
屬性用于充當構造函數時構建對象的__proto__
屬性。
原型鏈
到此你也許會疑惑為什么要這樣設計,這是因為 JavaScript 也是面向對象的語言,它通過這樣的設計來構建原型鏈以實現繼承。
在上面代碼的基礎上我們再聲明一個 Student 的構造函數
function Student(name, score) {
Person.call(this, name)
this.score = score
}
// 只執行上面的語句還不夠,需要通過這行代碼將它們產生鏈接也就是繼承關系
Student.prototype = Object.create(Person.prototype)
// 這段代碼是為了讓 Student.prototype 的構造函數指向 Student 函數,不指定的話會指向 Person 函數。(許多地方都沒有這一步,也可以不寫。這里為了保持 constructor 指向一致)
Student.prototype.constructor = Student
// 創建一個對象
var jim = new Student('Jim', 90)
分析下內存布局
Student.prototype.constructor == Student // true
Student.prototype.__proto__ == Person.prototype // true
jim.__proto__ == Student.prototype // true
jim.constructor == Student // true
可以明顯的看到一個鏈條,一個靠 __proto__
屬性串聯起來的鏈條,這就是所謂的原型鏈。
正是有了原型鏈當我們訪問一個對象的屬性時,會先在基本屬性中查找,如果沒有,再沿著 __proto__
這條鏈向上找。這樣就實現了繼承。
我們還可以發現這個鏈條在 Person.prototype 這里斷了,而且圖中有些屬性沒有標注出來。別急,我們后面來把它慢慢補全。
Function 和 Object
我們先看下面的代碼
var obj = { a: 5, b: 10 }
var fun = function(){}
等價于
// var obj = { a: 5, b: 10 }
var obj = new Object()
obj.a = 5
obj.b = 10
typeof Object // function
// var fun = function(){}
var fun = new Function()
typeof Function // function
由此可見 Object 和 Function 是兩個比較總要的函數對象。我們來探究下它們的內存布局。
因為它們都是對象因此它們都有 constructor
、__proto__
屬性,又因為他們是函數對象,因此它們都有 prototype
屬性。
對于 Function 來說
// 雖然 Function.prototype 返回的類型是 function 但是它的 prototype 屬性并不存在,因此它是特殊的函數對象。
typeof Function.prototype // "function"
typeof Function.prototype.prototype // "undefined"
// Function.prototype 與 Function.__proto__ 指向同一個對象
Function.prototype == Function.__proto__
// Function.prototype 的 constructor 是 Function 函數
Function.prototype.constructor // ? Function() { [native code] }
// Function 的 constructor 是 Function 函數自己
Function.constructor // ? Function() { [native code] }
可以畫出下面的圖
對于 Object 來說
// Object.prototype 是一個對象
typeof Object.prototype // "object"
// Object.__proto__ 與 Object.prototype 指向的不是同一個對象
Object.prototype == Object.__proto__ // false
// Object.__proto__ 指向 Function.prototype 的對象
Object.__proto__ == Function.prototype // true
// Object.prototype 的 constructor 是 Object 函數
Object.prototype.constructor // ? Object() { [native code] }
// Object 的 constructor 是 Function 函數
Object.constructor // ? Function() { [native code] }
可以畫出下面的圖
我們發現還有兩個屬性沒確定分別是 Object.prototype.__proto__
和 Function.prototype.__proto__
。我們通過代碼在確認一下。
// Function.prototype 的 __proto__ 屬性指向 Object.prototype
Function.prototype.__proto__ == Object.prototype // true
// Object.prototype 的 __proto__ 屬性指向 null
Object.prototype.__proto__ // null
最終得到這個圖
我們再結合上面原型鏈那部分的內容。
// Person 函數的構造函數是 Function
Person.constructor // ? Function() { [native code] }
// Person 函數的原型是 Function.prototype
Person.__proto__ // ? () { [native code] }
Person.__proto__ == Function.prototype // true
// Student 函數的構造函數是 Function
Student.constructor // ? Function() { [native code] }
// Student 函數的原型是 Function.prototype
Student.__proto__ // ? () { [native code] }
Student.__proto__ == Function.prototype // true
// Person.prototype 的原型是 Object.prototype
Person.prototype.__proto__ == Object.prototype
我們可以觀察到幾條明顯的原型鏈,見下圖:
- 綠色的是我們自定義的 Person Student 對象的原型鏈;
- 其他顏色的是 函數對象的原型鏈。
分析這張圖我們可以得出以下結論
- 所有函數的原型都是
Function.prototype
,包括 Function 函數自己 - 所有函數的構造函數都是 Function,包括 Function 函數自己
- 所有對象的原型終點都是
Object.prototype
,包括函數對象和普通對象,而Object.prototype.__proto__
的原型指向了null
這里面有個最初讓我比較疑惑的就是 Object 的原型為什么不是 Object.prototype
而是 Function.prototype
Object.__proto__ == Function.prototype // true
其實這是因為 Object 本身就是個函數,它跟其他函數一樣都是由 Function 來構造的。
這里還有張大佬的圖片,相信你可以很清楚的跟上面的圖片對應上。
與原型鏈相關的方法
instanceof
instanceof 主要的作用就是判斷一個實例是否屬于某種類型,實現原理就是通過原型鏈進行判斷。
function new_instance_of(leftVaule, rightVaule) {
let rightProto = rightVaule.prototype; // 取右表達式的 prototype 值
leftVaule = leftVaule.__proto__; // 取左表達式的__proto__值
while (true) {
if (leftVaule === null) {
return false;
}
if (leftVaule === rightProto) {
return true;
}
leftVaule = leftVaule.__proto__
}
}
可以看出來 instanceof 的實現思路就是判斷右值變量的 prototype 是否在左值變量的原型鏈上。
jim instanceof Person // true
jim instanceof Student // true
參考上方的圖我們也可以解釋一些看起來比較詭異的判斷
Object instanceof Object // true
Function instanceof Function // true
Function instanceof Object // true
下面這些你可以自行檢測。
function Foo() { } // 定義一個函數
Foo instanceof Object // true
Foo instanceof Function // true
hasOwnProperty
Object.hasOwnProperty() 返回一個布爾值,表示某個對象的實例是否含有指定的屬性,而且此屬性非原型鏈繼承。用來判斷屬性是來自實例屬性還是原型屬性。類似還有 in 操作符,in 操作符只要屬性存在,不管實在實例中還是原型中,就會返回 true。同時使用 in 和 hasOwnProperty 就可以判斷屬性是在原型中還是在實例中。
isPrototypeOf
返回一個布爾值,表示指定的對象是否在本對象的原型鏈中。
getPrototypeOf
返回該對象的原型。
創建對象和生成原型鏈
上面已經提到了創建對象以及實現繼承的部分方法,其實還有其他很多的方法來創建和生成原型鏈以實現繼承。
使用語法結構創建對象
var o = {a: 1}
o 這個對象繼承了 Object.prototype 上面的所有屬性
原型鏈: o ---> Object.prototype ---> null
var a = ["yo", "whadup", "?"]
數組都繼承于 Array.prototype
原型鏈: a ---> Array.prototype ---> Object.prototype ---> null
function f(){ return 2 }
函數都繼承于 Function.prototype
原型鏈: f ---> Function.prototype ---> Object.prototype ---> null
使用構造器創建對象
function Person(name) {
this.name = name
}
var peter = new Person('Peter')
構造器創建的對象繼承了對應構造函數的 prototype 屬性
原型鏈: peter ---> peter.prototype ---> Object.prototype ---> null
其實上面使用語法結構創建對象本質上也是調用相應的構造器。
使用 Object.create
創建的對象
ECMAScript 5 中引入了一個的新方法:Object.create()。可以調用這個方法來創建一個新對象。新對象的原型就是調用 create 方法時傳入的第一個參數。
var a = {a: 1};
// a ---> Object.prototype ---> null
var b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (繼承而來)
var c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null
var d = Object.create(null);
// d ---> null
console.log(d.hasOwnProperty); // undefined, 因為d沒有繼承Object.prototype
上面的創建方法都節選自 MDN 繼承與原型鏈。其實還有其他很多的方法來實現創建對象和繼承,但是萬變不離其宗。他們的本質就是保證原型鏈的正確構建就可以了。
class
通過上面的學習我們知道了很多創建對象和實現繼承的方式,但是這些方式都比較繁瑣,需要使用者自己保證原型鏈的正確構建。尤其是對于熟悉基于類面向對象語言的同學來說感覺這種方式比較怪異。
其實在 ES6 出現之前,就有很多框架來模擬類,使得 JS 能基于類實現繼承。但是由于社區和碎片化原因趨于小眾。
直到 ES6 的新特性 class,這讓類的概念成為了語言一個基礎特性。
當然它實質上是 JavaScript 現有的基于原型的繼承的語法糖。類語法不會為 JavaScript 引入新的面向對象的繼承模型。
定義 class
類實際上是個“特殊的函數”,與聲明一個函數類似,不過這里使用的是 class 關鍵字。
class Rectangle {
// 屬性
color = 'Red'
// constructor 構造函數
constructor(height, width) {
this.height = height
this.width = width
}
// Getter Setter
get prop() {
return 'getter'
}
set prop(value) {
console.log('setter: ' + value)
}
// Method
calcArea() {
return this.height * this.width
}
static
}
class Square extends Rectangle {
// constructor 構造函數
constructor(edge) {
super(edge, edge)
}
}
let square = new Square(10)
構造函數
constructor 方法是一個特殊的方法,這種方法用于創建和初始化一個由 class 創建的對象。它具有以下特性。
-
一個類只能擁有一個名為 constructor 的特殊方法。
如果類包含多個 constructor 的方法,則將拋出 一個 SyntaxError 。
-
一個構造函數可以使用 super 關鍵字來調用一個父類的構造函數。
比如我們定義的 Square 子類中就調用了父類的構造方法。
但是需要注意的一點是子類的構造方法中 this 必須在調用父類構造方法之后才能使用。這是因為如果在父類方法之前調用這時候 this 還沒有被初始化。
constructor 方法不用寫 return 它默認返回實例對象(即this),當然你也可以指定返回另外一個對象。
屬性
實例屬性
我們都知道 JavaScript 對象有兩類屬性:數據屬性和訪問器(getter/setter)屬性。
定義數據屬性有兩種方式:
- 在構造函數里創建。
- 也可以定義在類的最頂層,與構造函數同級的地方。
class Rectangle {
// 實例屬性 1
color = 'Red'
// constructor 構造函數
constructor(height, width) {
// 實例屬性 2
this.height = height
this.width = width
}
}
訪問器(getter/setter)屬性的定義跟之前類似。
// Getter Setter
get prop() {
return 'getter'
}
set prop(value) {
console.log('setter: ' + value)
}
靜態屬性
靜態屬性指的是 Class 本身的屬性,,即Class.propName,而不是定義在實例對象上的屬性。
// 現在的寫法
class Foo {
// ...
}
Foo.prop = 1;
// 提案的寫法
class Foo {
static prop = 1;
}
目前只有個上面的方法來定義,下面的寫法還只是一個提案。
方法
類相當于實例的原型,所有在類中定義的方法,都會被實例繼承,這些稱為實例方法。另外如果在一個方法前,加上 static 關鍵字,就表示該方法不會被實例繼承,而是直接通過類來調用,這就稱為“靜態方法”。
靜態方法
實例方法沒什么好說的,這里主要介紹下靜態方法。
class Foo {
static bar() {
this.baz();
}
static baz() {
console.log('hello');
}
baz() {
console.log('world');
}
}
Foo.bar() // hello
上面代碼中,靜態方法 bar 調用了 this.baz,注意,如果靜態方法包含this關鍵字,這個this指的是類,而不是實例。因此這里的 this 指的是 Foo 類,而不是 Foo 的實例,等同于調用Foo.baz。另外,從這個例子還可以看出,靜態方法可以與非靜態方法重名。
注意點
-
嚴格模式
類聲明和類表達式的主體都執行在嚴格模式下。比如,構造函數,靜態方法,實例方法,getter 和 setter 都在嚴格模式下執行。
只要你的代碼寫在類或模塊之中,就只有嚴格模式可用。考慮到未來所有的代碼,其實都是運行在模塊之中,所以 ES6 實際上把整個語言升級到了嚴格模式。
-
不存在提升
函數聲明和類聲明之間的一個重要區別是函數聲明會提升,類聲明不會。你首先需要聲明你的類,然后訪問它,否則代碼會拋出一個 ReferenceError。
-
this 的指向
類的方法內部如果含有 this,它默認指向類的實例。但是,必須非常小心,一旦單獨使用該方法,很可能報錯。
class Logger { printName(name = 'there') { this.print(`Hello ${name}`); } print(text) { console.log(text); } } const logger = new Logger(); const { printName } = logger; printName(); // TypeError: Cannot read property 'print' of undefined
printName 方法被提取出來單獨使用,this 會指向該方法運行時所在的環境(由于 class 內部是嚴格模式,所以 this 實際指向的是 undefined),從而導致找不到 print 方法而報錯。
可以通過在構造方法中綁定 this 或者使用箭頭函數來解決
class Logger { constructor() { this.printName = this.printName.bind(this); } } class Obj { constructor() { this.getThis = () => this; } } const myObj = new Obj(); myObj.getThis() === myObj // true
總結
通過上面我們可以看出來 JavaScript 中對象獨有的特色就是:對象具有高度的動態性。它是一個徹底的動態語言。這讓我想到了另一個動態語言 Objective-C。而且 OC 中的 NSObject class 和 meta class 之間的關系也類似于 JS 中 Function 和 Object 之間的關系。由此可見語言之間有些東西都是相通的,多學習一門語言也可以觸類旁通。??