带你一文读懂ES6的Symbol

avatar
SugarTurboS Club @SugarTurboS

前言

Symbol这个特性对于很多同学来说,可能是在学习ES6特性的过程中,感到比较困惑的一个特性点。在大部分开发场景中,你可能根本用不到这个特性,但理解Symbol各个属性和方法的作用和意义还是非常有必要的,在一些特定的场景中,你会发现它不可或缺。Symbol内含的方法和属性非常多,本文仅对大概率会用到的一些讲解。

基础类型

Javascript属性的小伙伴都知道,Javascript中有6大基础类型:BooleanBigIntundefinedNumberStringSymbol。可以看到,SymbolJavascript中的基础类型之一。简单的去理解,Symbol数据就是一个全局唯一的值,那全局唯一有什么用?当然是用于避免冲突啦~

我们先来看看Symbol最简单的用法:

const symbolOne = Symbol();
console.log(symbolOne); // Symbol()

由于Symbol是基础数据类型,所以我们不能用new的方式去创建Symbol对象,只需要直接调用Symbol即可。这里我们创建了一个简单的Symbol对象,你也可以像下面这样给Symbol传入一个description,用来标识一个Symbol:

const symbolOne = Symbol('foreverpx');
console.log(symbolOne); // Symbol('foreverpx')

设置这个标识在调试的时候非常有用,你可以通过不同的标识把Symbol值区分开来。

多人协同开发中创建Symbol的时候,description是有可能重复的。如果出现不同地方都用同一个description创建了Symbol,那这两个Symbol是不相等的,比如下面的比较:

const symbolOne = Symbol('foreverpx');
const symbolTwo = Symbol('foreverpx');
console.log(symbolOne === symbolTwo); // false

这种情况就会比较奇怪,原本只是想通过全局唯一的值解决冲突,不同的description返回不同的唯一值。但相同的description应该返回一样的Symbol值才对。

其实,description只是一个标签,用于更清晰的标识Symbol以及用于调试。虽然传入的description相同,但是并不意味着Symbol('foreverpx')的结果与Symbol('foreverpx')相同,它们是不同的对象。

Symbol

在我看来,Symbol的主要用途,是用来标识唯一的对象属性。

怎么理解?我们已经使用字符串来标识对象属性了,为什么还需要通过Symbol来标识呢?

我们来看下面的场景:

我们预先定义好两个const来标识用户在线与不在线的两个状态,比如:

const ONLINE = 'online';
const OFFLINE = 'offline';

接着写一个带有switch的函数来处理两种情况的业务逻辑:

function onUserStatus(status){
	switch(status){
		case ONLINE:
			//do something
			return 'user online';
		case OFFLINE:
			//do something
			return 'user offline';
		default:
			return 'unknown status'
	}
}
onUserStatus(ONLINE); // 'user online'

上面的代码现在看上去没有什么问题。但某天需求加了一种connenting的状态,在新增常量的时候,前端同学copy了上面的OFFLINE的语句来修改,但只修改了常量名为CONNECTING,而忘了修改值为connenting,那代码就会变成下面这样:

const ONLINE = 'online';
const OFFLINE = 'offline';
const CONNECTING = 'offline';

在调用onUserStatus时,结果就不对了,这显然是变量值冲突导致的:

function onUserStatus(status){
	switch(status){
		case ONLINE:
			//do something
			return 'user online';
		case OFFLINE:
			//do something
			return 'user offline';
		default:
			return 'unknown status'
	}
}
onUserStatus(CONNECTING); //user offline

接下来我们把上面的代码,用Symbol来写写看:

const ONLINE = Symbol('online');
const OFFLINE = Symbol('offline');
const CONNECTING = Symbol('offline');
function onUserStatus(status){
	switch(status){
		case ONLINE:
			//do something
			return 'user online';
		case OFFLINE:
			//do something
			return 'user offline';
		default:
			return 'unknown status'
	}
}
onUserStatus(CONNECTING); //unknown status

通过Symbol改写后,会发现即使在copy代码后忘了改值,也不会产生与上面同样的结果。由于每次调用Symbol都会生成一个全局唯一的值,所以传入上方任何一个常量,都不会得到相同的结果。

我们再来看一个对象的例子:

const foreverpx = {
	cnName: 'px'
}

这里定义了一个叫foreverpxObject,其中有一个叫cnName的属性。如果我们像这样用字符串来作为对象属性的key,那任何一个可以访问这个对象的地方,都可以改变它的值,比如: foreverpx.cnName = 'anthor'。在某些情况下,我们不期望这样。

Symbol我们可以解决这个问题:

const cnName = Symbol('cnName');
const foreverpx = {
	[cnName]: 'px'
}

在这种写法下,只有当调用者同时拿到了cnName这个Symbol,才能修改这个属性的值。

从上面两个例子可以看到,使用Symbol在一些场景下可以让我们的程序更加健壮。

Symbol.for 与 Symbol.keyFor

前面讲了Symbol,写下来讲讲它的两个静态方法Symbol.forSymbol.keyFor

不记得什么是静态方法了?静态方法也就是在类上定义的,不需要实例化即可调用的方法,比如:

class ForeverPx{
	static getName(){
		console.log('foreverpx')
	}
}
ForeverPx.getName(); // foreverpx

Symbol与这两个静态方法的区别是啥?还记得最开始的例子吗,同一个字符串创建的两个Symbol,它的值是不相等的,Symbol.for可以解决这个问题。

Symbol.for被调用时,它首先会判断传入的key是否有被创建过,如果没有,则创建一个新唯一值。如果有,则返回之前的唯一值。

const symbolOne = Symbol.for('foreverpx')
const symbolTwo = Symbol.for('foreverpx')
console.log(symbolOne === symbolTwo); //true

所以,对于Symbol.for来说,key就是Symbol的标识。而对于Symbol来说,key只是个简单的描述而已。

const symbolOne = Symbol.for('foreverpx')
const symbolTwo = Symbol('foreverpx')
console.log(symbolOne === symbolTwo); //false
console.log(symbolOne === Symbol.for('foreverpx')); //true

接下来是Symbol.keyFor,其实这个方法是比较好理解的,从名字就能看出来,它是获取对应Symbol.for创建的唯一值的key的:

const symbolOne = Symbol.for('foreverpx')
console.log(Symbol.keyFor(symbolOne)); //foreverpx

Symbol.iterator

到目前为止,上面讲的都是Symbol的构造函数和方法,接下来开始介绍Symbol的一些静态属性。首先要介绍的就是Symbol.iterator了。

Symbol.iterator从名字上就能看出,跟我们经常接触到的迭代器有很大的关系。在讲Symbol.iterator之前,我们先来回顾下迭代器的概念。

什么是迭代器?迭代器就是能让你遍历并操作一个集合中的每一个元素的方法。在Javascript中,每一次循环都可以被称为迭代。你可以用for循环来遍历一个数据或者对象。如果一个对象的属性可以被诸如for of等表达式遍历,则称这个对象是可迭代的。比如:

const foreverpx = [1,2,3];
for(let i of foreverpx){
	console.log(num); //1,2,3
}

可以被迭代的对象,在其原型上都能找到Symbol.iterator属性,可以在控制台看到

在这里插入图片描述 所以如果你想知道一个对象能否被迭代器迭代,那么可以通过图上的方式来查看。

你会发现,并不是只要是对象就能被迭代,Object本身就是不能被迭代的。可以被迭代的对象还有StringMap之类的。

既然Symbol.iterator是某些对象原型上的一个属性的key值,那么如果我们调用它,会返回什么呢?

在这里插入图片描述 调用后返回了一个迭代器对象,里面包含了一个next方法,是不是跟yield*很像呢。从逻辑上来看,我们只要通过不停的调用这个对象的next方法,就能依次迭代对象里面的属性了。我们来验证一下:

const foreverpx = [1,2,3];
const iterator = foreverpx[Symbol.iterator]();
iterator.next(); //{value: 1, done: false}
iterator.next(); //{value: 2, done: false}
iterator.next(); //{value: 3, done: false}
iterator.next(); //{value: undefined, done: true}

next方法每次调用时,会返回一个对象,里面包含2个属性,value表示当前被迭代的值,done表示当前是否所有属性都已迭代完。

既然对象的Symbol.iterator属性对应的是一个方法,那么我们改写对应的方法,重新赋值给它,就能改变该对象的迭代行为呢?同样,我们通过代码来验证一下:

const foreverpx = [1,2,3];
foreverpx[Symbol.iterator] = function(){
	return {
		obj: foreverpx,
		index: 0,
		next(){
			const idDone = this.index === this.obj.length;
			this.index ++;
			if(this.obj[this.index] === 2){
				return {value: this.obj[this.index]*2, done: isDone }
			}else{
				return {value: this.obj[this.index], done: isDone }
			}	
		}
	}
}
for (let item of foreverpx) {
  console.log(item);
}
//1,4,3

刚才上面有提到,不是所有的对象都能被迭代,就比如Object

const foreverpx = {
	name: 'px',
	age: '18'
}
for(let item of foreverpx){
	console.log(item);
}

这段代码执行完毕之后,我们会发现报错了,这确实如我们所预期的那样:

在这里插入图片描述 但我们可以通过Symbol.iterator让它变得可以被迭代

const foreverpx = {
	name: 'px',
	age: '18'
}
foreverpx[Symbol.iterator] = function(){
	return {
		obj: foreverpx,
		index: 0,
		next(){
			const idDone = this.index === this.obj.length;
			this.index ++;
			return {value: this.obj[this.index], done: isDone }
		}
	}
}
for (let item of foreverpx) {
  console.log(item);  //px, 18
}

另外,有些同学可能会搞混for offor in的概念,这里对它们的差别不做过多的赘述,如果想了解更多,可以查看这篇文章for in 和for of的区别

Symbol.search

我们在想要匹配某个正则在字符串中的位置时,有时候会使用下面的 方法来获取:

'foreverpx'.search('px'); // 7

上面代码返回了pxforeverpx字符串中出现的第一个位置。

当字符串的search方法被调用时,也即是调用了Symbol.search方法。

按照Symbol.iterator的思路,我们同样可以通过改写Symbol.search来覆盖字符串调用search时候的默认行为。

const str = 'foreverpx';
const reg = '/px/'
reg[Symbol.search] = function(str){
	return 2020;
}
str.search(reg); //2020

接下来的几个Symbol属性也是同样的作用和用法。

Symbol.split

同理,在字符串调用split方法的时候,也即是调用了Symbol.split。我们同样可以改写字符串在调用split方法时候的行为。

const str = 'foreverpx cjl';
const splitReg = / /; //空格
splitReg[Symbol.split] = function(str){
	return ['px', 'cjl'];
}
str.split(splitReg); // ['px', 'cjl']

Symbol.toPrimitive

就跟它的名字一样,定义如何让一个对象变得原始、简单。

比如你有一个数组,你想让他变primitive,你可能会这么做:

const arr = ['px', 'cjl'];
const prim = `${arr}`; //"px,cjl"

可以通过Symbol.toPrimitive改写这个默认行为:

const arr = ['px', 'cjl'];
arr[Symbol.toPrimitive] = function() {
  return `foreverpx`;
};
const prim = `${arr}`; //"foreverpx"

对于Object亦可

const obj = {name: 'px'};
obj[Symbol.toPrimitive] = function() {
  return `foreverpx`;
};
const prim = `${obj}`; //"foreverpx"

把原本的结果[object Object]变成了foreverpx

总结

总的来说,Symbol其实更像是提供了一个工具集,这个集合既能让你去生成一个全局唯一的值,也能在运行时去修改很多原始对象的默认行为,这些是在Symbol出现之前是难以做到的。虽然在大部分情下,你可能很少会用到Symbol,但学习Symbol并理解它,或许能在你设计并实现一些逻辑时,为你提供一种解决思路。

如果文章对你有帮助,顺手点个赞和关注吧~