元編程(JS 原型鏈)

1 屬性的特性
JavaScript 的屬性有名字和值,但每個屬性也有 3 個關(guān)聯(lián)的特性,用于指定屬性的行為以及你可以對它執(zhí)行什么操作。
可寫(writable)特性指定是否可以修改屬性的值。
可枚舉(enumerable)特性指定是否可以通過 for/in 循環(huán)和 Object.keys() 方法枚舉屬性。
可配置(configurable)特性指定是否可以刪除屬性,以及是否可以修改屬性的特性。
對象字面量中定義的屬性,或者通過常規(guī)賦值方式給對象定義的屬性都可寫、可枚舉和可配置。但 JavaScript 標(biāo)準(zhǔn)庫中定義的很多屬性并非如此。
本節(jié)講解與查詢和設(shè)置屬性的特性有關(guān)的 API。這個 API 對庫作者來說尤其重要,因為:
它允許庫作者給原型對象添加方法,并讓它們像內(nèi)置方法一樣不可枚舉;
它允許庫作者“鎖住”自己的對象,定義不能修改或刪除的屬性。
我們在 6.10.6 節(jié)介紹過,“數(shù)據(jù)屬性”有一個值,而“訪問器屬性”有一個獲取方法和設(shè)置方法。對于本節(jié)而言,我們將把訪問器屬性的獲取方法(get)和設(shè)置方法(set)作為屬性的特性來看待。按照這個邏輯,我們甚至也會把數(shù)據(jù)屬性的值(value)當(dāng)成一個特性。這樣我們就可以說一個屬性有一個名字和 4 個特性。數(shù)據(jù)屬性的 4 個特性是 value、writable、enumerable 和 configurable。訪問器屬性沒有 value 特性或 writable 特性,它們的可寫能力取決于是否存在設(shè)置方法。因此訪問器屬性的 4 個特性是 get、set、enumerable 和 configurable。
用于查詢和設(shè)置屬性特性的 JavaScript 方法使用一個被稱為屬性描述符(property descriptor)的對象,這個對象用于描述屬性的 4 個特性。屬性描述符對象擁有與它所描述的屬性的特性相同的屬性名。因此,數(shù)據(jù)屬性的屬性描述符有如下屬性:value、writable、enumerable 和 configurable。而訪問器屬性的屬性描述符沒有 value 和 writable 屬性,只有 get 和 set 屬性。其中,writable、enumerable 和 configurable 屬性是布爾值,而 get 和 set 屬性是函數(shù)值。
要獲得特定對象某個屬性的屬性描述符,可以調(diào)用 Object.getOwnPropertyDescriptor():

顧名思義,Object.getOwnPropertyDescriptor() 只對自有屬性有效。要查詢繼承屬性的特性,必須自己沿原型鏈上溯(可以參考 14.3 節(jié)的 Object.getPrototypeOf() 或 14.6 節(jié)的 Reflect.getPrototypeOf()[1])。
要設(shè)置屬性的特性或者要創(chuàng)建一個具有指定特性的屬性,可以調(diào)用 Object.defineProperty() 方法,傳入要修改的對象、要創(chuàng)建或修改的屬性的名字,以及屬性描述符對象:

傳給 Object.defineProperty() 的屬性描述符不一定 4 個特性都包含。如果是創(chuàng)建新屬性,那么省略的特性會取得 false 或 undefined 值。如果是修改已有的屬性,那么省略的特性就不用修改。注意,這個方法只修改已經(jīng)存在的自有屬性,或者創(chuàng)建新的自有屬性,不會修改繼承的屬性。也可以參考 14.6 節(jié)非常類似的 Reflect.defineProperty()。
如果想一次性創(chuàng)建或修改多個屬性,可以使用 Object.defineProperties()。第一個參數(shù)是要修改的對象,第二個參數(shù)也是一個對象,該對象將要創(chuàng)建或修改的屬性的名稱映射到這些屬性的屬性描述符。例如:

這段代碼操作的是一個空對象,為該對象添加了兩個數(shù)據(jù)屬性和一個只讀的訪問器屬性。它依賴的事實是 Object.defineProperties()(與 Object.defineProperty() 一樣)返回修改后的對象。
6.2 節(jié)介紹過 Object.create() 方法,這個方法的第一個參數(shù)是新創(chuàng)建對象的原型對象。這個方法也接收第二個可選的參數(shù),該參數(shù)與 Object.defineProperties() 的第二個參數(shù)一樣。給 Object.create() 傳入一組屬性描述符,可以為新創(chuàng)建的對象添加屬性。
如果創(chuàng)建或修改屬性的行為是不被允許的,Object.defineProperty() 和 Object.defineProperties() 會拋出 TypeError。比如給一個不可擴(kuò)展的對象(參見 14.2 節(jié))添加新屬性。導(dǎo)致這兩個方法拋出 TypeError 的其他原因涉及特性本身。writable 特性控制對 value 屬性的修改,而 configurable 特性控制對其他特性的修改(也控制是否可以刪除某個屬性)。不過,這里的規(guī)則并非直觀明了。比如,雖然某個屬性是不可寫的,但如果它是可以配置的,仍然可以修改它的值。再比如,即使某個屬性是不可配置的,但仍然可以把該屬性由可寫修改為不可寫。下面給出了全部規(guī)則,在調(diào)用 Object.defineProperty() 或 Object.defineProperties() 時如果違反這些規(guī)則就會拋出 TypeError。
如果對象不可擴(kuò)展,可以修改其已有屬性,但不能給它添加新屬性。
如果屬性不可配置,不能修改其 configurable 或 enumerable 特性。
如果訪問器屬性不可配置,不能修改其獲取方法或設(shè)置方法,也不能把它修改為數(shù)據(jù)屬性。
如果數(shù)據(jù)屬性不可配置,不能把它修改為訪問器屬性。
如果數(shù)據(jù)屬性不可配置,不能把它的 writable 特性由 false 修改為 true,但可以由 true 修改為 false。
如果數(shù)據(jù)屬性不可配置且不可寫,則不能修改它的值。不過,如果這個屬性可配置但不可寫,則可以修改它的值(相當(dāng)于先把它配置為可寫,然后修改它的值,再把它配置為不可寫)。
6.7 節(jié)介紹了 Object.assign() 函數(shù),該函數(shù)可以把一個或多個源對象的屬性值復(fù)制到目標(biāo)對象。Object.assign() 只復(fù)制可枚舉屬性和屬性值,但不復(fù)制屬性的特性。這個結(jié)果通常都是我們想要的,但是也要清楚這個結(jié)果意味著什么。比如,它意味著如果源對象有一個訪問器屬性,那么復(fù)制到目標(biāo)對象的是獲取函數(shù)的返回值,而不是獲取函數(shù)本身。示例 14-1 演示了如何使用 Object.getOwnPropertyDescriptor() 和 Object.defineProperty() 創(chuàng)建 Object.assign() 的一個變體,讓這個變體能夠復(fù)制全部屬性描述符而不僅僅復(fù)制屬性的值。


示例 14-1:從一個對象向另一個對象復(fù)制屬性及它們的特性
[1] 原文的 Reflect.getOwnPropertyDescriptor() 是錯誤的。
2 對象的可擴(kuò)展能力
對象的可擴(kuò)展(extensible)特性控制是否可以給對象添加新屬性,即是否可擴(kuò)展。普通 JavaScript 對象默認(rèn)是可擴(kuò)展的,但可以使用本節(jié)介紹的方法修改。
要確定一個對象是否可擴(kuò)展,把它傳給 Object.isExtensible() 即可。要讓一個對象不可擴(kuò)展,把它傳給 Object.preventExtensions() 即可。如此,如果再給該對象添加新屬性,那么在嚴(yán)格模式下就會拋出 TypeErrror,而在非嚴(yán)格模式下則會靜默失敗。此外,修改不可擴(kuò)展對象的原型始終都會拋出 TypeError。
注意,把對象修改為不可擴(kuò)展是不可逆的(即無法再將其改回可擴(kuò)展)。也要注意,調(diào)用 Object.preventExtensions() 只會影響對象本身的可擴(kuò)展能力。如果給一個不可擴(kuò)展對象的原型添加了新屬性,則這個不可擴(kuò)展對象仍然會繼承這些新屬性。
14.6 節(jié)還介紹了兩個類似的函數(shù):Reflect.isExtensible() 和 Reflect.preventExtensions()。
這個 extensible 特性的作用是把對象“鎖定”在已知狀態(tài),阻止外部篡改。對象的 extensible 特性經(jīng)常需要與屬性的 configurable 和 writable 特性協(xié)同發(fā)揮作用。JavaScript 為此還定義了可以一起設(shè)置這些特性的函數(shù)。
Object.seal() 類似 Object.preventExtensions(),但除了讓對象不可擴(kuò)展,它也會讓對象的所有自有屬性不可擴(kuò)展。這意味著不能給對象添加新屬性,也不能刪除或配置已有屬性。不過,可寫的已有屬性依然可寫。沒有辦法“解封”已被“封存”的對象??梢允褂?Object.isSealed() 確定對象是否被封存。
Object.freeze() 會更嚴(yán)密地“鎖定”對象。除了讓對象不可擴(kuò)展,讓它的屬性不可配置,該函數(shù)還會把對象的全部自有屬性變成只讀的(如果對象有訪問器屬性,且該訪問器屬性有設(shè)置方法,則這些屬性不會受影響,仍然可以調(diào)用它們給屬性賦值)。使用 Object.isFrozen() 確定對象是否被凍結(jié)。
對于 Object.seal() 和 Object.freeze(),關(guān)鍵在于理解它們只影響傳給自己的對象,而不會影響該對象的原型。如果你想徹底鎖定一個對象,那可能也需要封存或凍結(jié)其原型鏈上的對象。
Object.preventExtensions()、Object.seal() 和 Object.freeze() 全都返回傳給它們的對象,這意味著可以在嵌套函數(shù)調(diào)用中使用它們:

如果你寫的 JavaScript 庫要把某些對象傳給用戶寫的回調(diào)函數(shù),為避免用戶代碼修改這些對象,可以使用 Object.freeze() 凍結(jié)它們。這樣做雖然簡單方便,但也有弊端。比如被凍結(jié)的對象可能影響常規(guī)的 JavaScript 測試策略。
3 prototype 特性
對象的 prototype 特性指定對象從哪里繼承屬性(更多關(guān)于原型和屬性繼承的內(nèi)容可以參見 6.2.3 節(jié)和 6.3.2 節(jié))。由于這個特性實在太重要了,我們平時只會說“o 的原型”,而不說“o 的 prototype 特性”。但也要記住,當(dāng) prototype 以代碼字體出現(xiàn)時,它指的是一個普通對象的屬性,而不是 prototype 特性。第 9 章解釋過,構(gòu)造函數(shù)的 prototype 屬性用于指定通過該構(gòu)造函數(shù)創(chuàng)建的對象的 prototype 特性。
對象的 prototype 特性是在對象被創(chuàng)建時設(shè)定的。使用對象字面量創(chuàng)建的對象使用 Object.prototype 作為其原型。使用 new 創(chuàng)建的對象使用構(gòu)造函數(shù)的 prototype 屬性的值作為其原型。而使用 Object.create() 創(chuàng)建的對象使用傳給它的第一個參數(shù)(可能是 null)作為其原型。
要查詢?nèi)魏螌ο蟮脑?,都可以把該對象傳給 Object.getPrototypeOf():

14.6 節(jié)介紹了一個非常類似的函數(shù):Reflect.getPrototypeOf()。
要確定一個對象是不是另一個對象的原型(或原型鏈中的一環(huán)),可以使用 isPrototypeOf() 方法:

注意,isPrototypeOf() 的功能與 instanceof 操作符類似(參見 1.9.4 節(jié))。
對象的 prototype 特性在它創(chuàng)建時會被設(shè)定,且通常保持不變。不過,可以使用 Object.setPrototypeOf() 修改對象的原型:

一般來說很少需要使用 Object.setPrototypeOf()。JavaScript 實現(xiàn)可能會基于對象原型固定不變的假設(shè)實現(xiàn)激進(jìn)的優(yōu)化。這意味著如果你調(diào)用過 Object.setPrototypeOf(),那么任何使用該被修改對象的代碼都可能比正常情況下慢很多。
14.6 節(jié)介紹了一個非常類似的函數(shù):Reflect.setPrototypeOf()。
JavaScript 的一些早期瀏覽器實現(xiàn)通過proto(前后各有兩個下劃線)屬性暴露了對象的 prototype 特性。這個屬性很早以前就已經(jīng)被廢棄了,但網(wǎng)上仍然有很多已有代碼依賴proto。ECMAScript 標(biāo)準(zhǔn)為此也要求所有瀏覽器的 JavaScript 實現(xiàn)都必須支持它(盡管標(biāo)準(zhǔn)并未要求,但 Node 也支持它)。在現(xiàn)代 JavaScript 中,proto是可讀且可寫的,你可以(但不應(yīng)該)使用它代替 Object.getPrototypeOf() 和 Object.setPrototypeOf()。proto的一個有意思的用法是通過它定義對象字面量的原型:

4 公認(rèn)符號
Symbol 類型是在 ES6 中添加到 JavaScript 中的。之所以增加這個新類型,主要是為了便于擴(kuò)展 JavaScript 語言,同時又不會破壞對 Web 上已有代碼的向后兼容性。第 12 章介紹了一個符號的例子,通過該例子我們知道一個類只要實現(xiàn)“名字”為 Symbol.iterator 符號的方法,這個類就是可迭代的。
Symbol.iterator 是最為人熟知的“公認(rèn)符號”(well-known symbol)。所謂“公認(rèn)符號”,其實就是 Symbol() 工廠函數(shù)的一組屬性,也就是一組符號值。通過這些符號值,我們可以控制 JavaScript 對象和類的某些底層行為。接下來幾節(jié)將分別介紹這些公認(rèn)符號及它們的用途。
4.1 Symbol.iterator 和 Symbol.asyncIterator
Symbol.iterator 和 Symbol.asyncIterator 符號可以讓對象或類把自己變成可迭代對象和異步可迭代對象。第 12 章和 13.4.2 節(jié)分別詳盡介紹了這兩個符號。出于完整性的考慮,這里我們只提及一下。
4.2 Symbol.hasInstance
在 4.9.4 節(jié)講述 instanceof 操作符時,我們說過其右側(cè)必須是一個構(gòu)造函數(shù),而表達(dá)式 o instanceof f 在求值時會在 o 的原型鏈中查找 f.prototype 的值,這是沒有問題的,但在 ES6 及之后的版本中,Symbol.hasInstance 提供了一個替代選擇。在 ES6 中,如果 instanceof 的右側(cè)是一個有[Symbol.hasInstance]方法的對象,那么就會以左側(cè)的值作為參數(shù)來調(diào)用這個方法并返回這個方法的值,返回值會被轉(zhuǎn)換為布爾值,變成 intanceof 操作符的值。當(dāng)然,如果右側(cè)的值沒有[Symbol.hasInstance]方法且是一個函數(shù),則 instanceof 操作符仍然照常行事。
Symbol.hasInstance 意味著我們可以使用 instanceof 操作符對適當(dāng)定義的偽類型對象去執(zhí)行通用類型檢查。例如:

注意,這個例子很巧妙,但卻讓人困惑。因為它使用了不是類的對象,而正常情況下應(yīng)該是一個類。實際上,要寫一個不依賴 Symbol.hasInstance 的 isUnit8() 函數(shù)也很容易(而且對讀者來說代碼也更清晰)。
4.3 Symbol.toStringTag
調(diào)用一個簡單 JavaScript 對象的 toString() 方法會得到字符串"[object Object]":

如果調(diào)用與內(nèi)置類型實例的方法相同的 Object.prototype.toString() 函數(shù),則會得到一些有趣的結(jié)果:

這說明,使用這種 Object.prototype.toString().call() 技術(shù)檢查任何 JavaScript 值,都可以從一個包含類型信息的對象中獲取以其他方式無法獲取的“類特性”。下面這個 classof() 函數(shù)無論怎么說都比 typeof 操作符更有用,因為 typeof 操作符無法區(qū)分不同對象的類型:

在 ES6 之前,Object.prototype.toString() 這種特殊的用法只對內(nèi)置類型的實例有效。如果你對自己定義的類的實例調(diào)用 classof(),那只能得到“Object”。而在 ES6 中,Object.prototype.toString() 會查找自己參數(shù)中有沒有一個屬性的符號名是 Symbol.toStringTag。如果有這樣一個屬性,則使用這個屬性的值作為輸出。這意味著如果你自己定義了一個類,那很容易可以讓它適配 classof() 這樣的函數(shù):
4.4 Symbol.species
在 ES6 之前,JavaScript 沒有提供任何實際的方式去創(chuàng)建內(nèi)置類(如 Array)的子類。但在 ES6 中,我們使用 class 和 extends 關(guān)鍵字就可以方便地擴(kuò)展任何內(nèi)置類。9.5.2 節(jié)使用下面這個簡單的 Array 子類演示了這一點:
Array 定義了 concat()、filter()、map()、slice() 和 splice() 方法,這些方法仍然返回數(shù)組。在創(chuàng)建類似 EZArray 的數(shù)組子類時也會繼承這些方法,這些方法應(yīng)該返回 Array 的實例,還是返回 EZArray 的實例?兩種結(jié)果似乎都有其合理的一面,但 ES6 規(guī)范認(rèn)為這 5 個數(shù)組方法(默認(rèn))將返回子類的實例。
以下是實現(xiàn)過程。
在 ES6 及之后版本中,Array() 構(gòu)造函數(shù)有一個名字為 Symbol.species 符號屬性(注意這個符號是構(gòu)造函數(shù)的屬性名。這里介紹的其他大多數(shù)公認(rèn)符號都是原型對象的方法名)。
在使用 extends 創(chuàng)建子類時,子類構(gòu)造函數(shù)會從超類構(gòu)造函數(shù)繼承屬性(這是除子類實例繼承超類方法這種常規(guī)繼承之外的一種繼承)。這意味著 Array 的每個子類的構(gòu)造函數(shù)也會繼承名為 Symbol.species 的屬性(如果需要,子類也可以用同一個名字定義自有屬性)。
在 ES6 及之后的版本中,map() 和 slice() 等創(chuàng)建并返回新數(shù)組的方法經(jīng)過了一些修改。修改后它們不僅會創(chuàng)建一個常規(guī)的 Array,還(實際上)會調(diào)用 new this.constructorSymbol.species 創(chuàng)建新數(shù)組。
接下來是最有意思的部分。假設(shè) Array[Symbol.species]僅僅是一個常規(guī)數(shù)據(jù)屬性,是按如下的方式定義的:
那么子類構(gòu)造函數(shù)將作為它的“物種”(species)繼承 Array() 構(gòu)造函數(shù),在數(shù)組子類上調(diào)用 map() 將返回這個超類的實例而不返回子類實例。但 ES6 中實際結(jié)果并非如此。原因在于 Array[Symbol.species]是一個只讀的訪問器屬性,其獲取函數(shù)簡單地返回 this。子類構(gòu)造函數(shù)繼承了這個獲取函數(shù),這意味著默認(rèn)情況下,每個子類構(gòu)造函數(shù)都是它自己的“物種”。
不過,有時候我們可能需要修改這個默認(rèn)行為。如果想讓 EZArray 繼承的返回數(shù)組的方法都返回常規(guī) Array 對象,只需將 EZArray[Symbol.species]設(shè)置為 Array 即可。但由于這個繼承的屬性是一個只讀的訪問器,不能直接用賦值操作符來設(shè)置這個值。此時可以使用 defineProperty():
最簡單的做法其實是在一開始創(chuàng)建子類時就定義自己的 Symbol.species 獲取方法:
增加 Symbol.species 的主要目的就是允許更靈活地創(chuàng)建 Array 的子類,但這個公認(rèn)符號的用途并不局限于此。定型數(shù)組與 Array 類一樣,也以同樣的方式使用了這個符號。類似地,ArrayBuffer 的 slice() 方法也會查找 this.constructor 的 Symbol.species 屬性,而不是簡單地創(chuàng)建新 ArrayBuffer。而返回新 Promise 對象的方法(比如 then())同樣也是通過這個“物種協(xié)議”來創(chuàng)建返回的期約。最后,(舉個例子)如果某一天你會創(chuàng)建 Map 的子類,并且會定義返回新 Map 對象的方法,那么為這個子類的子類考慮,或許你會用到 Symbol.species。
4.5 Symbol.isConcatSpreadable
Array 的方法 concat() 是使用 Symbol.species 確定對返回的數(shù)組使用哪個構(gòu)造函數(shù)的方法之一。但 concat() 也使用 Symbol.isConcatSpreadable。7.8.3 節(jié)介紹過,數(shù)組的 concat() 方法對待自己的 this 值和它的數(shù)組參數(shù)不同于對待非數(shù)組參數(shù)。換句話說,非數(shù)組參數(shù)會被簡單地追加到新數(shù)組末尾,但對于數(shù)組參數(shù),this 數(shù)組和參數(shù)數(shù)組都會被打平或“展開”,從而實現(xiàn)數(shù)組元素的拼接,而不是拼接數(shù)組參數(shù)本身。
在 ES6 之前,concat() 只使用 Array.isArray() 確定是否將某個值作為數(shù)組來對待。在 ES6 中,這個算法進(jìn)行了一些調(diào)整:如果 concat() 的參數(shù)(或 this 值)是對象且有一個 Symbol.isConcatSpreadable 符號屬性,那么就根據(jù)這個屬性的布爾值來確定是否應(yīng)該“展開”參數(shù)。如果這個屬性不存在,那么就像語言之前的版本一樣使用 Array.isArray()。
在兩種情況下可能會用到這個 Symbol。
如果你創(chuàng)建了一個類數(shù)組對象(參見 7.9 節(jié)),并且希望把它傳給 concat() 時該對象能像真正的數(shù)組一樣,那可以給這個對象添加這么一個符號屬性:
Array 的子類默認(rèn)是可展開的,因此如果你定義了一個數(shù)組的子類,但不希望它在傳給 concat() 時像數(shù)組一樣,那么可以[1] 像下面這樣給這個子類添加一個獲取方法:
4.6 模式匹配符號
11.3.2 節(jié)記述了使用 RegExp 參數(shù)執(zhí)行模式匹配操作的 String 方法。在 ES6 及之后的版本中,這些方法都統(tǒng)一泛化為既能夠使用 RegExp 對象,也能使用任何通過具有符號名的屬性定義了模式匹配行為的對象。match()、matchAll()、search()、replace() 和 split() 這些字符串方法中的任何一個,都有一個與之對應(yīng)的公認(rèn)符號:Symbol.match、Symbol.search,等等。
RegExp 是描述文本模式的一種通用且強(qiáng)大的方式,但同時它們也比較復(fù)雜,而且也不太適合模糊匹配。有了泛化之后的字符串方法,你可以使用公認(rèn)的符號方法定義自己的模式類,提供自定義匹配。例如,可以使用 Intl.Collator(參見 11.7.3 節(jié))執(zhí)行字符串比較,從而在比較時忽略重音?;蛘?,可以基于 Soundex 算法實現(xiàn)一個模式類,從而根據(jù)讀音的近似程度匹配單詞或者近似地匹配到某個給定的萊文斯坦(Levenshtein)距離。
一般來說,在像下面這樣調(diào)用上面 5 個字符串方法時:
該調(diào)用會轉(zhuǎn)換為對模式對象上相應(yīng)符號化命名方法的調(diào)用:
以下面這個模式匹配類為例。這個類使用我們在文件系統(tǒng)中熟悉的*和?通配符實現(xiàn)了模式匹配。這種風(fēng)格的模式匹配可以追溯到 Unix 操作系統(tǒng)誕生之初,而模式也被稱為 glob[2]:
4.7 Symbol.toPrimitive
3.9.3 節(jié)解釋過 JavaScript 有 3 個稍微不同的算法,用于將對象轉(zhuǎn)換為原始值。大致來講,對于預(yù)期或偏好為字符串值的轉(zhuǎn)換,JavaScript 會先調(diào)用對象的 toString() 方法。如果 toString() 方法沒有定義或者返回的不是原始值,還會再調(diào)用對象的 valueOf() 方法。對于偏好為數(shù)值的轉(zhuǎn)換,JavaScript 會先嘗試調(diào)用 valueOf() 方法,然后在 valueOf() 沒有定義或者返回的不是原始值時再調(diào)用 toString()。最后,如果沒有偏好,JavaScript 會讓類來決定如何轉(zhuǎn)換。Date 對象首先使用 toString(),其他所有類型則首先調(diào)用 valueOf()。
在 ES6 中,公認(rèn)符號 Symbol.toPrimitive 允許我們覆蓋這個默認(rèn)的對象到原始值的轉(zhuǎn)換行為,讓我們完全控制自己的類實例如何轉(zhuǎn)換為原始值。為此,需要定義一個名字為這個符號的方法。這個方法必須返回一個能夠表示對象的原始值。這個方法在被調(diào)用時會收到一個字符串參數(shù),用于告訴你 JavaScript 打算對你的對象做什么樣的轉(zhuǎn)換。
如果這個參數(shù)是"string",則表示 JavaScript 是在一個預(yù)期或偏好(但不是必需)為字符串的上下文中做這個轉(zhuǎn)換。比如,把對象作為字符串插值到一個模板字面量中。
如果這個參數(shù)是"number",則表示 JavaScript 是在一個預(yù)期或偏好(但不是必需)為數(shù)值的上下文中做這個轉(zhuǎn)換。在通過<或>操作符比較對象,或者使用算術(shù)操作符-或*來計算對象時屬于這種情況。
如果這個參數(shù)是"default",則表示 JavaScript 做這個轉(zhuǎn)換的上下文可以接受數(shù)值也可以接受字符串。在使用+、==或!=操作符時就是這樣。
很多類都可以忽略這個參數(shù),在任何情況下都返回相同的原始值。如果你希望自己類的實例可以通過<或>來比較,那么就需要給這個類定義一個[Symbol.toPrimitive]方法。
4.8 Symbol.unscopables
最后一個要介紹的公認(rèn)符號不好理解,它是針對廢棄的 with 語句所導(dǎo)致的兼容性問題而引入的一個變通方案。我們知道,with 語句會取得一個對象,而在執(zhí)行語句體時,就好像在相應(yīng)的作用域中該對象的屬性是變量一樣。但這樣一來如果再給 Array 類添加新方法就會導(dǎo)致兼容性問題,有可能破壞某些既有代碼。Symbol.unscopables 應(yīng)運(yùn)而生。在 ES6 及之后的版本中,with 語句被稍微進(jìn)行了修改。在取得對象 o 時,with 語句會計算 Object.keys(o[Symbol.unscopables]||{}) 并在創(chuàng)建用于執(zhí)行語句體的模擬作用域時,忽略名字包含在結(jié)果數(shù)組中的那些屬性。ES6 使用這個機(jī)制給 Array.prototype 添加新方法,同時又不會破壞線上已有的代碼。這意味著可以通過如下方式獲取最新 Array 方法的列表:
最后一個要介紹的公認(rèn)符號不好理解,它是針對廢棄的 with 語句所導(dǎo)致的兼容性問題而引入的一個變通方案。我們知道,with 語句會取得一個對象,而在執(zhí)行語句體時,就好像在相應(yīng)的作用域中該對象的屬性是變量一樣。但這樣一來如果再給 Array 類添加新方法就會導(dǎo)致兼容性問題,有可能破壞某些既有代碼。Symbol.unscopables 應(yīng)運(yùn)而生。在 ES6 及之后的版本中,with 語句被稍微進(jìn)行了修改。在取得對象 o 時,with 語句會計算 Object.keys(o[Symbol.unscopables]||{}) 并在創(chuàng)建用于執(zhí)行語句體的模擬作用域時,忽略名字包含在結(jié)果數(shù)組中的那些屬性。ES6 使用這個機(jī)制給 Array.prototype 添加新方法,同時又不會破壞線上已有的代碼。這意味著可以通過如下方式獲取最新 Array 方法的列表:
[1] 由于 V8 JavaScript 引擎的一個 bug,這段代碼在 Node 13 中將無法正確運(yùn)行。
[2] glob 是 global 的簡寫。
5 模板標(biāo)簽
位于反引號之間的字符串被稱為“模板字面量”,我們在 3.3.4 節(jié)介紹過。如果一個求值為函數(shù)的表達(dá)式后面跟著一個模板字面量,那就會轉(zhuǎn)換為一個函數(shù)調(diào)用,而我們稱其為“標(biāo)簽化模板字面量”??梢园讯x使用標(biāo)簽化模板字面量的標(biāo)簽函數(shù)看成是元編程,因為標(biāo)簽化模板經(jīng)常用于定義 DSL(Domain-Specific Language,領(lǐng)域?qū)S谜Z言)。而定義新的標(biāo)簽函數(shù)類似于給 JavaScript 添加新語法。標(biāo)簽化模板字面量已經(jīng)被很多前端 JavaScript 包采用了。GraphQL 查詢語言使用 gql*** 標(biāo)簽函數(shù)支持在JavaScript代碼中嵌入查詢。而Emotion庫使用***css
標(biāo)簽函數(shù)支持在 JavaScript 中嵌入 CSS 樣式。本節(jié)講解如何寫類似這樣的標(biāo)簽函數(shù)。
標(biāo)簽函數(shù)并沒有什么特別之處,它們就是普通的 JavaScript 函數(shù),定義它們不涉及任何特殊語法。當(dāng)函數(shù)表達(dá)式后面跟著一個模板字面量時,這個函數(shù)會被調(diào)用。第一個參數(shù)是一個字符串?dāng)?shù)組,然后是 0 或多個額外參數(shù),這些參數(shù)可以是任何類型的值。
參數(shù)的個數(shù)取決于被插值到模板字面量中值的個數(shù)。如果模板字面量就是一個字符串,沒有插值的位置,那么標(biāo)簽函數(shù)在被調(diào)用時只會收到一個該字符串的數(shù)組,沒有額外的參數(shù)。如果模板字面量包含一個要插入的值,那么標(biāo)簽函數(shù)被調(diào)用時會收到兩個參數(shù)。第一個是包含兩個字符串的數(shù)組,第二個是被插入的值。第一個數(shù)組中的兩個字符串:一個是插入值左側(cè)的字符串,另一個是插入值右側(cè)的字符串。而且這兩個字符串都可能是空字符串。如果模板字面量包含兩個要插入的值,那么標(biāo)簽函數(shù)在被調(diào)用時會收到三個參數(shù):一個包含三個字符串的數(shù)組和兩個要插入的值。數(shù)組中的三個字符串(其中任何一個甚至全部都可能是空字符串)分別是第一個插入值左側(cè)、兩個插入值之間和最后一個插入值右側(cè)的字符串。推而廣之,如果模板字面量有 n 個插值,那么標(biāo)簽函數(shù)在被調(diào)用時會收到 n+1 個參數(shù)。第一個參數(shù)是一個 n+1 個字符串的數(shù)組,其余 n 個參數(shù)是要插入的值,順序為它們在模板字面量中出現(xiàn)的順序。
模板字面量的值始終是一個字符串。但標(biāo)簽化模板字面量的值則是標(biāo)簽函數(shù)返回的值。這個值可能是字符串,但在標(biāo)簽函數(shù)被用于實現(xiàn) DSL 時,返回的值通常是一個非字符串?dāng)?shù)據(jù)結(jié)構(gòu)或者說是對字符串進(jìn)行解析之后的表示。
作為一個返回字符串的標(biāo)簽函數(shù)的例子,可以看看下面這個 html``模板。這個模板可以保證向 HTML 字符串中安全地插值。在使用要插入的值構(gòu)建最終字符串之前,標(biāo)簽會先對它們進(jìn)行 HTML 轉(zhuǎn)義:
下面這個例子是一個不返回字符串而返回字符串解析后表示的標(biāo)簽函數(shù),其中用到了 14.4.6 節(jié)定義的 Glob 模式類。由于 Glob() 構(gòu)造函數(shù)只接收一個字符串參數(shù),我們可以定義一個標(biāo)簽函數(shù)來創(chuàng)建新 Glob 對象:
我們在 3.3.4 節(jié)曾提到過一個 String.raw``標(biāo)簽函數(shù),這個函數(shù)返回字符串“未處理”(raw)的形式,不會解釋任何反斜杠轉(zhuǎn)義序列。這個函數(shù)使用了當(dāng)時還沒有討論的標(biāo)簽函數(shù)調(diào)用特性實現(xiàn)。當(dāng)標(biāo)簽函數(shù)被調(diào)用時,我們知道它的第一個參數(shù)是一個字符串?dāng)?shù)組。不過這個數(shù)組也有一個名為 raw 的屬性,該屬性的值是另一個字符串?dāng)?shù)組,數(shù)組的元素個數(shù)相同。參數(shù)數(shù)組中包含的字符串已經(jīng)跟往常一樣解釋了轉(zhuǎn)義序列。而未處理數(shù)組中包含的字符串并沒有解釋轉(zhuǎn)義序列。如果你想定義 DSL,而文法中會使用反斜杠,那么這個不起眼的特性很重要。
例如,如果我們想讓 glob``標(biāo)簽函數(shù)支持匹配 Windows 風(fēng)格路徑(使用反斜杠而不是正斜杠)的模式,也不希望用戶雙寫每個反斜杠,那可以使用 strings.raw[]取代 strings[]來重寫該函數(shù)。當(dāng)然,這樣做的問題在于我們不能再在 glob 字面量中使用類似\u 的轉(zhuǎn)義序列。
6 反射 API
與 Math 對象類似,Reflect 對象不是類,它的屬性只是定義了一組相關(guān)函數(shù)。這些 ES6 添加的函數(shù)為“反射”對象及其屬性定義了一套 API。這里有一個小功能:Reflect 對象在同一個命名空間里定義了一組便捷函數(shù),這些函數(shù)可以模擬核心語言語法的行為,復(fù)制各種既有對象功能的特性。
Reflect 函數(shù)雖然沒有提供新特性,但它們用一個方便的 API 篩選出了一組特性。重點在于,這組 Reflect 函數(shù)一對一地映射了我們要在 14.7 節(jié)學(xué)習(xí)的 Proxy 處理器方法。
反射 API 包括下列函數(shù)。
Reflect.apply(f, o, args)
這個函數(shù)將函數(shù) f 作為 o 的方法進(jìn)行調(diào)用(如果 o 是 null,則調(diào)用函數(shù) f 時沒有 this 值),并傳入 args 數(shù)組的值作為參數(shù)。相當(dāng)于 f.apply(o, args)。
Reflect.construct(c, args, newTarget)
這個函數(shù)像使用了 new 關(guān)鍵字一樣調(diào)用構(gòu)造函數(shù) c,并傳入 args 數(shù)組的元素作為參數(shù)。如果指定了可選的 newTarget 參數(shù),則將其作為構(gòu)造函數(shù)調(diào)用中 new.target 的值。如果沒有指定,則 new.target 的值是 c。
Reflect.defineProperty(o, name, descriptor)
這個函數(shù)在對象 o 上定義一個屬性,使用 name(字符串或符號)作為屬性名。描述符對象 descriptor 應(yīng)該定義這個屬性的值(或獲取方法、設(shè)置方法)和特性。Reflect.defineProperty() 與 Object.defineProperty() 非常類似,但在成功時返回 true,失敗時返回 false(Object.defineProperty() 成功時返回 o,失敗時拋出 TypeError)。
Reflect.deleteProperty(o, name)
這個函數(shù)根據(jù)指定的字符串或符號名 name 從對象 o 中刪除屬性。如果成功(或指定屬性不存在)則返回 true,如果無法刪除該屬性則返回 false。調(diào)用這個函數(shù)類似于執(zhí)行 delete o[name]。
Reflect.get(o, name, receiver)
這個函數(shù)根據(jù)指定的字符串或符號名 name 返回屬性的值。如果屬性是一個有獲取方法的訪問器屬性,且指定了可選的 receiver 參數(shù),則將獲取方法作為 receiver 而非 o 的方法調(diào)用。調(diào)用這個函數(shù)類似于求值 o[name]。
Reflect.getOwnPropertyDescriptor(o, name)
這個函數(shù)返回描述對象 o 的 name 屬性的特性的描述符對象。如果屬性不存在則返回 undefined。這個函數(shù)基本等于 Object.getOwnPropertyDescriptor(),只不這個反射 API 的版本要求第一個參數(shù)必須是對象,否則會拋出 TypeError。
Reflect.getPrototypeOf(o)
這個函數(shù)返回對象 o 的原型,如果 o 沒有原型則返回 null。如果 o 是原始值而非對象,則拋出 TypeError。這個函數(shù)基本等于 Object.getPrototypeOf(),只不過 Object.getPrototypeOf() 只對 null 和 undefined 參數(shù)拋出 TypeError,且會將其他原始值轉(zhuǎn)換為相應(yīng)的包裝對象。
Reflect.has(o, name)
這個函數(shù)在對象 o 有指定的屬性 name(必須是字符串或符號)時返回 true。調(diào)用這個函數(shù)類似于求值 name in o。
Reflect.isExtensible(o)
這個函數(shù)在對象 o 可擴(kuò)展(參見 14.2 節(jié))時返回 true,否則返回 false。如果 o 不是對象則拋出 TypeError。Object.isExtensible() 與這個函數(shù)類似,但在參數(shù)不是對象時只會返回 false。
Reflect.ownKeys(o)
這個函數(shù)返回包含對象 o 屬性名的數(shù)組,如果 o 不是對象則拋出 TypeError。返回數(shù)組中的名字可能是字符串或符號。調(diào)用這個函數(shù)類似于調(diào)用 Object.getOwn Property-Names() 和 Object.getOwnPropertySymbols() 并將它們返回的結(jié)果組合起來。
Reflect.preventExtensions(o)
這個函數(shù)將對象 o 的可擴(kuò)展特性(參見 14.2 節(jié))設(shè)置為 false,并返回表示成功的 true。如果 o 不是對象則拋出 TypeError。Object.preventExtensions() 具有相同的效果,但返回對象 o 而不是 true,另外對非對象參數(shù)也不拋出 TypeError。
Reflect.set(o, name, value, receiver)
這個函數(shù)根據(jù)指定的 name 將對象 o 的屬性設(shè)置為指定的 value。如果成功則返回 true,失敗則返回 false(如屬性是只讀的)。如果 o 不是對象則拋出 TypeError。如果指定的屬性是一個有設(shè)置方法的訪問器屬性,且如果指定了可選的 receiver 參數(shù),則將設(shè)置方法作為 receiver 而非 o 的方法進(jìn)行調(diào)用。調(diào)用這個函數(shù)類似于求值 o[name] = value。
Reflect.setPrototypeOf(o, p)
這個函數(shù)將對象 o 的原型設(shè)置為 p,成功返回 true,失敗返回 false(如果 o 不可擴(kuò)展或操作本身會導(dǎo)致循環(huán)原型鏈)。如果 o 不是對象或 p 既不是對象也不是 null 則拋出 TypeError。Object.setPrototypeOf() 與這個函數(shù)類似,但在成功時返回 o,在失敗時拋出 TypeError。注意,調(diào)用這兩個函數(shù)中的任何一個都可能導(dǎo)致代碼變慢,因為它們會破壞 JavaScript 解釋器的優(yōu)化。
7 代理對象
ES6 及之后版本中的 Proxy 類是 JavaScript 中最強(qiáng)大的元編程特性。使用它可以修改 JavaScript 對象的基礎(chǔ)行為。14.6 節(jié)介紹的反射 API 是一組函數(shù),通過它們可以直接對 JavaScript 對象執(zhí)行基礎(chǔ)操作。而 Proxy 類則提供了一種途徑,讓我們能夠自己實現(xiàn)基礎(chǔ)操作,并創(chuàng)建具有普通對象無法企及能力的代理對象。
創(chuàng)建代理對象時,需要指定另外兩個對象,即目標(biāo)對象(target)和處理器對象(handlers):
得到的代理對象沒有自己的狀態(tài)或行為。每次對它執(zhí)行某個操作(讀屬性、寫屬性、定義新屬性、查詢原型、把它作為函數(shù)調(diào)用)時,它只會把相應(yīng)的操作發(fā)送給處理器對象或目標(biāo)對象。
代理對象支持的操作就是反射 API 定義的那些操作。假設(shè) p 是一個代理對象,我們想執(zhí)行 delete p.x。而 Reflect.deleteProperty() 函數(shù)具有與 delete 操作符相同的行為。當(dāng)使用 delete 操作符刪除代理對象上的一個屬性時,代理對象會在處理器對象上查找 deleteProperty() 方法。如果存在這個方法,代理對象就調(diào)用它。如果不存在這個方法,代理對象就在目標(biāo)對象上執(zhí)行屬性刪除操作。
對所有基礎(chǔ)操作,代理都這樣處理:如果處理器對象上存在對應(yīng)方法,代理就調(diào)用該方法執(zhí)行相應(yīng)操作(這里方法的名字和簽名與 14.6 節(jié)介紹的反射函數(shù)完全相同)。如果處理器對象上不存在對應(yīng)方法,則代理就在目標(biāo)對象上執(zhí)行基礎(chǔ)操作。這意味著代理可以從目標(biāo)對象或處理器對象獲得自己的行為。如果處理器對象是空的,那代理本質(zhì)上就是目標(biāo)對象的一個透明包裝器:
這種透明包裝代理本質(zhì)上就是底層目標(biāo)對象,這意味著沒有理由使用代理來代替包裝的對象。然而,透明包裝器在創(chuàng)建“可撤銷代理”時有用。創(chuàng)建可撤銷代理不使用 Proxy() 構(gòu)造函數(shù),而要使用 Proxy.revocable() 工廠函數(shù)。這個函數(shù)返回一個對象,其中包含代理對象和一個 revoke() 函數(shù)。一旦調(diào)用 revoke() 函數(shù),代理立即失效:
注意,除了演示可撤銷代理,前面的代碼也演示了代理既可以封裝目標(biāo)函數(shù)也可以封裝目標(biāo)對象。但這里的關(guān)鍵是可撤銷代理充當(dāng)了某種代碼隔離的機(jī)制,而這可以在我們使用不信任的第三方庫時派上用場。如果必須向一個不受自己控制的庫傳一個函數(shù),則可以給它傳一個可撤銷代理,然后在使用完這個庫之后撤銷代理。這樣可以防止第三方庫持有對你函數(shù)的引用,在你不知道的時候調(diào)用它。這種防御型編程并非 JavaScript 程序特有的,但 Proxy 類讓它成為可能。
如果我們給 Proxy() 構(gòu)造函數(shù)傳一個非空的處理器對象,那定義的就不再是一個透明包裝器對象了,而是要在代理中實現(xiàn)自定義行為。有了恰當(dāng)自定義的處理器,底層目標(biāo)對象本質(zhì)上就變得不相干了。
例如,在下面的代碼中,我們實現(xiàn)了一個對象,讓它看起來好像有無數(shù)個只讀屬性,而每個屬性的值就是屬性的名字:

代理對象可以從目標(biāo)對象和處理器對象獲得它們的行為,到目前為止我們看到的示例都只用到它們其中一個。而同時用到這兩個對象的代理通常才更有用。
例如,下面的代碼為目標(biāo)對象創(chuàng)建了一個只讀包裝器。當(dāng)代碼嘗試從該對象讀取值時,讀取操作會正常轉(zhuǎn)發(fā)給目標(biāo)對象。但當(dāng)代碼嘗試修改對象或它的屬性時,處理器對象的方法會拋出 TypeError。類似這樣的代理在編寫測試的時候有用。假設(shè)你寫了一個函數(shù),它接收一個對象參數(shù),你希望這個函數(shù)不會以任何方式修改它收到的參數(shù)。如果你的測試接收只讀的包裝器對象,那么任何寫入操作都會拋出異常從而導(dǎo)致測試失?。?/p>
另一種使用代理的技術(shù)是為它定義處理器方法,攔截對象操作,但仍然把操作委托給目標(biāo)對象。反射 API(參見 14.6 節(jié))的函數(shù)與處理器方法具有完全相同的簽名,從而實現(xiàn)這種委托也很容易。
例如,下面這個函數(shù)返回的代理會把所有操作都委托給目標(biāo)對象,只通過處理器方法打印出執(zhí)行了什么操作:


前面定義的 loggingProxy() 函數(shù)創(chuàng)建的代理可以把使用對象的各種操作打印出來。如果你想知道某個沒有文檔的函數(shù)怎么使用你傳給它的對象,那就創(chuàng)建這樣一個日志代理吧。
來看下面的例子,通過日志可以看到數(shù)組迭代的真正過程:


根據(jù)第一段日志輸出,我們知道 Array.map() 方法會先檢查每個數(shù)組元素是否存在(因為調(diào)用了 has() 處理器),然后才真正讀取元素的值(此時觸發(fā) get() 處理器)。由此可以推斷,它能夠區(qū)分不存在的和存在但值為 undefined 的數(shù)組元素。
第二段日志輸出可以提醒我們,傳給 Array.map() 的函數(shù)在被調(diào)用時會收到 3 個參數(shù):元素的值、元素的索引和數(shù)組本身(這個日志輸出有個問題:Array.toString() 方法的返回值不包含方括號,如果輸出的參數(shù)列表是(10, 0, [10, 20]) 就更清楚了)。
第三段日志輸出告訴我們,for/of 循環(huán)依賴于一個符號名為[Symbol.iterator]的方法。同時也表明,Array 類對這個迭代器方法的實現(xiàn)在每次迭代時都會檢查數(shù)組長度,并沒有假定數(shù)組長度在迭代過程中保持不變。
7.1 代理不變式
前面定義的 readOnlyProxy() 函數(shù)創(chuàng)建的代理對象實際上是凍結(jié)的,即修改屬性值或?qū)傩蕴匦裕砑踊騽h除屬性,都會拋出異常。但是,只要目標(biāo)對象沒有被凍結(jié),那么通過 Reflect.isExtensible() 和 Reflect.getOwnPropertyDescriptor() 查詢代理對象,都會告訴我們應(yīng)該可以設(shè)置、添加或刪除屬性。也就是說,readOnlyProxy() 創(chuàng)建的對象與目標(biāo)對象的狀態(tài)不一致。為此,可以再添加 isExtensible() 和 getOwnProperty Descriptor() 處理器來消除一致,或者也可以保留這種輕微的不一致。
代理處理器 API 允許我們定義存在重要不一致的對象,但在這種情況下,Proxy 類本身會阻止我們創(chuàng)建不一致得離譜的代理對象。本節(jié)一開始,我們說代理是一種沒有自己的行為的對象,因為它們只負(fù)責(zé)把所有操作轉(zhuǎn)發(fā)給處理器對象和目標(biāo)對象。其實這么說也不全對:轉(zhuǎn)發(fā)完操作后,Proxy 類會對結(jié)果執(zhí)行合理性檢查,以確保不違背重要的 JavaScript 不變式(invariant)。如果檢查發(fā)現(xiàn)違背了,代理會拋出 TypeError(不讓操作繼續(xù))。
舉個例子,如果我們?yōu)橐粋€不可擴(kuò)展對象創(chuàng)建了代理,而它的 isExtensible() 處理器返回 true,代理就會拋出 TypeError:

相應(yīng)地,不可擴(kuò)展目標(biāo)的代理就不能定義返回目標(biāo)真正原型之外其他值的 getProto-typeOf() 處理器。同樣,如果目標(biāo)對象的某個屬性不可寫、不可配置,那么如果 get() 處理器返回了跟這個屬性的實際值不一樣的結(jié)果,Proxy 類也會拋出 TypeError:

Proxy 還遵循其他一些不變式,幾乎都與不可擴(kuò)展的目標(biāo)對象和目標(biāo)對象上不可配置的屬性有關(guān)。
我的圖床


































