「这是我参与2022首次更文挑战的第21天,活动详情查看:2022首次更文挑战」。
前言
在本篇中,我们将要讨论JavaScript中的“symbol”和它的一些新特性,也会讨论它是如何帮助元编程的。
在JavaScript中哪些数据类型是基础数据类型(primitive data types)呢?它们分别是 null, undefined, string, boolean。这里还有其他的基本数据类型吗?对了,symbol是在ES6中新增的一种基本数据类型,与 symbol 一同而来的还有 bigint 这种数据类型,关于 bigint,本文中不做介绍,我们后续的文章会专门介绍 bigint。
typeof **null**; *// '* ***object*** *' (it's a bug)*\
typeof **undefined**; *// '* ***undefined*** *'*\
typeof **100**; *// '* ***number*** *'*\
typeof "**hello**"; *// '* ***string*** *'*\
typeof **true**; *// '* ***boolean*** *'*\
typeof **Symbol()** ; *// '* ***symbol*** *'*\
typeof **100n**; *// '* ***bigint*** *'*
Symbol
在本文中,我们只讨论symbol,其他的留待以后再谈。symbol很迷人,与你以前见过的任何其他数据类型都不一样。对于初学者来说,symbol是一种原始数据类型,然而,你不能使用字面量的形式(literal form)的,因为它没有这种形式的表示法。
var sym = Symbol(description)
创建一个symbol, 你需要想上面的实例代码那样调用 Symbol 函数。该函数会返回一个独一无二的 symbol 值,其内部实现取决于JavaScript引擎。它既不是 string 也不是 number 和 boolean。让我们看看它是什么样子的。
Symbol构造函数总是返回一个新的symbol。它接受一个描述性的参数,该参数在将一个 symbol 打印到控制台时用作调试辅助,它不应该与符号的实际值混淆。
如果从Symbol()调用创建的两个符号不可能有相同的值,因为每个 Symbol() 调用,无论是否有description参数,总是返回一个具有自己独特值的新 symbol。因此,一个 symbol 只能等于它自己。
由于 symbol 的值是唯一的,并且在运行时不能够查看其值,它们不能像字符串或数字那样用字面量的形式表示。因此,没有像 var sym = #$@%#; 这样的神奇语法来制作一个符号。你需要使用 Symbol() 函数调用。
Symbol函数与其他的函数不同,它不是一个构造函数,所以
new Symbol这样的调用方式会抛出一个错误:TypeError: Symbol is not a constructor,但是它提供了一些静态函数,我们后续会提及。
一个 symbol值允许被用作对象的键,由于 symbol 不是字符串且不能用字面量的形式所表示。所以我们需要在对象中使用方括号来包裹它来表示一个键。
在上面的粒子中,我们创建了一个名为 sym 的symbol来表示 person 对象的薪资 (salary)。我们能够读写这个值得唯一方法就是使用 sym 这个临时变量 person[sym]。
如果你失去了用来表示一个对象的属性的symbol,那么后续读写这个值得时候将会变得十分困难。正如你所看到的那样,使用类似 person["Symbol("age")] 这样的方法是行不通的。
对象中的使用symbol定义的属性是不能够被枚举的,这意味着它不能够通过 Object.keys 方法访问到其键值,也不能被 for-in 和 Object.getOwnPropertyNames() 所访问到。如下图所示:
但是,我并不认为这是一个缺点,相反这是一个强大的特性。由于 symbol 属性是不可枚举的,那么我们就可以在程序中保留一些特别的含义。如果我们想要重写某个symbol定义的属性值,那么我们必须要得到这个symbol值才可以。所以这个特性对于想要暴露了一个具有symbol属性的对象但是没有暴露symbol的模块来说,效果非常好。
然而,JavaScript提供了 Object.getOwnPropertySymbols() 这个API,它返回一个对象中键是symbol的那些属性。所以不要认为symbol的作用类似于私有属性。symbol 仅仅只是难以访问,并不是没有办法访问它。
噢,我的天呐。这里有一件事情我忘记告诉你,symbol 还具有一个属性description,它会返回我们之前作为参数传入到 Symbol 函数中的那个 description,如果我们没有提供 description, 那么会返回 undefined。在上面的例子中,我们把它作为hack的方法来使用,以获得作为属性键来表示年龄的symbol值,但这并不是一个聪明的方法。
到目前为止,有一件事对我们来说是非常清楚的,一个symbol和一个字符串完全不同,但当在某些情况下期望从一个symbol得到一个字符串值时,这可能会造成一个问题。例如,如果一个symbol被插在一个字符串中,或者一个具有symbol属性的对象被序列化为JSON,那该怎么办?
从上面的例子可以看出,JSON.stringify会简单地忽略任何属于symbol的属性名,并将其余的字段序列化,不会有任何错误。然而,试图将symbol读成字符串的操作会导致TypeError。
Global Symbol Registry
我还没有完全对你说实话。有一种方法可以让你在没有引用的情况下访问一个symbol,或者避免每次都创建唯一的符号。
Symbol.for(key)方法从全局注册表中返回一个带有唯一键的符号,否则它就会临时创建一个并返回。
这个全局注册表对每个script文件、每个module和每个realm(如iframe、web worker、service worker等)都是通用的。由于这个原因,这个注册表被称为运行时间范围内的symbol注册表。因此,当你在任何领域访问Symbol.for(key)时,你每次都会得到相同的symbol。
Symbol.keyFor(sym) 方法只在一个符号是运行时间范围内的符号时才会返回symbol的 description,否则会返回undefined。
当我们使用 Symbol.for(key) 表达式创建一个symbol时,key不仅成为符号的唯一标识符,而且还成为它的description,这很好,因为没有其他方法来提供 description。
💡 如果你在寻找一个从全局注册表中删除一个symbol的函数,请放弃,因为它不存在。
Well-known Symbols
创建一个symbol并在所有的代码领域中分享它的最好方法是什么?也许你会采用Symbol.for()的方法,因为由它创建的symbol可以在所有领域内访问。JavaScript还为特定的使用情况提供了一些预定义的全局符号(运行时间范围)。
如果一个对象属性(它自己的或继承的)有特殊的含义,那么用一个字符串名称来表示它并不是一个好主意,因为它可能被意外地覆盖。
JavaScript在对象上有很多特殊的继承属性,比如 valueOf 和 toString ,它们应该被重写(但不是意外),以提供自定义的对象行为。让我们再看一遍它们。(如下图所示)
每个继承 Object 的对象都有 valueOf 和 toString 方法的默认实现,因为它们是在Object 本身上定义的。当你对对象进行算术运算时,会调用 valueOf 方法,而当需要一个对象的字符串表示时,会调用 toString 方法。
默认的 toString 实现返回 "[object Object]" 字符串,表示它是一个 Object 值的字符串表示。默认的 valueOf 实现返回对象本身,因此任何算术运算都会返回 NaN ,因为最终结果不是一个数字。
我们可以通过在对象本身或其原型链上提供一个具有相同属性名称的函数值来覆盖这些默认实现。因此,如果你有一个类,你可以提供 toString 和 valueOf 实例方法来覆盖这些默认行为,如下所示。
Symbol.toPrimitive
这些特殊方法(属性)的问题是,它们很容易被错误地重写。在 toString 和 valueOf 的情况下,对象的原始值(字符串或数字)在某些情况下被分割在这两个方法中,有时会让人感到困惑。
为了解决这些问题,JavaScript提供了well-known symbols来作为特殊的属性名,其中之一就是 toPrimitive 符号。所有的well-known symbols都作为Symbol对象(实际上是函数)的静态属性暴露给公众,它们在所有的代码领域都是共享的。
Symbol.toPrimitive 符号可以同时完成 toString 和 valueOf 方法的工作,并获得比它们更多的优先权。如果一个对象有(自己的或继承的)名称为 Symbol.toPrimitive 的方法,它将被调用,并可以通过hint 判断需要哪个基础类型的值。
这个方法的 hint 参数值可能是 "number"、"string "或 "default",这取决于对对象执行的操作。default 用于 + 运算符的情况,因为它可以在需要数字的地方作为算术加法运算符使用,或者在需要字符串的地方作为字符串连接运算符使用。
Symbol.toStringTag
我们已经知道,用一个字符串值添加一个对象会返回一个奇怪的字符串,格式为"[object Object]"。发生这种情况是因为Object的 toString 方法的实现返回了"[obejct Object]"字符串。
然而,这并不是只有Object才有的。JavaScript对大多数的类都实现了这个方法。
💡如果你对 "Undefined "和 "Null "感到奇怪,那么就不要奇怪了。这些构造函数是运行时无法访问的,它们纯粹是在JavaScript引擎内部实现的。
由于JavaScript中的每一个值都来自于一个构造函数(类),这就是为什么我们说JavaScript中的每一个东西都是一个对象,每一个类都有自己的实现来返回一个描述该对象的字符串。如果它没有,那么它就会使用 Object 的实现。
从上面的结果可以看出,只有"[object <Tag>]"的 Tag 部分的表示方法在改变。JavaScript给了我们改变Tag值的权力,也许是为了让事情变得简单易懂一些。
当toString方法在对象上执行时(直接或者是间接的)对象上的Symbol.toStringTag属性会作为Tag使用。你可以在对象本身或其原型链上提供这个属性。如果你使用的是一个类,那么你应该把它作为一个getter。
Symbol.hasInstance
想象一下,如果你有两个具有一些共同领域的类。你通常会选择一种继承模式,即一个类扩展另一个类以继承共同的属性。但这并不总是可用的。
如果你有一个函数接受一个对象,并使用 instanceof 操作符检查该对象是否是一个特定类的实例,那么就会产生一个问题,因为即使一个对象可能具有这个特定类的属性,它也不符合这个检查的条件,除非它是从这个类派生出来的。
在上面的例子中,由于 Employee 对象不是 Person 类的实例,Employee 类也没有继承Person 类,所以instanceof 操作符返回false 。为了解决这个问题,你需要放一个OR条件来检查该对象是否也是Employee类的实例。
但使用一个名为 Symbol.hasInstance 的类上的静态方法,你可以覆盖 instanceof 操作符的默认行为。你所要做的是检查传入的对象值,并从这个方法中返回一个布尔值。让我们来看看这是怎么做的。
每当 <LHS> instanceof Person 表达式被执行时,Person[Symbol.hasInstance] 方法就会被调用。在上面的例子中,我们使用 in 操作符来检查 name 属性是否存在于实例中或其原型链上,因为一个对象是Person的实例的唯一标准是name属性的存在。
译者注:通过该属性我们可以用于进行 “鸭子类型” 的检查。
Symbol.isConcatSpreadable
你一定使用过数组的 [].concat() 原型方法,通过追加一个或多个元素来创建一个新的数组。concat 方法的神奇之处在于,如果一个参数是一个数组,它就会将参数平铺。这样做的问题是你可能不需要一直这样做。
你可以通过在数组(Array的实例)或其原型上放置布尔属性 Symbol.isConcatSpreadable 来避免这种情况。如果这个属性存在并且被设置为false ,那么 concat 方法将不会对数组进行平铺。
在上面的例子中,numbers 和drivers数组在concat中是不可展开的,因此,它们在newArray中仍然是数组值,然而,sports数组被展开了,因为它没有 Symbol.inConcatSpreadable 方法,也没有在它的原型上。
Symbol.species
在下面的例子中,我们定义了 MyArray 类,它扩展了内置的 Array 类。MyArray 唯一自己实现的东西是Symbol.isConcatSpreadable getter。技术上讲,MyArray 的任何实例都继承了 Array 类的所有属性。
然而,有一个问题。如果我们使用map、forEach或任何数组类的原型方法来返回一个新的数组,但在MyArray 的实例上,我们将得到一个 MyArray 的实例,这应该是我们想要的输出。
然而,有时候,你需要使用一个封装类来为基类提供额外的行为,但要保持底层基类的核心功能一致。例如,我们想要的是,当我们在 MyArray 的一个实例上使用 map 或 forEach 时,它应该返回一个 Array 而不是 MyArray。
Symbol.species 是一个类的静态getter属性,它指向应该用于派生对象的构造函数(类),例如在数组的情况下从map 或 forEach 方法内派生。
正如你在上面的例子中看到的,map方法的隐式或显式调用返回了一个Array的实例(而不是 MyArray)。
Regular Expression Methods
到目前为止,当我们谈到正则表达式时,我们指的是正则表达式的一个实例。同样的实例可以由 /.../ 形式的字面表达式创建。然后这个正则表达式对象可以用来匹配字符串中的文本模式。
'google.com'.match(/^ [a-z]+.com$/gi)
▶ ["google.com"]
采用ES2015的JavaScript使得任何对象都可以像正则表达式对象一样行事。因此,你可以在str.match() 调用中传递一个自定义对象,它将被用作matcher,就像正则表达式一样。这个对象应该实现一些众所周知的方法(well-known method),这些方法将被调用以获得匹配操作的结果。
当一个匹配器对象有 Symbol.match 方法时,它可以被用于 String.prototype.match() 方法。这个符号方法被JavaScript调用以获得 str.match() 的结果。
在上面的例子中,我们创建了一个 Matcher 类,它实现了 Symbol.match 实例方法,因此这个方法在 Matcher 对象上是可用的。当我们调用 text.match(matcher) 函数时,这个方法会与文本一起执行。我们可以使用Symbol.match 方法中的文本来返回一个有效的响应
💡 你也有
Symbol.matchAll知名符号来处理String.prototype.matchAll()调用。
当搜索原型方法在一个字符串上被执行时,匹配器的 Symbol.search 方法被执行。与Symbol.match 类似,该方法也接收字符串作为搜索方法被调用的参数。
匹配器的 Symbol.replace 方法在对String执行 replace prototype方法时被执行。因为替换原型方法需要一个替换文本,所以这个方法会被调用,并带有原始文本和替换文本。你应该从这个方法返回一个字符串。
当对一个字符串执行split 原型方法时,匹配器的 Symbol.split 方法被执行。你将得到原始字符串作为参数,你应该从这个方法中返回一个数组。
Symbol.iterator
我们已经在另一课中谈到了 Symbol.iterator,但让我们总结一下那一课。ES2015引入了一个新的迭代协议,包含了使任何对象可迭代的准则。一个可迭代的对象是一个可以在 for-of 循环或展开操作中使用的对象。
到目前为止,我们只能使用数组作为迭代对象,而要迭代一个对象,你需要使用for-in循环。另外,你不能展开一个对象,也许 Object.values() 或 Object.entries() 可以帮助你。
使用iterable协议,你可以使任何对象在 for-of 循环或展开操作中表现得像一个数组。在这些情况下,JavaScript首先通过调用迭代器的 Symbol.iterator 方法(也可以是在其原型上)从中获得一个迭代器。
这个 Symbol.iterator 方法在迭代的开始被调用,它应该返回一个迭代器对象。这个迭代器对象有下一个方法,它返回一个有 done 布尔字段和 value字段的对象。
var obj = { max: 5 };
obj[Symbol.iterator] = function() {
let max = this.max; // `this` here is the `obj`
return {
next: () => {
return { done: 0 === max, value: max-- };
}
};
}
console.log( [ ...obj ] ); // [5, 4, 3, 2, 1]
这个迭代器.next() 方法在迭代中被无限期地调用,直到 done 为 true。这个 value 值字段是我们所感兴趣的。这个字段代表每次迭代的值。当done为 true 时,value值被忽略。
设计迭代器并不是一个有趣的过程,因为它涉及到大量的模板代码,正如你在上面看到的。这就是为什么JavaScript为我们提供了另一个叫做 Generator 的功能,这是一种特殊的函数。这些 generator 函数(或简称生成器)在被调用时返回一个迭代器。因此,Symbol.iterator 属性也可以是一个generator。
当在generator返回的迭代器上调用下一个方法时,每一个 yield 表达式都被执行,产生一个迭代对象,其中有 done 和 value 字段。一旦所有的 yield 表达式被评估,只要调用下一个方法,迭代器就会返回一个迭代对象,并将done 设置为 true 。
*[ Symbol.iterator ] 方法提供了一个生成器,这就是为什么我们需要在方法名称的开头提供*。JavaScript为多个类实现了迭代器协议(默认),如Array、Set、Map、String、TypedArray等,如下图所示。
typeof Array.prototype[Symbol.iterator]; // 'function'
typeof String.prototype[Symbol.iterator]; // 'function'
typeof Map.prototype[Symbol.iterator]; // 'function'
typeof Set.prototype[Symbol.iterator]; // 'function'
typeof Uint8Array.prototype[Symbol.iterator]; // 'function' // hence you can spread a string
let vowels = [...'aeiou']; // [ 'a', 'e', 'i', 'o', 'u' ]
Symbol.asyncIterator
JavaScript支持 for-of 循环的另一种变体,用于以同步的方式迭代Promise。for-await-of循环可以以同步的方式迭代一个可迭代对象,每次迭代都会返回一个Promise。
在上面的例子中,promises 数组包含一个 Promise 列表,该列表在几毫秒后 resolve 一个字符串值。如果我们在上面运行一个正常的for-of循环,每次迭代都会收到作为值的Promise。但我们需要的是每个 Promise 的解析值。
for await 语法使之成为可能。它等待每个承诺被resolve,结果变量得到 Promise 的解析值。由于我们使用 await 关键字,我们需要把 for-await-of 循环放在一个异步函数中,它可以是一个 **IIFE**。
就像 for-of 循环一样,for-async-of 循环也使用迭代器的 Symbol.iterator 方法来接收一个迭代器。如果iterator为每次迭代返回一个promise,那么结果就会和上面一样。
然而,如果迭代器上有 Symbol.asyncIterator 方法,for-await-of 循环更倾向于使用该方法。这个方法也像 Symbol.iterator 一样工作,但它是一个异步方法,因此,你可以在生成器(或返回一个迭代器的自定义方法)中使用 await 关键字。
在上面的例子中,每个 yield 表达式将等待promise resolve,这样它就可以提供 promise 的解析值作为迭代的值。然而,你也可以yield promise本身(只要去掉 await 关键字),事情会以同样的方式进行。
什么是 @@iterator?
你可能在JavaScript文档中看到过 @@iterato r符号,比如MDN的Array文档,或者在可能的控制台日志或堆栈跟踪中看到过。@@ 符号是well-known symbols的规范前缀,它在运行时没有任何意义。
下表是ES2021的一个官方的well-known symbol表。在左边一栏,你可以找到well-known symbol的规范名称,而在右边一栏是在运行时可以访问的实际符号。
💡 Symbol.unscopables这个well-knwon symbol是用来控制一个对象的行为的,当在with语句中使用时。但是,由于with语句并不被推荐,而且它是一种有争议的东西,你应该避免使用它。
Symbol的好处是什么?
嗯,这是整篇文章中最难的部分。我想说的是,symbol对于避免hack你的API是非常好的。例如,如果你不希望人们意外地覆盖一个对象的属性,就把它变成一个symbol,并通过一个全局对象公开它,或者把它变成一个global symbol。
symbol的另一个好处是代表一个独特的值。例如,如果你想创建一个枚举来代表一组固定的唯一值,那么就用symbol创建一个对象。
// worst 😵
print( "red" );
----------------------
// bad😔
var COLORS = {
RED: "RED",
GREEN: "GREEN",
BLUE: "BLUE"
};
print( COLOR.RED );
----------------------
// good 😃
var COLORS = {
RED: Symbol( 'COLORS.RED' ),
GREEN: Symbol( 'GREEN.GREEN' ),
BLUE: Symbol( 'COLORS.BLUE' )
};
print( COLOR.RED );