首页 JavaScript JavaScript symbol 简介及其在元编程中的应用

JavaScript symbol 简介及其在元编程中的应用

0 1K

本章我们将介绍 JavaScript symbol,以及依赖于此的 JavaScript 新特性。

什么是 JavaScript 中的基本数据类型 primitive data types?简单来说,就是nullundefinedstringnumberboolean这几种数据类型。还有别的吗?是的!符号(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 引擎),但是,符号不能是stringnumberboolean值。来看下面的代码:

// 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的符号;如果没有这样的符号,则直接创建然后返回。

全局注册表对于每一个脚本文件、每一个模块、每一个上下文(比如 iframeweb workerservice 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 的对象有一大堆继承而来的特殊属性,例如valueOftoString,其目的就是为了要重写(并不是无意的重写),以便实现对象的自定义行为。我们可以看看下面的例子。

// 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的对象都有valueOftoString的默认实现,这两个函数是在Object定义的。当针对对象执行算术运算时,会自动调用valueOf函数;当需要将对象转换为字符串时,则调用toString函数。

toString默认实现是返回[object Object]字符串,这就是一个对象的字符串表示。valueOf的默认实现是返回对象本身,因此,任何算术操作都会返回NaN,因为最终结果不是一个数字。

通过在对象本身或者原型链上增加同名的函数值,我们可以覆盖这些默认实现。因此,如果你有一个类的话,你可以提供toStringvalueOf实例方法,覆盖默认行为。看下面的代码。

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

上面这些特殊方法(属性)有一个问题是,它们很容易被错误地覆盖。例如toStringvalueOf,对象(字符串或数字)的原始值在某些上下文中由这两个方法被分为两种不同类型,有时很容易引起误会。

为了解决这些问题,JavaScript 提供了一系列公开的符号,用于这些特殊属性的名字。其中之一就是toPrimitive符号。所有这些公开的符号都是Symbol对象(实际是一个函数)的公共的静态属性,并且在整个程序范围内共享。

Symbol.toOrimitive符号的作用一次性实现了toStringvalueOf的作用,并且具有更高的优先级。如果对象有一个名为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]。这是因为ObjecttoString函数的默认实现就是返回这样的字符串。

不过,这并非只有对象(也就是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]

如果你对UndefinedNull感到奇怪,大可不必。这些构造器函数并不能在运行时访问,它们纯粹是在 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.hasInstancestatic函数,我们可以改变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] ]

在上面的例子中,numbersdrivers属性并不会在concat运算中被扁平化,因此,在newArray中,它们仍然是数组值。但是,sports属性被展开了,因为它或其原型链上没有设置Symbol.isConcatSpreadable属性。

Symbol.species

在上面的例子中,我们定义了MyArray类。这个类扩展了内置的Array类。MyArray唯一改变的是Symbol.isConcatSpreadable属性。从技术上说,MyArray的所有实例都继承了Array的所有属性。

但是,现在有一个问题。如果我们对MyArray实例调用Array类的任意返回新的数组的原型函数,比如mapforEach等,结果都是返回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实例调用mapforEach等操作时,返回值还是Array实例。

Symbol.species是一个类的static属性,指向用于子类的构造函数,例如在Array例子中的mapforEach函数。

// 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,同样适用于SetMapRegExpPromiseTypedArray以及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()函数会被一直调用,直到donefalsevalue字段是我们感兴趣的,代表每一次循环中的当前值。当donefalse时,value被忽略。

可以在MDN文档中了解更多关于遍历协议的相关内容。

设计遍历器并不是一件多么令人愉快的事情,因为会有很多重复的代码。这也就是为什么,JavaScript 还提供了另外一个被称为生成器的机制。生成器是一种特殊的函数。当调用生成器函数(也就是生成器)时,会返回一个遍历器。因此,Symbol.iterator同样适用于生成器。

由生成器返回的遍历器的next函数被调用时,每一个yield表达式都会被求值,产生一个包含donevalue的遍历对象。一旦所有的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 默认给很多类实现了遍历器协议,例如ArraySetMapStringTypedArray等。

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"
来源:ES2021

Symbol.unscopables用于控制对象在with语句内的行为。但是因为with语句并不推荐使用,因此这个函数也应该避免。

这些符号的好处是什么?

这是这篇文章中最难写的部分。简单来说,这些符号能够避免 API 被错误的使用。例如,如果不想让别人意外覆盖某个对象属性,就可以将其声明为一个符号,然后通过全局对象暴露,或者作为全局符号。

另外,符号特别适合表达为唯一值。例如,如果你创建了唯一值的枚举,就可以使用符号

发表评论

关于我

devbean

devbean

豆子,生于山东,定居南京。毕业于山东大学软件工程专业。软件工程师,主要关注于 Qt、Angular 等界面技术。

主题 Salodad 由 PenciDesign 提供 | 静态文件存储由又拍云存储提供 | 苏ICP备13027999号-2