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中的一个非常重要的概念,之后会继续探讨。

发布了37 篇原创文章 · 获赞 90 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/103322409