Symbol是 ECMAScript 2015 引入的新原始数据类型(primitive type)。使用 Symbol 可以创建独一(unique)的标识符,用法是:let uniqueKey = Symbol('SymbolName').
Symbol 可以被用作对象中属性的键名。一系列被 JavaScript 特殊处理的 Symbol 值被发布为内置 Symbol 值(well-known symbols)
这些内置 Symbol 值被 JavaScript 内建算法所使用。例如 Symbol.iterator
被用来迭代遍历数组项或字符串,甚至定义自己的迭代器函数。
这些特殊的 Symbol 值非常重要,因为它们是对象的系统属性,这些属性允许你定义定制的行为。听起来不错吧,用它们来打入 JavaScript 内部!
由于其独一性,使用 Symbol 作为键名(而不是字符串字面值),可以轻而易举地给对象引入许多新的功能。在使用字符串字面值的时候,键名碰撞会是个问题,但使用 Symbol 你就不必为此担心。
本文将导览所有的内置 Symbol 值,并说明如何得心应手地在代码中使用它们。
简洁起见,内置值 Symbol.<name>
通常被简记为 _@@\<name\>_
格式。例如 Symbol.iterator
简记作 _@@iterator_
,Symbol.toPrimitive
简记作 _@@toPrimitive_
。
我们可能称某对象拥有某 _@@iterator_
方法。这种说法表明该对象拥有一个名为 Symbol.iterator
的属性,且该属性持有一个函数:
{ [Symbol.iterator]: function(){...} }.
1. Symbol
简介
Symbol 是一个原始数据类型(正如数值类型、布尔类型、字符串等),它是独一(unique
)且不可变(immutable
)的。
调用 Symbol
函数即可创建一个 Symbol 值,函数带有一个可选的名字参数:(在 repl.it 中试试)
let mySymbol = Symbol();
let namedSymbol = Symbol('myName');
typeof mySymbol; // => 'symbol'
typeof namedSymbol; // => 'symbol'
mySymbol
和 namedSymbol
都是 Symbol 类型的原始值。namedSymbol
有一个关联名字 'myName'
,有助于调试。
需要特别注意,每次调用 Symbol()
都会创建一个独一的新 Symbol 值。即便两个 Symbol 值有同样的名字,它们也是独一(或者说不同)的两个值:(在 repl.it 中试试)
let first = Symbol();
let second = Symbol();
first === second; // => false
let firstNamed = Symbol('Lorem');
let secondNamed = Symbol('Lorem');
firstNamed === secondNamed; // => false
first
和 second
方法都创建独一的 Symbol 值,但彼此不相同。
firstNamed
和 secondNamed
变量拥有同样的名字 'Lorem'
,但彼此仍然不相同。
Symbol 值可以作为对象中属性的键名。如果在对象字面值或类声明中这样做,必须要使用属性名表达式语法 [symbol]
:(在 repl.it 中试试)
let stringSymbol = Symbol('String');
let myObject = {
number: 1,
[stringSymbol]: 'Hello World'
};
myObject[stringSymbol]; // => 'Hello World'
Object.getOwnPropertyNames(myObject); // => ['number']
Object.getOwnPropertySymbols(myObject); // => ['Symbol(String)']
当我们用字面值定义 myObject
的时候,使用了属性名表达式语法把 Symbol 值[stringSymbol]
设置为属性键名。
使用 Symbol 值定义的属性无法用 Object.keys()
函数或 Object.getOwnPropertyNames()
函数访问到。要访问它们,需要调用特殊函数 Object.getOwnPropertySymbols()
。
使用 Symbol 值作为键名是一个重要方面。特殊的 Symbol 值(也就是内置 Symbol 值)允许定义定制化的对象行为,例如迭代遍历、对象到原始数据类型或字符串类型的转换,等等。
内置 Symbol 值可以作为 Symbol
函数对象的不可枚举、不可写、不可配置属性被使用。只需在 Symbol
函数对象上使用属性访问器即可获得它们:例如 Symbol.iterator
, Symbol.hasInstance
等等。
可以用下面的方法获取内置 Symbol 值的列表:(在 repl.it 中试试)
Object.getOwnPropertyNames(Symbol);
// => ["hasInstance", "isConcatSpreadable", "iterator", "toPrimitive",
// "toStringTag", "unscopables", "match", "replace", "search",
// "split", "species", ....];
typeof Symbol.iterator; // => 'symbol'
Object.getOwnPropertiesNames(Symbol)
返回 Symbol
函数对象自身的属性,包括内置 Symbol 值列表。
2. 用 @@iterator使对象可迭代遍历
Symbol.iterator
大概是最广为人知的 Symbol
值。它允许定义当对象被 for...of
语句作用或被展开操作符 ...
作用时应当如何迭代遍历。
许多内建类型都是可迭代遍历的,例如字符串、数组、Map、Set 等,换句话说它们都有 @@iterator方法:(在 repl.it 中试试)
let myString = 'Hola';
typeof myString[Symbol.iterator]; // => 'function'
for (let char of myString) {
console.log(char); // logs on each iterator 'H', 'o', 'l', 'a'
}
[...myString]; // => ['H', 'o', 'l', 'a']
原始数据类型字符串变量myString
有一个属性 Symbol.iterator
。这个属性持有一个方法用于迭代遍历字符串中字符。
若一个对象定义了名为 Symbol.iterator
的方法,则该对象遵从可迭代协议。
该方法应该返回一个遵从可迭代协议的对象,可迭代协议对象应该拥有一个方法 next()
返回 {value: <iterator_value>, done: <boolean_finished_iterator>}
。
我们来看看如何定义一个定制的迭代器。下面的例子创建了一个可迭代对象 myMethods
,允许遍历该对象拥有的方法:
function methodsIterator() {
let index = 0;
let methods = Object.keys(this).filter((key) => {
return typeof this[key] === 'function';
}).map(key => this[key]);
return {
next: () => ({ // Conform to Iterator protocol
done : index >= methods.length,
value: methods[index++]
})
};
}
let myMethods = {
toString: function() {
return '[object myMethods]';
},
sumNumbers: function(a, b) {
return a + b;
},
numbers: [1, 5, 6],
[Symbol.iterator]: methodsIterator // Conform to Iterable Protocol
};
for (let method of myMethods) {
console.log(method); // logs methods toString and sumNumbers
}
methodsIterator()
函数返回一个可迭代对象 { next: function() {...} }
。
myMethods
对象设置了一个属性,以 Symbol.iterator
为键名,以 methodIterator
为键值。这令 myMethods
可迭代,现在在 for...of
循环中可以遍历到 toString
和 sumNumbers
方法。
此外你也可以通过调用 ...myMethods
或 Array.from(myMethods)
来获得这些方法。
_@@iterator_
属性还接受Generator 函数,这使得它更具价值。Generator 函数返回一个遵从迭代接口的Generator 对象。
让我们用 _@@iterator_
接口来创建一个 Fibonacci
类,该类可以产生一个 Fibonacci 序列。
class Fibonacci {
constructor(n) {
this.n = n;
}
*[Symbol.iterator]() {
let a = 0, b = 1, index = 0;
while (index < this.n) {
index++;
let current = a;
a = b;
b = current + a;
yield current;
}
}
}
let sequence = new Fibonacci(6);
let numbers = [...sequence];
numbers; // => [0, 1, 1, 2, 3, 5]
*[Symbol.iterator]() {...}
声明了一个 Generator 函数的类方法,因而 Fibonacci
类的实例遵从迭代协议。
然后 sequence
对象被展开操作符 ...sequence
所用。展开操作符调用 _@@iterator_
方法从生成的数字创建数组。因此计算结果是头5个 Fibonacci 数构成的数组。
如果原始数据类型或对象拥有 _@@iterator_
接口,则可以被应用于下列构造:
- 在
for...of
循环中遍历元素 - 使用展开操作符
[...iteratorObject]
创建元素的数组 - 使用
Array.from(iteratorObject)
创建元素的数组 - 在
yield*
表达式中代理给另一个 Generator - 在
Map(iterableObject)`, `WeakMap(iterableObject)`, `Set(iterableObject)`, `WeakSet(iterableObject)
构造器中 - 在
Promise.all(iterableObject)
和Promise.race(iterableObject)
等 Promise 类静态方法中
3. 用 @@hasInstance定制化 instanceof
obj instanceof Constructor
操作符默认检验 obj
的原型链是否包含 Constructor.prototype
对象。我们来看一个例子:
function Constructor() {
// constructor code
}
let obj = new Constructor();
let objProto = Object.getPrototypeOf(obj);
objProto === Constructor.prototype; // => true
obj instanceof Constructor; // => true
obj instanceof Object; // => true
obj instanceof Constructor
求值为真,因为 obj
的原型等于 Constructor.prototype
(这是调用构造函数的结果)。
instanceof
也检验 obj
的原型链,因此 obj instanceof Object
也为真。
通常实际应用中不处理原型,而是要求更具体的实例判定。
幸运的是我们可以在可调用(callable)类型 Type
上定义一个 _@@hasInstance_
方法来定制化 instanceof
求值。表达式 obj instanceof Type
现在等价于 Type[Symbol.hasInstance]
。
例如检验一个对象或原始数据类型是否可迭代遍历:
class Iterable {
static [Symbol.hasInstance](obj) {
return typeof obj[Symbol.iterator] === 'function';
}
}
let array = [1, 5, 5];
let string = 'Welcome';
let number = 15;
array instanceof Iterable; // => true
string instanceof Iterable; // => true
number instanceof Iterable; // => false
Iterable
是一个包含 _@@hasInstance_
静态方法的类。该方法可以检验给定的 obj
参数是否可迭代遍历,换言之也就是检验 obj
是否包含一个 Symbol.iterable
属性。
随后我们用 Iterable
来检验不同类型的变量。数组和字符串是可迭代遍历的,而数值类型不可以。
以我之见,像这样配合 instanceof
和构造器使用 _@@hasInsatnce_
比单纯调用 isIterable(array)
要更优雅。
表达式 array instanceof Iterable
清楚地表明 array
通过了可迭代协议的检验。
4. 用 @@toPrimitive将对象转换成原始类型值
使用 Symbol.toPrimitive
来指定一个属性,属性值是一个函数,用于将对象转换为原始类型值。
举个例子,我们用 _@@toPrimitive_
方法来增强一个数组实例:
function arrayToPrimitive(hint) {
if (hint === 'number') {
return this.reduce((sum, num) => sum + num);
} else if (hint === 'string') {
return [${this.join(', ')}];
} else {
// hint is default
return this.toString();
}
}
let array = [1, 5, 3];
array[Symbol.toPrimitive] = arrayToPrimitive;
// array to number. hint is 'number'
+ array; // => 9
// array to string. hint is 'string'
array is ${array}; // => 'array is [1, 5, 3]'
// array to default. hint is 'default''array elements: ' + array; // => 'array elements: 1,5,3'
arrayToPrimitive(hint)
是一个根据 hint
参数值将数组转换成原始类型值的函数。赋值语句 array[Symbol.toPrimitive] = arrayToPrimitive
令数组使用新的转换方法。
执行 + array
会以 'number'
为 hint
参数调用 _@@toPrimitive_
方法。array
被转换成一个数字,数值是所有元素之和。
array is ${array} 会以 'string'
为 hint
参数调用 _@@toPrimitive_
方法。数组被转换成字符串 '[1, 5, 3]'
最后的 'array elements: ' + array
使用了 'defualt'
为转换过程的 hint
参数值。这种情况下 array
的值为 '1,5,3'
。
_@@toPrimitive_
方法在下列对象与原始类型交互的场景下被调用:
- 相等操作符
object == primitive
- 相加/连接操作符
object + primitive
- 相减操作符
object - primitive
- 对象被强制转换为原始类型的各种场景:
String(object)
,Number(object)
等等。
5. 用 @@toStringTag创建对象的默认描述
使用 Symbol.toStringTag
来指定一个属性,属性值为一个字符串,描述对象的类型标签。_@@toStringTag_
方法会被 Object.prototype.toString()
使用。
Object.prototype.toString()
的规范标准表明很多 JavaScript 类型都有默认标签:
let toString = Object.prototype.toString;
toString.call(undefined); // => '[object Undefined]'
toString.call(null); // => '[object Null]'
toString.call([1, 4]); // => '[object Array]'
toString.call('Hello'); // => '[object String]'
toString.call(15); // => '[object Number]'
toString.call(true); // => '[object Boolean]'
// etc for Function, Arguments, Error, Date, RegExp
toString.call({}); // => '[object Object]'
这些类型都没有 [Symbol.toStringTag]
属性,因为 Object.prototype.toString()
使用另外的算法对它们求值。
其他很多 JavaScript 类型定义了 _@@toStringTag_
属性,比如 Symbol,Generator 函数,Map,Promise 等等。我们来看看:
let toString = Object.prototype.toString;
let noop = function() {};
Symbol.iterator[Symbol.toStringTag]; // => 'Symbol'
(function* () {})[Symbol.toStringTag]; // => 'GeneratorFunction'
new Map()[Symbol.toStringTag]; // => 'Map'
new Promise(noop)[Symbol.toStringTag]; // => 'Promise'
toString.call(Symbol.iterator); // => '[object Symbol]'
toString.call(function* () {}); // => '[object GeneratorFunction]'
toString.call(new Map()); // => '[object Map]'
toString.call(new Promise(noop)); // => '[object Promise]'
从上面的例子可以看出,很多 JavaScript 类型定义了它们自己的 _@@toStringTag_
属性。
在其他情况下,比如一个对象所属的类型没有默认标记,或未提供 _@@toStringTag_
属性,那么它就会被简单标记为 'Object'
。
当然你可以定义一个定制化的 _@@toStringTag_
属性:
let toString = Object.prototype.toString;
class SimpleClass {}
toString.call(new SimpleClass); // => '[object Object]'
class MyTypeClass {
constructor() {
this[Symbol.toStringTag] = 'MyType';
}
}
toString.class(new TagClass); // => '[object MyType]'
new SimpleClass
实例没有定义@@toStringTag属性。Objecct.prototype.toString()
为它返回默认的类型描述 '[object Object]'
。
在 MyTypeClass
构造器中,为实例配置了一个定制化标签 'MyType'
。对于该类实例,Object.prototype.toString()
返回定制化的类型描述 '[object MyType]'
。
注意到 _@@toStringTag_
更多是因为后向兼容性存在的,并不被鼓励使用。你可能更应该使用其他方法去判断对象类型,例如 instanceof
(包括使用 _@@hasInstance_ Symbol
值)或者 typeof
。
6. 用 @@species创建衍生对象
使用 Symbol.species
来指定一个属性,属性值为一个构造器方法,用来创建衍生对象。
很多 JavaScript 构造器都有 _@@species_
,值等于构造器本身。
Array[Symbol.species] === Array; // => true
Map[Symbol.species] === Map; // => true
RegExp[Symbol.species] === RegExp; // => true
首先,注意衍生对象是对原对象做特定操作后创建的。举例来说,在原数组上调用 .map()
方法会返回一个衍生对象:映射(mapping)结果数组。
通常衍生对象和原对象拥有同样的构造器,正如预料的那样。但有时有必要指定定制化的构造器(或者是基类构造器):这就是 _@species_
的用武之地。
设想一个场景,你为了加上些有用的方法,从 Array
构造器继承了子类 MyArray
。之后在 MyArray
的实例上使用 .map()
方法时,你想要一个 Array
类的实例,而不是 MyArray
的实例。为了做到这点,定义一个访问器属性 _@@species_
并指明衍生对象构造器:Array
。我们来试一个例子:
class MyArray extends Array {
isEmpty() {
return this.length === 0;
}
static get [Symbol.species]() {
return Array;
}
}
let array = new MyArray(3, 5, 4);
array.isEmpty(); // => false
let odds = array.filter(item => item % 2 === 1);
odds instanceof Array; // => true
odds instanceof MyArray; // => false
MyArray
中定义了一个静态访问器属性 static get [Symbol.species]() {}
,这表明衍生对象应该拥有 Array
构造器。
随后当使用 filter
方法过滤数组元素时,array.filter()
方法返回了一个 Array
。
如果 _@@species_
属性不是定制化的,那么 array.filter()
会返回一个 MyArray
实例。
像 .map()
,.concat()
,.slice()
等等这些 Array
和 TypedArray
类的方法会使用 _@species_
属性返回衍生对象。
也可以用它在继承 Map、正则表达式对象、Promise 的同时保持原构造器。
7. 用 @@match, @@replace, @@search和 @@split创建类正则表达式的对象
JavaScript 的字符串原型有四个方法接受正则表达式对象参数输入:
String.prototype.match(regExp)
String.prototype.replace(regExp, newSubstr)
String.prototype.search(regExp)
String.prototype.split(regExp, limit)
ECMAScript 2015 允许上述4个方法接受 RegExp
以外的类型,条件是定义对应的函数属性 _@@match_
, _@@replace_
, _@@search_
和 _@@split_
。
有趣的是 RegExp
原型也是用 Symbol 值来定义这些方法的:
typeof RegExp.prototype[Symbol.match]; // => 'function'
typeof RegExp.prototype[Symbol.replace]; // => 'function'
typeof RegExp.prototype[Symbol.search]; // => 'function'
typeof RegExp.prototype[Symbol.split]; // => 'function'
现在我们来创建一个定制的匹配模式(pattern)类。下面的例子定义了一个简化的类,在使用中可取代 RegExp
类型:
class Expression {
constructor(pattern) {
this.pattern = pattern;
}
[Symbol.match](str) {
return str.includes(this.pattern);
}
[Symbol.replace](str, replace) {
return str.split(this.pattern).join(replace);
}
[Symbol.search](str) {
return str.indexOf(this.pattern);
}
[Symbol.split](str) {
return str.split(this.pattern);
}
}
let sunExp = new Expression('sun');
'sunny day'.match(sunExp); // => true
'rainy day'.match(sunExp); // => false
'sunny day'.replace(sunExp, 'rai'); // => 'rainy day'"It's sunny".search(sunExp); // => 5
"daysunnight".split(sunExp); // => ['day', 'night']
Expression
类定义了 _@@match_
, _@@replace_
, _@@search_
和 _@@split_
方法。
sunExp
实例随后被用在对应的字符串方法中,粗略地模拟了一个正则表达式。
8. 用 @@ isConcatSpreadable将对象摊平为数组元素
Symbol.isConcatSpreadable
是一个布尔值属性,表明一个对象是否被 Array.prototype.concat()
方法摊平为其数组元素。
默认行为下,.concat()
方法在拼接数组时将数组展开为它的元素:
let letters = ['a', 'b'];
let otherLetters = ['c', 'd'];
otherLetters.concat('e', letters); // => ['c', 'd', 'e', 'a', 'b']
为了拼接两个数组,letters
被作为参数作用到 .concat()
方法。letters
的元素在拼接结果中被展开。
要避免展开,并在拼接过程中保持整个数组作为一个元素,可以将 _@@isConcatSpreadable_
设置为 false
:
let letters = ['a', 'b'];
letters[Symbol.isConcatSpreadable] = false;
let otherLetters = ['c', 'd'];
otherLetters.concat('e', letters); // => ['c', 'd', 'e', ['a', 'b']]
通过将 _@@isConcatSpreadable_
属性设置为 false
,letters
数组在拼接结果 ['c', 'd', 'e', ['a', 'b']]
中保持完整不变。
与数组相反,.concat()
方法默认不展开类数组对象(array-like objects)(点此查看原因)。
这一行为也可以通过改变 @@isConcatSpreadable属性来配置:
let letters = {0: 'a', 1: 'b', length: 2};
let otherLetters = ['c', 'd'];
otherLetters.concat('e', letters);
// => ['c', 'd', 'e', {0: 'a', 1: 'b', length: 2}]
letters[Symbol.isConcatSpreadable] = true;
otherLetters.concat('e', letters); // => ['c', 'd', 'e', 'a', 'b']
在第一个 .concat()
方法调用中,类数组对象 letters
在拼接结果数组中保持不变。这是类数组对象的默认行为。
然后 letters
的 _@@isConcatSpreadable_
属性被置为 true
。所以拼接过程将类数组对象展开为其元素。
9. 用 @@unscopables在 with
语句中设置属性可访问性
Symbol.unscopables
是一个以对象为值的属性,该属性值自己的属性名就是对象在 with
语句环境绑定中不被包含的属性名。
_@@unscopables_
属性值拥有这种格式:{ propertyName: <boolean_exclude_binding> }
。
ES2015 只为数组默认定义了 _@@unscopables_
值。其意义是将新方法隐去,以免覆盖旧 JavaScript 代码中的同名变量。
Array.prototype[Symbol.unscopables];
// => { copyWithin: true, entries: true, fill: true,
// find: true, findIndex: true, keys: true }
let numbers = [3, 5, 6];
with (numbers) {
concat(8); // => [3, 5, 6, 8]
entries; // => ReferenceError: entries is not defined
}
.concat()
方法能在 with
语句主体中被访问,因为它没在 _@@unscopables_
属性值中出现。
entries()
方法在 _@@unscopables_
属性中被列为 true
,因此在 with
语句内是不可访问的。
_@@unscopables_
主要是为了使用了 with
的旧 JavaScript 代码后向兼容性而存在的。(而 with
的使用已经被不提倡,甚至不允许在严格模式中出现)
10. 后记
内置 Symbol 值是深入操纵 JavaScript 内部算法的有力属性。他们的独一性有利于可扩展性:对象属性不被污染。
_@@iterable_
对于配置 JavaScript 如何迭代遍历对象元素是很有用的属性。它被 for...of
, Array.from()
, 展开操作符 ...
等等所使用。
使用 _@@hasInstance_
做不绕弯的类型验证。对我而言,obj instanceof Iterable
比 isIterable(obj)
看起来更舒服。
_@@toStringTag_
和 _@@unscopables_
是为陈旧的 JavaScript 历史代码后向兼容性而存在的内置 Symbol 值。不建议使用。
你有没有受到启发?我建议你花几个钟头分析你现有的 JavaScript 项目。保证能使用内置 Symbols 值对项目有所改进!
本文根据@Dmitri Pavlutin的《Detailed overview of well-known symbols》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://rainsoft.io/detailed-overview-of-well-known-symbols/。
如需转载,烦请注明出处:http://www.w3cplus.com/javascript/detailed-overview-of-well-known-symbols.html