介紹
本章是關於ECMAScript物件導向實現的第2篇,第1篇我們討論的是概論和CEMAScript的比較,如果你還沒讀第1篇,在進行本章之前,我強烈建議你先讀一下第1篇,因為本篇實在太長了(35頁)。
英文原文:http://dmitrysoshnikov.com/ecmascript/chapter-7-2-oop-ecmascript-implementation/
註:由於篇幅太長了,難免出現錯誤,時時保持修正中。
在概論裡,我們延伸到了ECMAScript,現在,當我們知道它OOP實現時,我們再來準確定義一下:
ECMAScript is an object-oriented programming language supporting delegating inheritance based on prototypes.
ECMAScript是一種物件導向語言,支援基於原型的委託式繼承。
我們將從最基本的資料類型來分析,首先要了解的是ECMAScript用原始值(primitive values)和物件(objects)來區分實體,因此有些文章裡所說的「在JavaScript裡,一切都是物件」是錯誤的(不完全對),原始值就是我們這裡要討論的一些資料型態。
資料型別
雖然ECMAScript是可以動態轉換型別的動態弱型別語言,它還是有資料型別的。也就是說,一個物件要屬於一個實實在在的類型。
標準規格裡定義了9種資料型,但只有6種是在ECMAScript程式裡可以直接存取的,它們是:Undefined、Null、Boolean、String、Number、Object。
另外3種類型只能在實作層級存取(ECMAScript物件是不能使用這些類型的)並用於規範來解釋一些操作行為、保存中間值。這3種類型是:Reference、List和Completion。
因此,Reference是用來解釋delete、typeof、this這樣的操作符,並且包含一個基底物件和一個屬性名稱;List描述的是參數列表的行為(在new表達式和函數呼叫的時候); Completion是用來解釋行為break、continue、return和throw語句的。
原始值型別
回頭來看6中用於ECMAScript程式的資料型別,前5種是原始值型,包括Undefined、Null、Boolean、String、Number、Object。
原始值類型範例:
var a = undefined;
var b = null;
var c = true;
var d = 'test';
var e = 10;
這些值是在底層上直接實現的,他們不是object,所以沒有原型,沒有建構子。
叔叔註:這些原生值和我們平常用的(Boolean、String、Number、Object)雖然名字上相似,但不是同一個東西。所以typeof(true)和typeof(Boolean)結果是不一樣的,因為typeof(Boolean)的結果是function,所以函數Boolean、String、Number是有原型的(下面的讀寫屬性章節也會提到)。
想知道資料是哪種類型用typeof是最好不過了,有個例子要注意一下,如果用typeof來判斷null的類型,結果是object,為什麼呢?因為null的型別是定義為Null的。
alert(typeof null); // "object"
顯示"object"原因是因為規範就是這麼規定的:對於Null值的typeof字串值回傳"object「。
規範沒有想像解釋這個,但是Brendan Eich (JavaScript發明人)注意到null相對於undefined大多數都是用於物件出現的地方,例如設定一個物件為空引用。但有些文檔裡有些氣人將之歸結為bug,而且將該bug放在Brendan Eich也參與討論的bug列表裡,結果就是任其自然,還是把typeof null的結果設置為object(儘管262-3的標準是定義null的型別是Null,262-5已經將標準修改為null的型別是object了)。
Object型
接著,Object型別(不要跟Object建構函式混淆了,現在只討論抽象型別)是描述 ECMAScript物件的唯一一個資料型別。
Object is an unordered collection of key-value pairs.
物件是一個包含key-value對的無序集合
物件的key值稱為屬性,屬性是原始值和其他物件的容器。如果屬性的值是函數我們稱它為方法 。
例如:
var x = { // 物件"x"有3個屬性: a, b, c
a: 10, // 原始值
b: {z: 100}, // 物件"b"有一個屬性z
c: function () { // 函數(方法)
alert('method x.c');
}
};
alert(x.a); // 10
alert(x.b); // [object Object]
alert(x.b.z); // 100
x.c(); // 'method x.c'
動態性
正如我們在第17章中指出的,ES中的物件是完全動態的。這意味著,在程式執行的時候我們可以任意地添加,修改或刪除物件的屬性。
例如:
var foo = {x: 10};
// 新增屬性
foo.y = 20;
console.log(foo); // {x: 10, y: 20}
// 將屬性值修改為函數
foo.x = function () {
console.log('foo.x');
};
foo.x(); // 'foo.x'
// 刪除屬性
delete foo.x;
console.log(foo); // {y: 20}
有些屬性不能被修改-(唯讀屬性、已刪除屬性或不可配置的屬性)。 我們將稍後在屬性特性裡講解。
另外,ES5規格規定,靜態物件不能擴充新的屬性,而且它的屬性頁不能刪除或修改。他們是所謂的凍結對象,可以透過應用Object.freeze(o)方法來得到。
var foo = {x: 10};
// 凍結物件
Object.freeze(foo);
console.log(Object.isFrozen(foo)); // true
// 不能修改
foo.x = 100;
// 不能擴充
foo.y = 200;
// 不能刪除
delete foo.x;
console.log(foo); // {x: 10}
在ES5規範裡,也使用Object.preventExtensions(o)方法來防止擴展,或使用Object.defineProperty(o)方法來定義屬性:
var foo = {x : 10};
Object.defineProperty(foo, "y", {
value: 20,
writable: false, // 只讀
configurable: false // 不可設定
});
// 不能修改
foo.y = 200;
// 不能刪除
delete foo.y; // false
// 防治擴充
Object.preventExtensions(foo);
console.log(Object.isExtensible(foo)); // false
// 不能新增屬性
foo.z = 30;
console.log(foo); {x: 10, y: 20}
內建物件、原生物件及宿主物件
有必要需要注意的是規範也區分了這內建物件、元素物件和宿主物件。
內建物件和元素物件是由ECMAScript規範定義和實現的,兩者之間的差異微不足道。所有ECMAScript實作的物件都是原生物件(其中一些是內建物件、一些在程式執行的時候創建,例如使用者自訂物件)。內建物件是原生物件的子集、是在程式開始之前內建到ECMAScript裡的(例如,parseInt, Match等)。所有的宿主物件是由宿主環境提供的,通常是瀏覽器,並可能包括如window、alert等。
注意,宿主物件可能是ES本身實現的,完全符合規範的語意。從這點來說,他們能稱為「原生宿主」物件(盡快很理論),不過規範沒有定義「原生宿主」物件的概念。
Boolean,String與Number物件
另外,規範也定義了一些原生的特殊包裝類,這些物件是:
1.布爾對象
2.字串物件
3.數字物件
這些物件的創建,是透過相應的內建建構器創建,並且包含原生值作為其內部屬性,這些物件可以轉換省原始值,反之亦然。
var c = new Boolean(true);
var d = new String('test');
var e = new Number(10);
// 轉換成原始值
// 使用不含new關鍵字的函數
с = Boolean(c);
d = String(d);
e = Number(e);
// 重新轉換成物件
с = Object(c);
d = Object(d);
e = Object(e);
此外,也有物件是由特殊的內建建構函式建立: Function(函式物件建構器)、Array(陣列建構器) RegExp(正規表示式建構器)、Math(數學模組)、 Date(日期的建構器)等等,這些物件也是Object物件類型的值,他們彼此的差異是由內部屬性管理的,我們在下面討論這些內容。
字面量Literal
對於三個物件的值:物件(object),陣列(array)和正規表示式(regular expression),他們分別有簡寫的標示符稱為:物件初始化器、陣列初始化器、和正規表示式初始化器:
// 等價於new Array(1, 2, 3);
// 或array = new Array();
// array[0] = 1;
// array[1] = 2;
// array[2] = 3;
var array = [1, 2, 3];
// 等價於
// var object = new Object();
// object.a = 1;
// object.b = 2;
// object.c = 3;
var object = {a: 1, b: 2, c: 3};
// 等價於new RegExp("^\d $", "g")
var re = /^d $/g;
注意,如果上述三個物件進行重新賦值名稱到新的型別上的話,那隨後的實作語意就是依照新賦值的型別來使用,例如在目前的Rhino和舊版SpiderMonkey 1.7的實作上,會成功以new關鍵字的建構器來建立對象,但有些實作(當前Spider/TraceMonkey)字面量的語意在型別改變以後不一定會改變。
var getClass = Object.prototype.toString;
Object = Number;
var foo = new Object;
alert([foo, getClass.call(foo)]); // 0, "[object Number]"
var bar = {};
// Rhino, SpiderMonkey 1.7中 - 0, "[object Number]"
// 其它: still "[object Object]", "[object Object]"
alert([bar, getClass.call(bar)]);
// Array也是一樣的效果
Array = Number;
foo = new Array;
alert([foo, getClass.call(foo)]); // 0, "[object Number]"
bar = [];
// Rhino, SpiderMonkey 1.7中 - 0, "[object Number]"
// 其它: still "", "[object Object]"
alert([bar, getClass.call(bar)]);
// 但RegExp,字面量的語意是不被改變的。 semantics of the literal
// isn't being changed in all tested implementations
RegExp = Number;
foo = new RegExp;
alert([foo, getClass.call(foo)]); // 0, "[object Number]"
bar = /(?!)/g;
alert([bar, getClass.call(bar)]); // /(?!)/g, "[object RegExp]"
正規表示式字面量和RegExp物件
注意,下面2個例子在第三版的規範裡,正則表達式的語意都是等價的,regexp字面量只在一句裡存在,並且再解析階段創建,但RegExp構造器創建的卻是新對象,所以這可能會導致出一些問題,如lastIndex的值在測試的時候結果是錯誤的:
for (var k = 0; k
var re = /ecma/g;
alert(re.lastIndex); // 0, 4, 0, 4
alert(re.test("ecmascript")); // true, false, true, false
}
// 對比
for (var k = 0; k
var re = new RegExp("ecma", "g");
alert(re.lastIndex); // 0, 0, 0, 0
alert(re.test("ecmascript")); // true, true, true, true
}
註:不過這些問題在第5版的ES規範都已經修正了,不管是基於字面量的還是構造器的,正則都是創建新物件。
關聯陣列
各種文字靜態討論,JavaScript物件(經常是用物件初始化器{}來建立)被稱為哈希表雜湊表或其它簡單的稱謂:哈希(Ruby或Perl裡的概念), 管理數組(PHP裡的概念),字典(Python裡的概念)等。
只有這樣的術語,主要是因為他們的結構都是相似的,就是使用「鍵-值」對來儲存對象,完全符合「關聯數組 」或「雜湊表 」理論定義的資料結構。 此外,哈希表抽象資料類型通常是在實作層面使用。
但是,儘管術語上來描述這個概念,但實際上這個是錯誤,從ECMAScript來看:ECMAScript只有一個物件以及類型以及它的子類型,這和「鍵-值」對儲存沒有什麼區別,因此在這上面沒有特別的概念。 因為任何物件的內部屬性都可以儲存為鍵-值」對:
var a = {x: 10};
a['y'] = 20;
a.z = 30;
var b = new Number(1);
b.x = 10;
b.y = 20;
b['z'] = 30;
var c = new Function('');
c.x = 10;
c.y = 20;
c['z'] = 30;
// 等等,任意物件的子類型"subtype"
此外,由於在ECMAScript中物件可以是空的,所以"hash"的概念在這裡也是不正確的:
Object.prototype.x = 10;
var a = {}; // 建立空"hash"
alert(a["x"]); // 10, 但不為空
alert(a.toString); // function
a["y"] = 20; // 新增新的鍵值對到 "hash"
alert(a["y"]); // 20
Object.prototype.y = 20; // 新增原型屬性
delete a["y"]; // 刪除
alert(a["y"]); // 但這裡key和value依然有值 – 20
請注意, ES5標準可以讓我們建立沒原型的物件(使用Object.create(null)方法實作)對,從這個角度來說,這樣的物件可以稱之為雜湊表:
var aHashTable = Object.create(null);
console.log(aHashTable.toString); // 未定義
此外,有些屬性有特定的getter / setter方法,所以也可能導致混淆這個概念:
var a = new String("foo");
a['length'] = 10;
alert(a['length']); // 3
然而,即使認為「雜湊」可能有一個「原型」(例如,在Ruby或Python裡委託雜湊物件的類別),在ECMAScript裡,這個術語也是不對的,因為2個表示法之間沒有語意上的差異(即用點表示法a.b和a["b"]表示法)。
在ECMAScript中的「property屬性」的概念語意上和"key"、陣列索引、方法沒有分開的,這裡所有物件的屬性讀寫都要遵循統一的規則:檢查原型鏈。
在下面Ruby的範例中,我們可以看到語意上的差異:
a = {}
a.class # Hash
a.length # 0
# new "key-value" pair
a['length'] = 10;
# 語意上,用點存取的是屬性或方法,而不是key
a.length # 1
# 而索引器存取存取的是hash裡的key
a['length'] # 10
# 就類似於在現有物件上動態宣告Hash類別
# 然後宣告新屬性或方法
class Hash
def z
100
end
end
# 新屬性可以存取
a.z # 100
# 但不是"key"
a['z'] # nil
ECMA-262-3標準並沒有定義「哈希」(以及類似)的概念。但是,有這樣的結構理論的話,那可能以此命名的對象。
物件轉換
將物件轉換成原始值可以用valueOf方法,正如我們所說的,當函數的建構函式呼叫做為function(對於某些類型的),但如果不用new關鍵字就是將物件轉換成原始值,就相當於隱式的valueOf方法呼叫:
var a = new Number(1);
var primitiveA = Number(a); // 隱式"valueOf"呼叫
var alsoPrimitiveA = a.valueOf(); // 明確呼叫
alert([
typeof a, // "object"
typeof primitiveA, // "number"
typeof alsoPrimitiveA // "number"
]);
這種方式允許物件參與各種操作,例如:
var a = new Number(1);
var b = new Number(2);
alert(a b); // 3
// 甚至
var c = {
x: 10,
y: 20,
valueOf: function () {
return this.x this.y;
}
};
var d = {
x: 30,
y: 40,
// 和c的valueOf功能一樣
valueOf: c.valueOf
};
alert(c d); // 100
valueOf的預設值會根據根據對象的型別改變(如果不被覆寫的話),對某些對象,他回傳的是this-例如:Object.prototype.valueOf(),還有計算型的值:Date.prototype.valueOf()回傳的是日期時間:
var a = {};
alert(a.valueOf() === a); // true, "valueOf"回傳this
var d = new Date();
alert(d.valueOf()); // time
alert(d.valueOf() === d.getTime()); // true
此外,物件還有一個更原始的代表性—字串展示。 這個toString方法是可靠的,它在某些操作上是自動使用的:
var a = {
valueOf: function () {
return 100;
},
toString: function () {
return '__test';
}
};
// 這個操作裡,toString方法自動呼叫
alert(a); // "__test"
// 但這裡,呼叫的卻是valueOf()方法
alert(a 10); // 110
// 但,一旦valueOf刪除以後
// toString又可以自動呼叫了
delete a.valueOf;
alert(a 10); // "_test10"
Object.prototype上定義的toString方法具有特殊意義,它傳回的我們下面將要討論的內部[[Class]]屬性值。
和轉換成原始值(ToPrimitive)相比,將值轉換成物件類型也有一個轉換規格(ToObject)。
一個明確方法是使用內建的Object建構子作為function來呼叫ToObject(有些類似透過new關鍵字也可以):
var n = Object(1); // [object Number]
var s = Object('test'); // [object String]
// 有些類似,使用new操作符也可以
var b = new Object(true); // [object Boolean]
// 應用參數new Object的話創建的是簡單物件
var o = new Object(); // [object Object]
// 如果參數是一個現有的物件
// 那建立的結果就是簡單回傳該物件
var a = [];
alert(a === new Object(a)); // true
alert(a === Object(a)); // true
關於呼叫內建建構函數,使用或不適用new操作符沒有通用規則,取決於建構子。 例如Array或Function當使用new運算子的建構子或不使用new運算子的簡單函式使用產生相同的結果的:
var a = Array(1, 2, 3); // [object Array]
var b = new Array(1, 2, 3); // [object Array]
var c = [1, 2, 3]; // [object Array]
var d = Function(''); // [object Function]
var e = new Function(''); // [object Function]
有些操作符使用的時候,也有一些顯示和隱式轉換:
var a = 1;
var b = 2;
// 隱式
var c = a b; // 3, number
var d = a b '5' // "35", string
// 顯式
var e = '10'; // "10", string
var f = e; // 10, number
var g = parseInt(e, 10); // 10, number
// 等等
屬性的特性
所有的屬性(property) 都可以有很多特性(attributes)。
1.{ReadOnly}——忽略向屬性賦值的寫入操作嘗,但只讀屬性可以由宿主環境行為改變——也就是說不是「恆定值」 ;
2.{DontEnum}-屬性不能被for..in迴圈枚舉
3.{DontDelete}-糊了delete運算子的行為被忽略(即刪不掉);
4.{Internal}-內部屬性,沒有名字(僅在實現層面使用),ECMAScript裡無法存取這樣的屬性。
注意,在ES5裡{ReadOnly},{DontEnum}和{DontDelete}被重新命名為[[Writable]],[[Enumerable]]和[[Configurable]],可以手工通過Object.defineProperty或類似的方法來管理這些屬性。
var foo = {};
Object.defineProperty(foo, "x", {
value: 10,
writable: true, // 即{ReadOnly} = false
enumerable: false, // 即{DontEnum} = true
configurable: true // 即{DontDelete} = false
});
console.log(foo.x); // 10
// 透過descriptor取得特性集attributes
var desc = Object.getOwnPropertyDescriptor(foo, "x");
console.log(desc.enumerable); // false
console.log(desc.writable); // true
// 等等
內部屬性與方法
物件也可以有內部屬性(實現層面的一部分),並且ECMAScript程式無法直接存取(但是下面我們將看到,一些實作允許存取一些這樣的屬性)。 這些屬性透過嵌套的中括號[[ ]]進行存取。我們來看其中的一些,這些屬性的描述可以到規範裡查閱。
每個物件都應該實作以下內部屬性和方法:
1.[[Prototype]]-物件的原型(將在下面詳細介紹)
2.[[Class]]-字串物件的一種表示(例如,Object Array ,Function Object,Function等);用來區分物件
3.[[Get]]——獲得屬性值的方法
4.[[Put]]——設定屬性值的方法
5.[[CanPut]]——檢查屬性是否可寫入
6.[[HasProperty]]-檢查物件是否已經擁有該屬性
7.[[Delete]]——從物件刪除該屬性
8.[[DefaultValue]]傳回物件對於的原始值(呼叫valueOf方法,某些物件可能會拋出TypeError異常)。
透過Object.prototype.toString()方法可以間接得到內部屬性[[Class]]的值,該方法應該傳回下列字串: "[object " [[Class]] "]" 。例如:
var getClass = Object.prototype.toString;
getClass.call({}); // [object Object]
getClass.call([]); // [object Array]
getClass.call(new Number(1)); // [object Number]
// 等等
這個功能通常是用來檢查物件用的,但規範上說宿主物件的[[Class]]可以為任意值,包括內建物件的[[Class]]屬性的值,所以理論上來看是不能100%來保證準確的。例如,document.childNodes.item(...)方法的[[Class]]屬性,在IE裡傳回"String",但其它實作裡傳回的確實"Function"。
// in IE - "String", in other - "Function"
alert(getClass.call(document.childNodes.item));
建構子
因此,正如我們上面提到的,在ECMAScript中的物件是透過所謂的建構子來創建的。
Constructor is a function that creates and initializes the newly created object.
建構函數是一個函數,用來建立並初始化新建立的物件。
物件創建(記憶體分配)是由建構函數的內部方法[[Construct]]負責的。這個內部方法的行為是定義好的,所有的建構子都是使用該方法來為新物件分配記憶體的。
而初始化是透過新建物件上下上呼叫該函數來管理的,這是由建構函式的內部方法[[Call]]來負責任的。
注意,使用者程式碼只能在初始化階段訪問,雖然在初始化階段我們可以傳回不同的物件(忽略第一階段建立的tihs物件):
function A() {
// 更新新建立的物件
this.x = 10;
// 但回傳的是不同的物件
return [1, 2, 3];
}
var a = new A();
console.log(a.x, a); undefined, [1, 2, 3]
引用15章函數-建立函數的演算法小節,我們可以看到該函數是原生對象,包含[[Construct]] ]和[[Call]] ]屬性以及顯示的prototype原型屬性-未來物件的原型(註:NativeObject是對於native object原生物件的約定,在下面的偽代碼中使用)。
F = new NativeObject();
F.[[Class]] = "Function"
.... // 其它屬性
F.[[Call]] = // function自身
F.[[Construct]] = internalConstructor // 普通的內部建構子
.... // 其它屬性
// F建構函式所建立的物件原型
__objectPrototype = {};
__objectPrototype.constructor = F // {DontEnum}
F.prototype = __objectPrototype
[[Call]] ]是除[[Class]]屬性(這裡等同於"Function" )之外區分物件的主要方式,因此,物件的內部[[Call]]屬性作為函數呼叫。 這樣的物件用typeof運算運算子的話回傳的是"function"。然而它主要是和原生物件有關,有些情況的實作在用typeof取得值的是不一樣的,例如:window.alert (...)在IE中的效果:
// IE瀏覽器中 - "Object", "object", 其它瀏覽器 - "Function", "function"
alert(Object.prototype.toString.call(window.alert));
alert(typeof window.alert); // "Object"
內部方法[[Construct]]是透過使用帶有new運算子的建構子來啟動的,正如我們所說的這個方法是負責記憶體分配和物件創建的。如果沒有參數,呼叫建構子的括號也可以省略:
function A(x) { // constructor А
this.x = x || 10;
}
// 不傳參數的話,括號也可以省略
var a = new A; // 或 new A();
alert(a.x); // 10
// 明確傳入參數x
var b = new A(20);
alert(b.x); // 20
我們也知道,建構函式(初始化階段)裡的shis被設定為新建立的物件 。
讓我們研究一下物件創建的演算法。
物件建立的演算法
內部方法[[Construct]] 的行為可以描述成如下:
F.[[Construct]](initialParameters):
O = new NativeObject();
// 屬性[[Class]]被設定為"Object"
O.[[Class]] = "Object"
// 引用F.prototype的時候取得該物件g
var __objectPrototype = F.prototype;
// 如果__objectPrototype是對象,就:
O.[[Prototype]] = __objectPrototype
// 否則:
O.[[Prototype]] = Object.prototype;
// 這裡O.[[Prototype]]是Object物件的原型
// 新建立物件初始化的時候應用了F.[[Call]]
// 將this設定為新建立的物件O
// 參數跟F裡的initialParameters是一樣的
R = F.[[Call]](initialParameters); this === O;
// 這裡R是[[Call]]的回傳值
// 在JS看,像這樣:
// R = F.apply(O, initialParameters);
// 如果R是物件
return R
// 否則
return O
請注意兩個主要特點:
1.首先,新創建物件的原型是從當前時刻函數的prototype屬性獲取的(這意味著同一個構造函數創建的兩個創建對象的原型可以不同是因為函數的prototype屬性也可以不同) 。
2.其次,正如我們上面提到的,如果在對像初始化的時候,[[Call]]返回的是對象,這正是用於整個new操作符的結果:
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10 – 從原型上得到
// 設定.prototype屬性為新物件
// 為什麼顯式宣告.constructor屬性將在下面說明
A.prototype = {
constructor: A,
y: 100
};
var b = new A();
// 物件"b"有了新屬性
alert(b.x); // undefined
alert(b.y); // 100 – 從原型上得到
// 但a物件的原型依然可以得到原來的結果
alert(a.x); // 10 - 從原型上得到
function B() {
this.x = 10;
return new Array();
}
// 如果"B"建構子沒有回傳(或回傳this)
// 那麼this物件就可以使用,但是下面的情況回傳的是array
var b = new B();
alert(b.x); // undefined
alert(Object.prototype.toString.call(b)); // [object Array]
讓我們來詳細了解原型
原型
每個物件都有一個原型(有些系統物件除外)。原型通訊是透過內部的、隱式的、不可直接存取[[Prototype]]原型屬性來進行的,原型可以是一個對象,也可以是null值。
屬性建構子(Property constructor)
上面的範例有2個重要的知識點,第一個是關於函數的constructor屬性的prototype屬性,在函數所建立的演算法裡,我們知道constructor屬性在函數建立階段被設定為函數的prototype屬性,constructor屬性的值是函數本身的重要引用:
function A() {}
var a = new A();
alert(a.constructor); // function A() {}, by delegation
alert(a.constructor === A); // true
通常在這種情況下,存在著一個誤區:constructor構造屬性作為新創建對象自身的屬性是錯誤的,但是,正如我們所看到的的,這個屬性屬於原型並且通過繼承來訪問對象。
透過繼承constructor屬性的實例,可以間接得到的原型物件的引用:
function A() {}
A.prototype.x = new Number(10);
var a = new A();
alert(a.constructor.prototype); // [object Object]
alert(a.x); // 10, 透過原型
// 和a.[[Prototype]].x效果一樣
alert(a.constructor.prototype.x); // 10
alert(a.constructor.prototype.x === a.x); // true
但請注意,函數的constructor和prototype屬性在物件建立以後都可以重新定義的。在這種情況下,對象失去上面所說的機制。如果透過函數的prototype屬性去編輯元素的prototype原型的話(新增物件或修改現有物件),實例上將會看到新新增的屬性。
然而,如果我們徹底改變函數的prototype屬性(透過分配一個新的物件),那麼原始建構函式的參考就是遺失,這是因為我們建立的物件不包括constructor屬性:
function A() {}
A.prototype = {
x: 10
};
var a = new A();
alert(a.x); // 10
alert(a.constructor === A); // false!
因此,函數的原型引用需要手動恢復:
function A() {}
A.prototype = {
constructor: A,
x: 10
};
var a = new A();
alert(a.x); // 10
alert(a.constructor === A); // true
注意雖然手動恢復了constructor屬性,和原來遺失的原型相比,{DontEnum}特性沒有了,也就是說A.prototype裡的for..in循環語句不支援了,不過第5版規範裡,透過[[Enumerable]] 特性提供了控制可枚舉狀態enumerable的能力。
var foo = {x: 10};
Object.defineProperty(foo, "y", {
value: 20,
enumerable: false // aka {DontEnum} = true
});
console.log(foo.x, foo.y); // 10, 20
for (var k in foo) {
console.log(k); // only "x"
}
var xDesc = Object.getOwnPropertyDescriptor(foo, "x");
var yDesc = Object.getOwnPropertyDescriptor(foo, "y");
console.log(
xDesc.enumerable, // true
yDesc.enumerable // false
);
顯式prototype與隱式[[Prototype]]屬性
通常,一個物件的原型透過函數的prototype屬性明確引用是不正確的,他引用的是同一個對象,對象的[[Prototype]]屬性:
a.[[Prototype]] ----> Prototype
此外, 實例的[[Prototype]]值確實是在建構子的prototype屬性上取得的。
然而,提交prototype屬性不會影響已經創建物件的原型(只有在建構函式的prototype屬性改變的時候才會影響到),就是說新建立的物件才有新的原型,而已建立物件還是引用到原來的舊原型(這個原型已經不能再被修改了)。
// 在修改A.prototype原型之前的情況
a.[[Prototype]] ----> Prototype
// 修改之後
A.prototype ----> New prototype // 新物件會擁有這個原型
a.[[Prototype]] ----> Prototype // 引導的原來的原型上
例如:
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
A.prototype = {
constructor: A,
x: 20
y: 30
};
// 物件a是透過隱式的[[Prototype]]引用從原油的prototype上取得的值
alert(a.x); // 10
alert(a.y) // undefined
var b = new A();
// 但新物件是從新原型上取得的值
alert(b.x); // 20
alert(b.y) // 30
因此,有的文章說「動態修改原型將影響所有的物件都會擁有新的原型」是錯誤的,新原型僅在原型修改以後的新創建物件上生效。
這裡的主要規則是:對象的原型是對象的創建的時候創建的,並且在此之後不能修改為新的對象,如果依然引用到同一個對象,可以通過構造函數的顯式prototype引用,物件創建以後,只能對原型的屬性進行新增或修改。
非標準的__proto__屬性
然而,有些實作(例如SpiderMonkey),提供了不標準的__proto__明確屬性來引用物件的原型:
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
var __newPrototype = {
constructor: A,
x: 20,
y: 30
};
// 引用到新物件
A.prototype = __newPrototype;
var b = new A();
alert(b.x); // 20
alert(b.y); // 30
// "a"物件使用的還是舊的原型
alert(a.x); // 10
alert(a.y); // undefined
// 明確修改原型
a.__proto__ = __newPrototype;
// 現在"а"物件引用的是新物件
alert(a.x); // 20
alert(a.y); // 30
請注意,ES5提供了Object.getPrototypeOf(O)方法,直接傳回物件的[[Prototype]]屬性—實例的初始原型。 然而,和__proto__相比,它只是getter,它不允許set值。
var foo = {};
Object.getPrototypeOf(foo) == Object.prototype; // true
物件獨立於建構子
因為實例的原型獨立於建構函式和建構函式的prototype屬性,建構函式完成了自己的主要工作(建立物件)以後可以刪除。原型物件透過引用[[Prototype]]屬性繼續存在:
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
// 設定A為null - 顯示引用建構子
A = null;
// 但如果.constructor屬性沒有改變的話,
// 依然可以透過它創建物件
var b = new a.constructor();
alert(b.x); // 10
// 隱式的引用也刪除掉
delete a.constructor.prototype.constructor;
delete b.constructor.prototype.constructor;
// 透過A的建構子再也不能建立物件了
// 但這2個物件還是有自己的原型
alert(a.x); // 10
alert(b.x); // 10
instanceof操作符的特性
我們是透過建構函式的prototype屬性來顯示引用原型的,這和instanceof操作符有關。這個操作符是和原型鏈一起工作的,而不是構造函數,考慮到這一點,當檢測對象的時候往往會有誤解:
if (foo instanceof Foo) {
...
}
這不是用來偵測物件foo是否是用Foo建構函式建立的,所有instanceof運算子只需要一個物件屬性-foo.[[Prototype]],在原型鏈中從Foo.prototype開始檢查其是否存在。 instanceof運算子是透過建構函式裡的內部方法[[HasInstance]]來啟動的。
讓我們來看看這個例子:
function A() {}
A.prototype.x = 10;
var a = new A();
alert(a.x); // 10
alert(a instanceof A); // true
// 如果設定原型為null
A.prototype = null;
// ..."a"仍可透過a.[[Prototype]]存取原型
alert(a.x); // 10
// 不過,instanceof操作符不能再正常使用了
// 因為它是從建構子的prototype屬性來實現的
alert(a instanceof A); // 錯誤,A.prototype不是物件
另一方面,可以由建構函式來建立對象,但如果物件的[[Prototype]]屬性和建構函式的prototype屬性的值設定的是一樣的話,instanceof檢查的時候會回傳true:
function B() {}
var b = new B();
alert(b instanceof B); // true
function C() {}
var __proto = {
constructor: C
};
C.prototype = __proto;
b.__proto__ = __proto;
alert(b instanceof C); // true
alert(b instanceof B); // false
原型可以存放方法並共享屬性
大部分程式裡使用原型是用來儲存物件的方法、預設狀態和共享物件的屬性。
事實上,物件可以擁有自己的狀態 ,但方法通常是一樣的。 因此,為了記憶體優化,方法通常是在原型中定義的。 這意味著,這個建構函式所建立的所有實例都可以共享找個方法。
function A(x) {
this.x = x || 100;
}
A.prototype = (function () {
// 初始化上下文
// 使用額外的物件
var _someSharedVar = 500;
function _someHelper() {
alert('internal helper: ' _someSharedVar);
}
function method1() {
alert('method1: ' this.x);
}
function method2() {
alert('method2: ' this.x);
_someHelper();
}
// 原型自身
return {
constructor: A,
method1: method1,
method2: method2
};
})();
var a = new A(10);
var b = new A(20);
a.method1(); // method1: 10
a.method2(); // method2: 10, internal helper: 500
b.method1(); // method1: 20
b.method2(); // method2: 20, internal helper: 500
// 2個物件使用的是原型裡相同的方法
alert(a.method1 === b.method1); // true
alert(a.method2 === b.method2); // true
讀寫屬性
正如我們提到,讀取和寫入屬性值是透過內部的[[Get]]和[[Put]]方法。這些內部方法是透過屬性存取器啟動的:點標記法或索引標記法:
// 寫入
foo.bar = 10; // 呼叫了[[Put]]
console.log(foo.bar); // 10, 呼叫了[[Get]]
console.log(foo['bar']); // 效果一樣
讓我們用偽代碼來看一下這些方法是如何運作的:
[[Get]]方法
[[Get]]也會從原型鏈中查詢屬性,所以透過物件也可以存取原型中的屬性。
O.[[Get]](P):
// 如果是自己的屬性,就回傳
if (O.hasOwnProperty(P)) {
return O.P;
}
// 否則,繼續分析原型
var __proto = O.[[Prototype]];
// 如果原型是null,回傳undefined
// 這是可能的:最頂層Object.prototype.[[Prototype]]是null
if (__proto === null) {
return undefined;
}
// 否則,對原型鏈遞歸呼叫[[Get]],在各層的原型中尋找屬性
// 直到原型為null
return __proto.[[Get]](P)
請注意,因為[[Get]]在以下情況也會回傳undefined:
if (window.someObject) {
...
}
這裡,在window裡沒有找到someObject屬性,然後會在原型裡找,原型的原型裡找,以此類推,如果都找不到,按照定義就返回undefined。
注意:in操作符也可以負責找出屬性(也會找原型鏈):
if ('someObject' in window) {
...
}
這有助於避免一些特殊問題:例如即便someObject存在,在someObject等於false的時候,第一輪檢測就通不過。
[[Put]]方法
[[Put]]方法可以建立、更新物件本身的屬性,並且掩蓋原型裡的同名屬性。
O.[[Put]](P, V):
// 若無法為屬性寫值,就退出
if (!O.[[CanPut]](P)) {
return;
}
// 如果物件沒有自身的屬性,就建立它
// 所有的attributes特性都是false
if (!O.hasOwnProperty(P)) {
createNewProperty(O, P, attributes: {
ReadOnly: false,
DontEnum: false,
DontDelete: false,
Internal: false
});
}
// 如果屬性存在就設定值,但不改變attributes特性
O.P = V
return;
例如:
Object.prototype.x = 100;
var foo = {};
console.log(foo.x); // 100, 繼承屬性
foo.x = 10; // [[Put]]
console.log(foo.x); // 10, 自身屬性
delete foo.x;
console.log(foo.x); // 重新是100,繼承屬性
請注意,不能掩蓋原型裡的唯讀屬性,賦值結果將忽略,這是由內部方法[[CanPut]]控制的。
// 例如,屬性length是唯讀的,我們來掩蓋一下length試試
function SuperString() {
/* nothing */
}
SuperString.prototype = new String("abc");
var foo = new SuperString();
console.log(foo.length); // 3, "abc"的長度
// 嘗試掩蓋
foo.length = 5;
console.log(foo.length); // 依然是3
但在ES5的嚴格模式下,如果掩飾只讀屬性的話,就會儲存TypeError錯誤。
屬性存取器
內部方法[[Get]]和[[Put]]在ECMAScript裡是透過點符號或索引法來啟動的,如果屬性標示符是合法的名字的話,可以透過「.」來訪問,而索引方運行動態定義名稱。
var a = {testProperty: 10};
alert(a.testProperty); // 10, 點
alert(a['testProperty']); // 10, 索引
var propertyName = 'Property';
alert(a['test' propertyName]); // 10, 動態屬性透過索引的方式
這裡有一個非常重要的特性-屬性存取器總是使用ToObject規格來對待「.」左邊的值。這種隱式轉換和這句「在JavaScript中一切都是物件」有關係,(然而,當我們已經知道了,JavaScript裡不是所有的值都是物件)。
如果對原始值進行屬性存取器取值,存取之前會先對原始值進行物件包裝(包括原始值),然後透過包裝的物件存取屬性,屬性存取以後,包裝物件就會被刪除。
例如:
var a = 10; // 原始值
// 但是可以存取方法(就像物件一樣)
alert(a.toString()); // "10"
// 另外,我們可以在a上建立一個心屬性
a.test = 100; // 好像是沒問題的
// 但,[[Get]]方法沒有傳回該屬性的值,回傳的卻是undefined
alert(a.test); // undefined
那麼,為什麼整個範例裡的原始值可以存取toString方法,而不能存取新建立的test屬性呢?
答案很簡單:
首先,正如我們所說,使用屬性存取器以後,它已經不是原始值了,而是一個包裝過的中間物件(整個例子是使用new Number(a)),而toString方法這時候是透過原型鏈查找到的:
// 執行a.toString()的原理:
1. wrapper = new Number(a);
2. wrapper.toString(); // "10"
3. delete wrapper;
接下來,[[Put]]方法建立新屬性時候,也是透過包裝裝的物件進行的:
// 執行a.test = 100的原理:
1. wrapper = new Number(a);
2. wrapper.test = 100;
3. delete wrapper;
我們看到,在第3步的時候,包裝的物件以及刪除了,隨著新建立的屬性頁被刪除了-刪除包裝物件本身。
然後使用[[Get]]來取得test值的時候,再一次建立了包裝對象,但那時包裝的對像已經沒有test屬性了,所以返回的是undefined:
// 執行a.test的原理:
1. wrapper = new Number(a);
2. wrapper.test; // undefined
這種方式解釋了原始值的讀取方式,另外,任何原始值如果經常用在訪問屬性的話,時間效率考慮,都是直接用一個對象替代它;與此相反,如果不經常訪問,或者只是用於計算的話,到可以保留這種形式。
繼承
我們知道,ECMAScript是使用基於原型的委託式繼承。鍊和原型在原型鏈裡已經提到過了。其實,所有委託的實作和原型鏈的查找分析都濃縮到[[Get]]方法了。
如果你完全理解[[Get]]方法,那麼JavaScript中的繼承這個問題將不解自答了。
經常在論壇上談論JavaScript中的繼承時,我都是用一行程式碼來展示,事實上,我們不需要建立任何物件或函數,因為語言已經是基於繼承的了,程式碼如下:
alert(1..toString()); // "1"
我們已經知道了[[Get]]方法和屬性存取器的原理了,我們來看看都發生了什麼:
1.首先,從原始值1,透過new Number(1)建立包裝物件
2.然後toString方法是從這個包裝物件繼承得到的
為什麼是繼承的? 因為在ECMAScript中的物件可以有自己的屬性,包裝物件在這種情況下沒有toString方法。 因此它是從原理裡繼承的,即Number.prototype。
注意有個微妙的地方,在上面的例子中的兩個點不是一個錯誤。第一點是代表小數部分,第二個是屬性存取器:
1.toString(); // 語法錯誤!
(1).toString(); // OK
1..toString(); // OK
1['toString'](); // OK
原型鏈
讓我們展示如何為使用者定義物件建立原型鏈,非常簡單:
function A() {
alert('A.[[Call]] activated');
this.x = 10;
}
A.prototype.y = 20;
var a = new A();
alert([a.x, a.y]); // 10 (自身), 20 (繼承)
function B() {}
// 最近的原型鏈方式就是設定物件的原型為另一個新物件
B.prototype = new A();
// 修復原型的constructor屬性,否則的話是A了
B.prototype.constructor = B;
var b = new B();
alert([b.x, b.y]); // 10, 20, 2個都是繼承的
// [[Get]] b.x:
// b.x (no) -->
// b.[[Prototype]].x (yes) - 10
// [[Get]] b.y
// b.y (no) -->
// b.[[Prototype]].y (no) -->
// b.[[Prototype]].[[Prototype]].y (yes) - 20
// 其中 b.[[Prototype]] === B.prototype,
// and b.[[Prototype]].[[Prototype]] === A.prototype
這種方法有兩個特性:
首先,B.prototype將包含x屬性。乍看之下這可能不對,你可能會想x屬性是在A裡定義的並且B構造函數也是這樣期望的。儘管原型繼承正常情況是沒問題的,但B建構函式有時候可能不需要x屬性,與基於class的繼承相比,所有的屬性都複製到後代子類別裡了。
儘管如此,如果有需要(模擬基於類別的繼承)將x屬性賦給B構造函數創建的物件上,有一些方法,我們後來來展示其中一種方式。
其次,這不是一個特徵而是缺點——子類原型創建的時候,構造函數的程式碼也執行了,我們可以看到訊息"A.[[Call]] activated"顯示了兩次——當用A建構子建立物件賦給B.prototype屬性的時候,另外一場是a物件創建自身的時候!
下面的例子比較關鍵,在父類別的建構子拋出的異常:可能實際物件創建的時候需要檢查吧,但很明顯,同樣的case,也就是就是使用這些父物件作為原型的時候就會出錯。
function A(param) {
if (!param) {
throw 'Param required';
}
this.param = param;
}
A.prototype.x = 10;
var a = new A(20);
alert([a.x, a.param]); // 10, 20
function B() {}
B.prototype = new A(); // Error
此外,在父類別的建構子有太多程式碼的話也是一種缺點。
解決這些「功能」和問題,程式設計師使用原型鏈的標準模式(下面展示),主要目的就是在中間包裝構造函數的創建,這些包裝構造函數的鏈裡包含需要的原型。
function A() {
alert('A.[[Call]] activated');
this.x = 10;
}
A.prototype.y = 20;
var a = new A();
alert([a.x, a.y]); // 10 (自身), 20 (整合)
function B() {
// 或使用A.apply(this, arguments)
B.superproto.constructor.apply(this, arguments);
}
// 繼承:透過空的中間建構子將原型連在一起
var F = function () {};
F.prot