ES6之Symbol詳解

一、什麼是Symbol?

Symbol是ES6中引入的一種新的基本數據類型,用於表示一個獨一無二的值。它是JavaScript中的第七種數據類型,與undefined、null、Number(數值)、String(字符串)、Boolean(布爾值)、Object(對象)並列。

你可以這樣創建一個Symbol值:

const a = Symbol();

console.log(a);  //Symbol()

使用Symbol函數可以生成一個Symbol類型的值,但是你不能在調用Symbol時使用new關鍵字,因爲Symbol是基本數據類型,而不是對象。比如下面的寫法是錯誤的:

//報錯,Symbol is not a constructor
const a = new Symbol();

使用Symbol()創建一個Symbol類型的值並賦值給a變量後,你就得到了一個在內存中獨一無二的值。現在除了通過變量a,任何人在任何作用域內都無法重新創建出這個值。例如當你這樣寫:

const b = Symbol();

儘管a和b都是使用Symbol()創建出來的,但是它們在內存中看起來卻是這樣的:
在這裏插入圖片描述
實際上,a變量拿到了內存中某塊內存的唯一引用(這裏所說的引用,其實就是該內存的地址)。如果不借助a變量,你不可能再得到這個地址。因此:

a !== b;  //a和b持有的是兩塊內存的引用

const c = a;  //手動把a裏保存的地址保存在c變量中
a === c;  //c和a現在指向同一塊內存,因爲它們保存了同樣的地址

在這裏插入圖片描述
這種行爲看似難以理解,但其實它與對象遵循相同的規則,如:

var a = {};
var b = {};

a !== b;  //a和b各自被分配了不同的內存,因此它們保存了不同的地址

//藉助變量a,變量c拿到了a指向的那個對象的地址,因此兩者相等
var c = a;
a === c;

但是對於同爲基本數據類型的字符串來說,它不遵循類似的規則。

比如:

var a = "123";
var b = "123";

a === b;  //返回true。兩者在常量區引用同一個字符串

在這裏插入圖片描述
我們首先通過變量a在內存中創建了字符串“123”,然後在不借助變量a的情況下,又通過var b = "123"拿到了對“123”這個字符串的引用,兩者指向內存中的同一塊內存地址。

因此我們說,a無法確保別的變量無法拿到它保存的地址(前提是不通過a)。但是對於var a = Symbol()這樣的語句,a變量內保存的值是唯一的,因爲除了藉助a變量,你永遠無法得到a中保存的值。這也是Symbol的本質。

可能很多人比較奇怪,一個Symbol類型的變量裏面到底保存了什麼呢?

我們看兩行代碼:

var a = Symbol();

console.log(a);  //Symbol()

我們試圖輸出a的值,但js引擎輸出了Symbol()。顯然它不能說明a的值是字符串,因爲:

typeof a === "symbol";

所以說如果你想問js引擎a的值是多少,引擎只會告訴你它是一個Symbol類型的值。也就是說,Symbol真正存儲了什麼並不重要,重要的是它的值永遠不會與別的值相等。Symbol的中文釋義爲“標誌,符號”,一個Symbol類型的變量只是爲了標記一塊唯一的內存而存在的。也正是因爲這樣,Symbol類型的值不參與運算。

二、Symbol的作用

上面我們說到,Symbol只是標記一塊內存,不能與其他數據類型進行運算,那麼新增這樣一種數據類型有什麼用呢?

舉個例子:

//文件A.js
var a = {
	name: "夕山雨",
    getName(){
        return this.name;
    }
}
exports default a;

//文件B.js
var a = require("A.js");
a.getName = function(){
    return "xxx";
}

由於getName這個鍵本質上只是個字符串,而無論在哪個模塊或作用域內,都可以直接引用到“getName”這個字符串,因此字符串類型的屬性很容易被意外覆蓋。

但是如果a中的屬性是使用Symbol類型的變量作爲鍵,那麼它就無法被篡改:

//模塊A.js
var s = Symbol();
var a = {
	name: "夕山雨",
	//s是個變量,因此需要用中括號包裹起來
    [s]: function(){  
        return this.name;
    }
}
exports default a;

//模塊B.js
var a = require("A.js");
var s = Symbol();

a[s] = function(){
    ...  //它不會對A模塊中的[s]屬性造成任何影響,因爲兩個模塊的[s]不是同一個屬性
}

現在,我們使用一個Symbol類型的變量作爲對象屬性的鍵。由於s是一個變量,而不是字符串,因此需要使用中括號括起來(否則它會被當做字符串對待)。

在模塊B中我們使用同樣的語句var s = Symbol();創建了一個同名變量s,“企圖”通過爲a[s]重新賦值覆蓋a對象上原來的[s]屬性,但這並不能生效,因爲模塊A中的變量s和模塊B中的變量s是各自獨立的Symbol,他們並不相等,因此無法覆蓋。

根本原因在任何情況下都滿足:

"getName" === "getName"
//而
Symbol() !== Symbol() //該行爲類似{} !== {}

通過把對象的屬性的鍵值設置爲Symbol類型,我們有效避免了對象屬性被修改,在模塊化開發中,對象本身也就更安全。

現在,在模塊A中,我們可以像訪問普通屬性一樣用a[s]訪問該屬性,在其他模塊中,由於引用不到變量s,因此不可以直接訪問該屬性。此時的內存引用情況大致如下:
在這裏插入圖片描述
內存形成過程爲:

  1. var s = Symbol();語句在內存中創建了一個Symbol類型的變量,並將地址保存在變量s中。

  2. var a = {
    [s]: function(){ … }
    }
    該語句爲對象a添加了一個[s]屬性。在內存中,js引擎首先需要開闢一塊內存保存這個匿名函數,然後在對象a中添加一對鍵值對,它的鍵是變量s所指向的內存地址,而值是上述匿名函數在內存中的地址。

通常來說,如果想要修改對象的某個屬性,那麼你首先需要獲得這個屬性的鍵,參考上面的內存圖,實際上就是獲得這個鍵在內存中的地址(也就是變量s指向的那個內存區)。

傳統方式下,我們以字符串作爲對象屬性的鍵。這樣,我們只要能得到這個字符串在內存中的地址,就可以訪問該屬性。由於同一個字符串只會在常量區生成一次,因此我們可以在任何時候通過以下方式得到“getName”這個字符串在內存中的地址:

var x = "getName";

a[x]
//即使不借助中間變量,也可以拿到該字符串在內存中的地址
a["getName"] 
a.getName

這樣在不同模塊下,修改該屬性就變得很簡單。

而使用Symbol類型數據作爲鍵,該鍵的內存地址只會被當前作用域的變量s引用,在其他作用域由於無法引用到這裏的變量s,也就無法訪問對象的這個屬性。

除了上面最常見的用法,Symbol還可以用於消除“魔術字符串”。所謂“魔術字符串”,就是與代碼緊密耦合在一起的某個具體的字符串(或者數字,因爲往往難以解釋它爲什麼出現,以及代表什麼含義,所以被稱爲魔術字符串),如:

...
switch (shape) {
    case 'Triangle': // 魔術字符串
      area = .5 * options.width * options.height;
      break;
    /* ... more code ... */
  }

這樣的“魔術字符串”會造成代碼難以維護。常見的解決辦法是使用一個變量來代替,如:

const shapeType = {
  triangle: 'Triangle'
};

switch (shape) {
    case shapeType.triangle:  //消除了一個魔術字符串
      area = .5 * options.width * options.height;
      break;
    /* ... more code ... */
  }

實際上,變量triangle的值等於什麼並不重要,我們的真正目的是比較switch內的變量shape與shapeType.triangle是否相等,而不在乎它們的值是否都是“Triangle”。因此上述對象shapeType可以用下面的對象代替:

const shapeType = {
  triangle: Symbol()
};

只要shapeType的所有屬性值都不相等,就不需要對代碼做其他修改。

三、Symbol的語法規範

1. 基本語法

上面介紹到,使用如下語法即可創建一個Symbol變量:

var s = Symbol();

由於Symbol不是繼承自Object,因此不可以使用new關鍵字來生成Symbol變量。使用上述語句創建的變量s,在控制檯中進行輸出時會顯示爲Symbol()。假如有另一個變量:

var b = Symbol();

console.log(s);  //Symbol()
console.log(b);  //Symbol()

變量s和變量b並不是同一個值,但它們在控制檯的輸出卻是一樣的,這樣不利於我們區分兩個變量。爲此,我們可以在調用Symbol的時候傳入一個字符串作爲對當前Symbol變量的描述:

var s = Symbol("symbol1");
var b = Symbol("symbol2");

console.log(s); //Symbol("symbol1")
console.log(b); //Symbol("symbol2")

現在我們可以在控制檯中區分開變量s和變量b了。

需要注意的是,使用相同描述符的兩個Symbol並不相等:

var s = Symbol("s");
var b = Symbol("s");

s !== b;

打個比方,即使兩個碗都被叫做碗,它們仍然不是同一個碗。同理,描述符也僅僅是對Symbol變量的一個描述而已。

如果你希望得到一個Symbol的描述符,可以藉助Symbol原型上的description屬性(Symbol.prototype.description):

const s = Symbol("symbol");

console.log(s.description); //symbol

Symbol還可以顯式的轉化爲字符串或布爾值,但是不能轉化爲數值:

let sym = Symbol('My symbol');
String(sym) // 'Symbol(My symbol)'
sym.toString() // 'Symbol(My symbol)'

let sym2 = Symbol();
Boolean(sym2) // true

2. Symbol屬性的遍歷

以Symbol類型的變量作爲對象屬性時,該屬性不會出現在for … in、for … of循環中,也不會被Object.keys()、Object.getOwnPropertyNames()、JSON.stringify()返回。

但該屬性並不是私有屬性,它可以被專門的Object.getOwnPropertySymbols()方法遍歷出來。該方法返回一個數組,包含了當前對象的所有用作屬性名的Symbol值:

var s1 = Symbol('a');
var s2 = Symbol('b');

var a = {
    name: "夕山雨",
    [s1]: 24,
    [s2]: function(){}
}

var s = Object.getOwnPropertySymbols(a); //[Symbol(a), Symbol(b)]
a[s[0]] = 24; //返回的數組元素不是字符串,而是實際的Symbol值,
               //因此可以通過它引用到對象的該屬性

因此遍歷該方法的返回值即可遍歷所有的Symbol屬性。

另外,ES6新增的Reflect.ownKeys()方法可以遍歷出所有的常規鍵名和Symbol鍵名。語法爲:

Reflect.ownKeys(a); //["name", Symbol(a), Symbol(b)]

3. Symbol.for(),Symbol.keyFor()

Symbol提供了一種可以創建相同Symbol的機制,就是使用Symbol.for()方法進行註冊。通過該方法生成的Symbol會根據描述符進行全局註冊,之後再次通過Symbol.for()傳入相同的描述符時,就可以得到相同的Symbol值。如:

var s1 = Symbol.for('symbol');  //向全局註冊了以"symbol"爲描述符的Symbol
//由於描述符"symbol"已被註冊到全局,因此這裏創建的Symbol與上面是同一個
var s2 = Symbol.for('symbol');  

s1 === s2;

這裏指的全局不單指該變量所在的作用域,它在各個iframe甚至service worker中都是有效的,因此這是一種允許不同作用域創建相同Symbol的機制。

如果你想得到一個全局註冊的Symbol的描述符,可以使用Symbol.keyFor()方法:

Symbol.keyFor(s1);  //"symbol"

它輸出了變量s1的全局註冊標識符“symbol”。

四、內置的Symbol值

上面講到的Symbol的用法都是自定義的Symbol,在ES6中還定義了11個內置的Symbol,用於指向語言內部使用的方法。

1. Symbol.hasInstance

當使用instanceof運算符判斷某個對象是否爲某個構造函數的實例時,就是在調用該構造函數上的靜態方法[Symbol.hasInstance],它是js引擎預先定義好的。如:

[] instanceof Array;  //true

//瀏覽器實際上是在調用下面的方法
Array[Symbol.hasInstance]([]);

如果你想要看一下瀏覽器是如何實現該方法的,非常抱歉,你只會得到這樣的輸出:

< Array[Symbol.hasInstance];

> ƒ [Symbol.hasInstance]() { [native code] }

native code表示當前函數是使用本地代碼實現的,通常是C++或C,因此瀏覽器不會直接輸出它的源代碼。

實際上,instanceof右側不要求一定是構造函數,也可以是一個普通的對象,只要該對象實現了[Symbol.hasInstance]方法即可。如:

//1. 使用構造函數 
function F(){
    this[Symbol.hasInstance] = function(obj){
        return obj.constructor === F;
    }
}
var f = new F();
f instanceof new F();  //true

//2. 使用class
class MyClass {
  [Symbol.hasInstance](foo) {
    return foo instanceof Array;
  }
}

[1, 2, 3] instanceof new MyClass() // true

//3. 直接使用一個實現了Symbol.hasInstance的對象
var a = {
  [Symbol.hasInstance](foo) {
    return foo instanceof Array;
  }
}

[1, 2, 3] instanceof a // true

總的來說,instanceof的行爲就是,遇到a instanceof b這樣的語句,就調用b[Symbol.hasInstance](a),該函數的返回值就是該語句的返回值。這裏如果b是構造函數,就調用它的靜態方法,如果是對象,就調用它的實例方法或原型方法。

不過,如果instanceof右側不包含[Symbol.hasInstance]方法,那麼瀏覽器會拋出這樣的錯誤:Right-hand side of ‘instanceof’ is not callable,表示右側不可被instanceof運算符調用。

2. Symbol.isConcatSpreadable

該屬性決定了當前對象作爲concat的參數時是否可以展開。通常:

var obj = {age: 24};
[1].concat(obj); //[1, {age: 24}]

obj被傳入concat後會直接作爲一個元素添加到數組中。通過將obj的Symbol.isConcatSpreadable屬性設置爲true,obj會在執行concat時嘗試展開,如果該對象無法展開,obj不會被拼接到數組中去。所謂的可展開,指的是obj是否爲數組或類數組結構。如果obj是數組,顯然是可展開的,如果它有length屬性,並且有"0","1"這樣的屬性鍵,那麼它就是類數組,也是可以展開的:

//設置了該對象需要展開,但它無法展開,因此最終結果爲[]
var obj = {age: 24, [Symbol.isConcatSpreadable]: true};
[].concat(obj); //[]

//這是一個類數組對象,它是可展開的
var obj = {
  length: 2, 
  "0": 24, 
  "1": 25, 
  name: "夕山雨",
  [Symbol.isConcatSpreadable]: true
};
//name屬性被丟棄了,因爲它無法被obj[index]的方式引用到
[].concat(obj); //[24, 25]

3. Symbol.species

該屬性用於在繼承的時候指定一個類的類別。如:

class T1 extends Promise {
}

class T2 extends Promise {
  static get [Symbol.species]() {
    return Promise;
  }
}

new T1() instanceof T1 // true
new T2() instanceof T2 // false

對於T1,由它構造出的實例默認都是T1的實例。而在T2中我們爲該類定義了[Symbol.species]方法,它始終返回Promise,因此由T2構造出的實例都不再被認爲是T2的實例,而是Promise的實例。

該方法允許我們在定義衍生對象時,人爲指定由它構造出的實例的構造函數。

4. Symbol.match/replace/search/split

這四個方法允許我們以對象的方式自定義String的match、replace、search、split方法。以match爲例,我們通常這樣調用它:

var s = "hello";
s.match(RegExp);  //匹配一個正則表達式

假如我們需要爲當前的字符串s定製一個自己的match方法,但是又不希望修改String原型上的match方法(因爲這樣會影響到其他的字符串調用match方法)。Symbol.match就爲我們提供了這種能力。

對於上面的例子,如果傳入的對象具有[Symbol.match]方法,那麼js引擎就會修改match方法默認的行爲,去調用定義的[Symbol.match]方法。如:

var a = {
    [Symbol.match](){
        return true;
    }
}

"hello".match(a);  //true

當調用字符串的match方法並傳入具有[Symbol.match]屬性的對象時,js引擎就會調用對象的這個方法。

上面的寫法等同於下面的寫法:

a[Symbol.match]("hello");  //true

replace、search和split也是相同的原理。下面分別給一個簡單的例子:
replace:

const x = {};
x[Symbol.replace] = (...s) => console.log(s);

'Hello'.replace(x, 'World') // ["Hello", "World"]

由於replace的第一個參數有[Symbol.replace]方法,因此js引擎會調用這個方法,並把調用者‘Hello’和第二個參數‘World’作爲參數傳遞給該方法。這樣,上面的寫法就等同於:

x[Symbol.replace]("Hello", "world");

search:

var a = {
    [Symbol.match](){
        return true;
    }
}

"hello".search(a);  //true

原理同match。
split:

var a = {
    sep: ",",
    [Symbol.match](t){
        return t.split(this.sep);
    }
}

"hello,world".split(a);  //["hello", "world"]

原理也與match相同。

5. Symbol.iterator

定義一個對象的遍歷器方法。凡是具有[Symbol.iterator]方法的對象都是可遍歷的,可以使用for … of循環依次輸出對象的每個屬性。數組和類數組,以及ES6新增的Map、Set等都原生部署了該方法,因此它們都可遍歷。如:

for(var item of [1,2,3]){
  console.log(item); //依次輸出1,2,3
}

任何一個數組都具備這個原生的遍歷器方法:

> [][Symbol.iterator]

< ƒ values() { [native code] } //C++實現

普通對象默認不具有該遍歷器方法,因此無法用for … of循環遍歷出對象所有的屬性值。如果你希望讓普通對象可遍歷,可以手動爲該對象定義遍歷器方法,如:

var a = {
    name: "夕山雨",
    age: 24,
    [Symbol.iterator]: function* (){
        yield this.name;
        yield this.age;
    }
}

這裏爲了簡單,使用了ES6的Generator函數,它定義該遍歷器先輸出name屬性,再輸出age屬性。因此當你用for … of來輸出a的屬性值時,就可以得到結果:

for(var item of a){
  console.log(item);  //依次輸出:"夕山雨"  24
}

iterator是ES6非常重要的概念,我們後續會有專門的文章來介紹它。

6. Symbol.toPrimitive

該方法定義了一個對象如何被轉化爲一個基本數據類型。通常對象是不能直接與基本數據類型的變量進行運算的,但是如果你爲它定義了[Symbol.toPrimitive]方法,它就可以按照你所指定的規則轉化爲基本數據類型。它接收一個字符串,表示需要轉換成的數據類型:

let obj = {
  [Symbol.toPrimitive](hint) {
    switch (hint) {
      case 'number':
        return 123;
      case 'string':
        return 'str';
      case 'default':
        return 'default';
      default:
        throw new Error();
     }
   }
};

2 * obj // 246
3 + obj // '3default'
obj == 'default' // true
String(obj) // 'str'

這裏表示,如果對象需要轉化爲數字,就返回123;如果需要轉化爲字符串,就轉化爲’str’;如果沒有指定要轉化的類型,那就返回字符串’Default’。

由於乘法運算*只能對數值操作,因此js引擎會調用[Symbol.toPrimitive]並傳入"number",將obj轉化爲數字。而加法既可以對數值生效,也可以對字符串生效,因此js引擎傳入了"default"。該方法默認只接受number、string和default這三個值。

7. Symbol.toStringTag

可以自定義對象的toString()方法。通常對象的toString方法會返回一個類似[object Object]的字符串,表示該對象的類型,如:

var a = {};

a.toString();  //"[object Object]"

但是如果你修改了對象的Symbol.toStringTag方法,返回值就會發生變化:

a[Symbol.toStringTag] = function(){
  return "xxx";
}
a.toString(); //"[object xxx]"

可以看到,我們定義的返回值覆蓋了之前的字符串中的後半部分“Object”,因此該方法可以用於定製對象的toString()的返回值。

8. Symbol.unscopables

該方法用於with語句。它指定在使用with語句時,哪些屬性不屬於with環境。舉個例子:

var author = {
    name: "夕山雨",
    age: 24,
    stature: "179",
    weight: 65
}

var name = "張三";
var age = "28";

with(author){
  console.log(name);  //“夕山雨”
  console.log(age);   //24
  console.log(stature);  //"179"
  console.log(weight);   //65
}

默認情況下,對於with語句內引用的變量,js引擎會優先去with的作用對象上查找對應的屬性,如果找不到,才認爲是外部變量。但是你可以人爲指定哪些屬性不應該去作用對象上查找,如:

var author = {
    name: "夕山雨",
    age: 24,
    stature: "179",
    weight: 65,
    get [Symbol.unscopables](){
      return { name: true, age: true }
    }
}

var name = "張三";
var age = "28";
var stature = "153";
var weight = 80;

with(author){
  console.log(name);  //“張三”
  console.log(age);   //28
  console.log(stature);  //"179"
  console.log(weight);   //65
}

可以看到,由於我們認爲指定了name和age兩個屬性不作用域with環境,因此這裏的name和age輸出的是外部的變量,而stature和weight輸出的仍然是author的屬性值。

總結

Symbol作爲一種新的數據類型,有着與String相似的特性,與String不同的是它是獨一無二的,因此適合作爲對象屬性的鍵值,防止該屬性被覆蓋。除了自定義的Symbol值外,靈活掌握內置的Symbol,對ES6的學習有帶來極大幫助,特別是Symbol.iterator,它是ES6中的一個非常重要的概念,之後會繼續探討。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章