[譯]ES6 中的超程式設計:第一部分 —— Symbol,了不起的 Symbol

吳曉軍發表於2017-11-17

你已經聽說過 ES6 了,是吧?這是一個在多方面表現卓著的 JavaScript 的新版本。每當在 ES6 中發現令人驚歎的新特性,我就會開始對我的同事滔滔不絕起來(但是因此佔用了別人的午休時間並不是所有人樂意的)。

一系列優秀的 ES6 的新特性都來自於新的超程式設計工具,這些工具將底層鉤子(hooks)注入到了程式碼機制中。目前,介紹 ES6 超程式設計的文章寥寥,所以我認為我將撰寫 3 篇關於它們的博文(附帶一句,我太懶了,這篇完成度 90% 的博文都在我的草稿箱裡面躺了三個月了,自打我說了要撰文之後,更多內容都已在這裡完成):

第一部分:Symbols(本篇文章)、第二部分:Reflect第三部分: Proxies

超程式設計

首先,讓我們快速認識一下超程式設計,去探索超程式設計的美妙世界。超程式設計(籠統地說)是所有關於一門語言的底層機制,而不是資料建模或者業務邏輯那些高階抽象。如果程式可以被描述為 “製作程式”,超程式設計就能被描述為 “讓程式來製作程式”。你可能已經在日常程式設計中不知不覺地使用到了超程式設計。

超程式設計有一些 “子分支(subgenres)” —— 其中之一是 程式碼生成(Code Generation),也稱之為 eval —— JavaScript 在一開始就擁有程式碼生成的能力(JavaScript 在 ES1 中就有了 eval,它甚至早於 try/catchswitch 的出現)。目前,其他一些流行的程式語言都具有 程式碼生成 的特性。

超程式設計另一個方面是反射(Reflection) —— 其用於發現和調整你的應用程式結構和語義。JavaScript 有幾個工具來完成反射。函式有 Function#nameFunction#length、以及 Function#bindFunction#callFunctin#apply。所有 Object 上可用的方法也算是反射,例如 Object.getOwnProperties。JavaScript 也有反射/內省運算子,如 typeofinstancesof 以及 delete

反射是超程式設計中非常酷的一部分,因為它允許你改變應用程式的內部工作機制。以 Ruby 為例,你可以宣告一個運算子作為方法,從而重寫該運算子針對這個類的工作機制(這一手段通常稱為 “運算子過載”):

class BoringClass
end
class CoolClass
  def ==(other_object)
   other_object.is_a? CoolClass
  end
end
BoringClass.new == BoringClass.new #=> false
CoolClass.new == CoolClass.new #=> true!複製程式碼

對比到其他類似 Ruby 或者 Python 的語言,JavaScript 的超程式設計特性要落後不少 —— 尤其考慮到它缺乏諸如運算子過載這樣的好工具時更是如此,但是 ES6 開始幫助 JavaScript 在超程式設計上趕上其他語言。

ES6 下的超程式設計

ES6 帶來了三個全新的 API:SymbolReflect、以及 Proxy。剛看到它們時會有些疑惑 —— 這三個 API 都是服務於超程式設計的嗎?如果你分開看這幾個 API,你不難發現它們確實很有意義:

  • Symbols 是 實現了的反射(Reflection within implementation)—— 你將 Symbols 應用到你已有的類和物件上去改變它們的行為。
  • Reflect 是 通過自省(introspection)實現反射(Reflection through introspection) —— 通常用來探索非常底層的程式碼資訊。
  • Proxy 是 通過調解(intercession)實現反射(Reflection through intercession) —— 包裹物件並通過自陷(trap)來攔截物件行為。

所以,它們是怎麼工作的?它們又是怎麼變得有用的?這邊文章將討論 Symbols,而後續兩篇文章則分別討論反射和代理。

Symbols —— 實現了的反射

Symbols 是新的原始型別(primitive)。就像是 NumberString、和 Boolean 一樣。Symbols 具有一個 Symbol 函式用於建立 Symbol。與別的原始型別不同,Symbols 沒有字面量語法(例如,String 有 '')—— 建立 Symbol 的唯一方式是使用類似建構函式而又非建構函式的 Symbol 函式:

Symbol(); // symbol
console.log(Symbol()); // 輸出 "Symbol()" 至控制檯
assert(typeof Symbol() === 'symbol')
new Symbol(); // TypeError: Symbol is not a constructor複製程式碼

Symbols 擁有內建的 debug 能力

Symbols 可以指定一個描述,這在 debug 時很有用,當我們能夠輸出更有用的資訊到控制檯時,我們的程式設計體驗將更為友好:

console.log(Symbol('foo')); // 輸出 "Symbol(foo)" 至控制檯
assert(Symbol('foo').toString() === 'Symbol(foo)');複製程式碼

Symbols 能被用作物件的 key

這是 Symbols 真正有趣之處。它們和物件緊密的交織在一起。Symbols 能用作物件的 key (類似字串 key),這意味著你可以分配無限多的具有唯一性的 Symbols 到一個物件上,這些 key 保證不會和現有的字串 key 衝突,或者和其他 Symbol key 衝突:

var myObj = {};
var fooSym = Symbol('foo');
var otherSym = Symbol('bar');
myObj['foo'] = 'bar';
myObj[fooSym] = 'baz';
myObj[otherSym] = 'bing';
assert(myObj.foo === 'bar');
assert(myObj[fooSym] === 'baz');
assert(myObj[otherSym] === 'bing');複製程式碼

另外,Symbols key 無法通過 for infor of 或者 Object.getOwnPropertyNames 獲得 —— 獲得它們的唯一方式是 Object.getOwnPropertySymbols

var fooSym = Symbol('foo');
var myObj = {};
myObj['foo'] = 'bar';
myObj[fooSym] = 'baz';
Object.keys(myObj); // -> [ 'foo' ]
Object.getOwnPropertyNames(myObj); // -> [ 'foo' ]
Object.getOwnPropertySymbols(myObj); // -> [ Symbol(foo) ]
assert(Object.getOwnPropertySymbols(myObj)[0] === fooSym);複製程式碼

這意味著 Symbols 能夠給物件提供一個隱藏層,幫助物件實現了一種全新的目的 —— 屬性不可迭代,也不能夠通過現有的反射工具獲得,並且能被保證不會和物件任何已有屬性衝突。

Symbols 是完全唯一的......

預設情況下,每一個新建立的 Symbol 都有一個完全唯一的值。如果你新建立了一個 Symbol(var mysym = Symbol()),在 JavaScript 引擎內部,就會建立一個全新的值。如果你不保留 Symbol 物件的引用,你就無法使用它。這也意味著兩個 Symbol 將絕不會等同於同一個值,即使它們有一樣的描述:

assert.notEqual(Symbol(), Symbol());
assert.notEqual(Symbol('foo'), Symbol('foo'));
assert.notEqual(Symbol('foo'), Symbol('bar'));

var foo1 = Symbol('foo');
var foo2 = Symbol('foo');
var object = {
    [foo1]: 1,
    [foo2]: 2,
};
assert(object[foo1] === 1);
assert(object[foo2] === 2);複製程式碼

......等等,也有例外

稍安勿躁,這有一個小小的警告 —— JavaScript 也有另一個建立 Symbol 的方式來輕易地實現 Symbol 的獲得和重用:Symbol.for()。該方法在 “全域性 Symbol 註冊中心” 建立了一個 Symbol。額外注意的一點:這個註冊中心也是跨域的,意味著 iframe 或者 service worker 中的 Symbol 會與當前 frame Symbol 相等:

assert.notEqual(Symbol('foo'), Symbol('foo'));
assert.equal(Symbol.for('foo'), Symbol.for('foo'));

// 不是唯一的:
var myObj = {};
var fooSym = Symbol.for('foo');
var otherSym = Symbol.for('foo');
myObj[fooSym] = 'baz';
myObj[otherSym] = 'bing';
assert(fooSym === otherSym);
assert(myObj[fooSym] === 'bing');
assert(myObj[otherSym] === 'bing');

// 跨域
iframe = document.createElement('iframe');
iframe.src = String(window.location);
document.body.appendChild(iframe);
assert.notEqual(iframe.contentWindow.Symbol, Symbol);
assert(iframe.contentWindow.Symbol.for('foo') === Symbol.for('foo')); // true!複製程式碼

全域性 Symbol 會讓東西變得更加複雜,但我們又捨不得它好的方面。現在,你們當中的一些人可能會說:“我要怎樣知道哪些 Symbol 是唯一的,哪些不是?”,對此,我會說 “別擔心,我們還有 Symbol.keyFor()”:

var localFooSymbol = Symbol('foo');
var globalFooSymbol = Symbol.for('foo');

assert(Symbol.keyFor(localFooSymbol) === undefined);
assert(Symbol.keyFor(globalFooSymbol) === 'foo');
assert(Symbol.for(Symbol.keyFor(globalFooSymbol)) === Symbol.for('foo'));複製程式碼

Symbols 是什麼,又不是什麼?

上面我們對於 Symbol 是什麼以及它們如何工作有一個概覽,但更重要的是,我們得知道 Symbol 適合和不適合什麼場景,如果認識寥寥,很可能會對 Symbol 產生誤區:

  • Symbols 絕不會與物件的字串 key 衝突。這一特性讓 Symbol 在擴充套件已有物件時表現卓著(例如,Symbol 作為了一個函式引數),它不會顯式地影響到物件:

  • Symbols 無法通過現有的反射工具讀取。你需要一個新的方法 Object.getOwnPropertySymbols() 來訪問物件上的 Symbols,這讓 Symbol 適合儲存那些你不想讓別人直接獲得的資訊。使用 Object.getOwnPropertySymbols() 是一個非常特殊的用例,一般人可不知道。

  • Symbols 不是私有的。作為雙刃劍的另一面 —— 物件上所有的 Symbols 都可以直接通過 Object.getOwnPropertySymbols() 獲得 —— 這不利於我們使用 Symbol 儲存一些真正需要私有化的值。不要嘗試使用 Symbols 儲存物件中需要真正私有化的值 —— Symbol 總能被拿到。

  • 可列舉的 Symbols 能夠被複制到其他物件,複製會通過類似這樣的 Object.assign 新方法完成。如果你嘗試呼叫 Object.assign(newObject, objectWithSymbols),並且所有的可迭代的 Symbols 作為了第二個引數(objectWithSymbols)傳入,這些 Symbols 會被複制到第一個引數(newObject)上。如果你不想要這種情況發生,就用 Obejct.defineProperty 來讓這些 Symbols 變得不可迭代。

  • Symbols 不能強制型別轉換為原始物件。如果你嘗試強制轉換一個 Symbol 為原始值物件(+Symbol()-Symbol()Symbol() + 'foo'),將會丟擲一個錯誤。這防止你將 Symbol 設定為物件屬性名時,不小心字串化了(stringify)它們。(譯註:經 @Raoul1996 測試,Symbol 可以被轉化為 bool 值(typeof !!Symbol('') === 'boolean'),因此原文作者在此的描述稍顯武斷)

  • Symbols 不總是唯一的。上文中就提到過了,Symbol.for() 將為你返回一個不唯一的 Symbol。不要總認為 Symbol 具有唯一性,除非你自己能夠保證它的唯一性。

  • Symbols 與 Ruby 的 Symbols 不是一回事。二者有一些共性,例如都有一個 Symbol 註冊中心,但僅僅如此。JavaScript 中 Symbol 不能當做 Ruby 中 Symbol 去使用。

Symbols 真正適合的是什麼?

現實中,Symbols 只是一個略有不同繫結物件屬性的方式 —— 你能夠輕易地提供一些著名的 Symbols(例如 Symbols.iterator) 作為標準方法,正如 Object.prototype.hasOwnProperty 這個方法就出現在了所有繼承自 Object 的物件(繼承自 Object,基本上也就意味著一切物件都有 hasOwnProperty 這個方法了)。實際上,例如 Python 這樣的語言是這樣提供標準方法的 —— 在 Python 中,等同於 Symbol.iterator 的是 __iter__,等同於 Symbole.hasInstance 的是 __instancecheck__,並且我猜 __cmp__ 也類似於 Symbole.toPrimitive。Python 的這個做法可能是一種較差的做法,而 JavaScript 的 Symbols 不需要依賴任何古怪的語法就能提供標準方法,並且,任何情況下使用者都不會和這些標準方法遭遇衝突。

在我看來,Symbols 可以被用在下面兩個場景:

1. 作為一個可替換字串或者整型使用的唯一值

假定你有一個日誌庫,該庫包含了多個日誌級別,例如 logger.levels.DEBUGlogger.levels.INFOlogger.levels.WARN 等等。在 ES5 中,你通過字串或者整型設定或者判斷級別:logger.levels.DEBUG === 'debug'logger.levels.DEBUG === 10。這些方式都不是理想方式,因為它們不能保證級別取值唯一,但是 Symbols 的唯一效能夠出色地完成這個任務!現在 logger.levels 變成了:

log.levels = {
    DEBUG: Symbol('debug'),
    INFO: Symbol('info'),
    WARN: Symbol('warn'),
};
log(log.levels.DEBUG, 'debug message');
log(log.levels.INFO, 'info message');複製程式碼

2. 作為一個物件中放置元資訊(metadata)的場所

你也可以用 Symbol 來儲存一些對於真實物件來說較為次要的元資訊屬性。把這看作是不可迭代性的另一層面(畢竟,不可迭代的 keys 仍然會出現在 Object.getOwnProperties 中)。讓我們建立一個可靠的集合類,併為其新增一個 size 引用來獲得集合規模這一元資訊,該資訊藉助於 Symbol 不會暴露給外部(只要記住,Symbols 不是私有的 —— 並且只有當你不在乎應用的其他部分會修改到 Symbols 屬性時,再使用 Symbol):

var size = Symbol('size');
class Collection {
    constructor() {
        this[size] = 0;
    }

    add(item) {
        this[this[size]] = item;
        this[size]++;
    }

    static sizeOf(instance) {
        return instance[size];
    }

}

var x = new Collection();
assert(Collection.sizeOf(x) === 0);
x.add('foo');
assert(Collection.sizeOf(x) === 1);
assert.deepEqual(Object.keys(x), ['0']);
assert.deepEqual(Object.getOwnPropertyNames(x), ['0']);
assert.deepEqual(Object.getOwnPropertySymbols(x), [size]);複製程式碼

3. 給予開發者在 API 中為物件新增鉤子(hook)的能力

這聽起來有點奇怪,但大家不妨多點耐心,聽我解釋。假定我們有一個 console.log 風格的工具函式 —— 這個函式可以接受 任何 物件,並將其輸出到控制檯。它有自己的機制去決定如何在控制檯顯示物件 —— 但是你作為一個使用該 API 的開發者,得益於 inspect Symbol 實現的一個鉤子,你能夠提供一個方法去重寫顯示機制 :

// 從 API 的 Symbols 常量中獲得這個充滿魔力的 Inspect Symbol
var inspect = console.Symbols.INSPECT;

var myVeryOwnObject = {};
console.log(myVeryOwnObject); // 日誌 `{}`

myVeryOwnObject[inspect] = function () { return 'DUUUDE'; };
console.log(myVeryOwnObject); // 日誌輸出 `DUUUDE`複製程式碼

這個審查(inspect)鉤子大致實現如下:

console.log = function (…items) {
    var output = '';
    for(const item of items) {
        if (typeof item[console.Symbols.INSPECT] === 'function') {
            output += item[console.Symbols.INSPECT](item);
        } else {
            output += console.inspect[typeof item](item);
        }
        output += '  ';
    }
    process.stdout.write(output + '\n');
}複製程式碼

需要說明的是,這不意味著你應該寫一些會改變給定物件的程式碼。這是決不允許的事(對於此,可以看下 WeakMaps,它為你提供了輔助物件來收集你自己在物件上定義的元資訊)。

譯註:如果你對 WeakMap 存有疑惑,可以參看 stackoverflow —— What are the actual uses of ES6 WeakMap?

Node.js 已經在其 console.log 中已經有了類似的實現。其使用了一個字串('inspect')而不是 Symbol,這意味著你可以設定 x.inspect = function(){} —— 這不是聰明的做法,因為某些時候,這可能會和你的類方法衝突。而使用 Symbol 是一個非常有前瞻性的方式來防止這樣的情況發生

這樣使用 Symbols 的方式是意義深遠的,這已經成為了這門語言的一部分,藉此,我們開始深入到一些有名的 Symbol 中去。

內建的 Symbols

一個使 Symbols 有用的關鍵部分就是一系列的 Symbol 常量,這些常量被稱為 “內建的 Symbols”。這些常量實際上是一堆在 Symbol 類上的由其他諸如陣列(Array),字串(String)等原生物件以及 JavaScript 引擎內部實現的靜態方法。這就是真正 “實現了的反射(Reflection within Implementation)” 一部分發生的地方,因為這些內建的 Symbol 改變了 JavaScript 內部行為。接下來,我將詳述每個 Symbol 做了什麼以及為何這些 Symbols 是如此的棒。

Symbol.hasInstance: instanceof

Symbol.hasInstance 是一個實現了 instanceof 行為的 Symbol。當一個相容 ES6 的引擎在某個表示式中看到了 instanceof 運算子,它會呼叫 Symbol.hasInstance。例如,表示式 lho instanceof rho 將會呼叫 rho[Symbol.hasInstance](lho)rho 是運算子的右運算元,而 lho 則是左運算數)。然後,該方法能夠決定是否某個物件繼承自某個特殊例項,你可以像下面這樣實現這個方法:

class MyClass {
    static [Symbol.hasInstance](lho) {
        return Array.isArray(lho);
    }
}
assert([] instanceof MyClass);複製程式碼

Symbol.iterator

如果你或多或少聽說過了 Symbols,你很可能聽說的是 Symbol.iterator。ES6 帶來了一個新的模式 —— for of 迴圈,該迴圈是呼叫 Symbol.iterator 作為右手運算元來取得當前值進行迭代的。換言之,下面兩端程式碼是等效的:

var myArray = [1,2,3];

// 使用 `for of` 的實現
for(var value of myArray) {
    console.log(value);
}

// 沒有 `for of` 的實現
var _myArray = myArray[Symbol.iterator]();
while(var _iteration = _myArray.next()) {
    if (_iteration.done) {
        break;
    }
    var value = _iteration.value;
    console.log(value);
}複製程式碼

Symbol.ierator 將允許你重寫 of 運算子 —— 這意味著如果你使用它來建立一個庫,那麼開發者愛死你了:

class Collection {
  *[Symbol.iterator]() {
    var i = 0;
    while(this[i] !== undefined) {
      yield this[i];
      ++i;
    }
  }

}
var myCollection = new Collection();
myCollection[0] = 1;
myCollection[1] = 2;
for(var value of myCollection) {
    console.log(value); // 1, then 2
}複製程式碼

Symbol.isConcatSpreadable

Symbol.isConcatSpreadable 是一個非常特別的 Symbol —— 驅動了 Array#concat 的行為。正如你所見到的,Array#concat 能夠接收多個引數,如果你傳入的引數是多個陣列,那麼這些陣列會被展平,又在之後被合併。考慮到下面的程式碼:

x = [1, 2].concat([3, 4], [5, 6], 7, 8);
assert.deepEqual(x, [1, 2, 3, 4, 5, 6, 7, 8]);複製程式碼

在 ES6 下,Array#concat 將利用 Symbol.isConcatSepreadable 來決定它的引數是否可展開。關於此,應該說是你的繼承自 Array 的類不是特別適用於 Array#concat,而非其他理由:

class ArrayIsh extends Array {
    get [Symbol.isConcatSpreadable]() {
        return true;
    }
}
class Collection extends Array {
    get [Symbol.isConcatSpreadable]() {
        return false;
    }
}
arrayIshInstance = new ArrayIsh();
arrayIshInstance[0] = 3;
arrayIshInstance[1] = 4;
collectionInstance = new Collection();
collectionInstance[0] = 5;
collectionInstance[1] = 6;
spreadableTest = [1,2].concat(arrayInstance).concat(collectionInstance);
assert.deepEqual(spreadableTest, [1, 2, 3, 4, <Collection>]);複製程式碼

Symbol.unscopables

這個 Symbol 有一些有趣的歷史。實際上,當開發 ES6 的時候,TC(Technical Committees:技術委員會)發現在一些流行的 JavaScript 庫中,有這樣一些老程式碼:

var keys = [];
with(Array.prototype) {
    keys.push('foo');
}複製程式碼

這個程式碼在 ES5 或者更早版本的 JavaSacript 中工作良好,但是 ES6 現在有了一個 Array#keys —— 這意味著當你執行 with(Array.prototype) 時,keys 指代的是 Array 原型上的 keys 方法,即 Array#keys ,而不是 with 外部你定義的 keys。有三個辦法解決這個問題:

  1. 檢索所有使用了該程式碼的網站,升級對應的程式碼庫。(這基本是不可能的)
  2. 刪除 Array#keys ,並祈禱類似 bug 不會出現。(這也沒有真正解決這個問題)
  3. 寫一個 hack 包裹所有這樣的程式碼,防止 keys 出現在 with 語句的作用域中。

技術委員會選擇的是第三種方式,因此 Symbol.unscopables 應運而生,它為物件定義了一系列 “unscopable(不被作用域的)” 的值,當這些值用在了 with 語句中,它們不會被設定為物件上的值。你幾乎用不到這個 Symbol —— 在日常的 JavaScript 程式設計中,你也遇不到這樣的情況,但是這仍然體現了 Symbols 的用法,並且保障了 Symbol 的完整性:

Object.keys(Array.prototype[Symbol.unscopables]); // -> ['copyWithin', 'entries', 'fill', 'find', 'findIndex', 'keys']

// 不使用 unscopables:
class MyClass {
    foo() { return 1; }
}
var foo = function () { return 2; };
with (MyClass.prototype) {
    foo(); // 1!!
}

// 使用 unscopables:
class MyClass {
    foo() { return 1; }
    get [Symbol.unscopables]() {
        return { foo: true };
    }
}
var foo = function () { return 2; };
with (MyClass.prototype) {
    foo(); // 2!!
}複製程式碼

Symbol.match

這是另一個針對於函式的 Symbol。String#match 函式將能夠自定義 macth 規則流判斷給定的值是否匹配。現在,你能夠實現自己的匹配策略,而不是使用正規表示式:

class MyMatcher {
    constructor(value) {
        this.value = value;
    }
    [Symbol.match](string) {
        var index = string.indexOf(this.value);
        if (index === -1) {
            return null;
        }
        return [this.value];
    }
}
var fooMatcher = 'foobar'.match(new MyMatcher('foo'));
var barMatcher = 'foobar'.match(new MyMatcher('bar'));
assert.deepEqual(fooMatcher, ['foo']);
assert.deepEqual(barMatcher, ['bar']);複製程式碼

Symbol.replace

Symbol.match 類似,Symbol.replace 也允許傳遞自定義的類來完成字串的替換,而不僅是使用正規表示式:

class MyReplacer {
    constructor(value) {
        this.value = value;
    }
    [Symbol.replace](string, replacer) {
        var index = string.indexOf(this.value);
        if (index === -1) {
            return string;
        }
        if (typeof replacer === 'function') {
            replacer = replacer.call(undefined, this.value, string);
        }
        return `${string.slice(0, index)}${replacer}${string.slice(index + this.value.length)}`;
    }
}
var fooReplaced = 'foobar'.replace(new MyReplacer('foo'), 'baz');
var barMatcher = 'foobar'.replace(new MyReplacer('bar'), function () { return 'baz' });
assert.equal(fooReplaced, 'bazbar');
assert.equal(barReplaced, 'foobaz');複製程式碼

Symbol.search

Symbol.matchSymbol.replace 類似,Symbol.search 增強了 String#search —— 允許傳入自定義的類替代正規表示式:

class MySearch {
    constructor(value) {
        this.value = value;
    }
    [Symbol.search](string) {
        return string.indexOf(this.value);
    }
}
var fooSearch = 'foobar'.search(new MySearch('foo'));
var barSearch = 'foobar'.search(new MySearch('bar'));
var bazSearch = 'foobar'.search(new MySearch('baz'));
assert.equal(fooSearch, 0);
assert.equal(barSearch, 3);
assert.equal(bazSearch, -1);複製程式碼

Symbol.split

現在到了最後一個字串相關的 Symbol 了 —— Symbol.split 對應於 String#split。用法如下:

class MySplitter {
    constructor(value) {
        this.value = value;
    }
    [Symbol.split](string) {
        var index = string.indexOf(this.value);
        if (index === -1) {
            return string;
        }
        return [string.substr(0, index), string.substr(index + this.value.length)];
    }
}
var fooSplitter = 'foobar'.split(new MySplitter('foo'));
var barSplitter = 'foobar'.split(new MySplitter('bar'));
assert.deepEqual(fooSplitter, ['', 'bar']);
assert.deepEqual(barSplitter, ['foo', '']);複製程式碼

Symbol.species

Symbol.species 是一個非常機智的 Symbol,它指向了一個類的建構函式,這允許類能夠建立屬於自己的、某個方法的新版本。以 Array#map 為例,其能建立一個新的陣列,新陣列中的值來源於傳入的回撥函式每次的返回值 —— ES5 的 Array#map 實現可能是下面這個樣子:

Array.prototype.map = function (callback) {
    var returnValue = new Array(this.length);
    this.forEach(function (item, index, array) {
        returnValue[index] = callback(item, index, array);
    });
    return returnValue;
}複製程式碼

ES6 中的 Array#map,以及其他所有的不可變 Array 方法(如 Array#filter 等),都已經更新到了使用 Symbol.species 屬性來建立物件,因此,ES6 中的 Array#map 實現可能如下:

Array.prototype.map = function (callback) {
    var Species = this.constructor[Symbol.species];
    var returnValue = new Species(this.length);
    this.forEach(function (item, index, array) {
        returnValue[index] = callback(item, index, array);
    });
    return returnValue;
}複製程式碼

現在,如果你寫了 class Foo extends Array —— 每當你呼叫 Foo#map,在其返回一個 Array 型別(這並不是我們想要的)的陣列之前,你本該撰寫一個自己的 Map 實現來建立 Foo 的型別陣列而不是 Array 類的陣列,但現在,有了 Sympbol.speciesFoo#map 能夠直接返回了一個 Foo 型別的陣列:

class Foo extends Array {
    static get [Symbol.species]() {
        return this;
    }
}

class Bar extends Array {
    static get [Symbol.species]() {
        return Array;
    }
}

assert(new Foo().map(function(){}) instanceof Foo);
assert(new Bar().map(function(){}) instanceof Bar);
assert(new Bar().map(function(){}) instanceof Array);複製程式碼

可能你會問,為什麼使用 this.constructor 來替代 this.constructor[Symbol.species]Symbol.species 為需要建立的型別提供了 可定製的 入口 —— 可能你不總是想用子類以及建立子類的方法,以下面這段程式碼為例:

class TimeoutPromise extends Promise {
    static get [Symbol.species]() {
        return Promise;
    }
}複製程式碼

這個 timeout promise 可以建立一個延時的操作 —— 當然,你不希望某個 Promise 會對整個 Prmoise 鏈上的後續的 Promise 造成延時,所以 Symbol.species 能夠用來告訴 TimeoutPromise 從其原型鏈方法返回一個 Promise(譯註:如果返回的是 TimeoutPromise,那麼由 Promise#then 串聯的 Promise 鏈上每個 Promise 都是 TimeoutPromise)。這實在是太方便了。

Symbol.toPrimitive

這個 Symbol 為我們提供了過載抽象相等性運算子(Abstract Equality Operator,簡寫是 ==)。基本上,當 JavaScript 引擎需要將你物件轉換為原始值時,Symbol.toPrimitive 會被用到 —— 例如,如果你執行 +object ,那麼 JavaScript 會呼叫 object[Symbol.toPrimitive]('number');,如果你執行 ''+object ,那麼 JavaScript 會呼叫 object[Symbol.toPrimive]('string'),而如果你執行 if(object),JavaScript 則會呼叫 object[Symbol.toPrimitive]('default')。在此之前,我們有 valueOftoString 來處理這些情況,但是二者多少有些粗糙並且你可能從不會從它們中獲得期望的行為。Symbol.toPrimitive 的實現如下:

class AnswerToLifeAndUniverseAndEverything {
    [Symbol.toPrimitive](hint) {
        if (hint === 'string') {
            return 'Like, 42, man';
        } else if (hint === 'number') {
            return 42;
        } else {
            // 大多數類(除了 Date)都預設返回一個數值原始值
            return 42;
        }
    }
}

var answer = new AnswerToLifeAndUniverseAndEverything();
+answer === 42;
Number(answer) === 42;
''+answer === 'Like, 42, man';
String(answer) === 'Like, 42, man';複製程式碼

Symbol.toStringTag

這是最後一個內建的 Symbol。 Symbol.toStringTag 確實是一個非常酷的 Symbol —— 如果你尚未嘗試實現一個你自己的用於替代 typeof 運算子的型別判斷,你可能會用到 Object#toString() —— 它返回的是奇怪的 '[object Object]' 或者 '[object Array]' 這樣奇怪的字串。在 ES6 之前,該方法的行為隱藏在了你看不到實現細節中,但在今天,在 ES6 的樂園中,我們有了一個 Symbol 來左右它的行為!任何傳遞到 Object#toString() 的物件將會被檢查是否有一個 [Symbol.toStringTag] 屬性,這個屬性是一個字串 ,如果有,那麼將使用該字串作為 Object#toString() 的結果,例子如下:

class Collection {

  get [Symbol.toStringTag]() {
    return 'Collection';
  }

}
var x = new Collection();
Object.prototype.toString.call(x) === '[object Collection]'複製程式碼

關於此的另一件事兒是 —— 如果你使用了 Chai 來做測試,它現在已經在底層使用了 Symbol 來做型別檢測,所以,你能夠在你的測試中寫 expect(x).to.be.a('Collection')x 有一個類似上面 Symbol.toStringTag 的屬性,這段程式碼需要執行在支援該 Symbol 的瀏覽器上)。

缺失的 Symbol:Symbol.isAbstractEqual

你可能已經知曉了 ES6 中的 Symbol 的意義和用法,但我真的很喜歡 Symbol 中有關反射的想法,因此還想再多說兩句。對於我來說,這還缺失了一個我會為之興奮的 Symbol:Symbol.isAbstractEqual。這個 Symbol 能夠讓抽象相等性運算子(==)重現榮光。像 Ruby、Python 等語言那樣,我們能夠用我們自己的方式,針對我們自己的類,使用它。當你看見諸如 lho == rho 這樣的程式碼時,JavaScript 能夠轉換為 rho[Symbol.isAbstractEqual](lho),允許類過載運算子 == 的意義。這可以以一種向後相容的方式實現 —— 通過為所有現在的原始值原型(例如 Number.prototype)定義預設值,該 Symbol 將使得很多規範更加清晰,並給開發者一個重新拾回 == 使用的理由。

結論

你是怎樣看待 Symbols 的?仍然疑惑不解嗎?想對某人大聲發洩嗎? 我是 Titterverse 上的 @keithamus —— 你可以在上面隨便叨擾我,說不準某天我就會花上整個午餐時間來告訴你我最喜歡的那些 ES6 新特性。

現在,你已經閱讀完了所有關於 Symbols 的東西,接下來你就該閱讀 第二部分 —— Reflect 了。

最後我也要感謝那些優秀的開發者 @focusaurus@mttshw, @colby_russell@mdmazzola,以及 @WebReflection 對於該文的校對和提升。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章