本章我们将介绍 JavaScript symbol,以及依赖于此的 JavaScript 新特性。
什么是 JavaScript 中的基本数据类型 primitive data types?简单来说,就是null
、undefined
、string
、number
和boolean
这几种数据类型。还有别的吗?是的!符号(symbol
)是在 ES2016(ES6)中与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'
本章我们主要讨论符号。符号很有趣,与之前你见到的那些数据类型都不同。符号是一种基本数据类型,但是你不能写出它的字面量形式,因为它没有字面量。
var sym = Symbol(description);
为了创建符号,需要像上面那样调用Symbol
函数。该函数返回唯一的符号值。符号的内部实现取决于实现者(也就是 JavaScript 引擎),但是,符号不能是string
、number
或boolean
值。来看下面的代码:
// create some symbols var sym1 = Symbol(); var sym2 = Symbol(); var sym3 = Symbol('apple'); var sym4 = Symbol('apple'); // type check console.log( '"typeof sym1" =>', typeof sym1 ); // "typeof sym1" => symbol console.log( '"typeof sym3" =>', typeof sym3 ); // "typeof sym3" => symbol // equality check console.log( '"sym1 === sym2" =>', sym1 === sym2 ); // "sym1 === sym2" => false console.log( '"sym1 == sym2" =>', sym1 == sym2 ); // "sym1 == sym2" => false console.log( '"sym3 === sym4" =>', sym3 === sym4 ); // "sym3 === sym4" => false console.log( '"sym1 === sym2" =>', sym1 === sym2 ); // "sym1 === sym2" => false console.log( '"sym1 === sym1" =>', sym1 === sym1 ); // "sym1 === sym1" => true // logging a symbol console.log( '"sym1" =>', sym1 ); // "sym1" => Symbol() console.log( '"sym3" =>', sym3 ); // "sym3" => Symbol(apple)
Symbol
函数始终返回新的符号。它接受一个description
参数,用于开发调试时将该符号记录到控制台。这个参数并不会影响到符号的实际值。
由Symbol()
函数调用创建的两个符号永远不能相等。因为每一次Symbol()
的调用,不管是否有description
参数,始终返回具有唯一值的新的符号。因此,符号只能与其自身相等。
由于符号的值都是唯一的,并且在运行时不可见,也就不能以常见的字符串或数值这样的字面量显示。因此,并没有类似var sym = #$@%#*#;
这样的语法来创建一个符号:你只能通过调用Symbol()
函数。
值得注意的是,Symbol
函数与其它函数有所不同,它不是可构造的,也就是说,不能使用类似new Symbol()
这样的表达式,否则会抛出TypeError: Symbol is not a constructor
这样的异常。然而,这个函数却提供了一些静态方法,我们会在后面的部分详细介绍。
符号可以当做对象的键。由于符号不是字符串,没有字面量形式,我们需要使用方括号语法,如同使用string
变量一样。
// create a new symbol var sym = Symbol('salary'); // symbol as a property of an object var person = { name: 'Ross', [ sym ]: 200, [ Symbol('age') ]: 21, }; // access normal property console.log( 'person.name =>', person.name ); // person.name => Ross console.log( 'person["name"] =>', person[ "name" ] ); // person["name"] => Ross // access symbol property console.log( 'person[sym] =>', person[ sym ] ); // person[sym] => 200 // assign new value person[ sym ] = 300; person[ Symbol('age') ] = 22; // oops // delete property delete person[ sym ]; // inspect object console.log( 'person =>', person ); // person => { name: 'Ross', [Symbol(age)]: 21, [Symbol(age)]: 22 } // not gonna happen dude console.log( 'person[ "Symbol(age)" ] =>', person[ "Symbol(age)" ]); // person[ "Symbol(age)" ] => undefined
上面的例子中,我们创建了sym
符号表示person
对象的收入。针对symbol
类型的属性名进行读取、写入以及删除的唯一方法,就是将原始的符号值保存到变量中,然后使用[]
记号。
如果你丢失了代表对象属性的符号值,那就很难再访问到对象这个属性了。正如上面显示的那样,使用[Symbol()]
语法并不是一个好主意,因为你再也无法访问到这个符号对应的属性的值了。
Symbol
类型的对象属性是不可枚举的,这意味着它们不会出现在Object.keys()
的返回值中(也就是拥有/可枚举属性),也不能使用for-in
循环访问到(也就是自己拥有+继承/可枚举属性),同样也不在Object.getOwnPropertyNames()
方法的返回值中(即拥有/可枚举+不可枚举属性)。
// create a new symbol var sym = Symbol('salary'); // symbol as a property of an object var person = { name: 'Ross', [ sym ]: 200, [ Symbol('age') ]: 21, }; // check enumerable properties using `Object.keys` console.log( 'Object.keys() =>', Object.keys( person ) ); // Object.keys() => [ 'name' ] // check properties using `Object.getOwnPropertyNames` console.log( 'Object.getOwnPropertyNames() =>', Object.getOwnPropertyNames( person ) ); // Object.getOwnPropertyNames() => [ 'name' ] // check properties using `for-in` loop for( let prop in person ) { console.log( 'for-in prop =>', prop ); // for-in prop => name }
我不会将这一特性看作是一个缺点,而是当成是一种强大的抽象特性。因为符号被当作不可枚举属性,就可以保存程序中的特殊含义的数据。一种必须使用原始符号的引用才能写入的符号属性。这对于导出对象的模块很有用,因为你可以只导出包含符号属性的对象,而不导出符号,这样,该属性值就永远不能被覆盖。
虽然符号属性是不可枚举的,但它的属性描述符的enumerable
属性却被设置为true
。初看起来这很奇怪,不过却有着必要的原因。阅读这个问答可以了解更多。
不过,JavaScript 提供了Object.getOwnPropertySymbols()
函数,返回对象所拥有的属性类型为符号的列表。所以,不能把符号属性当做是私有属性。符号只是很难访问,但并不是不能访问。
// create a new symbol var sym = Symbol('salary'); // symbol as a property of an object var person = { name: 'Ross', [ sym ]: 200, [ Symbol('age') ]: 21, }; // get symbol properties using `getOwnPropertySymbols` const symbols = Object.getOwnPropertySymbols( person ); console.log( 'symbols =>', symbols ); // symbols => [ Symbol(salary), Symbol(age) ] // find symbol that represents `age` const ageSym = symbols.find( sym => { return sym.description && sym.description.includes( 'age' ); } ); console.log( 'ageSym =>', ageSym ); // ageSym => Symbol(age) console.log( 'person[ageSym] =>', person[ageSym] ); // person[ageSym] => 21
还有一件事,每一个符号都有一个description
属性,返回在Symbol(description)
调用时传入的description
的值。如果在创建时没有传入,则返回undefined
。在上面的例子中,我们利用这一技巧去获取描述为age
的符号所对应的属性值,虽然这种写法并不聪明。
现在,至少有一件事是肯定的:符号并不是string
。然而,这一点在某些期望由符号返回string
的情景下,可能会造成一定的问题。例如,如果我们在字符串插值语句中加入一个符号,或者将带有符号的对象序列化为 JSON,会发生什么?
// create a new symbol var sym = Symbol('salary'); // symbol as a property of an object var person = { name: 'Ross', [ sym ]: 200, [ Symbol('age') ]: 21, }; // convert `person` to JSON string console.log( 'result/json ->', JSON.stringify( person ) ); // result/json -> {"name":"Ross"} // interpolate symbol as string try { console.log( `salary key is -> ${ sym }` ); } catch( err ) { console.log( 'result/error ->', err ); // result/error -> TypeError: Cannot convert a Symbol value to a string } // perform string concatenation try { console.log( 'salary key is ->' + sym ); } catch( err ) { console.log( 'result/error ->', err ); // result/error -> TypeError: Cannot convert a Symbol value to a string }
上面的代码中,JSON.stringify
简单地将属性名类型为符号的属性全部忽略,然后将其余字段序列化,没有任何错误。然而,那些试图将符号当做字符串读取的操作,都会抛出TypeError
。
你可能想知道,为什么之前使用console.log
可以将符号值显示为string
,这是因为console.log
调用了符号的toString
函数。你也可以手动调用sym.toString()
函数,该函数返回Symbol(salary)
字符串。
全局符号注册表
我没有对你说实话。其实还是有一种方法,让你在没有符号的引用的时候,可以访问到这个符号的,甚至是在每次创建的时候并不一定返回唯一的符号。Symbol.for(key)
函数从全局注册表返回具有唯一的key
的符号;如果没有这样的符号,则直接创建然后返回。
全局注册表对于每一个脚本文件、每一个模块、每一个上下文(比如 iframe、web worker、service worker 等),都是相同的。因此,这个注册表被称为运行时范围的符号注册表。所以,当你通过Symbol.for(key)
访问时,每次都可以得到同一个符号。
Symbol.keyFor(sym)
返回一个符号的key
值,注意,只有当这个符号是运行时范围的,才会返回,否则返回undefined
。
// create a new symbol var sym = Symbol.for('salary'); console.log( "sym's description ->", sym.description ); // sym's description -> salary console.log( "sym's key ->", Symbol.keyFor(sym) ); // sym's key -> salary // check for equality console.log( "Symbol.for('salary') === sym ->", Symbol.for('salary') === sym ); // Symbol.for('salary') === sym -> true // symbol as a property of an object var person = { name: 'Ross', [ sym ]: 200, [ Symbol.for('age') ]: 21, }; // access symbol property console.log( 'person[sym] ->', person[ sym ] ); // person[sym] -> 200 console.log( 'person[Symbol.for("salary")] ->', person[ Symbol.for('salary') ] ); // person[Symbol.for("salary")] -> 200 // inspect object console.log( 'person ->', person ); // person -> { name: 'Ross', [Symbol(salary)]: 200, [Symbol(age)]: 21 } // working just fine console.log( 'person[Symbol.for("age")] ->', person[ Symbol.for('age') ] ); // person[Symbol.for("age")] -> 21
当我们使用Symbol.for(key)
表达式创建符号时,key
不仅是符号的唯一标识符,而且会成为其description
属性值,这是合理的,因为这种方式创建符合是没有办法设置描述的。
如果你在寻找如何从全局注册表中移除符号,你最好放弃,因为并不存在这样的函数。
公开的符号
创建符号,并且将其在整个应用内共享的最好的方法是什么?或许你应该使用Symbol.for(key)
实现,因为这样创建的符号可以在整个上下文中访问到。JavaScript 提供了一些预定义的符号(运行时范围内的),用于某些特定的情景。
如果对象的属性(自己拥有的或者是继承的)具有特殊的含义,那么,使用string
类型的属性名并不是一个好主意,因为很容易将其重写,我们在前面的文章中见识过valueOf
函数的例子。
JavaScript 的对象有一大堆继承而来的特殊属性,例如valueOf
和toString
,其目的就是为了要重写(并不是无意的重写),以便实现对象的自定义行为。我们可以看看下面的例子。
// define a simple JavaScript object var person = { name: 'Ross', salary: 200, age: 21, }; // arithmetic operation triggers `valueOf` method console.log( 'before: person - 10 ->', person - 10 ); // before: person - 10 -> NaN // string operation triggers `toString` method console.log( 'before: `Hello ${ person }` ->', `Hello ${ person }` ); // before: `Hello ${ person }` -> Hello [object Object] /*---------*/ // provide `valueOf` and `toString` implementation person.valueOf = function() { return this.salary; } person.toString = function() { return this.name; } console.log( 'after: person - 10 ->', person - 10 ); // after: person - 10 -> 190 console.log( 'after: `Hello ${ person }` ->', `Hello ${ person }` ); // after: `Hello ${ person }` -> Hello Ross
每一个继承Object
的对象都有valueOf
和toString
的默认实现,这两个函数是在Object
定义的。当针对对象执行算术运算时,会自动调用valueOf
函数;当需要将对象转换为字符串时,则调用toString
函数。
toString
默认实现是返回[object Object]
字符串,这就是一个对象的字符串表示。valueOf
的默认实现是返回对象本身,因此,任何算术操作都会返回NaN
,因为最终结果不是一个数字。
通过在对象本身或者原型链上增加同名的函数值,我们可以覆盖这些默认实现。因此,如果你有一个类的话,你可以提供toString
和valueOf
实例方法,覆盖默认行为。看下面的代码。
class Person { constructor( name, salary, age ) { this.name = name; this.salary = salary; this.age = age; } valueOf() { return this.salary; } toString() { return this.name; } } // create an instance (object) of `Person` var person = new Person( 'Ross', 200, 21 ); // perform operations on person console.log( 'person - 10 ->', person - 10 ); // person - 10 -> 190 console.log( '`Hello ${ person }` ->', `Hello ${ person }` ); // `Hello ${ person }` -> Hello Ross
Symbol.toPrimitive
上面这些特殊方法(属性)有一个问题是,它们很容易被错误地覆盖。例如toString
和valueOf
,对象(字符串或数字)的原始值在某些上下文中由这两个方法被分为两种不同类型,有时很容易引起误会。
为了解决这些问题,JavaScript 提供了一系列公开的符号,用于这些特殊属性的名字。其中之一就是toPrimitive
符号。所有这些公开的符号都是Symbol
对象(实际是一个函数)的公共的静态属性,并且在整个程序范围内共享。
Symbol.toOrimitive
符号的作用一次性实现了toString
和valueOf
的作用,并且具有更高的优先级。如果对象有一个名为Symbol.toPrimitive
的方法(不论是自己所有还是继承而来),在期望一个对象的原始值的时候,这个函数就会被调用,其参数hint
即指明需要何种类型的原始值。
// define a simple JavaScript object var person = { name: 'Ross', salary: 200, age: 21, }; // define `Symbol.toPrimitive` method person[ Symbol.toPrimitive ] = function( hint ) { if( hint === 'number' ) { return this.salary; } else if( hint === 'string' ) { return this.name; } else { return 'person-default--'; } } // demands a `number` primitive value console.log( 'person - 10 ->', person - 10 ); // person - 10 -> 190 // demands a `string` primitive value console.log( `Hello ${person} ->`, `Hello ${person}` ); // Hello ${person} -> Hello Ross // demands a `default` primitive value console.log( `person + true ->`, person + true ); // person + true -> person-default--true console.log( `person + 1 ->`, person + 1 ); // person + 1 -> person-default--1 console.log( `person + '' ->`, person + '' ); // person + '' -> person-default--
该函数的参数hint
的值可能为"number"
、"string"
或"default"
,这取决于该对象要进行哪种操作。在使用+
运算符时,hint
的值为"default"
,因为这可以是算术加运算(此时是"number"
),也可以是字符串的拼接操作(此时则是"string"
)。
Symbol.toStringTag
我们知道,将一个对象与一个string
值相加,会得到一个奇怪的字符串:[object Object]
。这是因为Object
的toString
函数的默认实现就是返回这样的字符串。
不过,这并非只有对象(也就是Object
的子类)才有的情况。JavaScript 对大多数类都提供了类似的实现。
// get string representation of a `value` using `toString` prototype method of the `Object` function stringRepr( value ) { return Object.prototype.toString.call( value ); } console.log( 'undefined ->', stringRepr( undefined ) ); // undefined -> [object Undefined] console.log( 'null ->', stringRepr( null ) ); // null -> [object Null] console.log( 'string ->', stringRepr( '' ) ); // string -> [object String] console.log( 'number ->', stringRepr( 1 ) ); // number -> [object Number] console.log( 'boolean ->', stringRepr( true ) ); // boolean -> [object Boolean] console.log( 'symbol ->', stringRepr( Symbol() ) ); // symbol -> [object Symbol] console.log( '\nobject ->', stringRepr( {} ) ); // object -> [object Object] console.log( 'function ->', stringRepr( function(){} ) ); // function -> [object Function] console.log( 'array ->', stringRepr( [] ) ); // array -> [object Array] console.log( 'Math ->', stringRepr( Math ) ); // Math -> [object Math] console.log( 'JSON ->', stringRepr( JSON ) ); // JSON -> [object JSON]
如果你对Undefined
和Null
感到奇怪,大可不必。这些构造器函数并不能在运行时访问,它们纯粹是在 JavaScript 引擎内部实现的。
JavaScript 中,每一个单独的值都是由构造器创建而来,这就是为什么我们会说,JavaScript 中的所有值都是对象。每一种类都提供了各自实现的返回字符串的函数,用于描述该对象。如果没有提供自己的实现,那么就会使用Object
的实现。
正如上面的运行结果所示,字符串"[object <Tag>]"
中,只有Tag
部分是不同的。JavaScript 提供了修改Tag
值的强大功能,这可以让我们理解起来更简单一些。
// define a simple JavaScript object var person = { name: 'Ross', salary: 200, age: 21, }; // before: the `toString` call console.log( `before: ${ person }` ); // before: [object Object] /*-------*/ // provide `Symbol.toStringTag` implementation person[ Symbol.toStringTag ] = 'Person'; // after: the `toString` call console.log( `after: ${ person }` ); // after: [object Person]
对象的Symbol.toStringTag
属性,在toString
函数隐式或显式调用时,会作为该对象的Tag
。我们可以针对对象提供该属性,或者在对象原型链上提供。如果你使用的是类,那么,可以把它作为 getter 使用。
Symbol.hasInstance
想象一下,现在有两个类,这两个类有一些相同的字段。通常,你可以选择使用继承,让一个类继承另外的类,从而获得共有的字段。但这种机制并不一定始终有效。
考虑你有一个函数,该函数接受一个对象作为参数,函数会使用instanceof
运算符,检查这个对象是否是某一个类的实例。此时,可能会有一些问题。即使这个对象可能含有与特定类相同的字段,但除非这个对象真正继承了该类,否则,这种判断就会失败。
class Person { constructor( name ) { this.name = name; } } class Employee { constructor( name, salary ) { this.name = name; this.salary = salary; } } // get name of an `object` of the type `Person` function getName( object ) { if( object instanceof Person ) { console.log( 'success ->', object.name ); } else { console.log( 'error -> not a Person' ); } } // create a `Person` object and get name const person = new Person( 'Ross' ); getName( person ); // success -> Ross // create an `Employee` object and get name const employee = new Employee( 'Monica', 300 ); getName( employee ); // error -> not a Person
在上面的例子中,employee
对象并不是Person
类的实例,Employee
类也没有继承Person
类,因此,instanceof
运算符返回false
。为了解决这一问题,你需要增加OR
条件,检查object
对象是否是Employee
类的实例。
通过给类增加一个名为Symbol.hasInstance
的static
函数,我们可以改变instanceof
运算符的默认行为。你所要做的,就是检查传入的对象,然后返回一个boolean
值。看下面的代码。
class Person { constructor( name ) { this.name = name; } static [ Symbol.hasInstance ] ( instance ) { return 'name' in instance; } } class Employee { constructor( name, salary ) { this.name = name; this.salary = salary; } } // get name of an `object` of the type `Person` function getName( object ) { if( object instanceof Person ) { console.log( 'success ->', object.name ); } else { console.log( 'error -> not a Person' ); } } // create a `Person` object and get name const person = new Person( 'Ross' ); getName( person ); // success -> Ross // create an `Employee` object and get name const employee = new Employee( 'Monica', 300 ); getName( employee ); // success -> Monica
当<LHS> instanceof Person
进行计算时,Person[Symbol.hasInstance]
函数会被调用,函数参数是LHS
。在上面的例子中,我们使用in
运算符,检查name
属性是否存在于instance
或者其原型链上。这么做的理由是,只要对象具有name
属性,我们就认为这就是一个Person
对象。
Symbol.isConcatSpreadable
你一定使用过Array
的原型函数[].concat()
,由一个或多个元素创建一个新的数组。concat
函数的神奇之处在于,如果参数类型是Array
,那么这个参数会被扁平化。问题在于,有时候你可能并不想这么做。
你可以通过设置数组(Aarry
的实例)的Symbol.isConcatSpreadable
属性来改变这一行为。如果该属性存在,并且被设置为false
,那么,concat
函数就不会将数组类型的参数扁平化。
// declare custom Array class class MyArray extends Array { square() { return this.map( i => i * i ); } // do not spread `MyArray` values in `concat` get [ Symbol.isConcatSpreadable ]() { return false; } } const numbers = new MyArray( 5, 33 ); // custom non-spreadable array const drivers = [ "Seb", "Max" ]; drivers[ Symbol.isConcatSpreadable ] = false; // simple arrays const cars = [ "Ferrari", "RedBull" ]; const sports = [ "F1", "MotoGP" ]; // use concat const newArray = cars.concat( sports, drivers, numbers ); console.log( newArray ); // ["Ferrari", "RedBull", "F1", "MotoGP", ["Seb", "Max", [Symbol(Symbol.isConcatSpreadable)]: false], MyArray [5, 33] ]
在上面的例子中,numbers
和drivers
属性并不会在concat
运算中被扁平化,因此,在newArray
中,它们仍然是数组值。但是,sports
属性被展开了,因为它或其原型链上没有设置Symbol.isConcatSpreadable
属性。
Symbol.species
在上面的例子中,我们定义了MyArray
类。这个类扩展了内置的Array
类。MyArray
唯一改变的是Symbol.isConcatSpreadable
属性。从技术上说,MyArray
的所有实例都继承了Array
的所有属性。
但是,现在有一个问题。如果我们对MyArray
实例调用Array
类的任意返回新的数组的原型函数,比如map
、forEach
等,结果都是返回MyArray
的实例。这应该是可预期的结果。
// declare custom Array class class MyArray extends Array { square() { return this.map( i => i * i ); } } // `numbers` of type `MyArray` const numbers = new MyArray( 3, 5 ); console.log( 'numbers ->', numbers ); // numbers -> MyArray [ 3, 5 ] // `squares` of type `MyArray` const squares = numbers.square(); console.log( 'squares ->', squares ); // squares -> MyArray [ 9, 25 ] // `cubes` of type `MyArray` const cubes = numbers.map( i => i * i * i ); console.log( 'cubes ->', cubes ); // cubes -> MyArray [ 27, 125 ]
然而,有些时候,你可能只想为基类增加一些额外的行为,但是需要保留底层基类的核心功能。例如,我们想要在MyArray
实例调用map
、forEach
等操作时,返回值还是Array
实例。
Symbol.species
是一个类的static
属性,指向用于子类的构造函数,例如在Array
例子中的map
或forEach
函数。
// declare custom Array class class MyArray extends Array { square() { return this.map( i => i * i ); } static get [ Symbol.species ]() { return Array; } } // `numbers` of type `MyArray` const numbers = new MyArray( 3, 5 ); console.log( 'numbers ->', numbers ); // numbers -> MyArray [ 3, 5 ] // `squares` of type `Array` const squares = numbers.square(); console.log( 'squares ->', squares ); // squares -> [ 9, 25 ] // `cubes` of type `Array` const cubes = numbers.map( i => i * i * i ); console.log( 'cubes ->', cubes ); // cubes -> [ 27, 125 ]
上面的代码中,隐式或显式调用map
函数,返回值类型都是Array
,而不是MyArray
。
这一特性不仅适用于Array
,同样适用于Set
、Map
、RegExp
、Promise
、TypedArray
以及ArrayBuffer
。
正则表达式方法
现在,我们说起正则表达式,基本就是RegExp
实例。我们也可以通过/.../
字面量表达式的形式来创建正则表达式。这种正则表达式对象可以作用于字符串,用来匹配文本模式。
'google.com'.match(/^[a-z]+\.com$/gi) // google.com
从 ES2015 起,JavaScript 允许使用任意对象实现类似正则表达式的行为。因此,你可以将任意对象传递给str.match()
函数,该函数会将这个对象当作正则表达式去匹配。这种对象需要实现一些预定义的函数,这些函数会在匹配时被调用。
当一个对象实现了Symbol.match
函数,这个对象就可以用于String.prototype.match()
函数。在获取str.match()
的结果时,这个符号函数会被调用。
// defines a matcher object to `match` operation class Matcher { constructor( name, ends ) { this.name = name.toUpperCase(); this.ends = ends; } // `String.prototype.match()` processor [ Symbol.match ]( text ) { const pattern = this.ends + this.name + this.ends; return text.match( new RegExp( pattern, 'gi' ) ); } } // create a matcher to match `__EMAIL__` pattern const matcher = new Matcher( 'email', '__' ); /*------*/ // sample text const text = 'Enter your email address here: __EMAIL__ and provide an alternate email here: __EMAIL__'; console.log( text.match( matcher ) ); // [ '__EMAIL__', '__EMAIL__' ]
上面的例子,我们创建了Matcher
类,这个类实现了Symbol.match
实例函数,因此,matcher
对象就可以使用这个函数。当调用text.match(matcher)
函数时,Symbol.match
就会被执行,其参数是text
对象。我们可以在Symbol.match
函数内部使用text
对象,来返回一个合理的值。
我们还有Symbol.matchAll
函数,用于处理String.prototype.matchAll()
的调用。
当String
实例调用search
原型函数时,Symbol.search
函数就会被调用。类似于Symbol.match
,这个函数也接受调用search
函数的字符串作为参数。
// defines a matcher object for `search` operation class Matcher { constructor( name, ends ) { this.name = name.toUpperCase(); this.ends = ends; } // `String.prototype.search()` processor [ Symbol.search ]( text ) { const pattern = this.ends + this.name + this.ends; return text.indexOf( pattern ); } } // create a matcher to find index of `__EMAIL__` pattern const matcher = new Matcher( 'email', '__' ); /*------*/ // sample text const text = 'Enter your email address here: __EMAIL__ and provide an alternate email here: __EMAIL__'; console.log( text.search( matcher ) ); // 31
当String
实例调用replace
原型函数时,Symbol.replace
函数就会被调用。由于replace
函数需要替换字符串,这个函数同样需要两个参数:原始字符串和替换字符串。函数需要返回一个字符串。
// defines a matcher object for `replace` operation class Matcher { constructor( name, ends ) { this.name = name.toUpperCase(); this.ends = ends; } // `String.prototype.replace()` processor [ Symbol.replace ]( text, replacement ) { const pattern = this.ends + this.name + this.ends; const matchIndex = text.indexOf( pattern ); return text.substring( 0, matchIndex ) + `"${ replacement }"` + text.substring( matchIndex + pattern.length, text.length ); } } // create a matcher to replace `__EMAIL__` pattern const matcher = new Matcher( 'email', '__' ); /*------*/ // sample text const text = 'Enter your email address here: __EMAIL__.'; console.log( text.replace( matcher, 'a@b.com' ) ); // Enter your email address here: a@b.com
当String
实例调用split
原型函数时,Symbol.split
函数就会被调用。该函数的参数是调用了split
函数的字符串,返回值是一个数组。
// defines a matcher object for `split` operation class Matcher { constructor( name, ends ) { this.name = name.toUpperCase(); this.ends = ends; } // `String.prototype.split()` processor [ Symbol.split ]( text ) { const pattern = this.ends + this.name + this.ends; const matchIndex = text.indexOf( pattern ); return [ text.substring( 0, matchIndex ), text.substring( matchIndex + pattern.length, text.length ) ]; } } // create a matcher to split at `__EMAIL__` pattern const matcher = new Matcher( 'email', '__' ); /*------*/ // sample text const text = 'Enter your email address here: __EMAIL__.'; console.log( text.split( matcher ) ); // [ 'Enter your email address here: ', '.' ]
Symbol.iterator
我们曾经在别的文章中介绍过Symbol.iterator
。现在来总结一下。ES2015 引入了新的遍历协议,其中包含了使对象可遍历的一些准则。可遍历对象可以使用for-of
循环,或者使用展开运算符。
到目前为止,我们只能使用Array
作为可遍历的。为了遍历Object
对象,需要使用for-in
循环。而且,我们也不能原生展开一个对象,Object.values()
或Object.entrues()
或许可以帮到你。
利用新的遍历协议,我们可以像数组一样,使用for-of
循环对象,或者展开对象。此时,JavaScript 会通过调用可遍历对象的Symbol.iterator
函数获取一个遍历器。
Symbol.iterator
函数会在遍历开始时被调用,其返回值是一个遍历器对象。这个遍历器有一个next
函数,该函数返回一个对象,这个对象有一个boolean
类型的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]
在循环期间,iterator.next()
函数会被一直调用,直到done
为false
。value
字段是我们感兴趣的,代表每一次循环中的当前值。当done
为false
时,value
被忽略。
可以在MDN文档中了解更多关于遍历协议的相关内容。
设计遍历器并不是一件多么令人愉快的事情,因为会有很多重复的代码。这也就是为什么,JavaScript 还提供了另外一个被称为生成器的机制。生成器是一种特殊的函数。当调用生成器函数(也就是生成器)时,会返回一个遍历器。因此,Symbol.iterator
同样适用于生成器。
由生成器返回的遍历器的next
函数被调用时,每一个yield
表达式都会被求值,产生一个包含done
和value
的遍历对象。一旦所有的yield
表达式都被求值,无论何时再次调用next
函数,都会返回一个done
被设置为true
的遍历对象。
class Person { constructor( name, favs ) { this.name = name; this.favs = favs; } // implement iterator protocol using a generator *[ Symbol.iterator ]() { for( let i in this.favs ) { yield `${ this.name } likes ${ this.favs[ i ] }.`; } } } const person = new Person( 'Ross', [ 'Monkeys', 'Museums', 'Rocks', 'Music' ] ); // iterate over `person` using `for-of` loop for( let item of person ) { console.log( 'item ->', item ); } // iterate over `person` using spread operator console.log( 'favs -> ', [ ...person ] ); // --> output // item -> Ross likes Monkeys. // item -> Ross likes Museums. // item -> Ross likes Rocks. // item -> Ross likes Music. // favs -> [ // 'Ross likes Monkeys.', // 'Ross likes Museums.', // 'Ross likes Rocks.', // 'Ross likes Music.', //]
*[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
对象。
function makePromise( time ) { return new Promise( function( resolve ) { setTimeout(() => { resolve( `Resolved after: ${ time } ms.` ); }, time ); } ); } var promises = [ makePromise( 1000 ), makePromise( 800 ), makePromise( 2000 ), makePromise( 1500 ), ]; ( async function() { for await ( let result of promises ) { console.log( 'result ->', result ); } } )(); // --> output // result -> Resolved after: 1000ms. // result -> Resolved after: 800ms. // result -> Resolved after: 2000ms. // result -> Resolved after: 1500ms.
上面的例子中,promises
包含一组Promise
对象,每个promise
会在几毫秒后返回一个字符串。如果我们使用普通的for-of
循环,每次遍历都会接受到一个promise
作为值。但是,我们所需要的其实是每个promise
所持有的那个字符串。
for await
语法正是解决这一问题。它会等待直到每个promise
都完成,result
变量的值是promise
接受的值。由于我们使用了await
关键字,因此for-await-of
循环必须放在async
函数中,这可以是一个立即调用的函数表达式 IIFE。
正如for-of
循环那样,for-await-of
循环也会使用Symbol.iterator
函数获取遍历器。如果遍历器返回promise
对象,其结果就像上面描述的那样。
然而,for-await-of
循环更推荐使用Symbol.asyncIterator
函数。该函数与Symbol.iterator
类似,但是一个async
函数,你可以在这个函数中使用await
关键字。
function makePromise( time, fav ) { return new Promise( function( resolve ) { setTimeout(() => { resolve( `${ fav }: ${ time } ms.` ); }, time ); } ); } class Person { constructor( name, favs ) { this.name = name; this.favs = favs; } // implement async iterator protocol using a generator async *[ Symbol.asyncIterator ]() { for( let i in this.favs ) { yield await makePromise( i * 500, this.favs[ i ] ); } } } const person = new Person( 'Ross', [ 'Monkeys', 'Museums', 'Rocks', 'Music' ] ); // iterate over `person` using `for-of` loop ( async () => { for await ( let item of person ) { console.log( 'item ->', item ); } } )(); // --> output // item -> Monkeys: 0 ms. // item -> Museums: 500 ms. // item -> Rocks: 1000 ms. // item -> Music: 1500 ms.
在上面的例子中,每一个yield
表达式都会等待promise
执行完毕,这样它就可以在遍历中获取promise
接受的值。但是,你也可以直接返回promise
本身,不使用await
关键字,其效果与带有await
一致。
什么是@@iterator
?
在 JavaScript 文档中,比如 MDN Array
文档,或者在控制台或堆栈记录中,你可能看到过@@iterator
标记。@@
记号是一种特殊的前缀,用于表示预定义符号,这种记号并不会出现在运行时。
下面的表格是 ES2021 定义的预定义符号。左列是这些符号的标准名字,右列是运行时可访问到的实际符号。
标准名字 | [[Description]] |
@@asyncIterator | "Symbol.asyncIterator" |
@@hasInstance | "Symbol.hasInstance" |
@@isConcatSpreadable | "Symbol.isConcatSpreadable" |
@@iterator | "Symbol.iterator" |
@@match | "Symbol.match" |
@@matchAll | "Symbol.matchAll" |
@@replace | "Symbol.replace" |
@@search | "Symbol.search" |
@@species | "Symbol.species" |
@@split | "Symbol.split" |
@@toPrimitive | "Symbol.toPromitive" |
@@toStringTag | "Symbol.toStringTag" |
@@unscopables | "Symbol.unscopables" |
Symbol.unscopables
用于控制对象在with
语句内的行为。但是因为with
语句并不推荐使用,因此这个函数也应该避免。
这些符号的好处是什么?
这是这篇文章中最难写的部分。简单来说,这些符号能够避免 API 被错误的使用。例如,如果不想让别人意外覆盖某个对象属性,就可以将其声明为一个符号,然后通过全局对象暴露,或者作为全局符号。
另外,符号特别适合表达为唯一值。例如,如果你创建了唯一值的枚举,就可以使用符号