深刻理解JavaScript系列(18):面向對象編程之ECMAScript實現(推薦)

介紹

本章是關於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裏,一切都是對象」是錯誤的(不徹底對),原始值就是咱們這裏要討論的一些數據類型。express

數據類型

雖然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。
原始值類型例子:app

var a = undefined;
var b = null;
var c = true;
var d = 'test';
var e = 10;

這些值是在底層上直接實現的,他們不是object,因此沒有原型,沒有構造函數。ecmascript

大叔注:這些原生值和咱們平時用的(Boolean、String、Number、Object)雖然名字上類似,但不是同一個東西。因此typeof(true)和typeof(Boolean)結果是不同的,由於typeof(Boolean)的結果是function,因此函數Boolean、String、Number是有原型的(下面的讀寫屬性章節也會提到)。ide

想知道數據是哪一種類型用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 < 4; 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 < 4; 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]] = <reference to function> // 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; // or 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 <---- A.prototype

此外, 實例的[[Prototype]]值確實是在構造函數的prototype屬性上獲取的。

然而,提交prototype屬性不會影響已經建立對象的原型(只有在構造函數的prototype屬性改變的時候纔會影響到),就是說新建立的對象纔有有新的原型,而已建立對象仍是引用到原來的舊原型(這個原型已經不能被再被修改了)。

// 在修改A.prototype原型以前的狀況
a.[[Prototype]] ----> Prototype <---- A.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

// where 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.prototype = A.prototype; // 引用
B.prototype = new F();
B.superproto = A.prototype; // 顯示引用到另一個原型上, "sugar"

// 修復原型的constructor屬性,不然的就是A了
B.prototype.constructor = B;

var b = new B();
alert([b.x, b.y]); // 10 (自身), 20 (集成)

注意,咱們在b實例上建立了本身的x屬性,經過B.superproto.constructor調用父構造函數來引用新建立對象的上下文。

咱們也修復了父構造函數在建立子原型的時候不須要的調用,此時,消息"A.[[Call]] activated"在須要的時候纔會顯示。

爲了在原型鏈裏重複相同的行爲(中間構造函數建立,設置superproto,恢復原始構造函數),下面的模板能夠封裝成一個很是方面的工具函數,其目的是鏈接原型的時候不是根據構造函數的實際名稱。

function inherit(child, parent) {
var F = function () {};
F.prototype = parent.prototype
child.prototype = new F();
child.prototype.constructor = child;
child.superproto = parent.prototype;
return child;
}

所以,繼承:

function A() {}
A.prototype.x = 10;

function B() {}
inherit(B, A); // 鏈接原型

var b = new B();
alert(b.x); // 10, 在A.prototype查找到

也有不少語法形式(包裝而成),但全部的語法行都是爲了減小上述代碼裏的行爲。

例如,若是咱們把中間的構造函數放到外面,就能夠優化前面的代碼(所以,只有一個函數被建立),而後重用它:

var inherit = (function(){
function F() {}
return function (child, parent) {
F.prototype = parent.prototype;
child.prototype = new F;
child.prototype.constructor = child;
child.superproto = parent.prototype;
return child;
};
})();

因爲對象的真實原型是[[Prototype]]屬性,這意味着F.prototype能夠很容易修改和重用,由於經過new F建立的child.prototype能夠從child.prototype的當前值裏獲取[[Prototype]]:

function A() {}
A.prototype.x = 10;

function B() {}
inherit(B, A);

B.prototype.y = 20;

B.prototype.foo = function () {
alert("B#foo");
};

var b = new B();
alert(b.x); // 10, 在A.prototype裏查到

function C() {}
inherit(C, B);

// 使用"superproto"語法糖
// 調用父原型的同名方法

C.ptototype.foo = function () {
C.superproto.foo.call(this);
alert("C#foo");
};

var c = new C();
alert([c.x, c.y]); // 10, 20

c.foo(); // B#foo, C#foo

注意,ES5爲原型鏈標準化了這個工具函數,那就是Object.create方法。ES3可使用如下方式實現:

Object.create ||
Object.create = function (parent, properties) {
function F() {}
F.prototype = parent;
var child = new F;
for (var k in properties) {
child[k] = properties[k].value;
}
return child;
}

// 用法
var foo = {x: 10};
var bar = Object.create(foo, {y: {value: 20}});
console.log(bar.x, bar.y); // 10, 20

此外,全部模仿如今基於類的經典繼承方式都是根據這個原則實現的,如今能夠看到,它實際上不是基於類的繼承,而是鏈接原型的一個很方便的代碼重用。

結論

本章內容已經很充分和詳細了,但願這些資料對你有用,而且消除你對ECMAScript的疑問,若是你有任何問題,請留言,咱們一塊兒討論。

其它參考

相關文章
相關標籤/搜索