第 17 章 物件與繼承
目錄
購買書籍
(廣告,請勿阻擋)

第 17 章 物件與繼承

JavaScript 中的 物件導向程式設計 (OOP) 有好幾層:

每一層都只依賴於前一層,讓你可以逐步學習 JavaScript OOP。第 1 層和第 2 層形成一個簡單的核心,當你對較為複雜的第 3 層和第 4 層感到困惑時,可以隨時回頭參考。

第 1 層:單一物件

粗略來說,JavaScript 中的所有物件都是從字串到值的對應(字典)。物件中的(金鑰、值)項目稱為屬性屬性的金鑰永遠都是文字字串。屬性的值可以是任何 JavaScript 值,包括函式。 方法 是其值為函式的屬性。

物件文字

JavaScript 的 物件文字 讓你可以直接建立 純粹物件Object 的直接實例)。下列程式碼使用物件文字將物件指定給變數 jane。物件有兩個屬性:namedescribedescribe 是方法:

var jane = {
    name: 'Jane',

    describe: function () {
        return 'Person named '+this.name;  // (1)
    },  // (2)
};
  1. 在方法中使用 this 來參照目前的物件(也稱為方法呼叫的 接收器)。
  2. ECMAScript 5 允許在物件文字中使用尾隨逗號(在最後一個屬性之後)。遺憾的是,並非所有舊瀏覽器都支援。尾隨逗號很有用,因為你可以重新排列屬性,而不必擔心哪個屬性是最後一個。

你可能會認為物件 是從字串到值的對應。但它們不只如此:它們是真正的通用物件。例如,你可以使用物件之間的繼承(請參閱 第 2 層:物件之間的原型關係),而且可以保護物件不被變更。直接建立物件的能力是 JavaScript 的傑出功能之一:你可以從具體物件開始(不需要類別!)並在稍後加入抽象化。例如,建構函式 是物件的工廠(如 第 3 層:建構函式—實例的工廠 所述),與其他語言中的類別大致類似。

點運算子 (.):透過固定鍵存取屬性

點運算子提供一個簡潔的語法來存取屬性。屬性鍵必須是識別碼(請參閱合法識別碼)。如果您想要讀取或寫入具有任意名稱的屬性,您需要使用方括號運算子(請參閱方括號運算子 ([]):透過計算鍵存取屬性)。

本節中的範例使用下列物件

var jane = {
    name: 'Jane',

    describe: function () {
        return 'Person named '+this.name;
    }
};

取得屬性

點運算子讓您可以「取得」屬性(讀取其值)。以下是幾個範例

> jane.name  // get property `name`
'Jane'
> jane.describe  // get property `describe`
[Function]

取得不存在的屬性會傳回undefined

> jane.unknownProperty
undefined

呼叫方法

點運算子也用於呼叫方法:

> jane.describe()  // call method `describe`
'Person named Jane'

設定屬性

您可以使用指定運算子 (=) 來設定透過點符號參照的屬性值。例如:

> jane.name = 'John';  // set property `name`
> jane.describe()
'Person named John'

如果屬性尚未存在,設定它會自動建立它。如果屬性已存在,設定它會變更其值。

刪除屬性

delete 運算子讓您可以從物件中完全移除屬性(整個鍵值對)。例如:

> var obj = { hello: 'world' };
> delete obj.hello
true
> obj.hello
undefined

如果您僅將屬性設定為 undefined,屬性仍然存在,而物件仍然包含其鍵:

> var obj = { foo: 'a', bar: 'b' };

> obj.foo = undefined;
> Object.keys(obj)
[ 'foo', 'bar' ]

如果您刪除屬性,其鍵也會消失

> delete obj.foo
true
> Object.keys(obj)
[ 'bar' ]

delete 僅影響物件的直接(「自有」,非繼承)屬性。其原型不會受到影響(請參閱刪除繼承屬性)。

提示

請謹慎使用delete 運算子。如果建構函式建立的執行個體的「形狀」不會變更(大致上:沒有移除或新增任何屬性),大多數現代 JavaScript 引擎會最佳化其效能。刪除屬性會妨礙該最佳化。

delete 的傳回值

delete 如果屬性為自有屬性,但無法刪除,則傳回 false在所有其他情況下,它傳回 true。以下是幾個範例。

作為準備,我們建立一個可以刪除的屬性,以及另一個無法刪除的屬性 (透過描述子取得和定義屬性 說明 Object.defineProperty())

var obj = {};
Object.defineProperty(obj, 'canBeDeleted', {
    value: 123,
    configurable: true
});
Object.defineProperty(obj, 'cannotBeDeleted', {
    value: 456,
    configurable: false
});

delete 傳回 false,表示無法刪除的自有屬性

> delete obj.cannotBeDeleted
false

delete 在所有其他情況下傳回 true

> delete obj.doesNotExist
true
> delete obj.canBeDeleted
true

delete 傳回 true,即使它沒有變更任何內容(繼承的屬性永遠不會被移除)

> delete obj.toString
true
> obj.toString // still there
[Function: toString]

不尋常的屬性金鑰

雖然您無法使用 保留字(例如 varfunction)作為變數名稱,但您可以將它們用作屬性金鑰:

> var obj = { var: 'a', function: 'b' };
> obj.var
'a'
> obj.function
'b'

數字可以用作物件文字中的屬性金鑰,但它們會被解釋為字串。點運算子只能存取金鑰為識別碼的屬性。因此,您需要方括號運算子(在以下範例中顯示)來存取金鑰為數字的屬性:

> var obj = { 0.7: 'abc' };
> Object.keys(obj)
[ '0.7' ]
> obj['0.7']
'abc'

物件文字也允許您使用任意字串(既不是識別碼也不是數字)作為屬性金鑰,但您必須加上引號。同樣地,您需要方括號運算子來存取屬性值

> var obj = { 'not an identifier': 123 };
> Object.keys(obj)
[ 'not an identifier' ]
> obj['not an identifier']
123

方括號運算子 ([]):透過計算的鍵存取屬性

點運算子適用於固定的屬性金鑰,而方括號運算子允許您透過表達式來參照屬性。

透過方括號運算子取得屬性

方括號運算子讓 您透過表達式計算屬性的金鑰:

> var obj = { someProperty: 'abc' };

> obj['some' + 'Property']
'abc'

> var propKey = 'someProperty';
> obj[propKey]
'abc'

這也允許您存取金鑰不是識別碼的屬性

> var obj = { 'not an identifier': 123 };
> obj['not an identifier']
123

請注意,方括號運算子會將其內部強制轉換為字串。例如

> var obj = { '6': 'bar' };
> obj[3+3]  // key: the string '6'
'bar'

透過方括號運算子呼叫方法

呼叫方法的方式 與您預期的一樣:

> var obj = { myMethod: function () { return true } };
> obj['myMethod']()
true

透過方括號運算子設定屬性

設定屬性 與點運算子類似:

> var obj = {};
> obj['anotherProperty'] = 'def';
> obj.anotherProperty
'def'

透過括號運算子刪除屬性

刪除屬性也類似於點運算子:

> var obj = { 'not an identifier': 1, prop: 2 };
> Object.keys(obj)
[ 'not an identifier', 'prop' ]
> delete obj['not an identifier']
true
> Object.keys(obj)
[ 'prop' ]

將任何值轉換為物件

這不是一個常見的用例,但有時你需要將一個任意值轉換為一個物件。 Object(),用作函式(而不是建構函式),提供該服務。它會產生以下結果:

結果

(不帶任何參數呼叫)

{}

未定義

{}

null

{}

布林值 bool

new Boolean(bool)

數字 num

new Number(num)

字串 str

new String(str)

物件 obj

obj(未變更,無需轉換)

以下是一些範例

> Object(null) instanceof Object
true

> Object(false) instanceof Boolean
true

> var obj = {};
> Object(obj) === obj
true

以下函式檢查 value 是否為物件:

function isObject(value) {
    return value === Object(value);
}

請注意,如果 value 不是物件,前一個函式會建立一個物件。你可以透過 typeof 來實作相同的功能,而不用這麼做(請參閱 陷阱:typeof null)。

你也可以呼叫 Object 作為建構函式,它會產生與呼叫它作為函式相同的結果:

> var obj = {};
> new Object(obj) === obj
true

> new Object(123) instanceof Number
true

提示

避免使用建構函式;一個空的物件文字幾乎總是更好的選擇:

var obj = new Object(); // avoid
var obj = {}; // prefer

this 作為函式和方法的隱式參數

當你呼叫函式時,this 總是一個(隱式)參數:

鬆散模式下的正常函式

即使正常函式不需要 this,它仍然存在作為一個特殊變數,其值總是全域物件(瀏覽器中的 window;請參閱 全域物件

> function returnThisSloppy() { return this }
> returnThisSloppy() === window
true
嚴格模式下的正常函式

this 總是 undefined

> function returnThisStrict() { 'use strict'; return this }
> returnThisStrict() === undefined
true
方法

this 參照呼叫方法的物件

> var obj = { method: returnThisStrict };
> obj.method() === obj
true

在方法的情況下,this 的值稱為接收者 的方法呼叫。

呼叫函式同時設定 this:call()、apply() 和 bind()

請記住,函式也是物件。 因此,每個函式都有自己的方法。本節中介紹了其中的三個方法,並有助於呼叫函式。這三個方法在後續各節中用於解決呼叫函式的某些陷阱。接下來的範例都參照以下物件 jane

var jane = {
    name: 'Jane',
    sayHelloTo: function (otherName) {
        'use strict';
        console.log(this.name+' says hello to '+otherName);
    }
};

Function.prototype.call(thisValue, arg1?, arg2?, ...)

第一個參數是 this 在呼叫函式中擁有的值;其餘參數作為引數傳遞給呼叫函式。以下三個呼叫是等效的:

jane.sayHelloTo('Tarzan');

jane.sayHelloTo.call(jane, 'Tarzan');

var func = jane.sayHelloTo;
func.call(jane, 'Tarzan');

對於第二次呼叫,您需要重複 jane,因為 call() 不知道您如何取得它所呼叫的函式。

Function.prototype.apply(thisValue, argArray)

第一個參數this 在呼叫函式中所具有的值;第二個參數是一個提供呼叫引數的陣列。以下三個呼叫是等效的:

jane.sayHelloTo('Tarzan');

jane.sayHelloTo.apply(jane, ['Tarzan']);

var func = jane.sayHelloTo;
func.apply(jane, ['Tarzan']);

對於第二次呼叫,您需要重複 jane,因為 apply() 不知道您如何取得它所呼叫的函式。

適用於建構函式的 apply() 說明如何將 apply() 與建構函式搭配使用。

Function.prototype.bind(thisValue, arg1?, ..., argN?)

這個方法 執行 部分函式應用,意即它會建立一個新的函式,以以下方式呼叫 bind() 的接收器:this 的值是 thisValue,而引數從 arg1 開始,直到 argN,接著是新函式的引數。換句話說,新函式在呼叫原始函式時會將其引數附加到 arg1, ..., argN。我們來看一個範例:

function func() {
    console.log('this: '+this);
    console.log('arguments: '+Array.prototype.slice.call(arguments));
}
var bound = func.bind('abc', 1, 2);

陣列方法 slice 用於將 arguments 轉換為陣列,這是記錄它所必要的(此操作在 類似陣列的物件和一般方法 中說明)。bound 是新的函式。以下是互動

> bound(3)
this: abc
arguments: 1,2,3

以下三個 sayHelloTo 呼叫都是等效的

jane.sayHelloTo('Tarzan');

var func1 = jane.sayHelloTo.bind(jane);
func1('Tarzan');

var func2 = jane.sayHelloTo.bind(jane, 'Tarzan');
func2();

適用於建構函式的 apply()

讓我們假設 JavaScript 有一個三點運算子 (...),它會將陣列轉換為實際參數。 這樣的運算子允許您將 Math.max()(請參閱 其他函式)與陣列搭配使用。在這種情況下,以下兩個表達式將會等效

Math.max(...[13, 7, 30])
Math.max(13, 7, 30)

對於函式,您可以透過 apply() 達到三點運算子的效果

> Math.max.apply(null, [13, 7, 30])
30

三點運算子對於建構函式來說也很有意義

new Date(...[2011, 11, 24]) // Christmas Eve 2011

唉,這裡 apply() 無法運作,因為它只協助函式或方法呼叫,不協助建構函式呼叫。

手動模擬建構函式的 apply()

我們可以在 兩個步驟中 模擬 apply()

步驟 1

透過方法呼叫將參數傳遞給 Date(它們不在陣列中,但將會在陣列中)

new (Date.bind(null, 2011, 11, 24))

前述程式碼使用 bind() 建立沒有參數的建構函式,並透過 new 呼叫它。

步驟 2

使用 apply() 將陣列傳遞給 bind()。由於 bind() 是方法呼叫,因此我們可以使用 apply()

new (Function.prototype.bind.apply(
         Date, [null, 2011, 11, 24]))

前述陣列包含 null,接著是 arr 的元素。我們可以使用 concat() 建立它,方法是將 null 加到 arr 的前面

var arr = [2011, 11, 24];
new (Function.prototype.bind.apply(
         Date, [null].concat(arr)))

函式庫方法

前述手動解決方法的靈感來自 Mozilla 發布的 函式庫方法。以下是經過稍微編輯的版本

if (!Function.prototype.construct) {
    Function.prototype.construct = function(argArray) {
        if (! Array.isArray(argArray)) {
            throw new TypeError("Argument must be an array");
        }
        var constr = this;
        var nullaryFunc = Function.prototype.bind.apply(
            constr, [null].concat(argArray));
        return new nullaryFunc();
    };
}

以下是使用中的方法

> Date.construct([2011, 11, 24])
Sat Dec 24 2011 00:00:00 GMT+0100 (CET)

替代方法

替代前述方法的作法是透過 Object.create() 建立未初始化的執行個體,然後透過 apply() 呼叫建構函式(作為函式)。這表示您實際上重新實作了 new 營運子(省略了一些檢查)

Function.prototype.construct = function(argArray) {
    var constr = this;
    var inst = Object.create(constr.prototype);
    var result = constr.apply(inst, argArray); // (1)

    // Check: did the constructor return an object
    // and prevent `this` from being the result?
    return result ? result : inst;
};

警告

前述程式碼不適用於大多數內建建構函式,這些建構函式在作為函式呼叫時,總是會產生新的執行個體。換句話說,第 (1) 行的步驟並未依需求設定 inst

陷阱:從方法中提取時失去 this

如果您從物件中提取方法,它會再次變成真正的函式。它與物件的連線會中斷,而且通常不再正常運作。舉例來說,以下物件 counter

var counter = {
    count: 0,
    inc: function () {
        this.count++;
    }
}

提取 inc 並呼叫它(作為函式!)會失敗

> var func = counter.inc;
> func()
> counter.count  // didn’t work
0

以下是說明:我們已將 counter.inc 的值呼叫為函式。因此,this 是全域物件,而且我們已執行 window.count++。不存在 window.count,而且它是 undefined。對它套用 ++ 營運子會將它設定為 NaN

> count  // global variable
NaN

如何取得警告

如果方法 inc() 處於嚴格模式,您會收到警告

> counter.inc = function () { 'use strict'; this.count++ };
> var func2 = counter.inc;
> func2()
TypeError: Cannot read property 'count' of undefined

原因是當我們呼叫嚴格模式函式 func2 時,thisundefined,導致錯誤。

如何正確提取方法

感謝 bind(),我們可以確保 inc 不會失去與 counter 的連線

> var func3 = counter.inc.bind(counter);
> func3()
> counter.count  // it worked!
1

回呼和提取方法

在 JavaScript 中,有許多函式和方法接受回呼。瀏覽器的範例是 setTimeout() 和事件處理。如果我們傳入 counter.inc 作為回呼,它也會作為函式呼叫,導致剛才描述的相同問題。為了說明這個現象,我們使用一個簡單的回呼呼叫函式:

function callIt(callback) {
    callback();
}

透過 callIt 執行 counter.count 會觸發警告(由於嚴格模式)

> callIt(counter.inc)
TypeError: Cannot read property 'count' of undefined

與之前一樣,我們透過 bind() 修復問題

> callIt(counter.inc.bind(counter))
> counter.count  // one more than before
2

警告

每次呼叫 bind() 都會建立一個新函式。當您註冊和取消註冊回呼(例如,對於事件處理)時,這會有後果。您需要將註冊的值儲存在某個地方,並將其用於取消註冊。

陷阱:方法內的函式會隱藏 this

您經常在 JavaScript 中巢狀函式定義,因為函式可以是參數(例如,回呼),而且它們可以在函式表達式中就地建立。當方法包含一個常規函式,而且您想在後者中存取前者的 this 時,這會造成問題,因為方法的 this 會被常規函式的 this 隱藏(它甚至不會使用自己的 this)。在以下範例中,(1) 處的函式嘗試存取 (2) 處的方法 this

var obj = {
    name: 'Jane',
    friends: [ 'Tarzan', 'Cheeta' ],
    loop: function () {
        'use strict';
        this.friends.forEach(
            function (friend) {  // (1)
                console.log(this.name+' knows '+friend);  // (2)
            }
        );
    }
};

這會失敗,因為 (1) 處的函式有自己的 this,這裡是 undefined

> obj.loop();
TypeError: Cannot read property 'name' of undefined

有三個方法可以解決這個問題。

解決方法 1:that = this

我們將 this 指派給一個變數,它不會在巢狀函式內被覆蓋

loop: function () {
    'use strict';
    var that = this;
    this.friends.forEach(function (friend) {
        console.log(that.name+' knows '+friend);
    });
}

以下是互動

> obj.loop();
Jane knows Tarzan
Jane knows Cheeta

解決方法 2:bind()

我們可以使用 bind() 來提供 callbackthis 的固定值,也就是方法的 this(第 (1) 行):

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }.bind(this));  // (1)
}

解決方法 3:forEach() 的 thisValue

針對 forEach() 的特定解決方法(請參閱 檢查方法)是在 callback 之後提供第二個參數,它會變成 callback 的 this

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }, this);
}

第 2 層:物件之間的原型關係

兩個物件之間的原型關係與繼承有關:每個物件都可以有另一個物件作為其原型。然後前者物件會繼承其所有原型的屬性。物件透過內部屬性 [[Prototype]] 指定其原型。每個物件都有此屬性,但它可以是 null。由 [[Prototype]] 屬性連接的物件鏈稱為 原型鏈圖 17-1)。

要了解基於原型的(或 原型)繼承如何運作,我們來看一個範例(使用發明的語法來指定 [[Prototype]] 屬性)

var proto = {
    describe: function () {
        return 'name: '+this.name;
    }
};
var obj = {
    [[Prototype]]: proto,
    name: 'obj'
};

物件 objproto 繼承屬性 describe。它還有一個所謂的 自有(非繼承的、直接)屬性 name

繼承

obj 繼承屬性 describe;您可以存取 它,就像物件本身有該屬性一樣:

> obj.describe
[Function]

每當您透過 obj 存取屬性時,JavaScript 會從該物件開始搜尋,並繼續其原型、原型的原型,以此類推。這就是為什麼我們可以透過 obj.describe 存取 proto.describe。原型鏈表現得好像它是單一物件。當您呼叫方法時,這種錯覺會被維持:this 的值始終是開始搜尋方法的物件,而不是找到方法的物件。這允許方法存取原型鏈的所有屬性。例如

> obj.describe()
'name: obj'

describe() 內部,thisobj,這允許方法存取 obj.name

透過原型在物件之間共用資料

原型非常適合在物件之間共用資料:多個物件取得相同的原型,原型包含所有共用屬性。 讓我們來看一個範例。物件 janetarzan 都包含相同的方法 describe()。這是我們希望透過共用來避免的事情:

var jane = {
    name: 'Jane',
    describe: function () {
        return 'Person named '+this.name;
    }
};
var tarzan = {
    name: 'Tarzan',
    describe: function () {
        return 'Person named '+this.name;
    }
};

兩個物件都是人。它們的 name 屬性不同,但我們可以讓它們共用 describe 方法。我們透過建立一個稱為 PersonProto 的共用原型,並將 describe 放入其中來做到這一點(圖 17-2)。

以下程式碼建立物件 janetarzan,它們共用原型 PersonProto

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = {
    [[Prototype]]: PersonProto,
    name: 'Jane'
};
var tarzan = {
    [[Prototype]]: PersonProto,
    name: 'Tarzan'
};

以下是互動方式

> jane.describe()
Person named Jane
> tarzan.describe()
Person named Tarzan

這是常見的模式:資料存在於原型鏈的第一個物件中,而方法存在於較後的物件中。JavaScript 的原型繼承風格旨在支援此模式:設定屬性只會影響原型鏈中的第一個物件,而取得屬性則會考慮整個鏈(請參閱 設定和刪除只會影響自己的屬性)。

取得和設定原型

到目前為止,我們假裝你可以從 JavaScript 存取內部屬性 [[Prototype]]但語言不允許你這麼做。相反地,有函式用於讀取原型和建立具有給定原型的物件。

建立具有給定原型的物件

呼叫:

Object.create(proto, propDescObj?)

建立一個原型為 proto 的物件。另外,可以透過描述子 (在 屬性描述子 中說明) 加入屬性。在以下範例中,物件 jane 取得原型 PersonProto 和可變屬性 name,其值為 'Jane' (透過屬性描述子指定)

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = Object.create(PersonProto, {
    name: { value: 'Jane', writable: true }
});

以下是互動

> jane.describe()
'Person named Jane'

但你經常只建立一個空物件,然後手動加入屬性,因為描述子很冗長

var jane = Object.create(PersonProto);
jane.name = 'Jane';

讀取物件的原型

此方法 呼叫:

Object.getPrototypeOf(obj)

傳回 obj 的原型。繼續前一個範例

> Object.getPrototypeOf(jane) === PersonProto
true

檢查一個物件是否為另一個物件的原型

語法:

Object.prototype.isPrototypeOf(obj)

檢查方法的接收者是否為 obj 的 (直接或間接) 原型。換句話說:接收者和 obj 是否在同一個原型鏈中,而且 obj 是否在接收者之前?例如

> var A = {};
> var B = Object.create(A);
> var C = Object.create(B);
> A.isPrototypeOf(C)
true
> C.isPrototypeOf(A)
false

尋找定義屬性的物件

下列函式會反覆運算物件 obj 的屬性鏈。它會傳回第一個具有 propKey 鍵的自有屬性的物件,或如果沒有這樣的物件,則傳回 null:

function getDefiningObject(obj, propKey) {
    obj = Object(obj); // make sure it’s an object
    while (obj && !{}.hasOwnProperty.call(obj, propKey)) {
        obj = Object.getPrototypeOf(obj);
        // obj is null if we have reached the end
    }
    return obj;
}

在前述程式碼中,我們以一般的方式呼叫 方法 Object.prototype.hasOwnProperty (請參閱 一般方法:從原型借用方法).

特殊屬性 __proto__

某些 JavaScript 引擎有一個特殊屬性,用於取得和設定物件的原型:__proto__。它讓語言可以直接存取 [[Prototype]]

> var obj = {};

> obj.__proto__ === Object.prototype
true

> obj.__proto__ = Array.prototype
> Object.getPrototypeOf(obj) === Array.prototype
true

關於 __proto__,您需要知道下列幾件事

  • __proto__ 的發音為「dunder proto」,是「double underscore proto」的縮寫。這個發音是借用自 Python 程式語言(正如 Ned Batchelder 在 2006 年所建議)。在 Python 中,使用雙底線的特殊變數相當常見。
  • __proto__ 並非 ECMAScript 5 標準的一部分。因此,如果您希望程式碼符合該標準,並在目前的 JavaScript 引擎中可靠執行,就不得使用它。
  • 不過,越來越多引擎都加入了對 __proto__ 的支援,它也會成為 ECMAScript 6 的一部分。
  • 下列表示式會檢查引擎是否支援 __proto__ 作為特殊屬性

    Object.getPrototypeOf({ __proto__: null }) === null

設定和刪除只會影響自己的屬性

只有在取得屬性時,才會考慮物件的完整原型鏈。設定和刪除會略過繼承,只會影響自己的屬性。

設定屬性

設定屬性會建立一個自己的屬性,即使存在一個繼承的屬性具有相同的鍵。例如,假設有下列原始碼:

var proto = { foo: 'a' };
var obj = Object.create(proto);

objproto 繼承 foo

> obj.foo
'a'
> obj.hasOwnProperty('foo')
false

設定 foo 會得到預期的結果

> obj.foo = 'b';
> obj.foo
'b'

不過,我們建立了一個自己的屬性,並沒有變更 proto.foo

> obj.hasOwnProperty('foo')
true
> proto.foo
'a'

其理由在於,原型屬性是讓多個物件共用的。這種方法讓我們可以非破壞性地「變更」它們,只會影響目前的物件。

刪除繼承的屬性

您只能刪除自己的屬性。讓我們再建立一個物件 obj,其原型為 proto

var proto = { foo: 'a' };
var obj = Object.create(proto);

刪除繼承的屬性 foo 沒有任何效果

> delete obj.foo
true
> obj.foo
'a'

如需有關 delete 算子的更多資訊,請參閱 刪除屬性

在原型鏈中的任何地方變更屬性

如果您想要變更繼承的屬性,您必須先找到擁有它的物件(請參閱尋找定義屬性的物件),然後對該物件執行變更。例如,讓我們刪除先前範例中的屬性 foo

> delete getDefiningObject(obj, 'foo').foo;
true
> obj.foo
undefined

針對 反覆運算和偵測屬性的操作會受到下列因素影響:

繼承(自有屬性與繼承屬性)
物件的自有屬性會直接儲存在該物件中。繼承屬性會儲存在其原型之一中。
可列舉性(可列舉屬性與不可列舉屬性)
屬性的可列舉性是一種屬性(請參閱屬性屬性和屬性描述子),一個可以為 truefalse旗標。可列舉性通常不重要,而且通常可以忽略(請參閱可列舉性:最佳實務)。

您可以列出自有屬性鍵、列出所有可列舉屬性鍵,以及檢查屬性是否存在。下列小節會說明如何執行這些動作。

列出自有屬性鍵

您可以列出所有自有 屬性鍵,或只列出可列舉的屬性鍵:

請注意,屬性通常是可列舉的(請參閱 可列舉性:最佳實務),因此您可以使用 Object.keys(),特別是對於您所建立的物件。

列出所有屬性鍵

如果您想要列出物件的所有屬性(包括自有屬性和繼承屬性),則您有兩個選項。

選項 1 是使用迴圈

for («variable» in «object»)
    «statement»

來反覆運算 object 的所有可列舉屬性的鍵。請參閱for-in 以取得更詳盡的說明。

選項 2 是自己實作一個函式,以反覆運算所有屬性(不只是可列舉屬性)。例如

function getAllPropertyNames(obj) {
    var result = [];
    while (obj) {
        // Add the own property names of `obj` to `result`
        result = result.concat(Object.getOwnPropertyNames(obj));
        obj = Object.getPrototypeOf(obj);
    }
    return result;
}

檢查屬性是否存在

您可以檢查物件是否具有屬性,或檢查屬性是否直接存在於物件內部:

propKey in obj
如果 obj 具有鍵為 propKey 的屬性,則傳回 true。此測試包含繼承屬性。
Object.prototype.hasOwnProperty(propKey)
如果接收器 (this) 具有鍵為 propKey自有 (非繼承) 屬性,則傳回 true

警告

避免直接在物件上呼叫 hasOwnProperty(),因為它可能會被覆寫 (例如,鍵為 hasOwnProperty 的自有屬性)

> var obj = { hasOwnProperty: 1, foo: 2 };
> obj.hasOwnProperty('foo')  // unsafe
TypeError: Property 'hasOwnProperty' is not a function

相反地,最好以一般方式呼叫它 (請參閱 一般方法:從原型借用方法)

> Object.prototype.hasOwnProperty.call(obj, 'foo')  // safe
true
> {}.hasOwnProperty.call(obj, 'foo')  // shorter
true

範例

下列範例基於下列定義:

var proto = Object.defineProperties({}, {
    protoEnumTrue: { value: 1, enumerable: true },
    protoEnumFalse: { value: 2, enumerable: false }
});
var obj = Object.create(proto, {
    objEnumTrue: { value: 1, enumerable: true },
    objEnumFalse: { value: 2, enumerable: false }
});

Object.defineProperties()透過描述子取得和定義屬性 中說明,但它的運作方式應該很明顯: proto 具有自有屬性 protoEnumTrueprotoEnumFalse,而 obj 具有自有屬性 objEnumTrueobjEnumFalse (並繼承 proto 的所有屬性)。

注意

請注意,物件 (例如前一個範例中的 proto) 通常至少具有原型 Object.prototype (其中定義了標準方法,例如 toString()hasOwnProperty())

> Object.getPrototypeOf({}) === Object.prototype
true

可列舉性的影響

屬性相關操作 中,可列舉性只會影響 for-in 迴圈 Object.keys() (它也會影響 JSON.stringify(),請參閱 JSON.stringify(value, replacer?, space?))。

for-in 迴圈會反覆運算所有可列舉屬性的鍵,包括繼承的屬性 (請注意, Object.prototype 的所有不可列舉屬性都不會顯示)

> for (var x in obj) console.log(x);
objEnumTrue
protoEnumTrue

Object.keys() 會傳回所有自有 (非繼承) 可列舉屬性的鍵

> Object.keys(obj)
[ 'objEnumTrue' ]

如果您要取得所有自有屬性的鍵,您需要使用 Object.getOwnPropertyNames()

> Object.getOwnPropertyNames(obj)
[ 'objEnumTrue', 'objEnumFalse' ]

繼承的影響

只有 for-in 迴圈(請參閱前一個範例)in 算子會考量繼承:

> 'toString' in obj
true
> obj.hasOwnProperty('toString')
false
> obj.hasOwnProperty('objEnumFalse')
true

計算物件的自有屬性數量

物件沒有 方法,例如 lengthsize,因此您必須使用下列解決方法:

Object.keys(obj).length

最佳實務:反覆處理自有屬性

要反覆處理屬性金鑰:

  • for-inhasOwnProperty() 結合使用,其方式如 for-in 中所述。這甚至適用於較舊的 JavaScript 引擎。例如

    for (var key in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
            console.log(key);
        }
    }
  • Object.keys()Object.getOwnPropertyNames()forEach() 陣列反覆處理結合使用

    var obj = { first: 'John', last: 'Doe' };
    // Visit non-inherited enumerable keys
    Object.keys(obj).forEach(function (key) {
        console.log(key);
    });

要反覆處理屬性值或 (金鑰、值) 成對

  • 反覆處理金鑰,並使用每個金鑰來擷取對應的值。其他語言會讓這項工作更簡單,但 JavaScript 沒有。

存取器(取得器和設定器)

ECMAScript 5 讓您撰寫方法,其呼叫看起來就像您正在取得或設定屬性。這表示屬性是虛擬的,而非儲存空間。例如,您可以禁止設定屬性,並在讀取屬性時,總是計算傳回的值。

透過物件文字定義存取器

下列 範例使用物件文字來定義屬性 foo 的設定器和取得器:

var obj = {
    get foo() {
        return 'getter';
    },
    set foo(value) {
        console.log('setter: '+value);
    }
};

以下是互動

> obj.foo = 'bla';
setter: bla
> obj.foo
'getter'

透過屬性描述符定義存取器

指定取得器和設定器的另一種方式是透過屬性描述符(請參閱 屬性描述符)。下列程式碼定義與前一個文字相同的物件

var obj = Object.create(
    Object.prototype, {  // object with property descriptors
        foo: {  // property descriptor
            get: function () {
                return 'getter';
            },
            set: function (value) {
                console.log('setter: '+value);
            }
        }
    }
);

存取器和繼承

取得器和設定器會從原型繼承:

> var proto = { get foo() { return 'hello' } };
> var obj = Object.create(proto);

> obj.foo
'hello'

屬性屬性和屬性描述符

提示

屬性屬性和屬性描述符是進階主題。您通常不需要知道它們如何運作。

在本節中,我們將探討屬性的內部結構:

  • 屬性屬性 是屬性的原子構建區塊。
  • 一個 屬性描述符 是用於以程式方式處理屬性的資料結構。

屬性屬性

屬性的所有狀態,包括其資料及其元資料,都儲存在 屬性 中。它們是屬性所擁有的欄位,就像物件擁有屬性一樣。屬性金鑰通常以雙中括號撰寫。屬性對於一般屬性和存取器(取得器和設定器)來說很重要。

下列屬性是特定於一般屬性

  • [[Value]] 儲存屬性的值,其資料。
  • [[Writable]] 儲存一個布林值,表示屬性的值是否可以變更。

下列屬性是特定於存取器的:

  • [[Get]] 儲存取得器,一個在讀取屬性時呼叫的函式。此函式會計算讀取存取的結果。
  • [[Set]] 儲存設定器,一個在將屬性設定為某個值時呼叫的函式。此函式會接收該值作為參數。

所有屬性都具有下列 屬性:

  • [[Enumerable]] 儲存一個布林值。將屬性設為不可列舉會讓它在某些運算中隱藏(請參閱 屬性的反覆運算和偵測)。
  • [[Configurable]] 儲存一個布林值。如果它是 false,您就無法刪除屬性、變更其任何屬性([[Value]] 除外),或將它從資料屬性轉換為存取器屬性,反之亦然。換句話說,[[Configurable]] 控制屬性元資料的可寫性。此規則有一個例外—JavaScript 允許您將不可設定的屬性從可寫變更為唯讀,原因是 歷史因素;陣列的 length 屬性一直都是可寫且不可設定的。沒有這個例外,您就無法凍結(請參閱 凍結)陣列。

預設值

如果您未指定屬性,將使用下列預設值:

屬性鍵 預設值

[[值]]

未定義

[[取得]]

未定義

[[設定]]

未定義

[[可寫]]

false

[[可列舉]]

false

[[可組態]]

false

當您透過屬性描述子建立屬性時,這些預設值非常重要(請參閱下列章節)。

屬性描述子

屬性描述子是一種用於以程式化方式處理屬性的資料結構。它是一個編碼屬性屬性的物件。描述子的每個屬性都對應到一個屬性。例如,下列是值為 123 的唯讀屬性的描述子:

{
    value: 123,
    writable: false,
    enumerable: true,
    configurable: false
}

您可以透過存取器達成相同的目標,也就是不可變性。然後,描述子如下所示

{
    get: function () { return 123 },
    enumerable: true,
    configurable: false
}

屬性描述子用於兩種操作:

取得屬性
屬性的所有屬性都以描述子的形式傳回。
定義屬性

定義屬性會產生不同的結果,視屬性是否已存在而定

  • 如果屬性不存在,請建立一個新屬性,其屬性由描述子指定。如果屬性在描述子中沒有對應的屬性,請使用預設值。預設值由屬性名稱的意義決定。它們與透過指定建立屬性時所使用的值相反(然後,屬性可寫、可列舉且可組態)。例如:

    > var obj = {};
    > Object.defineProperty(obj, 'foo', { configurable: true });
    > Object.getOwnPropertyDescriptor(obj, 'foo')
    { value: undefined,
      writable: false,
      enumerable: false,
      configurable: true }

    我通常不依賴預設值,而是明確指出所有屬性,以完全清楚。

  • 如果屬性已存在,請根據描述子指定的方式更新屬性的屬性。如果屬性在描述子中沒有對應的屬性,請不要變更它。以下是一個範例(延續前一個範例)

    > Object.defineProperty(obj, 'foo', { writable: true });
    > Object.getOwnPropertyDescriptor(obj, 'foo')
    { value: undefined,
      writable: true,
      enumerable: false,
      configurable: true }

下列操作讓您透過屬性描述子取得和設定屬性的屬性:

Object.getOwnPropertyDescriptor(obj, propKey)

傳回鍵為propKeyobj的自有(非繼承)屬性的描述子。如果沒有這樣的屬性,將傳回undefined

> Object.getOwnPropertyDescriptor(Object.prototype, 'toString')
{ value: [Function: toString],
  writable: true,
  enumerable: false,
  configurable: true }

> Object.getOwnPropertyDescriptor({}, 'toString')
undefined
Object.defineProperty(obj, propKey, propDesc)

建立或變更 obj 的屬性,其金鑰為 propKey,其屬性透過 propDesc 指定。傳回已修改的物件。例如

var obj = Object.defineProperty({}, 'foo', {
    value: 123,
    enumerable: true
    // writable: false (default value)
    // configurable: false (default value)
});
Object.defineProperties(obj, propDescObj)

批次版本的 Object.defineProperty()propDescObj 的每個屬性都包含一個屬性描述。屬性的金鑰及其值會告訴 Object.defineProperties 要在 obj 上建立或變更哪些屬性。例如:

var obj = Object.defineProperties({}, {
    foo: { value: 123, enumerable: true },
    bar: { value: 'abc', enumerable: true }
});
Object.create(proto, propDescObj?)

首先,建立一個原型為 proto 的物件。然後,如果已指定選用參數 propDescObj,則以與 Object.defineProperties 相同的方式新增屬性。最後,傳回結果。例如,下列程式碼片段產生與前一個片段相同的結果

var obj = Object.create(Object.prototype, {
    foo: { value: 123, enumerable: true },
    bar: { value: 'abc', enumerable: true }
});

複製物件

若要建立物件的完全相同副本,您需要做好兩件事:

  1. 副本必須與原始物件具有相同的原型(請參閱第 2 層:物件之間的原型關係)。
  2. 副本必須具有與原始物件相同的屬性,且屬性具有相同的屬性。

下列函式執行此類複製

function copyObject(orig) {
    // 1. copy has same prototype as orig
    var copy = Object.create(Object.getPrototypeOf(orig));

    // 2. copy has all of orig’s properties
    copyOwnPropertiesFrom(copy, orig);

    return copy;
}

屬性會透過此函式從 orig 複製到 copy

function copyOwnPropertiesFrom(target, source) {
    Object.getOwnPropertyNames(source)  // (1)
    .forEach(function(propKey) {  // (2)
        var desc = Object.getOwnPropertyDescriptor(source, propKey); // (3)
        Object.defineProperty(target, propKey, desc);  // (4)
    });
    return target;
};

涉及的步驟如下

  1. 取得包含 source 所有自有屬性金鑰的陣列。
  2. 反覆處理這些金鑰。
  3. 擷取屬性描述。
  4. 使用該屬性描述在 target 中建立自有屬性。

請注意,此函式與 Underscore.js 函式庫中的函式 _.extend() 非常類似。

屬性:定義與指定

下列兩個作業非常類似:

不過,有一些細微的差異

  • 定義屬性表示建立新的自有屬性或更新現有自有屬性的屬性。在這兩種情況下,原型鏈完全被忽略。
  • 指定屬性 prop 表示變更現有屬性。程序如下:

    • 如果 prop 是 setter(自有或繼承),則呼叫該 setter。
    • 否則,如果 prop 是唯讀的(自己的或繼承的),則擲出例外(在嚴格模式下)或不執行任何動作(在寬鬆模式下)。下一節會更詳細地說明這個(有點意外的)現象。
    • 否則,如果 prop 是自己的(且可寫入),則變更該屬性的值。
    • 否則,表示沒有屬性 prop,或它是繼承的且可寫入。在這兩種情況下,定義一個可寫入、可設定和可列舉的自己的屬性 prop。在後一種情況下,我們剛剛覆寫了一個繼承的屬性(非破壞性地變更它)。在前一種情況下,會自動定義一個遺失的屬性。這種自動定義是有問題的,因為在指定中出現的錯字可能很難偵測。

無法指定繼承的唯讀屬性

如果一個物件 obj 從一個原型繼承一個屬性 foo,而 foo 是唯讀的,則無法指定 obj.foo

var proto = Object.defineProperty({}, 'foo', {
    value: 'a',
    writable: false
});
var obj = Object.create(proto);

objproto 繼承唯讀屬性 foo。在寬鬆模式下,設定屬性不會造成任何影響

> obj.foo = 'b';
> obj.foo
'a'

在嚴格模式下,您會得到一個例外

> (function () { 'use strict'; obj.foo = 'b' }());
TypeError: Cannot assign to read-only property 'foo'

這符合指定會變更繼承的屬性,但是非破壞性的概念。如果一個繼承的屬性是唯讀的,您會想要禁止所有變更,即使是非破壞性的變更。

請注意,您可以透過定義一個自己的屬性來規避這個保護(請參閱前一個小節,以了解定義和指定之間的差異)

> Object.defineProperty(obj, 'foo', { value: 'b' });
> obj.foo
'b'

可列舉性:最佳實務

一般的 規則是系統建立的屬性是不可列舉的,而使用者建立的屬性是可列舉的:

> Object.keys([])
[]
> Object.getOwnPropertyNames([])
[ 'length' ]

> Object.keys(['a'])
[ '0' ]

這對於內建實例原型的函式特別適用

> Object.keys(Object.prototype)
[]
> Object.getOwnPropertyNames(Object.prototype)
[ hasOwnProperty',
  'valueOf',
  'constructor',
  'toLocaleString',
  'isPrototypeOf',
  'propertyIsEnumerable',
  'toString' ]

可列舉性的主要目的是告訴 for-in 迴圈它應該忽略哪些屬性。正如我們剛剛在查看內建建構函式的實例時所看到的,任何非使用者建立的項目都會對 for-in 隱藏起來。

受到可列舉性影響的唯一作業是

以下是一些要記住的最佳實務

  • 對於您自己的程式碼,您通常可以忽略可列舉性,並且應該避免使用 for-in 迴圈(最佳實務:反覆運算陣列)。
  • 您通常不應該將屬性新增到內建原型和物件中。但是如果您這樣做了,您應該讓它們不可列舉,以避免中斷現有的程式碼。

保護物件

保護物件有三種層級,由弱到強列出如下:

  • 防止擴充
  • 封存
  • 凍結

防止擴充

防止擴充的方法:

Object.preventExtensions(obj)

obj無法新增屬性。例如

var obj = { foo: 'a' };
Object.preventExtensions(obj);

現在在隨意模式中新增屬性會靜默失敗

> obj.bar = 'b';
> obj.bar
undefined

而在嚴格模式中會擲回錯誤

> (function () { 'use strict'; obj.bar = 'b' }());
TypeError: Can't add property bar, object is not extensible

不過你仍然可以刪除屬性

> delete obj.foo
true
> obj.foo
undefined

你可以透過以下方式檢查物件是否可擴充

Object.isExtensible(obj)

封存

封存的方法:

Object.seal(obj)

防止擴充和讓所有屬性都「不可設定」。後者表示屬性的屬性(請參閱屬性屬性和屬性描述子)無法再變更。例如,唯讀屬性會永遠保持唯讀。

以下範例示範封存會讓所有屬性都不可設定

> var obj = { foo: 'a' };

> Object.getOwnPropertyDescriptor(obj, 'foo')  // before sealing
{ value: 'a',
  writable: true,
  enumerable: true,
  configurable: true }

> Object.seal(obj)

> Object.getOwnPropertyDescriptor(obj, 'foo')  // after sealing
{ value: 'a',
  writable: true,
  enumerable: true,
  configurable: false }

你仍然可以變更屬性foo

> obj.foo = 'b';
'b'
> obj.foo
'b'

但你無法變更它的屬性

> Object.defineProperty(obj, 'foo', { enumerable: false });
TypeError: Cannot redefine property: foo

你可以透過以下方式檢查物件是否已封存

Object.isSealed(obj)

凍結

凍結的方法:

Object.freeze(obj)

它會讓所有屬性都不可寫入,並封存obj。換句話說,obj不可擴充,且所有屬性都唯讀,而且無法變更。我們來看一個範例:

var point = { x: 17, y: -5 };
Object.freeze(point);

再一次,你在隨意模式中會得到靜默失敗

> point.x = 2;  // no effect, point.x is read-only
> point.x
17

> point.z = 123;  // no effect, point is not extensible
> point
{ x: 17, y: -5 }

而在嚴格模式中會得到錯誤

> (function () { 'use strict'; point.x = 2 }());
TypeError: Cannot assign to read-only property 'x'

> (function () { 'use strict'; point.z = 123 }());
TypeError: Can't add property z, object is not extensible

你可以透過以下方式檢查物件是否已凍結

Object.isFrozen(obj)

陷阱:保護是淺層的

保護物件是淺層的:它會影響自己的屬性,但不會影響那些屬性的值。例如,考慮以下物件:

var obj = {
    foo: 1,
    bar: ['a', 'b']
};
Object.freeze(obj);

即使你已凍結obj,它並非完全不可變—你可以變更屬性bar的(可變)值

> obj.foo = 2; // no effect
> obj.bar.push('c'); // changes obj.bar

> obj
{ foo: 1, bar: [ 'a', 'b', 'c' ] }

此外,obj具有原型Object.prototype,它也是可變的。

第 3 層:建構函式—實例的工廠

建構函式(簡稱:建構函式)有助於產生在某方面相似的物件。它是一個正常的函式,但它的命名、設定和呼叫方式不同。本節說明建構函式的運作方式。它們對應於其他語言中的類別。

我們已經看過兩個相似的物件範例(在透過原型在物件間共用資料中)

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = {
    [[Prototype]]: PersonProto,
    name: 'Jane'
};
var tarzan = {
    [[Prototype]]: PersonProto,
    name: 'Tarzan'
};

物件janetarzan都被視為「人」,並共用原型物件PersonProto。我們將那個原型轉換成建構函式Person,它會建立像janetarzan這樣的物件。建構函式建立的物件稱為它的實例。這些實例具有與janetarzan相同的結構,由兩個部分組成

  1. 資料是特定於執行個體,並儲存在執行個體物件的自身屬性中(在前一個範例中為 janetarzan)。
  2. 行為由所有執行個體共用—它們有一個共用原型物件,其中包含方法(在前一個範例中為 PersonProto)。

建構函式是一個透過 new 算子呼叫的函式。依慣例,建構函式的名稱以大寫字母開頭,而一般函式和方法的名稱則以小寫字母開頭。函式本身會設定第 1 部分

function Person(name) {
    this.name = name;
}

Person.prototype 中的物件會變成 Person 的所有執行個體的原型。它會提供第 2 部分

Person.prototype.describe = function () {
    return 'Person named '+this.name;
};

讓我們建立並使用 Person 的一個執行個體

> var jane = new Person('Jane');
> jane.describe()
'Person named Jane'

我們可以看到 Person 是個一般函式。它只有在透過 new 呼叫時才會變成建構函式。 new 算子會執行下列步驟:

  • 首先設定行為:建立一個新的物件,其原型為 Person. prototype
  • 接著設定資料: Person 會收到該物件作為隱含參數 this,並新增執行個體屬性。

圖 17-3 顯示執行個體 jane 的樣子。 Person.prototype 的屬性 constructor 會指向建構函式,並在 執行個體的 constructor 屬性 中說明。

instanceof 算子讓我們可以檢查物件是否為特定建構函式的執行個體

> jane instanceof Person
true
> jane instanceof Date
false

JavaScript 中實作的 new 算子

如果你要手動實作 new 算子,它大致會如下所示:

function newOperator(Constr, args) {
    var thisValue = Object.create(Constr.prototype); // (1)
    var result = Constr.apply(thisValue, args);
    if (typeof result === 'object' && result !== null) {
        return result; // (2)
    }
    return thisValue;
}

在第 (1) 行,您可以看到由建構函式 Constr 建立的執行個體原型是 Constr.prototype

第 (2) 行揭露了 new 營運子的另一個功能:您可以從建構函式傳回一個任意物件,而它會變成 new 營運子的結果。如果您想要建構函式傳回子建構函式的執行個體,這會很有用(在 從建構函式傳回任意物件 中提供了一個範例)。

術語:兩個原型

不幸的是,術語 原型 在 JavaScript 中使用 模稜兩可:

原型 1:原型關係

一個物件可以是另一個物件的原型

> var proto = {};
> var obj = Object.create(proto);
> Object.getPrototypeOf(obj) === proto
true

在前面的範例中,protoobj 的原型。

原型 2:屬性 prototype 的值

每個建構函式 C 都有一個 prototype 屬性,它會參照一個物件。該物件會變成 C 的所有執行個體的原型:

> function C() {}
> Object.getPrototypeOf(new C()) === C.prototype
true

通常,上下文會清楚說明指的是哪一個原型。如果需要消除歧義,那麼我們只能使用 原型 來描述物件之間的關係,因為這個名稱已經透過 getPrototypeOfisPrototypeOf 進入標準函式庫。因此,我們需要為 prototype 屬性參照的物件找到一個不同的名稱。一種可能性是 建構函式原型,但這是有問題的,因為建構函式也有原型

> function Foo() {}
> Object.getPrototypeOf(Foo) === Function.prototype
true

因此,執行個體原型 是最佳的 選項。

執行個體的建構函式屬性

預設情況下,每個函式 C 都包含一個執行個體原型物件 C.prototype,其屬性 constructor 指回 C

> function C() {}
> C.prototype.constructor === C
true

由於 constructor 屬性是由每個執行個體從原型繼承而來的,因此您可以使用它來取得執行個體的建構函式

> var o = new C();
> o.constructor
[Function: C]

建構式屬性的使用案例

切換物件的建構式

下列 catch 子句中,我們會根據捕捉到的例外狀況的建構式採取不同的動作:

try {
    ...
} catch (e) {
    switch (e.constructor) {
        case SyntaxError:
            ...
            break;
        case CustomError:
            ...
            break;
        ...
    }
}

警告

此方法僅偵測給定建構式的直接實例。相較之下,instanceof 會偵測直接實例和所有子建構式的實例。

判斷物件建構式的名稱

例如

> function Foo() {}
> var f = new Foo();
> f.constructor.name
'Foo'

警告

並非所有 JavaScript 引擎都支援函式的 name 屬性。

建立類似的物件

以下是建立新物件 y 的方式,其建構式與現有物件 x 相同

function Constr() {}
var x = new Constr();

var y = new x.constructor();
console.log(y instanceof Constr); // true

此技巧對於必須適用於子建構式實例的方法非常實用,而且想要建立與 this 類似的實例。如此一來,您無法使用固定的建構式

SuperConstr.prototype.createCopy = function () {
    return new this.constructor(...);
};
參照超建構式

有些繼承函式庫會將超原型指定給子建構式的屬性。例如,YUI 架構會透過 Y.extend 提供子類別化

function Super() {
}
function Sub() {
    Sub.superclass.constructor.call(this); // (1)
}
Y.extend(Sub, Super);

第 (1) 行的呼叫會運作,因為 extend 會將 Sub.superclass 設定為 Super.prototype。多虧了 constructor 屬性,您可以將超建構式呼叫為方法。

注意

instanceof 算子(請參閱 instanceof 算子)不依賴 constructor 屬性。

最佳實務

請確定對於每個建構式 C,都有下列斷言成立:

C.prototype.constructor === C

預設情況下,每個函式 f 都已經有正確設定的 prototype 屬性

> function f() {}
> f.prototype.constructor === f
true

因此,您應該避免取代此物件,而只新增屬性到其中

// Avoid:
C.prototype = {
    method1: function (...) { ... },
    ...
};

// Prefer:
C.prototype.method1 = function (...) { ... };
...

如果您有取代它,您應該手動將正確值指定給 constructor

C.prototype = {
    constructor: C,
    method1: function (...) { ... },
    ...
};

請注意,JavaScript 中沒有任何關鍵部分依賴於 constructor 屬性;但設定它是很好的風格,因為它能啟用本節中提到的技術。

instanceof 算子

instanceof 算子

value instanceof Constr

會判斷 value是否是由建構式 Constr 或子建構式建立的。它會透過檢查 Constr.prototype 是否在 value 的原型鏈中來執行此動作。因此,下列兩個表達式是等效的:

value instanceof Constr
Constr.prototype.isPrototypeOf(value)

以下是一些範例

> {} instanceof Object
true

> [] instanceof Array  // constructor of []
true
> [] instanceof Object  // super-constructor of []
true

> new Date() instanceof Date
true
> new Date() instanceof Object
true

正如預期,instanceof 對於原始值永遠是 false

> 'abc' instanceof Object
false
> 123 instanceof Number
false

最後,如果 instanceof 的右側不是函式,它會擲回例外狀況

> [] instanceof 123
TypeError: Expecting a function in instanceof check

陷阱:不是 Object 實例的物件

幾乎所有物件都是 Object 的實例,因為 Object.prototype 在其原型鏈中。但也有物件不是這種情況。以下是兩個範例:

> Object.create(null) instanceof Object
false
> Object.prototype instanceof Object
false

前一個物件在 字典模式:沒有原型的物件是更好的映射 中有更詳細的說明。後一個物件是大多數原型鏈的終點(而且它們必須在某處終止)。兩個物件都沒有原型

> Object.getPrototypeOf(Object.create(null))
null
> Object.getPrototypeOf(Object.prototype)
null

typeof 正確地將它們分類為物件

> typeof Object.create(null)
'object'
> typeof Object.prototype
'object'

這個陷阱對大多數 instanceof 的使用案例來說並非破壞性的,但你必須知道它。

陷阱:跨越領域(框架或視窗)

在網頁瀏覽器中,每個框架和視窗都有自己的 領域,其中有獨立的全球變數。這會阻止 instanceof 對跨越領域的物件運作。要了解原因,請查看以下程式碼:

if (myvar instanceof Array) ...  // Doesn’t always work

如果 myvar 是來自不同領域的陣列,則其原型是該領域的 Array.prototype。因此,instanceof 將找不到 myvar 原型鏈中的目前領域的 Array.prototype,並會傳回 false。ECMAScript 5 有函式 Array.isArray(),它總是運作:

<head>
    <script>
        function test(arr) {
            var iframe = frames[0];

            console.log(arr instanceof Array); // false
            console.log(arr instanceof iframe.Array); // true
            console.log(Array.isArray(arr)); // true
        }
    </script>
</head>
<body>
    <iframe srcdoc="<script>window.parent.test([])</script>">
    </iframe>
</body>

顯然,這也是非內建建構函式的問題。

除了使用 Array.isArray() 之外,還有幾件事可以解決這個問題

  • 避免物件跨越領域。瀏覽器有 postMessage() 方法,它可以將物件複製到另一個領域,而不是傳遞參考。
  • 檢查實例建構函式的名稱(僅適用於支援函式屬性 name 的引擎)

    someValue.constructor.name === 'NameOfExpectedConstructor'
  • 使用原型屬性將實例標記為屬於類型 T。有幾種方法可以做到這一點。檢查 value 是否是 T 的實例如下

    • value.isT()T 實例的原型必須從此方法傳回 true;共用超建構函式應傳回預設值 false
    • 'T' in value:您必須使用其金鑰為 'T'(或更獨特的名稱)的屬性標記 T 實例的原型。
    • value.TYPE_NAME === 'T':每個相關原型都必須具有 TYPE_NAME 屬性,且其值適當。

本節提供實作建構函式的幾個提示。

防範忘記 new:嚴格模式

如果您在使用建構函式時忘記 new,您將會將其呼叫為函式,而非建構函式。在隨意模式中,您不會取得實例,而且會建立全域變數。不幸的是,所有這些情況都會在沒有警告的情況下發生:

function SloppyColor(name) {
    this.name = name;
}
var c = SloppyColor('green'); // no warning!

// No instance is created:
console.log(c); // undefined
// A global variable is created:
console.log(name); // green

在嚴格模式下,您會得到一個例外

function StrictColor(name) {
    'use strict';
    this.name = name;
}
var c = StrictColor('green');
// TypeError: Cannot set property 'name' of undefined

從建構函式傳回任意物件

在許多物件導向語言中,建構函式只能產生直接實例。例如,考慮 Java:假設您要實作一個具有子類別 AdditionMultiplication 的類別 Expression。剖析會產生後兩者的直接實例。您無法將其實作為 Expression 的建構函式,因為該建構函式只能產生 Expression 的直接實例。作為解決方法,Java 中會使用靜態工廠方法:

class Expression {
    // Static factory method:
    public static Expression parse(String str) {
        if (...) {
            return new Addition(...);
        } else if (...) {
            return new Multiplication(...);
        } else {
            throw new ExpressionException(...);
        }
    }
}
...
Expression expr = Expression.parse(someStr);

在 JavaScript 中,您只要從建構函式傳回您需要的任何物件即可。因此,前述程式碼的 JavaScript 版本會如下所示

function Expression(str) {
    if (...) {
        return new Addition(..);
    } else if (...) {
        return new Multiplication(...);
    } else {
        throw new ExpressionException(...);
    }
}
...
var expr = new Expression(someStr);

這是個好消息:JavaScript 建構函式不會將您鎖定,因此您可以隨時改變主意,決定建構函式是否應該傳回直接實例或其他內容。

原型屬性中的資料

本節說明在大部分情況下,您不應該將資料放入原型屬性中。不過,這項規則有幾個例外。

避免使用具有實例屬性初始值的原型屬性

原型包含多個物件共用的屬性。因此,它們非常適合方法。此外,透過下一個說明的技術,您也可以使用它們為實例屬性提供初始值。稍後我將說明為何不建議這麼做。

建構函式通常會設定實例屬性為初始值。如果其中一個值是預設值,則不需要建立實例屬性。您只需要一個具有相同金鑰的原型屬性,其值為預設值。例如

/**
 * Anti-pattern: don’t do this
 *
 * @param data an array with names
 */
function Names(data) {
    if (data) {
        // There is a parameter
        // => create instance property
        this.data = data;
    }
}
Names.prototype.data = [];

參數 data 是選用的。如果它不存在,實例不會取得屬性 data,而是繼承 Names.prototype.data

這種方法大多數時候都有用:您可以建立 Names 的實例 n。取得 n.data 會讀取 Names.prototype.data。設定 n.data 會在 n 中建立新的自有屬性,並保留原型中的共用預設值。我們只會在 變更 預設值(而不是以新值取代它)時遇到問題

> var n1 = new Names();
> var n2 = new Names();

> n1.data.push('jane'); // changes default value
> n1.data
[ 'jane' ]

> n2.data
[ 'jane' ]

在前面的範例中,push() 變更了 Names.prototype.data 中的陣列。由於所有沒有自有屬性 data 的實例共用該陣列,n2.data 的初始值也變更了。

最佳實務:不要共用預設值

根據我們剛剛討論的內容,最好不要共用預設值,並始終建立新的預設值

function Names(data) {
    this.data = data || [];
}

顯然地,如果共用的預設值是不可變的(就像所有基本型別一樣;請參閱 基本型別值),則不會出現修改共用預設值的問題。但為了保持一致性,最好堅持使用單一的方式設定屬性。我也比較喜歡維持慣用的關注點分離(請參閱 第 3 層:建構函式—實例工廠):建構函式設定實例屬性,而原型包含方法。

ECMAScript 6 會讓這變成更佳的實務,因為建構函式參數可以有預設值,而且您可以透過類別定義原型方法,但不能定義具有資料的原型屬性。

依需求建立實例屬性

偶爾,建立屬性值會是一個昂貴的作業(在運算或儲存方面)。在這種情況下,您可以依需求建立實例屬性:

function Names(data) {
    if (data) this.data = data;
}
Names.prototype = {
    constructor: Names, // (1)
    get data() {
        // Define, don’t assign
        // => avoid calling the (nonexistent) setter
        Object.defineProperty(this, 'data', {
            value: [],
            enumerable: true,
            configurable: false,
            writable: false
        });
        return this.data;
    }
};

我們無法透過指定將屬性 data 加入實例,因為 JavaScript 會抱怨缺少設定器(當它只找到取得器時就會這樣)。因此,我們透過 Object.defineProperty() 加入它。請參閱 屬性:定義與指定 來檢閱定義與指定之間的差異。在第 (1) 行中,我們確保屬性 constructor 已正確設定(請參閱 實例的 constructor 屬性)。

顯然地,這會花費相當多的工作,因此您必須確定它值得這麼做。

避免使用非多型原型屬性

如果同一個屬性(相同鍵、相同語意,通常不同值)存在於多個原型中,它稱為多型。然後,透過實例讀取屬性的結果會透過該實例的原型動態決定。未多型使用的原型屬性可以替換為變數(這能更佳反映其非多型性質)。

例如,你可以將常數儲存在原型屬性中,並透過this存取它

function Foo() {}
Foo.prototype.FACTOR = 42;
Foo.prototype.compute = function (x) {
    return x * this.FACTOR;
};

此常數非多型。因此,你也可以透過變數存取它

// This code should be inside an IIFE or a module
function Foo() {}
var FACTOR = 42;
Foo.prototype.compute = function (x) {
    return x * FACTOR;
};

多型原型屬性

以下是多型原型屬性的範例 ,其中包含不可變資料。透過原型屬性標記建構函式的實例,你可以將它們與不同建構函式的實例區分開來:

function ConstrA() { }
ConstrA.prototype.TYPE_NAME = 'ConstrA';

function ConstrB() { }
ConstrB.prototype.TYPE_NAME = 'ConstrB';

由於多型「標籤」TYPE_NAME,你可以區分 ConstrAConstrB 的實例,即使它們跨越領域(然後 instanceof 無法運作;請參閱 陷阱:跨越領域(框架或視窗))。

保持資料私密

JavaScript 沒有 專用的方法來管理物件的私密資料。本節將說明三種解決此限制的方法:

  • 建構函式環境中的私密資料
  • 具有標記鍵的屬性中的私密資料
  • 具有具體化鍵的屬性中的私密資料

此外,我將說明如何透過 IIFE 保持全域資料私密。

建構函式環境中的私密資料(Crockford 隱私模式)

當建構函式 被呼叫時,會建立兩件事:建構函式的實例和環境(請參閱 環境:管理變數)。實例將由建構函式初始化。環境包含建構函式的參數和區域變數。在建構函式內建立的每個函式(包括方法)都會保留對環境的參考,也就是建立它的環境。由於這個參考,它將永遠可以存取環境,即使建構函式已完成。函式和環境的這種組合稱為封閉封閉:函式與其建立範圍保持連線)。因此,建構函式的環境是獨立於實例的資料儲存,而且只因為這兩者同時建立而與實例相關。為了正確連接它們,我們必須有存在於兩個世界的函式。使用 Douglas Crockford 的術語,實例可以有與其關聯的三種類型值(請參閱 圖 17-4

公開屬性
儲存在屬性中的值(在實例或其原型中)是公開可存取的。
私人值
儲存在環境中的資料和函式是 私人 的,只有建構函式和它建立的函式可以存取。
特權方法
私人函式可以存取公開屬性,但原型中的公開方法無法存取私人資料。 因此我們需要 特權 方法,也就是實例中的公開方法。特權方法是公開的,所有人都可以呼叫,但它們也可以存取私人值,因為它們是在建構函式中建立的。

以下各節會更詳細地說明每種類型的值。

公開屬性

請記住,給定一個建構函式 Constr,有兩種 公開 的屬性,所有人都可以存取。首先,原型屬性 儲存在 Constr.prototype 中,並由所有實例共用。 原型屬性通常是方法:

Constr.prototype.publicMethod = ...;

其次,實例屬性 是每個實例獨有的。 它們在建構函式中新增,通常包含資料(而非方法):

function Constr(...) {
    this.publicData = ...;
    ...
}

私人值

建構函式的環境包含參數和局部變數。 它們只能從建構函式內部存取,因此對實例而言是私人的:

function Constr(...) {
    ...
    var that = this; // make accessible to private functions

    var privateData = ...;

    function privateFunction(...) {
        // Access everything
        privateData = ...;

        that.publicData = ...;
        that.publicMethod(...);
    }
    ...
}

特權方法

私人資料非常安全,無法從外部存取,因此原型方法無法存取它。 但是,離開建構函式後,你還能如何使用它呢?答案是 特權方法:在建構函式中建立的函式會新增為實例方法。這表示一方面它們可以存取私人資料;另一方面,它們是公開的,因此原型方法可以看到它們。換句話說,它們充當私人資料和公開資料(包括原型方法)之間的仲介:

function Constr(...) {
    ...
    this.privilegedMethod = function (...) {
        // Access everything
        privateData = ...;
        privateFunction(...);

        this.publicData = ...;
        this.publicMethod(...);
    };
}

一個範例

以下是使用 Crockford 隱私模式實作的 StringBuilder

function StringBuilder() {
    var buffer = [];
    this.add = function (str) {
        buffer.push(str);
    };
    this.toString = function () {
        return buffer.join('');
    };
}
// Can’t put methods in the prototype!

以下是互動

> var sb = new StringBuilder();
> sb.add('Hello');
> sb.add(' world!');
> sb.toString()
’Hello world!’

Crockford 隱私模式的優缺點

以下是使用 Crockford 隱私模式時需要考慮的一些重點:

它並不優雅
透過特權方法調解對私人資料的存取會引入不必要的間接性。特權方法和私人函式都會破壞建構函式(設定執行個體資料)和執行個體原型(方法)之間的關注點分離。
它完全安全
沒有辦法從外部存取環境的資料,這使得此解決方案在需要時是安全的(例如,對於安全關鍵程式碼)。另一方面,私人資料無法從外部存取也可能造成不便。有時您會想要對私人功能進行單元測試。而某些暫時的快速修正則仰賴存取私人資料的能力。這種快速修正無法預測,因此無論您的設計有多好,都可能需要。
它可能會較慢
在原型鏈中存取屬性在目前的 JavaScript 引擎中經過高度最佳化。在封閉中存取值可能會較慢。但這些事情不斷改變,因此您必須衡量這是否真的對您的程式碼很重要。
它會消耗更多記憶體
保留環境並將特權方法放入執行個體會耗費記憶體。同樣地,請確定這是否真的對您的程式碼很重要並進行衡量。

具有標記金鑰的屬性中的私人資料

對於大多數非安全關鍵應用程式,隱私更像是對 API 用戶的提示:「您不需要看到這個。」這是封裝的主要好處,也就是隱藏複雜性。即使在幕後進行更多操作,您只需要了解 API 的公開部分。命名慣例的理念是讓用戶透過標記屬性的金鑰來了解隱私。前置底線通常用於此目的。

讓我們重新撰寫先前的StringBuilder範例,以便將緩衝區保存在屬性_buffer中,該屬性是私人的,但僅根據慣例

function StringBuilder() {
    this._buffer = [];
}
StringBuilder.prototype = {
    constructor: StringBuilder,
    add: function (str) {
        this._buffer.push(str);
    },
    toString: function () {
        return this._buffer.join('');
    }
};

以下是透過標記屬性金鑰進行隱私的優缺點

它提供更自然的編碼風格
能夠以相同的方式存取私人和公開資料比使用環境來進行隱私更為優雅。
它會汙染屬性的命名空間
具有標記金鑰的屬性可以在任何地方看到。越來越多的人使用 IDE,在公開屬性旁邊顯示這些屬性會越來越令人困擾,在應該隱藏它們的地方也是如此。理論上,IDE 可以透過辨識命名慣例並在可能的情況下隱藏私人屬性來進行調整。
私人屬性可以從「外部」存取
這對單元測試和快速修復很有用。此外,子建構函式和輔助函式(所謂的「友元函式」)可以從更輕鬆地存取私有資料中獲益。環境方法沒有提供這種靈活性;只能從建構函式內部存取私有資料。
它可能導致鍵衝突
私有屬性的鍵可能衝突。這對子建構函式來說已經是個問題,但如果你使用多重繼承(某些函式庫啟用),它會更成問題。使用環境方法時,永遠不會有任何衝突。

具有實體化鍵的屬性中的私有資料

使用私有屬性命名慣例的一個問題是鍵可能衝突(例如,建構函式的鍵與子建構函式的鍵衝突,或 mixin 的鍵與建構函式的鍵衝突)。你可以使用較長的鍵來降低此類衝突發生的機率,例如,包含建構函式的名稱。然後,在前述情況中,私有屬性 _buffer 會稱為 _StringBuilder_buffer。如果此類鍵對你來說太長,你可以選擇實體化它,將它儲存在變數中:

var KEY_BUFFER = '_StringBuilder_buffer';

我們現在透過 this[KEY_BUFFER] 存取私有資料

var StringBuilder = function () {
    var KEY_BUFFER = '_StringBuilder_buffer';

    function StringBuilder() {
        this[KEY_BUFFER] = [];
    }
    StringBuilder.prototype = {
        constructor: StringBuilder,
        add: function (str) {
            this[KEY_BUFFER].push(str);
        },
        toString: function () {
            return this[KEY_BUFFER].join('');
        }
    };
    return StringBuilder;
}();

我們在 StringBuilder 周圍包裝一個 IIFE,讓常數 KEY_BUFFER 保持在區域,且不會污染全域命名空間。

實體化屬性鍵讓你可以在鍵中使用 UUID(通用唯一識別碼)。例如,透過 Robert Kieffer 的 node-uuid

var KEY_BUFFER = '_StringBuilder_buffer_' + uuid.v4();

KEY_BUFFER 在每次執行程式碼時都有不同的值。例如,它可能如下所示

_StringBuilder_buffer_110ec58a-a0f2-4ac4-8393-c866d813b8d1

具有 UUID 的長鍵幾乎不可能發生鍵衝突。

透過 IIFE 保持全域資料私有

小節說明如何透過 IIFE(請參閱 透過 IIFE 介紹新的範圍),讓全域資料對單例物件、建構函式和方法保持私有。這些 IIFE 會建立新的環境(請參閱 環境:管理變數),這是你放置私有資料的地方。

將私有全域資料附加到單例物件

你不需要建構函式將物件與環境中的私有資料關聯起來。以下範例顯示如何使用 IIFE 達到相同的目的,方法是將它包裝在單例物件周圍:

var obj = function () {  // open IIFE

    // public
    var self = {
        publicMethod: function (...) {
            privateData = ...;
            privateFunction(...);
        },
        publicData: ...
    };

    // private
    var privateData = ...;
    function privateFunction(...) {
        privateData = ...;
        self.publicData = ...;
        self.publicMethod(...);
    }

    return self;
}(); // close IIFE

讓所有建構函式的全域資料保持私密

有些全域資料僅與建構函式和原型方法相關。透過將 IIFE 包裝在兩者周圍,您可以將其隱藏在公開檢視中。 具有具體化金鑰的屬性中的私密資料提供了一個範例:建構函式StringBuilder及其原型方法使用常數KEY_BUFFER,其中包含屬性金鑰。該常數儲存在 IIFE 的環境中

var StringBuilder = function () { // open IIFE
    var KEY_BUFFER = '_StringBuilder_buffer_' + uuid.v4();

    function StringBuilder() {
        this[KEY_BUFFER] = [];
    }
    StringBuilder.prototype = {
        // Omitted: methods accessing this[KEY_BUFFER]
    };
    return StringBuilder;
}(); // close IIFE

請注意,如果您使用模組系統(請參閱第 31 章),您可以透過將建構函式加上方法放入模組中,使用更簡潔的程式碼來達到相同的目的。

將全域資料附加至方法

有時您只需要針對單一方法使用全域資料。您可以透過將其放入您用來包裝方法的 IIFE 環境中,讓它保持私密。例如:

var obj = {
    method: function () {  // open IIFE

        // method-private data
        var invocCount = 0;

        return function () {
            invocCount++;
            console.log('Invocation #'+invocCount);
            return 'result';
        };
    }()  // close IIFE
};

以下是互動

> obj.method()
Invocation #1
'result'
> obj.method()
Invocation #2
'result'

第 4 層:建構函式之間的繼承

在本節中,我們將探討如何從建構函式繼承:給定建構函式Super,我們如何撰寫一個新的建構函式Sub,它具備Super的所有功能,以及它自己的部分功能?很遺憾,JavaScript 沒有內建機制來執行此任務。因此,我們必須進行一些手動工作。

圖 17-5說明了這個概念:子建構函式Sub應具備Super的所有屬性(原型屬性和執行個體屬性),以及它自己的屬性。因此,我們對於Sub的樣貌有粗略的概念,但不知道如何實現。有幾件事我們需要弄清楚,我將在下面說明

  • 繼承執行個體屬性。
  • 繼承原型屬性。
  • 確保 instanceof 運作:如果 subSub 的實例,我們也希望 sub instanceof Super 為真。
  • 覆寫方法,以調整 Sub 中的 Super 方法之一。
  • 進行 supercall:如果我們覆寫了 Super 的方法之一,我們可能需要從 Sub 呼叫原始方法。

繼承實例屬性

實例屬性會設定在 建構函式本身,因此繼承超建構函式的實例屬性涉及呼叫該建構函式:

function Sub(prop1, prop2, prop3, prop4) {
    Super.call(this, prop1, prop2);  // (1)
    this.prop3 = prop3;  // (2)
    this.prop4 = prop4;  // (3)
}

Sub 透過 new 呼叫時,它的隱式參數 this 參照一個新的實例。它首先將該實例傳遞給 Super (1),它會新增其實例屬性。之後,Sub 設定其自己的實例屬性 (2,3)。訣竅是不透過 new 呼叫 Super,因為那會建立一個新的超實例。相反地,我們將 Super 作為函式呼叫,並將目前的 (子) 實例作為 this 的值傳入。

繼承原型屬性

共用屬性(例如方法)會保留在實例原型中。因此,我們需要找到一個方法,讓 Sub.prototype 繼承所有 Super.prototype 的屬性。解決方案是讓 Sub.prototype 的原型為 Super.prototype

對兩種原型感到困惑嗎?

是的,JavaScript 的術語在此令人困惑。如果您感到迷失,請參閱 術語:兩個原型,它會說明它們的差異。

這是達成此目的的程式碼

Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.methodB = ...;
Sub.prototype.methodC = ...;

Object.create() 會產生一個新的物件,其原型為 Super.prototype。之後,我們會新增 Sub 的方法。如 實例的建構函式屬性 中所說明,我們也需要設定屬性 constructor,因為我們已經取代了原始實例原型,而它具有正確的值。

圖 17-6 顯示 SubSuper 現在的關聯方式。Sub 的結構確實類似我在 圖 17-5 中所繪製的內容。此圖表未顯示實例屬性,這些屬性是由圖表中提到的函式呼叫所設定的。

確保 instanceof 運作

「確保 instanceof 運作」表示 Sub 的每個實例也必須是 Super 的實例。圖 17-7 顯示 Sub 的實例 subInstance 的原型鏈的樣子:它的第一個原型是 Sub.prototype,而第二個原型是 Super.prototype

我們從一個較簡單的問題開始:subInstanceSub 的實例嗎?是的,因為以下兩個斷言是等效的(後者可被視為前者的定義)

subInstance instanceof Sub
Sub.prototype.isPrototypeOf(subInstance)

如前所述,Sub.prototypesubInstance 的原型之一,因此兩個斷言都是正確的。類似地,subInstance 也是 Super 的實例,因為以下兩個斷言成立

subInstance instanceof Super
Super.prototype.isPrototypeOf(subInstance)

覆寫方法

我們覆寫 Super.prototype 中的方法,方法名稱相同,新增到 Sub.prototype 中。 methodB 是個範例,在 圖 17-7 中,我們可以看到它的運作方式:尋找 methodB 會從 subInstance 開始,找到 Sub.prototype.methodB,在 Super.prototype.methodB 之前。

建立超呼叫

要了解超呼叫,您需要知道術語 主物件 方法的主物件是擁有屬性的物件,其值是方法。例如, Sub.prototype.methodB 的主物件是 Sub.prototype。超呼叫方法 foo 涉及三個步驟:

  1. 從(在)目前方法的主物件的原型「之後」開始搜尋。
  2. 尋找名稱為 foo 的方法。
  3. 使用目前的 this 呼叫該方法。理由是超方法必須與目前方法使用相同的執行個體;它必須能夠存取相同的執行個體屬性。

因此,子方法的程式碼如下所示。它超呼叫它自己,呼叫它已覆寫的方法

Sub.prototype.methodB = function (x, y) {
    var superResult = Super.prototype.methodB.call(this, x, y); // (1)
    return this.prop3 + ' ' + superResult;
}

在 (1) 處讀取超呼叫的一種方式如下:直接參照超方法,並使用目前的 this 呼叫它。但是,如果我們將它分成三部分,我們會找到上述步驟

  1. Super.prototype:在 Super.prototype 中開始搜尋,它是 Sub.prototype 的原型(目前方法 Sub.prototype.methodB 的主物件)。
  2. methodB:尋找名稱為 methodB 的方法。
  3. call(this, ...):呼叫在先前步驟中找到的方法,並維護目前的 this

避免對超建構函式名稱進行硬編碼

到目前為止,我們總是透過提及超建構函式名稱來參照超方法和超建構函式。這種硬編碼會讓您的程式碼較不靈活。您可以透過將超原型指定給 Sub 的屬性來避免它:

Sub._super = Super.prototype;

然後呼叫超級建構函式和超級方法如下所示

function Sub(prop1, prop2, prop3, prop4) {
    Sub._super.constructor.call(this, prop1, prop2);
    this.prop3 = prop3;
    this.prop4 = prop4;
}
Sub.prototype.methodB = function (x, y) {
    var superResult = Sub._super.methodB.call(this, x, y);
    return this.prop3 + ' ' + superResult;
}

設定 Sub._super 通常由一個實用函式處理,該函式也會將子原型連接到超級原型。例如

function subclasses(SubC, SuperC) {
    var subProto = Object.create(SuperC.prototype);
    // Save `constructor` and, possibly, other methods
    copyOwnPropertiesFrom(subProto, SubC.prototype);
    SubC.prototype = subProto;
    SubC._super = SuperC.prototype;
};

此程式碼使用輔助函式 copyOwnPropertiesFrom(),其顯示和說明於 複製物件

提示

將「子類別」視為動詞: SubC 子類別 SuperC。此類實用函式可以減輕建立子建構函式的部分負擔:手動執行的項目較少,而且從未重複提到超級建構函式的名稱。下列範例示範它如何簡化程式碼。

範例:使用建構函式繼承

作為具體範例,我們假設建構函式 Person 已存在:

function Person(name) {
    this.name = name;
}
Person.prototype.describe = function () {
    return 'Person called '+this.name;
};

現在我們要建立建構函式 Employee 作為 Person 的子建構函式。我們手動執行,如下所示

function Employee(name, title) {
    Person.call(this, name);
    this.title = title;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;
Employee.prototype.describe = function () {
    return Person.prototype.describe.call(this)+' ('+this.title+')';
};

以下是互動

> var jane = new Employee('Jane', 'CTO');
> jane.describe()
Person called Jane (CTO)
> jane instanceof Employee
true
> jane instanceof Person
true

前一節的實用函式 subclasses()Employee 的程式碼稍微簡單一些,並避免硬式編碼超級建構函式 Person

function Employee(name, title) {
    Employee._super.constructor.call(this, name);
    this.title = title;
}
Employee.prototype.describe = function () {
    return Employee._super.describe.call(this)+' ('+this.title+')';
};
subclasses(Employee, Person);

範例:內建建構函式的繼承階層

內建建構函式使用本節所述的相同子類別方法。例如, ArrayObject 的子建構函式。因此, Array 執行個體的原型鏈如下所示:

> var p = Object.getPrototypeOf

> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true

反模式:原型是超級建構函式的執行個體

在 ECMAScript 5 和 Object.create() 之前,一個常用的解決方案是透過呼叫超級建構函式來建立子原型:

Sub.prototype = new Super();  // Don’t do this

在 ECMAScript 5 中不建議這樣做。原型將擁有 Super 的所有執行個體屬性,但它沒有用途。因此,最好使用前述模式(包含 Object.create())。

所有物件的方法

幾乎所有物件在原型鏈中都有 Object.prototype

> Object.prototype.isPrototypeOf({})
true
> Object.prototype.isPrototypeOf([])
true
> Object.prototype.isPrototypeOf(/xyz/)
true

下列小節說明 Object.prototype 為其原型提供的方法。

轉換為原始值

以下兩個方法用於將物件轉換為原始值:

Object.prototype.toString()

傳回物件的字串表示形式

> ({ first: 'John', last: 'Doe' }.toString())
'[object Object]'
> [ 'a', 'b', 'c' ].toString()
'a,b,c'
Object.prototype.valueOf()

這是將物件轉換為數字的首選方式。預設實作傳回 this

> var obj = {};
> obj.valueOf() === obj
true

valueOf 會被包裝建構函式覆寫以傳回包裝的原始值

> new Number(7).valueOf()
7

轉換為數字和字串(無論是隱式或明確)建立在轉換為原始值之上(詳細資訊請參閱 演算法:ToPrimitive()—將值轉換為原始值)。這就是為什麼您可以使用上述兩個方法配置這些轉換。 valueOf() 是轉換為數字的首選

> 3 * { valueOf: function () { return 5 } }
15

toString() 是轉換為字串的首選

> String({ toString: function () { return 'ME' } })
'Result: ME'

轉換為布林值無法設定;物件總是會被視為 true(請參閱 轉換為布林值)。

原型繼承和屬性

下列方法有助於原型繼承 和屬性:

Object.prototype.isPrototypeOf(obj)

如果接收器是 obj 的原型鏈的一部分,則傳回 true

> var proto = { };
> var obj = Object.create(proto);
> proto.isPrototypeOf(obj)
true
> obj.isPrototypeOf(obj)
false
Object.prototype.hasOwnProperty(key)

如果 this 擁有一個其金鑰為 key 的屬性,則傳回 true。擁有表示屬性存在於物件本身中,而不是存在於其原型之一中。

警告

您通常應該以一般方式(而非直接方式)呼叫此方法,特別是在您無法靜態得知其屬性的物件上。原因和方法說明於 屬性的反覆運算和偵測

> var proto = { foo: 'abc' };
> var obj = Object.create(proto);
> obj.bar = 'def';

> Object.prototype.hasOwnProperty.call(obj, 'foo')
false
> Object.prototype.hasOwnProperty.call(obj, 'bar')
true
Object.prototype.propertyIsEnumerable(propKey)

如果接收器具有具有鍵 propKey 的屬性,則傳回 true,該屬性可列舉,否則傳回 false

> var obj = { foo: 'abc' };
> obj.propertyIsEnumerable('foo')
true
> obj.propertyIsEnumerable('toString')
false
> obj.propertyIsEnumerable('unknown')
false

泛型方法:從原型借用方法

有時,實例原型具有對比它們繼承的物件更實用的方法。本節說明如何使用原型的函式,而不用從原型繼承。例如,實例原型 Wine.prototype 具有函式 incAge()

function Wine(age) {
    this.age = age;
}
Wine.prototype.incAge = function (years) {
    this.age += years;
}

互動如下

> var chablis = new Wine(3);
> chablis.incAge(1);
> chablis.age
4

函式 incAge() 可用於具有屬性 age 的任何物件。我們如何對非 Wine 實例的物件呼叫它?讓我們看看前面的函式呼叫

chablis.incAge(1)

實際上有兩個參數

  1. chablis 是函式呼叫的接收器,透過 this 傳遞給 incAge
  2. 1 是參數,透過 years 傳遞給 incAge

我們無法用任意物件取代前者,接收器必須是 Wine 的實例。否則,找不到函式 incAge。但前面的函式呼叫等於(參閱 呼叫函式同時設定 this:call()、apply() 和 bind()

Wine.prototype.incAge.call(chablis, 1)

使用前面的模式,我們可以將物件設為接收器(call 的第一個參數),該物件不是 Wine 的實例,因為接收器不用於尋找函式 Wine.prototype.incAge。在以下範例中,我們將函式 incAge() 套用至物件 john

> var john = { age: 51 };
> Wine.prototype.incAge.call(john, 3)
> john.age
54

可以用這種方式的函式稱為泛型函式;它必須準備好 this 不是「其」建構函式的實例。因此,並非所有函式都是泛型的;ECMAScript 語言規範明確指出哪些函式是泛型的(請參閱 所有泛型函式的清單)。

透過字面值存取 Object.prototype 和 Array.prototype

以泛型方式呼叫函式相當冗長:

Object.prototype.hasOwnProperty.call(obj, 'propKey')

您可以透過空物件字面值 {} 建立的 Object 實例存取 hasOwnProperty,以縮短此程式碼

{}.hasOwnProperty.call(obj, 'propKey')

類似地,下列兩個表達式是等效的

Array.prototype.join.call(str, '-')
[].join.call(str, '-')

此模式的優點在於它較不冗長。但它也較不具自明性。效能不應成為問題(至少長期而言),因為引擎可以靜態地判斷文字不應建立物件。

呼叫方法的通用範例

以下是 通用方法的使用範例:

  • 使用 apply()(請參閱 Function.prototype.apply(thisValue, argArray))將陣列推入(而非個別元素;請參閱 新增和移除元素(破壞性)

    > var arr1 = [ 'a', 'b' ];
    > var arr2 = [ 'c', 'd' ];
    
    > [].push.apply(arr1, arr2)
    4
    > arr1
    [ 'a', 'b', 'c', 'd' ]

    此範例說明如何將陣列轉換為參數,而非從其他建構函數借用方法。

  • 將陣列方法 join() 套用至字串(非陣列)

    > Array.prototype.join.call('abc', '-')
    'a-b-c'
  • 將陣列方法 map() 套用至字串:[17]

    > [].map.call('abc', function (x) { return x.toUpperCase() })
    [ 'A', 'B', 'C' ]

    以通用方式使用 map() 比使用 split('') 更有效率,後者會建立中間陣列

    > 'abc'.split('').map(function (x) { return x.toUpperCase() })
    [ 'A', 'B', 'C' ]
  • 將字串方法套用至非字串。 toUpperCase() 會將接收器轉換為字串,並將結果轉換為大寫

    > String.prototype.toUpperCase.call(true)
    'TRUE'
    > String.prototype.toUpperCase.call(['a','b','c'])
    'A,B,C'

在一般物件上使用通用陣列方法,可讓您深入了解其運作方式

  • 在偽陣列上呼叫陣列方法

    > var fakeArray = { 0: 'a', 1: 'b', length: 2 };
    > Array.prototype.join.call(fakeArray, '-')
    'a-b'
  • 瞭解陣列方法如何轉換它視為陣列的物件

    > var obj = {};
    > Array.prototype.push.call(obj, 'hello');
    1
    > obj
    { '0': 'hello', length: 1 }

類陣列物件和通用方法

JavaScript 中有些物件感覺像是陣列,但實際上並非如此。 這表示它們雖然有索引存取和 length 屬性,但沒有任何陣列方法(forEach()pushconcat() 等)。這很不幸,但正如我們所見,通用陣列方法可提供解決方法。類陣列物件的範例包括:

  • 特殊變數 arguments(請參閱 依索引取得所有參數:特殊變數 arguments),它是一個重要的類陣列物件,因為它是 JavaScript 中的基本組成部分。 arguments 看起來像陣列

    > function args() { return arguments }
    > var arrayLike = args('a', 'b');
    
    > arrayLike[0]
    'a'
    > arrayLike.length
    2

    但沒有任何陣列方法可用

    > arrayLike.join('-')
    TypeError: object has no method 'join'

    這是因為 arrayLike 不是 Array 的實例(而且 Array.prototype 不在原型鏈中)

    > arrayLike instanceof Array
    false
  • 瀏覽器 DOM 節點清單,由 document.getElementsBy*()(例如 getElementsByTagName())、document.forms 等傳回

    > var elts = document.getElementsByTagName('h3');
    > elts.length
    3
    > elts instanceof Array
    false
  • 字串,也是類陣列

    > 'abc'[1]
    'b'
    > 'abc'.length
    3

術語 類陣列 也可視為通用陣列方法與物件之間的合約。物件必須符合特定需求;否則,方法無法對其運作。需求如下

  • 類陣列物件的元素必須可透過方括號和從 0 開始的整數索引來存取。所有方法都需要讀取權限,而有些方法額外需要寫入權限。請注意,所有物件都支援這種索引:括號中的索引會轉換為字串,並用作尋找屬性值的關鍵字

    > var obj = { '0': 'abc' };
    > obj[0]
    'abc'
  • 類陣列物件必須有 length 屬性,其值為其元素的數量。有些方法需要 length 是可變的(例如 reverse())。長度不可變的值(例如字串)無法與這些方法搭配使用。

處理類陣列物件的模式

下列模式 可用於處理類陣列物件:

  • 將類陣列物件轉換為陣列

    var arr = Array.prototype.slice.call(arguments);

    方法 slice()(請參閱 串接、切片、合併(非破壞性))在沒有任何參數的情況下,會建立類陣列接收者的副本

    var copy = [ 'a', 'b' ].slice();
  • 若要反覆處理類陣列物件的所有元素,可以使用簡單的 for 迴圈

    function logArgs() {
        for (var i=0; i<arguments.length; i++) {
            console.log(i+'. '+arguments[i]);
        }
    }

    但您也可以借用 Array.prototype.forEach()

    function logArgs() {
        Array.prototype.forEach.call(arguments, function (elem, i) {
            console.log(i+'. '+elem);
        });
    }

    在兩種情況下,互動如下所示

    > logArgs('hello', 'world');
    0. hello
    1. world

所有通用方法的清單

下列 清單包含所有通用方法,如 ECMAScript 語言規格中所述:

  • Array.prototype(請參閱 陣列原型方法

    • concat
    • every
    • filter
    • forEach
    • indexOf
    • join
    • lastIndexOf
    • map
    • pop
    • push
    • reduce
    • reduceRight
    • reverse
    • shift
    • slice
    • some
    • sort
    • splice
    • toLocaleString
    • toString
    • unshift
  • Date.prototype(請參閱 日期原型方法

    • toJSON
  • Object.prototype(請參閱 所有物件的方法

    • (所有 Object 方法都是自動泛用的,必須適用於所有物件。)
  • String.prototype(請參閱 字串原型方法

    • charAt
    • charCodeAt
    • concat
    • indexOf
    • lastIndexOf
    • localeCompare
    • match
    • replace
    • search
    • slice
    • split
    • substring
    • toLocaleLowerCase
    • toLocaleUpperCase
    • toLowerCase
    • toUpperCase
    • trim

陷阱:將物件用作映射

由於 JavaScript 沒有內建的映射資料結構,物件通常用作從字串到值的映射。唉呀,那比看起來更常出錯。本節說明此任務中涉及的三個陷阱。

陷阱 1:繼承會影響讀取屬性

讀取屬性的操作可以 區分為兩種:

  • 有些操作會考慮整個原型鏈,並看到繼承的屬性。
  • 其他操作只會存取物件的 自己的(非繼承的)屬性。

讀取物件當作映射的項目時,您需要在這些類型的操作之間仔細選擇。要了解原因,請考慮以下範例

var proto = { protoProp: 'a' };
var obj = Object.create(proto);
obj.ownProp = 'b';

obj 是具有單一自有屬性的物件,其原型是 proto,它也有一個自有屬性。 proto 的原型是 Object.prototype,就像所有由物件文字建立的物件一樣。因此,objprotoObject. prototype 繼承屬性。

我們希望將 obj 解釋為具有單一項目的映射

ownProp: 'b'

也就是說,我們想要忽略繼承的屬性,只考慮自有屬性。讓我們看看哪些讀取操作會以這種方式詮釋 obj,哪些不會。請注意,對於物件作為映射,我們通常想要使用儲存在變數中的任意屬性鍵。這排除了點表示法。

檢查屬性是否存在

in 算子檢查物件 是否具有具有給定鍵的屬性,但它會考慮繼承的屬性:

> 'ownProp' in obj  // ok
true
> 'unknown' in obj  // ok
false
> 'toString' in obj  // wrong, inherited from Object.prototype
true
> 'protoProp' in obj  // wrong, inherited from proto
true

我們需要檢查以忽略繼承的屬性。 hasOwnProperty() 會執行我們想要的操作

> obj.hasOwnProperty('ownProp')  // ok
true
> obj.hasOwnProperty('unknown')  // ok
false
> obj.hasOwnProperty('toString')  // ok
false
> obj.hasOwnProperty('protoProp')  // ok
false

收集屬性鍵

我們可以使用哪些操作來尋找 obj 的所有鍵,同時尊重我們將其解釋為映射? for-in 看起來可能有效。但是,唉,它沒有:

> for (propKey in obj) console.log(propKey)
ownProp
protoProp

它會考慮繼承的可列舉屬性。 Object.prototype 的所有屬性都沒有顯示在這裡的原因是它們都是不可列舉的。

相反, Object.keys() 僅列出自有屬性

> Object.keys(obj)
[ 'ownProp' ]

此方法僅傳回可列舉的自有屬性; ownProp 已透過指定新增,因此預設為可列舉。如果您想要列出所有自有屬性,您需要使用 Object.getOwnPropertyNames()

取得屬性值

對於讀取屬性的值,我們只能在點運算子與方括號運算子之間進行選擇。我們無法使用前者,因為我們有任意鍵,儲存在變數中。這讓我們只剩下方括號運算子,它會考慮繼承的屬性:

> obj['toString']
[Function: toString]

這不是我們想要的。沒有內建的操作可以僅讀取自有屬性,但您可以輕鬆地自己實作一個

function getOwnProperty(obj, propKey) {
    // Using hasOwnProperty() in this manner is problematic
    // (explained and fixed later)
    return (obj.hasOwnProperty(propKey)
            ? obj[propKey] : undefined);
}

使用該函式,繼承的屬性 toString 會被忽略

> getOwnProperty(obj, 'toString')
undefined

陷阱 2:覆寫會影響呼叫方法

函式 getOwnProperty()obj 上呼叫方法 hasOwnProperty()。通常,這很好

> getOwnProperty({ foo: 123 }, 'foo')
123

但是,如果您新增一個鍵為 hasOwnProperty 的屬性到 obj,則該屬性會覆寫方法 Object.prototype.hasOwnProperty(),而 getOwnProperty() 會停止運作

> getOwnProperty({ hasOwnProperty: 123 }, 'foo')
TypeError: Property 'hasOwnProperty' is not a function

您可以透過直接參照 hasOwnProperty() 來解決此問題。這避免透過 obj 來尋找它

function getOwnProperty(obj, propKey) {
    return (Object.prototype.hasOwnProperty.call(obj, propKey)
            ? obj[propKey] : undefined);
}

我們已呼叫 hasOwnProperty() 為泛型 (請參閱 泛型方法:從原型借用方法)。

陷阱 3:特殊屬性 __proto__

在許多 JavaScript 引擎中,屬性 __proto__ (請參閱 特殊屬性 __proto__) 是特殊的:取得它會擷取物件的原型,而設定它會變更物件的原型。這就是為什麼物件無法將對應資料儲存在其金鑰為 '__proto__' 的屬性中。如果您想要允許對應金鑰 '__proto__',您必須在將其用作屬性金鑰之前對其進行跳脫:

function get(obj, key) {
    return obj[escapeKey(key)];
}
function set(obj, key, value) {
    obj[escapeKey(key)] = value;
}
// Similar: checking if key exists, deleting an entry

function escapeKey(key) {
    if (key.indexOf('__proto__') === 0) {  // (1)
        return key+'%';
    } else {
        return key;
    }
}

我們還需要跳脫 '__proto__' (等) 的跳脫版本,以避免衝突;也就是說,如果我們將金鑰 '__proto__' 跳脫為 '__proto__%',那麼我們也需要跳脫金鑰 '__proto__%',這樣它才不會取代 '__proto__' 項目。這就是第 (1) 行中所發生的情況。

Mark S. Miller 在 電子郵件 中提到了此陷阱的實際影響

認為此練習是學術性的,且不會出現在實際系統中嗎?正如在支援執行緒中所觀察到的,直到最近,在所有非 IE 瀏覽器上,如果您在新的 Google 文件開頭輸入「__proto__」,您的 Google 文件就會當掉。這被追蹤到將物件當成字串對應使用時出現的錯誤。

dict 模式:沒有原型的物件是更好的對應

您可以建立一個沒有原型的物件 如下所示:

var dict = Object.create(null);

此類物件是一個比一般物件更好的對應 (字典),這就是為什麼此模式有時稱為 dict 模式 (dict 代表 dictionary)。讓我們先檢查一般物件,然後找出為什麼沒有原型的物件是更好的對應。

一般物件

通常,您在 JavaScript 中建立的每個物件至少在它的原型鏈中具有 Object.prototypeObject.prototype 的原型是 null,因此大多數原型鏈都會在此結束

> Object.getPrototypeOf({}) === Object.prototype
true
> Object.getPrototypeOf(Object.prototype)
null

沒有原型的物件

沒有原型的物件具有兩個優點,可用作對應

唯一的缺點是,你將會失去 Object.prototype 所提供的服務。例如,一個字典物件無法再自動轉換成字串了

> console.log('Result: '+obj)
TypeError: Cannot convert object to primitive value

但這並不是真正的缺點,因為直接呼叫字典物件的方法本來就不安全。

建議

使用字典模式進行快速修改,並作為函式庫的基礎。在(非函式庫)生產程式碼中,函式庫比較好,因為你可以確定避免所有陷阱。下一節列出幾個這樣的函式庫。

秘笈:使用物件

本節是一個快速參考,提供更詳細說明的指標。



[17] 以這種方式使用 map() 是 Brandon Benvie (@benvie) 的建議。

下一頁:18. 陣列