Symbol详解
1、概述
ES5的对象属性名都是字符串,这容易造成属性名的冲突。比如,你使用了一个他人提供的对象,但又想为这个对象添加新的方法(mixin模式),新方法的名字就有可能与现有方法产生冲突。如果有一种机制,能够保证每个属性的名字都是独一无二的就好了,这样就能从根本上防止属性名冲突。这就是ES6引入Symbol的原因。
ES6引入了一种新的原始数据类型Symbol,表示独一无二的值。它是JavaScript语言的第7种数据类型,前6种分别是:Undefined、Null、布尔值(Boolean)、字符串(String)、数值(Number)和对象(Object)。
Symbol值通过Symbol函数生成。这就是说,对象的属性名现在可以有两种类型:一种是原来就有的字符串,另一种就是新增的Symbol类型。只要属性名属于Symbol类型,就是独一无二的,可以保证不会与其他属性名产生冲突。
js
let s = Symbol();
console.log(typeof s);//symbol
上面的代码中,变量s就是一个独一无二的值。typeof运算符的结果表明变量s是Symbol数据类型,而不是字符串之类的其他类型。
注意,Symbol函数前不能使用new命令,否则会报错。这是因为生成的Symbol是一个原始类型的值,不是对象。也就是说,由于Symbol值不是对象,所以不能添加属性。基本上,它是一种类似于字符串的数据类型。
Symbol函数可以接受一个字符串作为参数,表示对Symbol实例的描述,主要是为了在控制台显示或转为字符串时比较容易区分。
js
let s1 = Symbol('foo');
let s2 = Symbol('bar');
console.log(s1);//Symbol(foo)
console.log(s2);//Symbol(bar)
console.log(s1.toString());//Symbol(foo)
console.log(s2.toString());//Symbol(bar)
上面的代码中,s1和s2是两个Symbol值。如果不加参数,它们在控制台的输出都是Symbol(),不利于区分。有了参数以后,就等于为它们加上了描述,输出时就能够分清到底是哪一个值。
注意,Symbol函数的参数只表示对当前Symbol值的描述,因此相同参数的Symbol函数的返回值是不相等的。
js
//没有参数的情况
let s1 = Symbol();
let s2 = Symbol();
console.log(s1 === s2);//false
//有参数的情况
let s3 = Symbol("foo");
let s4 = Symbol("foo");
console.log(s3 === s4);//false
上面的代码中,s1和s2都是Symbol函数的返回值,而且参数相同,但是它们是不相等的。Symbol值不能与其他类型的值进行运算,否则会报错。
js
let sym = Symbol("My symbol");
"your symbol is " + sym;//Error
`your symbol is ${sym}`;//Error
但是,Symbol值可以显式转为字符串。
js
let sym = Symbol('My symbol');
console.log(String(sym));//Symbol(My symbol)
console.log(sym.toString());//Symbol(My symbol)
另外,Symbol值也可以转为布尔值,但是不能转为数值。
js
let sym = Symbol();
Boolean(sym);//true
!sym //false
if(sym) {
//...
}
Number(sym);//TypeError
sym + 2 //TypeError
2、作为属性名的Symbol
由于每一个Symbol值都是不相等的,这意味着Symbol值可以作为标识符用于对象的属性名,保证不会出现同名的属性。这对于一个对象由多个模块构成的情况非常有用,能防止某一个键被不小心改写或覆盖。
js
let mySymbol = Symbol();
//第一种写法
let a = {};
a[mySymbol] = 'Hello!';
//第二种写法
let a = {
[mySymbol]: 'Hello!'
};
//第三种写法
let a = {};
Object.defineProperty(a, mySymbol, {value: 'Hello!'});
//以上写法都得到同样的结构
a[mySymbol]//'hello!'
上面的代码通过方括号结构和Object.defineProperty将对象的属性名指定为一个Symbol值。
注意,Symbol值作为对象属性名时不能使用点运算符。
js
let mySymbol = Symbol();
let a = {};
a.mySymbol = 'Hello!';
a[mySymbol]//undefined
a['mySymbol']//'Hello!'
上面的代码中,因为点运算符后面总是字符串,所以不会读取mySymbol作为标识名所指代的值,导致a的属性名实际上是一个字符串,而不是一个Symbol值。
同理,在对象的内部,使用Symbol值定义属性时,Symbol值必须放在方括号中。
js
let s = Symbol();
let obj = {
[s]: function(arg) {}
};
obj[s](123);
上面的代码中,如果s不放在方括号中,该属性的键名就是字符串s,而不是s所代表的Symbol值。
采用增强的对象写法,上面的obj对象可以写得更简洁一些。
js
let obj = {
[s](arg) {}
};
Symbol类型还可用于定义一组常量,保证这组常量的值都是不相等的。
js
log.levels = {
DEBUG: Symbol('debug'),
INFO: Symbol('info'),
WARN:Symbol('warn')
};
log(log.levels.DEBUG, 'debug message');
log(log.levels.INFO, 'info message');
下面是另外一个例子。
js
const COLOR_RED = Symbol();
const COLOR_GREEN = Symbol();
function getComplement(color) {
switch(color) {
case COLOR_RED:
return COLOR_GREEN;
case COLOR_GREEN:
return COLOR_RED;
default:
throw new Error('Undefined color');
}
}
常量使用Symbol值最大的好处,就是其他任何值都不可能有相同的值了,因此可以保证上面的switch语句会按设计的方式工作。
还有一点需要注意,Symbol值作为属性名时,该属性还是公开属性,不是私有属性。
3、消除魔术字符串
魔术字符串指的是,在代码之中多次出现、与代码形成强耦合的某一个具体的字符串或数值。风格良好的代码,应该尽量消除魔术字符串,而由含义清晰的变量代替。
js
function getArea(shape, options) {
let area = 0;
switch(shape) {
case 'Triangle': //魔术字符串
area = .5 * options.width * options.height;
break;
}
return area;
}
getArea('Triangle', {width: 100, height: 100});//魔术字符串
上面的代码中,字符串ˈTriangleˈ就是一个魔术字符串。它多次出现,与代码形成"强耦合",不利于将来的修改和维护。
常用的消除魔术字符串的方法,就是把它写成一个变量。
js
let shapeType = {
triangle: 'Triangle'
};
function getArea(shape, options) {
let area = 0;
switch(shape) {
case shapeType.triangle:
area = .5 * options.width *options.height;
break;
}
return area;
}
getArea(shapeType.triangle, {width: 100, height: 100});
上面的代码中,我们把ˈTriangleˈ写成shapeType对象的triangle属性,这样就消除了强耦合。如果仔细分析,可以发现shapeType.triangle等于哪个值并不重要,只要确保不会跟其他shapeType属性的值冲突即可。因此,这里就很适合改用Symbol值。
js
const shapeType = {
triangle: Symbol()
};
上面的代码中,除了将shapeType.triangle的值设为一个Symbol,其他地方都不用修改。
4、属性名的遍历
Symbol作为属性名,该属性不会出现在for...in、for...of循环中,也不会被Object.keys()、Object.getOwnPropertyNames()返回。但它也不是私有属性,有一个Object.getOwnPropertySymbols方法可以获取指定对象的所有Symbol属性名。
Object.getOwnPropertySymbols方法返回一个数组,成员是当前对象的所有用作属性名的Symbol值。
js
let obj = {};
let a = Symbol('a');
let b = Symbol.for('b');
obj[a] = 'Hello';
obj[b] = 'World';
let objectSymbols = Object.getOwnPropertySymbols(obj);
console.log(objectSymbols);
//[ Symbol(a), Symbol(b) ]
下面是另一个例子,将Object.getOwnPropertySymbols方法与for...in循环、Object.getOwnPropertyNames方法进行了对比。
js
let obj = {};
let foo = Symbol("foo");
Object.defineProperty(obj, foo, {
value: 'foobar'
});
for(let i in obj) {
console.log(i);//无输出
}
console.log(Object.getOwnPropertyNames(obj));//[]
console.log(Object.getOwnPropertySymbols(obj));//[ Symbol(foo) ]
上面的代码中,使用Object.getOwnPropertyNames方法得不到Symbol属性名,需要使用Object.getOwnPropertySymbols方法。
另一个新的API------Reflect.ownKeys方法可以返回所有类型的键名,包括常规键名和Symbol键名。
js
let obj = {
[Symbol('my_key')]: 1,
enum: 2,
nonEnum: 3
};
console.log(Reflect.ownKeys(obj));
//[ 'enum', 'nonEnum', Symbol(my_key) ]
以Symbol值作为名称的属性不会被常规方法遍历得到。我们可以利用这个特性,为对象定义一些非私有但又希望只用于内部的方法。
js
let size = Symbol('size');
class Collection {
constructor(){
this[size] = 0;
}
add(item) {
this[this[size]] = item;
this[size]++;
}
static sizeOf(instance) {
return instance[size];
}
}
let x = new Collection();
console.log(Collection.sizeOf(x));//0
x.add('foo')
console.log(Collection.sizeOf(x));//1
console.log(Object.keys(x));//[ '0' ]
console.log(Object.getOwnPropertyNames(x));//[ '0' ]
console.log(Object.getOwnPropertySymbols(x));//[ Symbol(size) ]
上面的代码中,对象x的size属性是一个Symbol值,所以Object.keys(x)、Object.getOwnPropertyNames(x)都无法获取它。这就造成了一种非私有的内部方法的效果。
5、Symbol.for(),Symbol.keyFor()
有时,我们希望重新使用同一个Symbol值,Symbol.for方法可以做到这一点。它接受一个字符串作为参数,然后搜索有没有以该参数作为名称的Symbol值。如果有,就返回这个Symbol值,否则就新建并返回一个以该字符串为名称的Symbol值。
js
let s1 = Symbol.for('foo');
let s2 = Symbol.for('foo');
console.log(s1 === s2);//true
上面的代码中,s1和s2都是Symbol值,但它们都是同样参数的Symbol.for方法生成的,所以实际上是同一个值。
Symbol.for()与Symbol()这两种写法,都会生成新的Symbol。它们的区别是,前者会被登记在全局环境中供搜索,而后者不会。Symbol.for()不会每次调用都返回一个新的Symbol类型的值,而是会先检查给定的key是否已经存在,如果不存在才会新建一个值。比如,如果你调用Symbol.for("cat")30次,每次都会返回同一个Symbol值,但是调用Symbol("cat")30次,会返回30个不同的Symbol值。
js
console.log(Symbol.for('bar') === Symbol.for('bar'));//true
console.log(Symbol('bar') === Symbol('bar'));//false
上面的代码中,由于Symbol()写法没有登记机制,所以每次调用都会返回一个不同的值。
Symbol.keyFor方法返回一个已登记的Symbol类型值的key。
js
let s1 = Symbol.for('foo');
console.log(Symbol.keyFor(s1));//foo
let s2 = Symbol('foo');
console.log(Symbol.keyFor(s2));//undefined
上面的代码中,变量s2属于未登记的Symbol值,所以返回undefined。
需要注意的是,Symbol.for为Symbol值登记的名字是全局环境的,可以在不同的iframe或service worker中取到同一个值。
js
iframe = document.createElement('iframe');
iframe.src = String(window.location);
document.body.appendChild(iframe);
iframe.contentWindow.Symbol.for('foo') === Symbol.for('foo');
//true
上面的代码中,iframe窗口生成的Symbol值可以在主页面得到。
6、内置的Symbol值
除了定义自己使用的Symbol值外,ES6还提供了11个内置的Symbol值,指向语言内部使用的方法。
6.1、Symbol.hasInstance
对象的Symbol.hasInstance属性指向一个内部方法,对象使用instanceof运算符时会调用这个方法,判断该对象是否为某个构造函数的实例。比如,foo instanceof Foo在语言内部实际调用的是FooSymbol.hasInstance。
Symbol.hasInstance的函数签名:
js
static [Symbol.hasInstance](value: any): boolean
- 必须是 静态方法
- 接收一个参数:instance(即 instanceof左边的值)
- 返回 true / false
js
class MyClass {
static [Symbol.hasInstance](foo) {
console.log('被调用...');
return false;
}
}
let o = new MyClass();
console.log(o instanceof MyClass);//false
应用示例:
- 包装类型 / 抽象类:适合用于"逻辑上的类型",而不是具体构造函数创建的对象。
js
class NumberLike {
static [Symbol.hasInstance](value) {
return typeof value === 'number' || value instanceof Number;
}
}
console.log(123 instanceof NumberLike); // true
console.log(new Number(123) instanceof NumberLike); // true
- 接口模拟(Duck Typing):不关心原型链,只关心"有没有某种能力"。
js
class Flyer {
static [Symbol.hasInstance](obj) {
return typeof obj.fly === 'function';
}
}
const bird = { fly() {} };
const nobird = {};
console.log(bird instanceof Flyer); // true
console.log(nobird instanceof Flyer); // false
- 兼容旧代码 / 多实现统一判断
js
class PromiseLike {
static [Symbol.hasInstance](obj) {
return obj && typeof obj.then === 'function';
}
}
console.log(Promise.resolve() instanceof PromiseLike); // true
6.2、Symbol.isConcatSpreadabIe
对象的Symbol.isConcatSpreadable属性等于一个布尔值,表示该对象使用Array.prototype.concat()时是否可以展开。
Symbol.isConcatSpreadable用来标识:当该对象作为参数传入 Array.prototype.concat时,是否应当被"展开(spread)"成数组元素。
简单说就是:
- true→ 像数组一样被展开
- false / 未设置→ 作为一个整体追加
js
let arr1 = ['c', 'd'];
console.log(['a', 'b'].concat(arr1, 'e'));//[ 'a', 'b', 'c', 'd', 'e' ]
let arr2 = ['c', 'd'];
arr2[Symbol.isConcatSpreadable] = false;
console.log(['a', 'b'].concat(arr2, 'e'));
// [
// 'a',
// 'b',
// [ 'c', 'd', [Symbol(Symbol.isConcatSpreadable)]: false ],
// 'e'
// ]
上面的代码说明,数组的Symbol.isConcatSpreadable属性默认为true,表示可以展开。
类似数组的对象也可以展开,但其Symbol.isConcatSpreadable属性默认为false,必须手动打开。
js
let obj = {length: 2, 0: 'c', 1: 'd'};
console.log(['a', 'b'].concat(obj, 'e'))
//[ 'a', 'b', { '0': 'c', '1': 'd', length: 2 }, 'e' ]
obj[Symbol.isConcatSpreadable] = true;
console.log(['a', 'b'].concat(obj, 'e'));
//[ 'a', 'b', 'c', 'd', 'e' ]
对于一个类而言,Symbol.isConcatSpreadable属性必须写成一个返回布尔值的方法。
js
class A1 extends Array {
[Symbol.isConcatSpreadable] = true;
}
class A2 extends Array {
[Symbol.isConcatSpreadable] = false;
}
let a1 = new A1();
a1[0] = 3;
a1[1] = 4;
let a2 = new A2();
a2[0] = 5;
a2[1] = 6;
console.log([1, 2].concat(a1).concat(a2));
// [
// 1,
// 2,
// 3,
// 4,
// A2(2) [ 5, 6, [Symbol(Symbol.isConcatSpreadable)]: false ]
// ]
上面的代码中,类A1是可扩展的,类A2是不可扩展的,所以使用concat时有不一样的结果。
6.3、Symbol.species
对象的Symbol.species属性指向一个方法,对象作为构造函数创造实例时会调用这个方法。即如果this.constructor[Symbol.species]存在,就会使用这个属性作为构造函数来创造新的实例对象。
Symbol.species用来指定:当内置方法返回一个新的实例时,应该使用哪个构造函数。
常见于:
- Array.prototype.map
- Array.prototype.filter
- Promise.prototype.then
- TypedArray.prototype.slice
Symbol.species属性默认的读取器如下。
js
static get [Symbol.species]() {
return this;
}
为什么需要Symbol.species
假设你创建了一个 自定义数组子类:
js
class MyArray extends Array {}
然后你这样做:
js
const a = new MyArray(1, 2, 3);
const b = a.map(x => x * 2);
问题来了:b应该是 Array还是 MyArray?
默认行为是:
js
b instanceof MyArray; // true
但在很多场景下(尤其是安全/性能考虑),你可能希望:
js
b instanceof Array; // true
Symbol.species就是用来解决这个问题的。
基本用法:
js
//默认行为(不使用 species)
class MyArray extends Array {}
const a = new MyArray(1, 2, 3);
const b = a.map(x => x);
console.log(b.constructor === MyArray); // true
使用 Symbol.species改变返回类型
js
class MyArray extends Array {
static get [Symbol.species]() {
return Array;
}
}
const a = new MyArray(1, 2, 3);
const b = a.map(x => x);
console.log(b instanceof MyArray); // false
console.log(b instanceof Array); // true
现在所有返回新实例的方法,都会使用 Array而不是 MyArray。
6.4、Symbol.match
对象的Symbol.match属性指向一个函数,当执行str.match(myObject)时,如果该属性存在,会调用它返回该方法的返回值。
它是 JavaScript 中用于定义对象是否可被当作正则表达式使用(尤其是在 String.prototype.match中)的内置 Symbol。
Symbol.match决定一个对象在被传给 String.prototype.match时,是否以及如何作为正则使用。
js
//如果 obj定义了 Symbol.match,就会调用它,而不是当作普通正则。
str.match(obj)
js
String.prototype.match(regexp);
//等价于
regexp[Symbol.match(this)];
js
class MyMatch {
[Symbol.match](string) {
return 'hello world'.indexOf(string);
}
}
console.log('e'.match(new MyMatch()));//1
6.5、Symbol.search
对象的Symbol.search属性指向一个方法,当对象被String.prototype.search方法调用时会返回该方法的返回值。
它是 JavaScript 中用于自定义对象在 String.prototype.search中如何参与查找位置的内置 Symbol。
Symbol.search决定一个对象在被传给 String.prototype.search时,如何进行"索引位置查找"。
js
//如果 obj定义了 Symbol.search,就会调用它,而不是当作普通正则。
str.search(obj)
js
String.prototype.search(regexp);
//等价于
regexp[Symbol.search](this);
js
class MySearch {
constructor(value) {
this.value = value;
}
[Symbol.search](string) {
return string.indexOf(this.value);
}
}
console.log('foobar'.search(new MySearch('foo')));//0
6.6、Symbol.spIit
对象的Symbol.split属性指向一个方法,当对象被String.prototype.split方法调用时会返回该方法的返回值。
它是 JavaScript 中用于自定义对象在 String.prototype.split中如何参与字符串分割的内置 Symbol。
Symbol.split决定一个对象在被传给 String.prototype.split时,如何进行字符串分割。
js
//如果 obj定义了 Symbol.split,就会调用它,而不是当作普通分隔符。
str.split(obj)
js
String.prototype.split(separator, limit);
//等价于
regexp[Symbol.split](this, limit);
js
//按长度分割
class MySplit {
[Symbol.split](str, limit) {
const result = [];
for (let i = 0; i < str.length; i += limit) {
result.push(str.slice(i, i + limit));
}
return result;
}
};
console.log('abcdef'.split(new MySplit(), 2));
// ['ab', 'cd', 'ef']
6.7、Symbol.iterator
对象的Symbol.iterator属性指向其默认遍历器方法,即对象在进行for...of循环时会调用这个方法,返回该对象的默认遍历器。
它是 JavaScript 中最基础、最重要的内置 Symbol 之一,也是理解 迭代协议、for...of、解构、展开运算符 的关键。
Symbol.iterator是一个方法,返回一个迭代器(Iterator)对象,用于定义对象的"可迭代行为"。只要对象实现了它:
js
for (const item of obj) { ... }
就能工作。
js
class Collection {
*[Symbol.iterator]() {
let i = 0;
while(this[i] !== undefined) {
yield this[i];
++i;
}
}
}
let myCollection = new Collection();
myCollection[0] = 1;
myCollection[1] = 2;
for(let value of myCollection) {
console.log(value);
}
//1
//2
js
class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
}
for (const n of new Range(1, 3)) {
console.log(n);
}
// 1
// 2
// 3
6.8、Symbol.toPrimitive
对象的Symbol.toPrimitive属性指向一个方法,对象被转为原始类型的值时会调用这个方法,返回该对象对应的原始类型值。
它是 JavaScript 中用于控制对象如何被转换为原始值(primitive)的内置 Symbol,是类型转换机制里的"终极钩子"。
Symbol.toPrimitive决定了一个对象在被强制转换为原始值(数字、字符串、布尔等)时,返回什么值。
Symbol.toPrimitive决定了一个对象在被强制转换为原始值(数字、字符串、布尔等)时,返回什么值。
Number:该场合需要转成数值。String:该场合需要转成字符串。Default:该场合可以转成数值,也可以转成字符串。
js
let 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');//false
console.log(String(obj));//str
6.9、Symbol.toStringTag
对象的Symbol.toStringTag属性指向一个方法,在对象上调用Object.prototype.toString方法时,如果这个属性存在,其返回值会出现在toString方法返回的字符串中,表示对象的类型。也就是说,这个属性可用于定制[objectObject]或[object Array]中object后面的字符串。
它是 JavaScript 中用于定制对象在 Object.prototype.toString.call()中返回的标签的内置 Symbol,也是判断对象"类型名"的关键机制。
Symbol.toStringTag决定了 Object.prototype.toString.call(obj)返回的 [object XXXX]中的 XXXX是什么。
js
class A {
[Symbol.toStringTag] = 'Foo'
}
console.log(new A().toString());//[object Foo]
console.log(Object.prototype.toString.call(new A()));//[object Foo]
ES6新增内置对象的Symbol.toStringTag属性值如下。
JSON[Symbol.toStringTag]:ˈJSONˈMath[Symbol.toStringTag]:ˈMathˈModule对象M[Symbol.toStringTag]:ˈModuleˈArrayBuffer.prototype[Symbol.toStringTag]:ˈArrayBufferˈDataView.prototype[Symbol.toStringTag]:ˈDataViewˈMap.prototype[Symbol.toStringTag]:ˈMapˈPromise.prototype[Symbol.toStringTag]:ˈPromiseˈSet.prototype[Symbol.toStringTag]:ˈSetˈ%TypedArray%.prototype[Symbol.toStringTag]:ˈUint8Arrayˈ等WeakMap.prototype[Symbol.toStringTag]:ˈWeakMapˈWeakSet.prototype[Symbol.toStringTag]:ˈWeakSetˈ%MapIteratorPrototype%[Symbol.toStringTag]:ˈMap Iteratorˈ%SetIteratorPrototype%[Symbol.toStringTag]:ˈSet Iteratorˈ%StringIteratorPrototype%[Symbol.toStringTag]:ˈString IteratorˈSymbol.prototype[Symbol.toStringTag]:ˈSymbolˈGenerator.prototype[Symbol.toStringTag]:ˈGeneratorˈGeneratorFunction.prototype[Symbol.toStringTag]:ˈGeneratorFunctionˈ
6.10、Symbol.unscopabIes
对象的Symbol.unscopables属性指向一个对象,指定了使用with关键字时哪些属性会被with环境排除。
Symbol.unscopables用来标记:在 with语句中,哪些属性不应该被当作"作用域变量"暴露出来。
js
with (obj) {
// 哪些属性可以直接访问?
}
历史背景:
早期 JS 中:
js
const obj = { a: 1, b: 2 };
with (obj) {
console.log(a); // 1
console.log(b); // 2
}
问题来了:
js
const obj = { toString: 'hello' };
with (obj) {
toString();
// ❌ 调用的是 obj.toString,而不是全局的
}
- 破坏了作用域直觉
- 严重破坏性能和可维护性
ES6 开始:
- with被严格模式禁用
- 新增 Symbol.unscopables作为"补救机制"
js
//没有unscapables时
class MyClass {
foo() {
return 1;
}
}
let foo = function() {
return 2;
}
with (MyClass.prototype) {
foo();//1
}
//有unscopables时
class MyClass {
foo() {
return 1;
}
get [Symbol.unscopables]() {
return {foo: true};
}
}
let foo = function() {
return 2;
}
with (MyClass.prototype) {
foo();//2
}