14. 類別之外的新 OOP 功能
目錄
請支持本書:購買(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

14. 類別之外的新 OOP 功能

類別(在下一章中說明)是 ECMAScript 6 中主要的新 OOP 功能。不過,它也包含物件文字的新功能和 Object 中的新工具方法。本章將說明這些功能。



14.1 概觀

14.1.1 新物件文字功能

方法定義

const obj = {
    myMethod(x, y) {
        ···
    }
};

屬性值簡寫

const first = 'Jane';
const last = 'Doe';

const obj = { first, last };
// Same as:
const obj = { first: first, last: last };

計算屬性鍵

const propKey = 'foo';
const obj = {
    [propKey]: true,
    ['b'+'ar']: 123
};

這個新的語法也可以用於方法定義

const obj = {
    ['h'+'ello']() {
        return 'hi';
    }
};
console.log(obj.hello()); // hi

計算屬性鍵的主要用例是讓符號容易用作屬性鍵。

14.1.2 Object 中的新方法

Object 最重要的新方法是 assign()。傳統上,這個功能在 JavaScript 世界中稱為 extend()。與這個經典操作運作方式相反,Object.assign() 僅考慮自己的(非繼承的)屬性。

const obj = { foo: 123 };
Object.assign(obj, { bar: true });
console.log(JSON.stringify(obj));
    // {"foo":123,"bar":true}

14.2 物件文字的新功能

14.2.1 方法定義

在 ECMAScript 5 中,方法是其值為函式的屬性

var obj = {
    myMethod: function (x, y) {
        ···
    }
};

在 ECMAScript 6 中,方法仍然是函式值屬性,但現在有一個更簡潔的方法來定義它們

const obj = {
    myMethod(x, y) {
        ···
    }
};

Getter 和 setter 繼續像在 ECMAScript 5 中那樣工作(注意它們在語法上與方法定義有多麼相似)

const obj = {
    get foo() {
        console.log('GET foo');
        return 123;
    },
    set bar(value) {
        console.log('SET bar to '+value);
        // return value is ignored
    }
};

讓我們使用 obj

> obj.foo
GET foo
123
> obj.bar = true
SET bar to true
true

還有一種簡潔定義其值為產生器函式的屬性的方法

const obj = {
    * myGeneratorMethod() {
        ···
    }
};

此程式碼等於

const obj = {
    myGeneratorMethod: function* () {
        ···
    }
};

14.2.2 屬性值簡寫

屬性值簡寫讓您縮寫物件文字中屬性的定義:如果指定屬性值的變數名稱也是屬性鍵,則可以省略該鍵。如下所示。

const x = 4;
const y = 1;
const obj = { x, y };

最後一行等於

const obj = { x: x, y: y };

屬性值簡寫與解構一起使用效果很好

const obj = { x: 4, y: 1 };
const {x,y} = obj;
console.log(x); // 4
console.log(y); // 1

屬性值簡寫的一個用例是多個回傳值(在解構章節中說明)。

14.2.3 計算屬性鍵

請記住,設定屬性時有兩種指定鍵的方法。

  1. 透過固定名稱:obj.foo = true;
  2. 透過表達式:obj['b'+'ar'] = 123;

在物件文字中,在 ECMAScript 5 中,你只有選項 #1。ECMAScript 6 另外提供選項 #2(A 行)

const obj = {
    foo: true,
    ['b'+'ar']: 123
};

這個新的語法也可以用於方法定義

const obj = {
    ['h'+'ello']() {
        return 'hi';
    }
};
console.log(obj.hello()); // hi

計算屬性金鑰的主要使用案例是符號:你可以定義一個公開符號,並將其用作永遠唯一的特殊屬性金鑰。一個突出的範例是儲存在 Symbol.iterator 中的符號。如果一個物件有一個具有該金鑰的方法,它就會變成 可迭代的:該方法必須傳回一個迭代器,而 for-of 迴圈等建構使用該迭代器來迭代物件。以下程式碼示範它是如何運作的。

const obj = {
    * [Symbol.iterator]() { // (A)
        yield 'hello';
        yield 'world';
    }
};
for (const x of obj) {
    console.log(x);
}
// Output:
// hello
// world

obj 是可迭代的,因為 產生器方法定義 從 A 行開始。

14.3 Object 的新方法

14.3.1 Object.assign(target, source_1, source_2, ···)

此方法會將來源合併到目標中:它會修改 target,首先將 source_1 的所有可列舉的 自己的(非繼承的)屬性複製到其中,然後是 source_2 的所有自己的屬性,依此類推。最後,它會傳回目標。

const obj = { foo: 123 };
Object.assign(obj, { bar: true });
console.log(JSON.stringify(obj));
    // {"foo":123,"bar":true}

讓我們更仔細地看看 Object.assign() 如何運作

14.3.1.1 複製所有自己的屬性

以下是你可以複製 所有 自己的屬性(不只是可列舉的屬性)的方法,同時正確地傳輸 getter 和 setter,而且不會在目標上呼叫 setter

function copyAllOwnProperties(target, ...sources) {
    for (const source of sources) {
        for (const key of Reflect.ownKeys(source)) {
            const desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
    return target;
}

請參閱「Speaking JavaScript」中的「屬性屬性和屬性描述符」章節,以取得更多關於屬性描述符(如 Object.getOwnPropertyDescriptor()Object.defineProperty() 所使用)的資訊。

14.3.1.2 警告:Object.assign() 不適用於移動方法

一方面,您無法移動使用 super 的方法:此類方法具有內部插槽 [[HomeObject]],將其繫結到建立它的物件。如果您透過 Object.assign() 移動它,它將繼續參照原始物件的 super 屬性。詳細說明請參閱 類別章節中的部分

另一方面,如果您將物件文字建立的方法移至類別的原型,則可列舉性會錯誤。前者方法皆可列舉(否則 Object.assign() 無法看到它們),但原型通常只有不可列舉的方法。

14.3.1.3 Object.assign() 的使用案例

讓我們來看幾個使用案例。

14.3.1.3.1 將屬性新增至 this

您可以在建構函式中使用 Object.assign() 將屬性新增至 this

class Point {
    constructor(x, y) {
        Object.assign(this, {x, y});
    }
}
14.3.1.3.2 提供物件屬性的預設值

Object.assign() 也可用於填入遺失屬性的預設值。在以下範例中,我們有一個具有屬性預設值的物件 DEFAULTS 和一個具有資料的物件 options

const DEFAULTS = {
    logLevel: 0,
    outputFormat: 'html'
};
function processContent(options) {
    options = Object.assign({}, DEFAULTS, options); // (A)
    ···
}

在 A 行,我們建立一個新的物件,將預設值複製到其中,然後將 options 複製到其中,覆寫預設值。Object.assign() 傳回這些作業的結果,我們將其指定給 options

14.3.1.3.3 將方法新增至物件

另一個使用案例是將方法新增至物件

Object.assign(SomeClass.prototype, {
    someMethod(arg1, arg2) {
        ···
    },
    anotherMethod() {
        ···
    }
});

您也可以手動指定函式,但這樣您就沒有好的方法定義語法,而且每次都需要提到 SomeClass.prototype

SomeClass.prototype.someMethod = function (arg1, arg2) {
    ···
};
SomeClass.prototype.anotherMethod = function () {
    ···
};
14.3.1.3.4 複製物件

Object.assign() 的最後一個使用案例是快速複製物件

function clone(orig) {
    return Object.assign({}, orig);
}

這種複製方式也有點髒,因為它不會保留 orig 的屬性屬性。如果您需要這樣,您必須使用屬性描述符,就像我們實作 copyAllOwnProperties() 時所做的那樣。

如果您希望複製具有與原始物件相同的原型,您可以使用 Object.getPrototypeOf()Object.create()

function clone(orig) {
    const origProto = Object.getPrototypeOf(orig);
    return Object.assign(Object.create(origProto), orig);
}

14.3.2 Object.getOwnPropertySymbols(obj)

Object.getOwnPropertySymbols(obj) 擷取 obj 的所有自有(非繼承)符號值屬性金鑰。它補充了 Object.getOwnPropertyNames(),後者擷取所有字串值自有屬性金鑰。請參閱後面的章節,以取得關於如何遍歷屬性的更多詳細資訊。

14.3.3 Object.is(value1, value2)

嚴格相等運算子 (===) 以與預期不同的方式處理兩個值。

首先,NaN 不等於它自己。

> NaN === NaN
false

這很不幸,因為它常常會阻止我們偵測 NaN

> [0,NaN,2].indexOf(NaN)
-1

其次,JavaScript 有兩個零,但嚴格相等會將它們視為相同的值。

> -0 === +0
true

這麼做通常是件好事。

Object.is() 提供了一種比較值的方式,其比 === 更精確。它的運作方式如下:

> Object.is(NaN, NaN)
true
> Object.is(-0, +0)
false

其他所有內容都與 === 相同。

14.3.3.1 使用 Object.is() 尋找陣列元素

在以下函式 myIndexOf() 中,我們將 Object.is() 與新的 ES6 陣列方法findIndex() 結合使用,以在陣列中尋找 NaN

function myIndexOf(arr, elem) {
    return arr.findIndex(x => Object.is(x, elem));
}

const myArray = [0,NaN,2];
myIndexOf(myArray, NaN); // 1
myArray.indexOf(NaN); // -1

如您在最後一行中所見,indexOf() 找不到 NaN

14.3.4 Object.setPrototypeOf(obj, proto)

此方法將 obj 的原型設定為 proto。ECMAScript 5 中的非標準做法(許多引擎支援)是透過指派給特殊屬性 __proto__ 來進行。設定原型的建議方式與 ECMAScript 5 中相同:在建立物件時,透過 Object.create()。這總是會比先建立一個物件,然後設定其原型更快。顯然地,如果您想變更現有物件的原型,這就不管用了。

14.4 在 ES6 中遍歷屬性

14.4.1 五個遍歷屬性的運算

在 ECMAScript 6 中,屬性的金鑰可以是字串或符號。以下是遍歷物件 obj 的屬性金鑰的五個運算:

14.4.2 屬性的遍歷順序

ES6 定義了屬性的兩個遍歷順序。

自己的屬性金鑰

可列舉的自己的名稱

for-in 遍歷屬性的順序未定義。 引用 Allen Wirfs-Brock

從歷史上看,for-in 順序未定義,瀏覽器實作在它們產生的順序(和其他具體事項)中有所不同。ES5 新增了 Object.keys 和它應該與 for-in 以相同順序排序金鑰的要求。在 ES5 和 ES6 的開發過程中,考慮定義一個特定的 for-in 順序,但由於網路舊版相容性問題和不確定瀏覽器是否願意更改它們目前產生的排序,因此未採用。

14.4.2.1 整數索引

即使您透過整數索引存取陣列元素,但規格會將它們視為一般的字串屬性金鑰

const arr=['a', 'b', 'c'];

console.log(arr['0']); // 'a'

// Operand 0 of [] is coerced to string:
console.log(arr[0]); // 'a'

整數索引只有兩種特殊情況:它們會影響陣列的length,而且在列出屬性金鑰時,它們會優先列出。

粗略地說,整數索引是一個字串,如果轉換成 53 位元的非負整數再轉回來,會得到相同的數值。因此

進一步閱讀

14.4.2.2 範例

下列程式碼示範「自有屬性金鑰」的遍歷順序

const obj = {
    [Symbol('first')]: true,
    '02': true,
    '10': true,
    '01': true,
    '2': true,
    [Symbol('second')]: true,
};
Reflect.ownKeys(obj);
    // [ '2', '10', '02', '01',
    //   Symbol('first'), Symbol('second') ]

說明

14.4.2.3 為什麼規格會標準化屬性金鑰的傳回順序?

Tab Atkins Jr. 的回答

因為至少對於物件來說,所有實作都使用大致相同的順序(符合目前的規格),而且許多程式碼無意間寫成依賴於該順序,如果以不同的順序列舉,就會中斷。由於瀏覽器必須實作這個特定順序才能與網路相容,因此將其指定為需求。

曾討論過在 Maps/Sets 中打破這個順序,但這麼做需要我們指定一個程式碼不可能依賴的順序;換句話說,我們必須強制順序是隨機的,而不能只是未指定。這被認為太費力,而且建立順序相當有價值(例如,請參閱 Python 中的 OrderedDict),因此決定讓 Maps 和 Sets 與 Objects 相符。

14.4.2.4 規格中屬性的順序

規格中下列部分與本節相關

14.5 指派與定義屬性

有兩種類似的方式可以將屬性 prop 加入物件 obj

有三個情況指派不會建立自有屬性 prop – 即使它尚未存在

  1. 原型鏈中存在唯讀屬性 prop。然後,指派會在嚴格模式中導致 TypeError
  2. 原型鏈中存在 prop 的設定器。然後,會呼叫該設定器。
  3. 原型鏈中存在沒有設定器的 prop 的取得器。然後,會在嚴格模式中擲出 TypeError。此情況類似於第一個情況。

這些情況都不會阻止 Object.defineProperty() 建立自有屬性。下一個章節會更詳細地探討情況 #3。

14.5.1 覆寫繼承的唯讀屬性

如果物件 obj 繼承了唯讀屬性 prop,則無法指派給該屬性

const proto = Object.defineProperty({}, 'prop', {
    writable: false,
    configurable: true,
    value: 123,
});
const obj = Object.create(proto);
obj.prop = 456;
    // TypeError: Cannot assign to read-only property

這類似於繼承屬性的運作方式,該屬性具有取得器,但沒有設定器。這符合將指派視為變更繼承屬性的值。它以非破壞性的方式進行:原始值不會被修改,而是被新建立的自有屬性覆寫。因此,繼承的唯讀屬性和繼承的沒有設定器的屬性都會阻止透過指派進行變更。不過,您可以透過定義屬性來強制建立自有屬性

const proto = Object.defineProperty({}, 'prop', {
    writable: false,
    configurable: true,
    value: 123,
});
const obj = Object.create(proto);
Object.defineProperty(obj, 'prop', {value: 456});
console.log(obj.prop); // 456

14.6 ECMAScript 6 中的 __proto__

屬性 __proto__(發音為「dunder proto」)已存在於大多數 JavaScript 引擎中一段時間。本節說明它在 ECMAScript 6 之前是如何運作的,以及 ECMAScript 6 有哪些變更。

對於本節,如果您知道原型鏈是什麼,將會有幫助。如有必要,請參閱「Speaking JavaScript」中的章節「第 2 層:物件之間的原型關係」。

14.6.1 ECMAScript 6 之前的 __proto__

14.6.1.1 原型

JavaScript 中的每個物件都會開始一個包含一個或多個物件的鏈,稱為原型鏈。每個物件都透過內部插槽 [[Prototype]](如果沒有繼承者,則為 null)指向其繼承者,即其原型。該插槽稱為內部,因為它只存在於語言規範中,且無法直接從 JavaScript 存取。在 ECMAScript 5 中,取得物件 obj 的原型 p 的標準方法是

var p = Object.getPrototypeOf(obj);

沒有標準方法可以變更現有物件的原型,但您可以建立一個具有給定原型 p 的新物件 obj

var obj = Object.create(p);
14.6.1.2 __proto__

很久以前,Firefox 取得了非標準屬性 __proto__。由於其普及性,其他瀏覽器最終也複製了該功能。

在 ECMAScript 6 之前,__proto__ 以模糊的方式運作

14.6.1.3 透過 __proto__ 建立 Array 的子類別

__proto__ 之所以變得普及的主要原因,是因為它啟用了在 ES5 中建立 Array 的子類別 MyArray 的唯一方法:Array 實例是無法透過一般建構函式建立的特殊物件。因此,使用了以下技巧

function MyArray() {
    var instance = new Array(); // exotic object
    instance.__proto__ = MyArray.prototype;
    return instance;
}
MyArray.prototype = Object.create(Array.prototype);
MyArray.prototype.customMethod = function (···) { ··· };

ES6 中的子類別 的運作方式與 ES5 中不同,並支援開箱即用的內建子類別。

14.6.1.4 為何 __proto__ 在 ES5 中有問題

主要問題是 __proto__ 混用兩個層級:物件層級(一般屬性,持有資料)和元層級。

如果你不小心將 __proto__ 當成一般屬性(物件層級!)來使用,用來儲存資料,你會遇到麻煩,因為這兩個層級會衝突。情況會更複雜,因為在 ES5 中你必須將物件當成映射來使用,因為它沒有內建的資料結構可用於此目的。映射應該可以持有任意鍵,但你無法在物件當映射使用時使用鍵 '__proto__'

理論上,你可以使用符號取代特殊名稱 __proto__ 來解決問題,但將元機制完全分開(如透過 Object.getPrototypeOf() 所做的那樣)是最好的方法。

14.6.2 ECMAScript 6 中兩種 __proto__

由於 __proto__ 獲得廣泛支援,因此決定將其行為標準化為 ECMAScript 6。然而,由於其有問題的性質,它被新增為已棄用的功能。這些功能位於 ECMAScript 規格的附錄 B 中,其說明如下

當 ECMAScript 主機是網路瀏覽器時,需要此附錄中定義的 ECMAScript 語言語法和語意。如果 ECMAScript 主機不是網路瀏覽器,此附錄的內容為規範性但為選用。

JavaScript 有許多不受歡迎的功能,但網路上的大量程式碼需要這些功能。因此,網路瀏覽器必須實作這些功能,但其他 JavaScript 引擎則不必。

為了說明 __proto__ 背後的魔法,ES6 中引入了兩個機制

14.6.2.1 Object.prototype.__proto__

ECMAScript 6 允許透過儲存在 Object.prototype 中的 getter 和 setter 來取得和設定屬性 __proto__。如果你要手動實作它們,大致上會像這樣

Object.defineProperty(Object.prototype, '__proto__', {
    get() {
        const _thisObj = Object(this);
        return Object.getPrototypeOf(_thisObj);
    },
    set(proto) {
        if (this === undefined || this === null) {
            throw new TypeError();
        }
        if (!isObject(this)) {
            return undefined;
        }
        if (!isObject(proto)) {
            return undefined;
        }
        const status = Reflect.setPrototypeOf(this, proto);
        if (! status) {
            throw new TypeError();
        }
        return undefined;
    },
});
function isObject(value) {
    return Object(value) === value;
}
14.6.2.2 物件文字中作為運算子的屬性金鑰 __proto__

如果 __proto__ 出現在物件文字中未加引號或加引號的屬性金鑰中,則由該文字建立的物件的原型會設定為屬性值

> Object.getPrototypeOf({ __proto__: null })
null
> Object.getPrototypeOf({ '__proto__': null })
null

使用字串值 '__proto__' 作為計算屬性金鑰不會變更原型,而是會建立一個自有屬性

> const obj = { ['__proto__']: null };
> Object.getPrototypeOf(obj) === Object.prototype
true
> Object.keys(obj)
[ '__proto__' ]

14.6.3 避免 __proto__ 的魔法

14.6.3.1 定義 (而非指定) __proto__

在 ECMAScript 6 中,如果您定義自有屬性 __proto__,則不會觸發任何特殊功能,而 getter/setter Object.prototype.__proto__ 會被覆寫

const obj = {};
Object.defineProperty(obj, '__proto__', { value: 123 })

Object.keys(obj); // [ '__proto__' ]
console.log(obj.__proto__); // 123
14.6.3.2 原型不是 Object.prototype 的物件

__proto__ getter/setter 是透過 Object.prototype 提供的。因此,原型鏈中沒有 Object.prototype 的物件也沒有 getter/setter。在以下程式碼中,dict 是此類物件的範例,它沒有原型。因此,__proto__ 現在就像任何其他屬性一樣運作

> const dict = Object.create(null);
> '__proto__' in dict
false
> dict.__proto__ = 'abc';
> dict.__proto__
'abc'
14.6.3.3 __proto__ 和 dict 物件

如果您想使用物件作為字典,最好讓它沒有原型。這就是為什麼沒有原型的物件也稱為dict 物件。在 ES6 中,您甚至不必跳脫 dict 物件的屬性金鑰 '__proto__',因為它不會觸發任何特殊功能。

__proto__ 作為物件文字中的運算子,讓您可以更簡潔地建立 dict 物件

const dictObj = {
    __proto__: null,
    yes: true,
    no: false,
};

請注意,在 ES6 中,您通常應該偏好內建資料結構 Map而非 dict 物件,特別是在金鑰沒有固定的情況下。

14.6.3.4 __proto__ 和 JSON

在 ES6 之前,JavaScript 引擎可能會發生以下情況

> JSON.parse('{"__proto__": []}') instanceof Array
true

由於 __proto__ 在 ES6 中是 getter/setter,因此 JSON.parse() 運作良好,因為它定義屬性,而不是指定屬性 (如果實作正確,舊版的 V8 會指定)。

JSON.stringify() 也不會受到 __proto__ 的影響,因為它只考慮自有屬性。名稱為 __proto__ 的自有屬性的物件運作良好

> JSON.stringify({['__proto__']: true})
'{"__proto__":true}'

14.6.4 偵測對 ES6 風格 __proto__ 的支援

對 ES6 風格 __proto__ 的支援因引擎而異。請參閱 kangax 的 ECMAScript 6 相容性表格,以取得現狀資訊

以下兩個區段說明如何以程式方式偵測引擎是否支援這兩種 __proto__

14.6.4.1 功能:__proto__ 作為 getter/setter

getter/setter 的簡單檢查

var supported = {}.hasOwnProperty.call(Object.prototype, '__proto__');

更精密的檢查

var desc = Object.getOwnPropertyDescriptor(Object.prototype, '__proto__');
var supported = (
    typeof desc.get === 'function' && typeof desc.set === 'function'
);
14.6.4.2 功能:__proto__ 作為物件文字中的運算子

您可以使用以下檢查

var supported = Object.getPrototypeOf({__proto__: null}) === null;

14.6.5 __proto__ 發音為「dunder proto」

在 Python 中,以雙底線括住名稱是常見做法,以避免元資料(例如 __proto__)和資料(使用者定義的屬性)之間的名稱衝突。這種做法在 JavaScript 中永遠不會變得普遍,因為它現在有符號來達成這個目的。然而,我們可以參考 Python 社群,了解如何發音雙底線。

Ned Batchelder 建議 以下發音

在 Python 中編程的尷尬之處:有很多雙底線。例如,語法糖底下的標準方法名稱有 __getattr__、建構函式是 __init__、內建運算子可以使用 __add__ 來覆寫,等等。[…]

我對雙底線的問題是,它很難發音。您如何發音 __init__?「底線底線 init 底線底線」?「底底 init 底底」?只說「init」似乎遺漏了某些重要的東西。

我有一個解決方案:雙底線應發音為「dunder」。因此,__init__ 是「dunder init dunder」,或僅為「dunder init」。

因此,__proto__ 發音為「dunder proto」。這種發音方式被採用的機率很高,JavaScript 創造者 Brendan Eich 就使用這種發音。

14.6.6 __proto__ 的建議

ES6 將 __proto__ 從模糊的東西轉變成容易理解的東西,這一點很好。

然而,我仍然建議不要使用它。它實際上是一個已棄用的功能,且不屬於核心標準的一部分。您不能依賴它存在於必須在所有引擎上執行的程式碼中。

更多建議

14.7 ECMAScript 6 中的可列舉性

可列舉性是物件屬性的屬性。本節說明它在 ECMAScript 6 中如何運作。我們先來探討什麼是屬性。

14.7.1 屬性屬性

每個物件都有零個或更多個屬性。每個屬性都有金鑰和三個或更多個屬性,稱為儲存屬性資料的槽(換句話說,屬性本身很像 JavaScript 物件或像資料庫中的具有欄位的記錄)。

ECMAScript 6 支援下列屬性(ES5 也是如此)

您可以透過 Object.getOwnPropertyDescriptor() 來擷取屬性的屬性,它會傳回屬性作為 JavaScript 物件

> const obj = { foo: 123 };
> Object.getOwnPropertyDescriptor(obj, 'foo')
{ value: 123,
  writable: true,
  enumerable: true,
  configurable: true }

本節說明屬性 enumerable 在 ES6 中如何運作。所有其他屬性以及如何變更屬性說明在「Speaking JavaScript」中的「屬性屬性和屬性描述符」一節中。

14.7.2 受可列舉性影響的建構

ECMAScript 5

ECMAScript 6

for-in 是唯一內建運算,其中可列舉性對繼承屬性至關重要。所有其他運算只適用於自身屬性。

14.7.3 可列舉性的使用案例

遺憾的是,可列舉性是一個相當獨特的特性。本節說明其數個使用案例,並主張除了避免舊有程式碼中斷外,其用途有限。

14.7.3.1 使用案例:從 for-in 迴圈隱藏屬性

for-in 迴圈會遍歷物件的所有可列舉屬性,包括自身和繼承的屬性。因此,enumerable 屬性用於隱藏不應遍歷的屬性。這就是 ECMAScript 1 中引入可列舉性的原因。

14.7.3.1.1 語言中的不可列舉性

不可列舉屬性出現在語言中的下列位置

讓所有這些屬性不可列舉的主要原因是將它們(尤其是繼承的屬性)隱藏起來,避免使用 for-in 迴圈或 $.extend()(以及同時複製繼承和自身屬性的類似運算;請參閱下一節)的舊有程式碼。ES6 中應避免這兩種運算。隱藏它們可確保舊有程式碼不會中斷。

14.7.3.2 使用案例:標記屬性為不可複製
14.7.3.2.1 歷史先例

在複製屬性時,有兩個重要的歷史先例會考量可列舉性

使用這種方式複製屬性的問題

標準庫中唯一不可列舉的實例屬性是陣列的屬性 length。但是,由於該屬性會透過其他屬性神奇地更新自身,因此只需要隱藏該屬性。您無法為自己的物件建立這種神奇的屬性(除非使用 Proxy)。

14.7.3.2.2 ES6:Object.assign()

在 ES6 中,Object.assign(target, source_1, source_2, ···) 可用於將來源合併到目標中。會考慮來源的所有可列舉自有屬性(也就是說,鍵可以是字串或符號)。Object.assign() 使用「取得」操作從來源讀取值,並使用「設定」操作將值寫入目標。

關於列舉性,Object.assign() 延續了 Object.extend()$.extend() 的傳統。引用 Yehuda Katz

Object.assign 會為所有已在流通的 extend() API 鋪平道路。我們認為,在這些情況下不複製可列舉方法的先例,足以讓 Object.assign 具有這種行為。

換句話說:Object.assign() 是在考慮從 $.extend()(和類似函式)升級的路徑下建立的。它的方法比 $.extend 更簡潔,因為它忽略了繼承的屬性。

14.7.3.3 將屬性標記為私有

如果您將屬性設為不可列舉,則 Object.keys()for-in 迴圈將無法再看到它。關於這些機制,該屬性是私有的。

但是,這種方法有幾個問題

14.7.3.4 JSON.stringify() 中隱藏自有屬性

JSON.stringify() 輸出時不包含不可列舉的屬性。因此,你可以使用可列舉性來決定哪些自有屬性應該匯出到 JSON。這個用例類似於將屬性標記為私有的前一個用例。但它也有所不同,因為這更多關於匯出,而且適用略有不同的考量。例如:物件可以從 JSON 完全重建嗎?

指定物件應如何轉換為 JSON 的另一種方法是使用 toJSON()

const obj = {
    foo: 123,
    toJSON() {
        return { bar: 456 };
    },
};
JSON.stringify(obj); // '{"bar":456}'

我發現 toJSON() 比可列舉性更簡潔,適用於目前的用例。它也給你更多控制權,因為你可以匯出物件上不存在的屬性。

14.7.4 命名不一致

一般來說,較短的名稱表示只考慮可列舉的屬性

然而,Reflect.ownKeys() 偏離了該規則,它忽略可列舉性並傳回所有屬性的鍵。此外,從 ES6 開始,做出了以下區別

因此,Object.keys() 現在更好的名稱應該是 Object.names()

14.7.5 展望未來

在我看來,可列舉性只適合於隱藏 for-in 迴圈和 $.extend()(以及類似操作)的屬性。這兩個都是舊功能,你應該在新的程式碼中避免使用它們。至於其他用例

我不確定可列舉性未來的最佳策略是什麼。如果在 ES6 中,我們開始假裝它不存在(除了讓原型屬性不可列舉,這樣舊程式碼才不會中斷),我們最終可能已經可以棄用可列舉性。然而,Object.assign() 考慮可列舉性與該策略相反(但它這樣做是有正當理由的,向後相容性)。

在我自己的 ES6 程式碼中,我沒有使用可列舉性,除了(隱含地)對於其 prototype 方法不可列舉的類別。

最後,在使用互動式命令列時,我偶爾會錯過一個操作,該操作傳回物件的所有屬性鍵,而不仅仅是自有的(Reflect.ownKeys)。這樣的操作將提供物件內容的良好概觀。

14.8 透過眾所周知的符號自訂基本語言操作

本節說明如何使用下列眾所周知的符號作為屬性金鑰自訂基本語言操作

14.8.1 屬性金鑰 Symbol.hasInstance(方法)

物件 C 可透過金鑰為 Symbol.hasInstance 的方法自訂 instanceof 運算子的行為,該方法具有下列簽章

[Symbol.hasInstance](potentialInstance : any)

x instanceof C 在 ES6 中的工作方式如下

14.8.1.1 標準函式庫中的用途

標準函式庫中唯一具有此金鑰的方法為

這是所有函式(包括類別)預設使用的 instanceof 實作。 引用規格

此屬性為不可寫入且不可設定,以防止竄改,而這可能會用於在全域公開繫結函式的目標函式。

之所以會發生竄改,是因為傳統的 instanceof 演算法 OrdinaryHasInstance() 會在遇到繫結函式時將 instanceof 套用至目標函式。

由於此屬性為唯讀,因此您無法使用賦值來覆寫它,如前所述

14.8.1.2 範例:檢查值是否為物件

舉例來說,我們實作一個物件 ReferenceType,其「實例」都是物件,不只是 Object 的實例(因此在原型鏈中具有 Object.prototype)。

const ReferenceType = {
    [Symbol.hasInstance](value) {
        return (value !== null
            && (typeof value === 'object'
                || typeof value === 'function'));
    }
};
const obj1 = {};
console.log(obj1 instanceof Object); // true
console.log(obj1 instanceof ReferenceType); // true

const obj2 = Object.create(null);
console.log(obj2 instanceof Object); // false
console.log(obj2 instanceof ReferenceType); // true

14.8.2 屬性金鑰 Symbol.toPrimitive(方法)

Symbol.toPrimitive 讓物件自訂如何將其強制轉換(自動轉換)為基本值。

許多 JavaScript 作業會將值強制轉換為它們需要的類型。

以下是值最常強制轉換的類型

因此,對於數字和字串,第一步是確保值是任何一種基本值。這是由規格內部作業 ToPrimitive() 處理的,它有三個模式

預設模式僅由下列使用

如果值是基本值,則 ToPrimitive() 已完成。否則,該值是物件 obj,它會轉換為基本值,如下所示

可以透過提供物件一個具有下列簽章的方法,來覆寫這個正常演算法

[Symbol.toPrimitive](hint : 'default' | 'string' | 'number')

在標準函式庫中,有兩個這樣的函式

14.8.2.1 範例

下列程式碼示範強制轉換如何影響物件 obj

const obj = {
    [Symbol.toPrimitive](hint) {
        switch (hint) {
            case 'number':
                return 123;
            case 'string':
                return 'str';
            case 'default':
                return 'default';
            default:
                throw new Error();
        }
    }
};
console.log(2 * obj); // 246
console.log(3 + obj); // '3default'
console.log(obj == 'default'); // true
console.log(String(obj)); // 'str'

14.8.3 屬性金鑰 Symbol.toStringTag(字串)

在 ES5 及更早版本中,每個物件都有內部自有屬性 [[Class]],其值暗示其類型。您無法直接存取它,但其值是 Object.prototype.toString() 傳回字串的一部分,這就是為什麼該方法用於類型檢查,作為 typeof 的替代方案。

在 ES6 中,不再有內部槽 [[Class]],而且不建議使用 Object.prototype.toString() 進行類型檢查。為了確保該方法的向後相容性,引入了金鑰為 Symbol.toStringTag 的公開屬性。您可以說它取代了 [[Class]]

Object.prototype.toString() 現在的運作方式如下

14.8.3.1 預設 toString 標籤

下列表格顯示各種物件的預設值。

toString 標籤
未定義 'Undefined'
null 'Null'
陣列物件 'Array'
字串物件 'String'
參數 '參數'
可呼叫的物件 '函式'
錯誤物件 '錯誤'
布林物件 '布林'
數字物件 '數字'
日期物件 '日期'
正規表示式物件 '正規表示式'
(否則) '物件'

左欄位中的大部分檢查都是透過查看內部插槽來執行。例如,如果物件有內部插槽 [[Call]],則它可呼叫。

下列互動示範預設的 toString 標籤。

> Object.prototype.toString.call(null)
'[object Null]'
> Object.prototype.toString.call([])
'[object Array]'
> Object.prototype.toString.call({})
'[object Object]'
> Object.prototype.toString.call(Object.create(null))
'[object Object]'
14.8.3.2 覆寫預設的 toString 標籤

如果物件有 (自己的或繼承的) 屬性,其金鑰為 Symbol.toStringTag,則其值會覆寫預設的 toString 標籤。例如

> ({}.toString())
'[object Object]'
> ({[Symbol.toStringTag]: 'Foo'}.toString())
'[object Foo]'

使用者定義類別的執行個體取得預設的 toString 標籤 (物件)

class Foo { }
console.log(new Foo().toString()); // [object Object]

覆寫預設值的一個選項是透過 getter

class Bar {
    get [Symbol.toStringTag]() {
      return 'Bar';
    }
}
console.log(new Bar().toString()); // [object Bar]

在 JavaScript 標準函式庫中,有下列自訂的 toString 標籤。沒有全域名稱的物件會用百分比符號引起來 (例如:%TypedArray%)。

所有金鑰為 Symbol.toStringTag 的內建屬性都有下列屬性描述

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

如前所述,您無法使用指定來覆寫這些屬性,因為它們是唯讀的。

14.8.4 屬性金鑰 Symbol.unscopables (物件)

Symbol.unscopables 讓物件可以隱藏某些屬性,使其不會出現在 with 陳述式中。

這樣做的原因是,它允許 TC39 在不中斷舊程式碼的情況下,將新方法新增到 Array.prototype。請注意,目前的程式碼很少使用 with,而嚴格模式和 ES6 模組(它們隱含地處於嚴格模式)中禁止使用 with

為什麼新增方法到 Array.prototype 會中斷使用 with 的程式碼(例如廣泛部署的 Ext JS 4.2.1)?請看以下程式碼。如果使用陣列呼叫 foo(),則 Array.prototype.values 屬性的存在會中斷 foo()

function foo(values) {
    with (values) {
        console.log(values.length); // abc (*)
    }
}
Array.prototype.values = { length: 'abc' };
foo([]);

with 陳述式內,values 的所有屬性都會變成局部變數,甚至會隱藏 values 本身。因此,如果 values 有一個 values 屬性,則第 * 行的陳述式會記錄 values.values.length,而不是 values.length

Symbol.unscopables 在標準函式庫中只使用一次

14.9 常見問題:物件文字

14.9.1 我可以在物件文字中使用 super 嗎?

可以!詳細說明請參閱 類別章節

下一章:15. 類別