Quantcast
Channel: w3cplus
Viewing all articles
Browse latest Browse all 1557

内置 Symbol 值详细概述

$
0
0

Symbol是 ECMAScript 2015 引入的新原始数据类型(primitive type)。使用 Symbol 可以创建独一(unique)的标识符,用法是:let uniqueKey = Symbol('SymbolName').

Symbol 可以被用作对象中属性的键名。一系列被 JavaScript 特殊处理的 Symbol 值被发布为内置 Symbol 值(well-known symbols)

这些内置 Symbol 值被 JavaScript 内建算法所使用。例如 Symbol.iterator被用来迭代遍历数组项或字符串,甚至定义自己的迭代器函数。

这些特殊的 Symbol 值非常重要,因为它们是对象的系统属性,这些属性允许你定义定制的行为。听起来不错吧,用它们来打入 JavaScript 内部!

由于其独一性,使用 Symbol 作为键名(而不是字符串字面值),可以轻而易举地给对象引入许多新的功能。在使用字符串字面值的时候,键名碰撞会是个问题,但使用 Symbol 你就不必为此担心。

本文将导览所有的内置 Symbol 值,并说明如何得心应手地在代码中使用它们。

简洁起见,内置值 Symbol.<name>通常被简记为 _@@\<name\>_格式。例如 Symbol.iterator简记作 _@@iterator_Symbol.toPrimitive简记作 _@@toPrimitive_

我们可能称某对象拥有某 _@@iterator_方法。这种说法表明该对象拥有一个名为 Symbol.iterator的属性,且该属性持有一个函数:

{ [Symbol.iterator]: function(){...} }.

1. Symbol简介

Symbol 是一个原始数据类型(正如数值类型、布尔类型、字符串等),它是独一(unique)且不可变(immutable)的。

调用 Symbol函数即可创建一个 Symbol 值,函数带有一个可选的名字参数:(在 repl.it 中试试

let mySymbol = Symbol();  
let namedSymbol = Symbol('myName');  
typeof mySymbol;    // => 'symbol'  
typeof namedSymbol; // => 'symbol'

mySymbolnamedSymbol都是 Symbol 类型的原始值。namedSymbol有一个关联名字 'myName',有助于调试。

需要特别注意,每次调用 Symbol()都会创建一个独一的新 Symbol 值。即便两个 Symbol 值有同样的名字,它们也是独一(或者说不同)的两个值:(在 repl.it 中试试

let first = Symbol();  
let second = Symbol();  
first === second; // => false  
let firstNamed = Symbol('Lorem');  
let secondNamed = Symbol('Lorem');  
firstNamed === secondNamed; // => false  

firstsecond方法都创建独一的 Symbol 值,但彼此不相同。 firstNamedsecondNamed变量拥有同样的名字 'Lorem',但彼此仍然不相同。

Symbol 值可以作为对象中属性的键名。如果在对象字面值或类声明中这样做,必须要使用属性名表达式语法 [symbol]:(在 repl.it 中试试

let stringSymbol = Symbol('String');  
let myObject = {  
  number: 1,
  [stringSymbol]: 'Hello World'
};
myObject[stringSymbol];                 // => 'Hello World'  
Object.getOwnPropertyNames(myObject);   // => ['number']  
Object.getOwnPropertySymbols(myObject); // => ['Symbol(String)']  

当我们用字面值定义 myObject的时候,使用了属性名表达式语法把 Symbol 值[stringSymbol]设置为属性键名。

使用 Symbol 值定义的属性无法用 Object.keys()函数或 Object.getOwnPropertyNames()函数访问到。要访问它们,需要调用特殊函数 Object.getOwnPropertySymbols()

使用 Symbol 值作为键名是一个重要方面。特殊的 Symbol 值(也就是内置 Symbol 值)允许定义定制化的对象行为,例如迭代遍历、对象到原始数据类型或字符串类型的转换,等等。

内置 Symbol 值可以作为 Symbol函数对象的不可枚举、不可写、不可配置属性被使用。只需在 Symbol函数对象上使用属性访问器即可获得它们:例如 Symbol.iterator, Symbol.hasInstance等等。 可以用下面的方法获取内置 Symbol 值的列表:(在 repl.it 中试试

Object.getOwnPropertyNames(Symbol);  
// => ["hasInstance", "isConcatSpreadable", "iterator", "toPrimitive", 
//     "toStringTag", "unscopables", "match", "replace", "search",    
//     "split", "species", ....];
typeof Symbol.iterator; // => 'symbol'

Object.getOwnPropertiesNames(Symbol)返回 Symbol函数对象自身的属性,包括内置 Symbol 值列表。

2. 用 @@iterator使对象可迭代遍历

Symbol.iterator大概是最广为人知的 Symbol值。它允许定义当对象被 for...of语句作用或被展开操作符 ...作用时应当如何迭代遍历。 许多内建类型都是可迭代遍历的,例如字符串、数组、Map、Set 等,换句话说它们都有 @@iterator方法:(在 repl.it 中试试

let myString = 'Hola';  
typeof myString[Symbol.iterator]; // => 'function'  
for (let char of myString) {  
  console.log(char); // logs on each iterator 'H', 'o', 'l', 'a'
}
[...myString]; // => ['H', 'o', 'l', 'a']

原始数据类型字符串变量myString有一个属性 Symbol.iterator。这个属性持有一个方法用于迭代遍历字符串中字符。

若一个对象定义了名为 Symbol.iterator的方法,则该对象遵从可迭代协议。 该方法应该返回一个遵从可迭代协议的对象,可迭代协议对象应该拥有一个方法 next()返回 {value: <iterator_value>, done: <boolean_finished_iterator>}

我们来看看如何定义一个定制的迭代器。下面的例子创建了一个可迭代对象 myMethods,允许遍历该对象拥有的方法:

function methodsIterator() {  
  let index = 0;
  let methods = Object.keys(this).filter((key) => {
return typeof this[key] === 'function';
  }).map(key => this[key]);
  return {
next: () => ({ // Conform to Iterator protocol
  done : index >= methods.length,
  value: methods[index++]
})
  };
}
let myMethods = {  
  toString: function() {
return '[object myMethods]';
  },
  sumNumbers: function(a, b) {
return a + b;
  },
  numbers: [1, 5, 6],
  [Symbol.iterator]: methodsIterator // Conform to Iterable Protocol
};
for (let method of myMethods) {  
  console.log(method); // logs methods toString and sumNumbers
}

methodsIterator()函数返回一个可迭代对象 { next: function() {...} }

myMethods对象设置了一个属性,以 Symbol.iterator为键名,以 methodIterator为键值。这令 myMethods可迭代,现在在 for...of循环中可以遍历到 toStringsumNumbers方法。 此外你也可以通过调用 ...myMethodsArray.from(myMethods)来获得这些方法。

_@@iterator_属性还接受Generator 函数,这使得它更具价值。Generator 函数返回一个遵从迭代接口的Generator 对象。 让我们用 _@@iterator_接口来创建一个 Fibonacci类,该类可以产生一个 Fibonacci 序列。

class Fibonacci {  
  constructor(n) {
this.n = n;    
  }
  *[Symbol.iterator]() {
let a = 0, b = 1, index = 0;
while (index < this.n) {  
  index++;
  let current = a;
  a = b;
  b = current + a;
  yield current;
}
  }
}
let sequence = new Fibonacci(6);  
let numbers = [...sequence];  
numbers; // => [0, 1, 1, 2, 3, 5]  

*[Symbol.iterator]() {...} 声明了一个 Generator 函数的类方法,因而 Fibonacci类的实例遵从迭代协议。

然后 sequence对象被展开操作符 ...sequence所用。展开操作符调用 _@@iterator_方法从生成的数字创建数组。因此计算结果是头5个 Fibonacci 数构成的数组。

如果原始数据类型或对象拥有 _@@iterator_接口,则可以被应用于下列构造:

  • for...of循环中遍历元素
  • 使用展开操作符 [...iteratorObject]创建元素的数组
  • 使用 Array.from(iteratorObject)创建元素的数组
  • yield*表达式中代理给另一个 Generator
  • Map(iterableObject)`, `WeakMap(iterableObject)`, `Set(iterableObject)`, `WeakSet(iterableObject)构造器中
  • Promise.all(iterableObject)Promise.race(iterableObject)等 Promise 类静态方法中

3. 用  @@hasInstance定制化 instanceof

obj instanceof Constructor操作符默认检验 obj的原型链是否包含 Constructor.prototype对象。我们来看一个例子:

function Constructor() {  
  // constructor code
}
let obj = new Constructor();  
let objProto = Object.getPrototypeOf(obj);  
objProto === Constructor.prototype; // => true  
obj instanceof Constructor;         // => true  
obj instanceof Object;              // => true  

obj instanceof Constructor求值为真,因为 obj的原型等于 Constructor.prototype (这是调用构造函数的结果)。 instanceof也检验 obj的原型链,因此 obj instanceof Object也为真。

通常实际应用中不处理原型,而是要求更具体的实例判定。

幸运的是我们可以在可调用(callable)类型 Type上定义一个 _@@hasInstance_方法来定制化 instanceof求值。表达式 obj instanceof Type现在等价于 Type[Symbol.hasInstance]

例如检验一个对象或原始数据类型是否可迭代遍历:

class Iterable {  
  static [Symbol.hasInstance](obj) {
return typeof obj[Symbol.iterator] === 'function';
  }
}
let array = [1, 5, 5];  
let string = 'Welcome';  
let number = 15;  
array instanceof Iterable;  // => true  
string instanceof Iterable; // => true  
number instanceof Iterable; // => false  

Iterable是一个包含 _@@hasInstance_静态方法的类。该方法可以检验给定的 obj参数是否可迭代遍历,换言之也就是检验 obj是否包含一个 Symbol.iterable属性。

随后我们用 Iterable来检验不同类型的变量。数组和字符串是可迭代遍历的,而数值类型不可以。

以我之见,像这样配合 instanceof和构造器使用 _@@hasInsatnce_比单纯调用 isIterable(array)要更优雅。

表达式 array instanceof Iterable清楚地表明 array通过了可迭代协议的检验。

4. 用 @@toPrimitive将对象转换成原始类型值

使用 Symbol.toPrimitive来指定一个属性,属性值是一个函数,用于将对象转换为原始类型值。

举个例子,我们用 _@@toPrimitive_方法来增强一个数组实例:

function arrayToPrimitive(hint) {  
  if (hint === 'number') {
return this.reduce((sum, num) => sum + num);
  } else if (hint === 'string') {
return [${this.join(', ')}];
  } else {
// hint is default
return this.toString();    
  }
}
let array = [1, 5, 3];  
array[Symbol.toPrimitive] = arrayToPrimitive;  
// array to number. hint is 'number'
+ array; // => 9
// array to string. hint is 'string'
array is ${array}; // => 'array is [1, 5, 3]'
// array to default. hint is 'default''array elements: ' + array; // => 'array elements: 1,5,3'

arrayToPrimitive(hint)是一个根据 hint参数值将数组转换成原始类型值的函数。赋值语句 array[Symbol.toPrimitive] = arrayToPrimitive令数组使用新的转换方法。

执行 + array会以 'number'hint参数调用 _@@toPrimitive_方法。array被转换成一个数字,数值是所有元素之和。

array is ${array} 会以 'string'hint参数调用 _@@toPrimitive_方法。数组被转换成字符串 '[1, 5, 3]'

最后的 'array elements: ' + array使用了 'defualt'为转换过程的 hint参数值。这种情况下 array的值为 '1,5,3'

_@@toPrimitive_方法在下列对象与原始类型交互的场景下被调用:

  • 相等操作符 object == primitive
  • 相加/连接操作符 object + primitive
  • 相减操作符 object - primitive
  • 对象被强制转换为原始类型的各种场景:String(object), Number(object)等等。

5. 用 @@toStringTag创建对象的默认描述

使用 Symbol.toStringTag来指定一个属性,属性值为一个字符串,描述对象的类型标签。_@@toStringTag_方法会被 Object.prototype.toString()使用。 Object.prototype.toString()的规范标准表明很多 JavaScript 类型都有默认标签:

let toString = Object.prototype.toString;  
toString.call(undefined); // => '[object Undefined]'  
toString.call(null);      // => '[object Null]'  
toString.call([1, 4]);    // => '[object Array]'  
toString.call('Hello');   // => '[object String]'  
toString.call(15);        // => '[object Number]'  
toString.call(true);      // => '[object Boolean]'  
// etc for Function, Arguments, Error, Date, RegExp
toString.call({});        // => '[object Object]'

这些类型都没有 [Symbol.toStringTag]属性,因为 Object.prototype.toString()使用另外的算法对它们求值。 其他很多 JavaScript 类型定义了 _@@toStringTag_属性,比如 Symbol,Generator 函数,Map,Promise 等等。我们来看看:

let toString = Object.prototype.toString;  
let noop = function() {};

Symbol.iterator[Symbol.toStringTag];   // => 'Symbol'  
(function* () {})[Symbol.toStringTag]; // => 'GeneratorFunction'
new Map()[Symbol.toStringTag];         // => 'Map'  
new Promise(noop)[Symbol.toStringTag]; // => 'Promise'

toString.call(Symbol.iterator);   // => '[object Symbol]'  
toString.call(function* () {});   // => '[object GeneratorFunction]'  
toString.call(new Map());         // => '[object Map]'  
toString.call(new Promise(noop)); // => '[object Promise]'

从上面的例子可以看出,很多 JavaScript 类型定义了它们自己的 _@@toStringTag_属性。

在其他情况下,比如一个对象所属的类型没有默认标记,或未提供 _@@toStringTag_属性,那么它就会被简单标记为 'Object'

当然你可以定义一个定制化的 _@@toStringTag_属性:

let toString = Object.prototype.toString;

class SimpleClass {}  
toString.call(new SimpleClass); // => '[object Object]'

class MyTypeClass {  
  constructor() {
this[Symbol.toStringTag] = 'MyType';
  }
}
toString.class(new TagClass); // => '[object MyType]'

new SimpleClass实例没有定义@@toStringTag属性。Objecct.prototype.toString()为它返回默认的类型描述 '[object Object]'

MyTypeClass构造器中,为实例配置了一个定制化标签 'MyType'。对于该类实例,Object.prototype.toString()返回定制化的类型描述 '[object MyType]'

注意到 _@@toStringTag_更多是因为后向兼容性存在的,并不被鼓励使用。你可能更应该使用其他方法去判断对象类型,例如 instanceof(包括使用 _@@hasInstance_ Symbol值)或者 typeof

6. 用 @@species创建衍生对象

使用 Symbol.species来指定一个属性,属性值为一个构造器方法,用来创建衍生对象。

很多 JavaScript 构造器都有 _@@species_,值等于构造器本身。

Array[Symbol.species] === Array;   // => true  
Map[Symbol.species] === Map;       // => true  
RegExp[Symbol.species] === RegExp; // => true  

首先,注意衍生对象是对原对象做特定操作后创建的。举例来说,在原数组上调用 .map()方法会返回一个衍生对象:映射(mapping)结果数组。

通常衍生对象和原对象拥有同样的构造器,正如预料的那样。但有时有必要指定定制化的构造器(或者是基类构造器):这就是 _@species_的用武之地。

设想一个场景,你为了加上些有用的方法,从 Array构造器继承了子类 MyArray。之后在 MyArray的实例上使用 .map()方法时,你想要一个 Array类的实例,而不是 MyArray的实例。为了做到这点,定义一个访问器属性 _@@species_并指明衍生对象构造器:Array。我们来试一个例子:

class MyArray extends Array {  
  isEmpty() {
return this.length === 0;
  }
  static get [Symbol.species]() {
return Array;
  }
}
let array = new MyArray(3, 5, 4);  
array.isEmpty(); // => false  
let odds = array.filter(item => item % 2 === 1);  
odds instanceof Array;   // => true  
odds instanceof MyArray; // => false  

MyArray中定义了一个静态访问器属性 static get [Symbol.species]() {},这表明衍生对象应该拥有 Array构造器。 随后当使用 filter方法过滤数组元素时,array.filter()方法返回了一个 Array。 如果 _@@species_属性不是定制化的,那么 array.filter()会返回一个 MyArray实例。

.map().concat().slice()等等这些 ArrayTypedArray类的方法会使用 _@species_属性返回衍生对象。 也可以用它在继承 Map、正则表达式对象、Promise 的同时保持原构造器。

7. 用 @@match, @@replace, @@search@@split创建类正则表达式的对象

JavaScript 的字符串原型有四个方法接受正则表达式对象参数输入:

  • String.prototype.match(regExp)
  • String.prototype.replace(regExp, newSubstr)
  • String.prototype.search(regExp)
  • String.prototype.split(regExp, limit)

ECMAScript 2015 允许上述4个方法接受 RegExp以外的类型,条件是定义对应的函数属性 _@@match_, _@@replace_, _@@search__@@split_

有趣的是 RegExp原型也是用 Symbol 值来定义这些方法的:

typeof RegExp.prototype[Symbol.match];   // => 'function'  
typeof RegExp.prototype[Symbol.replace]; // => 'function'  
typeof RegExp.prototype[Symbol.search];  // => 'function'  
typeof RegExp.prototype[Symbol.split];   // => 'function'

现在我们来创建一个定制的匹配模式(pattern)类。下面的例子定义了一个简化的类,在使用中可取代 RegExp类型:

class Expression {  
  constructor(pattern) {
this.pattern = pattern;
  }
  [Symbol.match](str) {
return str.includes(this.pattern);
  }
  [Symbol.replace](str, replace) {
return str.split(this.pattern).join(replace);
  }
  [Symbol.search](str) {
  return str.indexOf(this.pattern);
  }
  [Symbol.split](str) {
  return str.split(this.pattern);
  }
}
let sunExp = new Expression('sun');  
'sunny day'.match(sunExp);            // => true  
'rainy day'.match(sunExp);            // => false  
'sunny day'.replace(sunExp, 'rai'); // => 'rainy day'"It's sunny".search(sunExp);          // => 5
"daysunnight".split(sunExp);          // => ['day', 'night']

Expression类定义了 _@@match_, _@@replace_, _@@search__@@split_方法。 sunExp实例随后被用在对应的字符串方法中,粗略地模拟了一个正则表达式。

8. 用 @@ isConcatSpreadable将对象摊平为数组元素

Symbol.isConcatSpreadable是一个布尔值属性,表明一个对象是否被 Array.prototype.concat()方法摊平为其数组元素。

默认行为下,.concat()方法在拼接数组时将数组展开为它的元素:

let letters = ['a', 'b'];  
let otherLetters = ['c', 'd'];  
otherLetters.concat('e', letters); // => ['c', 'd', 'e', 'a', 'b']  

为了拼接两个数组,letters被作为参数作用到 .concat()方法。letters的元素在拼接结果中被展开。

要避免展开,并在拼接过程中保持整个数组作为一个元素,可以将 _@@isConcatSpreadable_设置为 false

let letters = ['a', 'b'];  
letters[Symbol.isConcatSpreadable] = false;  
let otherLetters = ['c', 'd'];  
otherLetters.concat('e', letters); // => ['c', 'd', 'e', ['a', 'b']]  

通过将 _@@isConcatSpreadable_属性设置为 falseletters数组在拼接结果 ['c', 'd', 'e', ['a', 'b']]中保持完整不变。

与数组相反,.concat()方法默认不展开类数组对象(array-like objects)(点此查看原因)。 这一行为也可以通过改变  @@isConcatSpreadable属性来配置:

let letters = {0: 'a', 1: 'b', length: 2};  
let otherLetters = ['c', 'd'];  
otherLetters.concat('e', letters);  
// => ['c', 'd', 'e', {0: 'a', 1: 'b', length: 2}]
letters[Symbol.isConcatSpreadable] = true;  
otherLetters.concat('e', letters); // => ['c', 'd', 'e', 'a', 'b']  

在第一个 .concat()方法调用中,类数组对象 letters在拼接结果数组中保持不变。这是类数组对象的默认行为。

然后 letters_@@isConcatSpreadable_属性被置为 true。所以拼接过程将类数组对象展开为其元素。

9. 用  @@unscopableswith语句中设置属性可访问性

Symbol.unscopables是一个以对象为值的属性,该属性值自己的属性名就是对象在 with语句环境绑定中不被包含的属性名。 _@@unscopables_属性值拥有这种格式:{ propertyName: <boolean_exclude_binding> }

ES2015 只为数组默认定义了 _@@unscopables_值。其意义是将新方法隐去,以免覆盖旧 JavaScript 代码中的同名变量。

Array.prototype[Symbol.unscopables];  
// => { copyWithin: true, entries: true, fill: true, 
//      find: true, findIndex: true, keys: true }
let numbers = [3, 5, 6];  
with (numbers) {  
  concat(8); // => [3, 5, 6, 8]
  entries;   // => ReferenceError: entries is not defined
}

.concat()方法能在 with语句主体中被访问,因为它没在 _@@unscopables_属性值中出现。 entries()方法在 _@@unscopables_属性中被列为 true,因此在 with语句内是不可访问的。

_@@unscopables_主要是为了使用了 with的旧 JavaScript 代码后向兼容性而存在的。(而 with的使用已经被不提倡,甚至不允许在严格模式中出现)

10. 后记

内置 Symbol 值是深入操纵 JavaScript 内部算法的有力属性。他们的独一性有利于可扩展性:对象属性不被污染。

_@@iterable_对于配置 JavaScript 如何迭代遍历对象元素是很有用的属性。它被 for...of, Array.from(), 展开操作符 ...等等所使用。

使用 _@@hasInstance_做不绕弯的类型验证。对我而言,obj instanceof IterableisIterable(obj)看起来更舒服。

_@@toStringTag__@@unscopables_是为陈旧的 JavaScript 历史代码后向兼容性而存在的内置 Symbol 值。不建议使用。

你有没有受到启发?我建议你花几个钟头分析你现有的 JavaScript 项目。保证能使用内置 Symbols 值对项目有所改进!

本文根据@Dmitri Pavlutin的《Detailed overview of well-known symbols》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://rainsoft.io/detailed-overview-of-well-known-symbols/

u9lyfish

常用昵称 u9lyfish,现就职于支付宝口碑前端团队,关注 ES6 和 React。

如需转载,烦请注明出处:http://www.w3cplus.com/javascript/detailed-overview-of-well-known-symbols.html


Viewing all articles
Browse latest Browse all 1557

Trending Articles