总结 ES6 新增 Symbol 数据类型和 Map 等集合引用类型。主要参考《JavaScript 高级程序设计(第 4 版)》相关内容学习它们的基本结构、方法和使用场景。
ES6 学习系列笔记
Symbol(符号) 是 ES6 新增的一种基本(简单)数据类型。符号是唯一的、不变的原始值。符号的用途是确保对象属性使用唯一的标识符,不会发生属性冲突。(参考章节 3.4.7 Symbol类型 )
1、符号需要使用 Symbol()
函数初始化,可以传入一个字符串参数作为符号描述。但是这个字符串参数与符号定义或者标识完全无关。通过实例只读属性 Symbol.prototype.description
可以获取该可选描述的字符串。
let sym0 = Symbol('tkop');
let sym1 = Symbol('tkop');
// Symbol 是原始数据类型,使用 typeof 检查结果。
console.log(typeof sym0); // 'symbol'
console.log(sym0 == sym1); // false
let sym3 = Symbol();
let sym4 = Symbol();
console.log(sym3 == sym4); // false
符号没有字面量语法,且 Symbol() 函数不能和 new 操作符一起作为构造函数使用(避免创建符号包装对象)。
// TypeError: Symbol is not a constructor
let sym = new Symbol();
2、符号是唯一的,一旦声明一个符号没有显式地保存它,则无法共享和重用它。ES6 的 Symbol.for()
方法在调用时,它会检查全局运行时注册表中是否存在传入的字符串参数对应的符号。如果有则返回该符号,没有则使用字符串作为键,在全局符号注册表中创建并重用符号。
Symbol.for('sym1');
let s1 = Symbol.for('sym1');
let s2 = Symbol.for('sym1');
console.log(s1 === s2); // true
// 但是使用一样的描述使用Symbol()定义的符号是不相同的。
let s3 = Symbol('sym1');
console.log(s1 === s3); // false
相对应着 Symbol.for() 方法的 Symbol.keyFor()
方法,它用来查询某个符号在全局注册表中对应的字符串键。如果该符号没有全局注册则会返回 undefined。
console.log(Symbol.keyFor(s2)); // 'sym1'
console.log(Symbol.keyFor(s3)); // undefined
3、凡是可以使用字符串或者数值作为属性的地方,都可以使用符号。对象字面量只能在计算属性语法中使用符号作为属性。
let o = {
[Symbol('id')]: 0,
};
o[Symbol('age')] = 18;
Object.defineProperty(o, Symbol('name'), {
value: '扬尘',
enumerable: false,
});
// Object.defineProperties(o, {
// [Symbol('name')]: {
// vale: '扬尘',
// enumerable: false,
// },
// [Symbol('age')]: {
// value: 18,
// enumerable: true,
// },
// });
// {Symbol(id): 0, Symbol(name): undefined, Symbol(age): 18}
console.log(o);
// [Symbol(id), Symbol(name), Symbol(age)]
console.log(Reflect.ownKeys(o));
因为符号属性是对内存中符号的一个引用,所以直接创建并用作属性的符号不会丢失。但是,如果没有显式地保存对这些符号属性地引用,那么必须遍历对象的所有符号属性才能找到相应的属性键。
// 这样是找不到相应的属性的。
console.log(o[Symbol('age')]); // undefined
// 遍历所有 Symbol 键并找到年龄对应的符号。
let ageSymbolKey = Object.getOwnPropertySymbols(o).find(sym => sym.toString().match(/age/));
console.log(o[ageSymbolKey]); // 18
ES6 引入了一批用于暴露语言内部行为的常用内置符号,开发者通过它们可以直接访问、重写或者模拟对应的行为。这些内置符号都以 Symbol 工厂函数字符串属性的形式存在,可以使用 Reflect.ownKeys(Symbol)
和 Object.getOwnPropertyNames(Symbol)
观察得到。它们指向一个符号实例,且所有的内置符号属性都是不可写、不可枚举、不可配置的。
在提到 ECMAScript 规范时,经常会引用符号在规范中的名称,前缀为 @@ 。比如,@@iterator 指的就是 Symbol.iterator。
内置符号属性 | 描述 | 掌握 |
---|---|---|
Symbol.iterator | @@iterator() 方法返回对象默认的迭代器,使用 for-of 循环这样的语言结构执行迭代操作时,会调用以 @@iterator 为键的函数。返回一个实现迭代器 API 的迭代器对象(生成器对象)供它们消费。 | ✔ |
Symbol.hasInstance | @@hasInstance () 方法决定构造对象是否认可某对象是它的实例。它是定义在 Function 的原型上的属性(默认所有的函数和类上都可以调用)。在使用 instanceof 操作符确定某构造函数的原型是否位于某对象原型链上时会执行以 @@hasInstance 为键的函数(将对象作为参数传入)。 | ✔ |
Symbol.species | @@species 是一个静态的 getter 访问器属性,它的值会作为派生类实例方法内部实例化对象的构造函数。常用于在继承内置类型时,派生类继承的实例方法(例如 Array.prototype.concat)内部会创建并返回一个默认与原始实例类型相同的实例 。通过它可以在实例化时使用超类 Array 的构造函数(改变类)。 | ✔ |
Symbol.isConcatSpreadable | @@isConcatSpreadable 是一个值为布尔值的属性,用于配置某对象作为 Array.prototype.concat() 方法的参数时是否展开(打平)其数组元素。数组对象默认情况下会被打平到已有的数组(类数组对象则相反),通过覆盖该属性值可以修改这个默认行为。 | ✔ |
Symbol.toPrimitive | @@toPrimitive 指定了一种接受首选类型并返回对象原始值的表示的方法。它被所有的强类型转换制算法优先调用。函数被调用时,会被传递一个字符串参数 hint,表示要转换到的原始值的预期类型。hint 参数的取值是 “number”、“string” 和 “default” 中的任意一个。 | |
Symbol.toStringTag | @@toStringTag 是一个字符串值属性,用于创建对象的默认字符串描述。它由 Object.prototype.toString() 方法内部访问。通过 toString() 方法获取对象标识时,会检索由 @@toStringTag 指定的实例标识符,默认为 ‘Object’。 | ✔ |
Symbol.unscopables | @@unscopables 属性引用的是一个对象。该对象属性值为 true 的属性将使得其关联对象相应的属性无法在 with 环境中绑定,例如 ES6 新增的一些 Array 引用类型实例属性(不推荐使用 with ,所以了解即可)。 | |
Symbol.asyncIterator | 略 |
下面的这几个符号属性是正则表达式方法,它们是在正则表达式的原型上定义的方法(函数)。区别于字符串键方法 test() 和 exec(),它们由相应的字符串方法使用,定义了默认的正则表达式行为。以 @@match()
为例,调用 String.prototype.match(re)
时会调用以 Symbol.match
为键的函数来对 re 求值,传递的参数是调用方法的字符串实例。如果传入的 re 不是正则表达式值会导致该值被转换为 RegExp 对象。重新定义 re 的 Symbol.match 方法可以覆盖在调用 String.prototype.match(re) 时默认对正则表达式求值的行为,从而让 match() 方法使用非正则表达式实例。
内置符号属性 | 关联的字符串方法 | 描述 |
---|---|---|
Symbol.match | String.prototype.match() | @@match() 除了具有上述特点外,还用于标识对象是否具有正则表达式的行为。例如 includes()、startsWith()、endsWith() 等字符串方法会通过检查参数的 @@match 属性值来判断是否传入了不合法的正则表达式,如果该属性值(转布尔值)为 true,则会报错 TypeError:方法参数必须不是一个正则表达式。 |
Symbol.replace | String.prototype.replace() | @@replace 指定了当一个字符串替换所匹配字符串时所调用的方法。 |
Symbol.search | String.prototype.search() | @@search 指定了一个搜索方法,这个方法接受用户输入的正则表达式,返回字符串中匹配该正则表达式的索引。 |
Symbol.split | String.prototype.split() | @@split 指向一个正则表达式的索引处分割字符串的方法。 |
Symbol.matchAll | String.prototype.matchAll() | 略 |
ES6 之前,Object 可以实现“键/值”式存储数据。但这种实现并非没有问题,为此 ES6 引入一种新的集合类型 Map。Map 为 JavaScript 带来了真正的键/值存储机制。
使用 new 关键字和 Map 构造函数可以创建一个空映射实例。如果想在创建的同时初始化实例,可以向构造函数传入一个包含键/值对数组的可迭代对象。可迭代对象中的键/值对会按照迭代顺序依次插入实例中。
const m0 = new Map();
const m1 = new Map([
['k1', 'v1'],
['k2', 'v2'],
['k3', 'v3'],
]);
// Map(0) {}
console.log(m0);
// Map(3) { 'k1' => 'v1', 'k2' => 'v2', 'k3' => 'v3' }
console.log(m1);
1、Map 实例可以通过 size
属性获取映射中的键/值对的数量。size 是只读属性,用 set 方法修改 size 返回 undefined,即不能改变它的值。
console.log(m0.size); // 0
console.log(m1.size); // 3
2、Map 实例具有 set()、get()、has()、delete() 和 clear() 等基本操作 API。它们的基本语法如下表所示。
方法 | 描述 | 返回值 |
---|---|---|
set(key, value) | set() 方法为 Map 对象添加或更新一个指定了键和值的(新)键值对。 | 调用方法的 Map 对象 |
get(key) | get() 方法从 Map 对象返回所提供的键关联的值。 | 映射键关联的值或 undefined |
has(key) | has() 方法返回一个布尔值,指示具有指定键的元素是否存在。 | 布尔值 |
delete(key) | delete() 方法用于移除 Map 对象中指定的元素。 | 布尔值 |
clear() | clear() 方法会移除 Map 对象中的所有元素。 | undefined |
// 各种 Map 方法的使用示例。
m0.set('key0', 'value0');
m0.set('key1', 'value1').set('key2', 'value2');
console.log(m0.size); // 3
console.log(m0.get('key1')); // 'value1'
console.log(m0.get('key3')); // undefined
console.log(m0.has('key2')); // true
console.log(m0.has('key3')); // false
console.log(m0.delete('key2')); // true
console.log(m0.delete('key3')); // false
console.log(m0.has('key2')); // false
console.log(m0.size); // 2
m0.clear();
console.log(m0.size); // 0
需要注意映射键的特性。Map 可以使用任何 JavaScript 数据类型作为键,且键的比较是基于零值相等(SameValueZero)算法的。一个键只能出现一次,再次出现在 set() 方法中只会更新 Map 实例该键关联的值。
m1.delete('k1');
m1.delete('k2');
// Map(1) { 'k3' => 'v3' }
console.log(m1);
m1.set('k3', 'newValue');
// Map(1) { 'k3' => 'newValue' }
console.log(m1);
const k4 = new Object();
const k5 = function () {};
m1.set(k4, k4);
m1.set(k5, k5);
// Map(3) { 'k3' => 'newValue', {} => {}, [Function: k5] => [Function: k5] }
console.log(m1);
k4.name = 'key4';
// Map(3) { 'k3' => 'newValue', { name: 'key4' } => { name: 'key4' }, [Function: k5] => [Function: k5] }
console.log(m1);
// 键比较的问题
console.log(m1.get(function () {})); // undefined
m1.clear();
m1.set(-0, '-0').set(+0, '+0').set(NaN, 'nan1').set(NaN, 'nan2');
console.log(m1); // Map(2) { 0 => '+0', NaN => 'nan2' }
与 Object 类型的一个主要差异是 Map 实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。接下来总结一下 Map 对象的迭代知识。
1、如果不使用迭代器,而是使用回调的方式,可以使用映射的 forEach(callbackFn, thisArg)
方法对其进行迭代。
const m = new Map().set('k1', 'v1').set('k2', 'v2').set('k3', 'v3');
// 回调函数三个参数分别是值、键和当前遍历的 map 实例。
m.forEach((value, key, mapInstance) => {
// 'k1 => v1' 'k2 => v2' 'k3 => v3'
console.log(`${key} => ${value}`);
});
2、Map 实例实现了可迭代协议,所以可以使用 for-of
结构进行遍历。或者调用默认的迭代器返回一个新的 Map 迭代器对象。Map 提供了一个 entries()
方法与默认迭代器引用的是相同的函数对象。
const m = new Map().set('k1', 'v1').set('k2', 'v2').set('k3', 'v3');
// true
console.log(m[Symbol.iterator] === m.entries);
// Symbol.iterator 返回一样的 Map 迭代对象。
// MapIterator {'k1' => 'v1', 'k2' => 'v2', 'k3' => 'v3'}
console.log(m.entries());
// 可以对其使用扩展操作转换为二维数组
// [ [ 'k1', 'v1' ], [ 'k2', 'v2' ], [ 'k3', 'v3' ] ]
console.log([...m]);
// 使用 for-of,会调用默认的迭代器对象。
for (const pair of m) {
console.log(pair);
}
// 使用 entries 返回的 Map 迭代对象
for (const pair of m.entries()) {
console.log(pair);
}
for (const pair of m[Symbol.iterator]()) {
console.log(pair);
}
上面三个遍历结果一致,因为都调用了默认的迭代器 Symbol.iterator 。
此外 Map 的 keys()
和 values()
分别返回以插入顺序生成的键和值的新的 Map 迭代器对象。
const m = new Map().set('k1', 'v1').set('k2', 'v2').set('k3', 'v3');
// MapIterator {'k1', 'k2', 'k3'}
console.log(m.keys());
// MapIterator {'v1', 'v2', 'v3'}
console.log(m.values());
for (let k of m.keys()) {
console.log(k);
}
for (let v of m.values()) {
console.log(v);
}
遍历时是否可以直接使用迭代变量修改键和值的问题,可以参考结构赋值的原理。原始数据类型拿到的只是相应的值,引用类型拿到的是对象引用。所以本质改动的是什么,能不能改就可以一清二楚了。
1、浅层面上的对比。一个 Map 的键可以是任意类型值,包含函数、对象或者任意基本类型。且 Map 中的键是有序的,Map 实例会维护键值对的插入顺序(迭代时会使用这个顺序)。Map 默认情况下不包含任何键,只包含显式插入的键。而一个 Object 有一个原型,原型链上的键名可能和你自己在对象上设置的键名其冲突。Map 键值对的个数可以使用 size 属性获取,而 Object 只能手动计算。Object 没有实现迭代协议,所以不能使用 for-of 结构遍历。
2、内存方面,单个键值对占用的内存虽然不同的浏览器的情况不同,但给定固定大小的内存,Map 大约可以比 Object 多存储 50% 的键/值对。
3、性能方面,大量插入键值对或者频繁增删键值对,Map 的性能更佳。但如果代码涉及大量查找操作,那么某些情况下可能选择 Object 更好一点。
ES6 增加的“弱映射”(WeakMap)是一种新的集合类型,为 JavaScript 带来了增强的键值对存储机制。WeakMap 中的 weak(弱)描述的是 JavaScript 垃圾回收程序对待 WeakMap 实例中键的方式。WeakMap 是 Map 的“兄弟”类型。其 API 也是 Map 的子集。这里主要记住两者的的不同之处。
WeakMap 跟 Map 一样的创建语法,一样具有 set()、get()、has() 和 delete() 方法。但是没有实现迭代协议,所以不存在与迭代有关的方法。具体原因与其键是弱键有关,后面再谈。这部分内容与 Map 最大的不同是,弱映射中的键只能是 Object 或者继承自 Object 的类型,尝试使用非对象设置键会抛出 TypeError。
const k1 = { keyName: 'key1' },
k2 = { keyName: 'key2' },
k3 = { keyName: 'key3' };
const wm = new WeakMap([
[k1, 'value1'],
[k2, 'value2'],
[k3, 'value3'],
]);
console.log(wm.get(k1)); // 'value1'
// TypeError: Invalid value used as weak map key 整个初始化失败
const wm_error = new WeakMap([k1, 'value1'], ['k2', 'value2'], [k3, 'value3']);
弱键是 WeakMap 的核心。弱映射中的键不属于真正的引用,不会阻止垃圾回收。相比之下,Map 中使用对象类型作为键,如果不使用 delete() 或者 clear() 将它们删除,那么它们就会一直存在与内存当中。
let container = {
key: {},
};
// 不会被回收,因为有 container.key 引用这该对象。
const wm = new WeakMap().set(container.key, 'val');
// 但是一旦执行 removeReference 函数移除引用,该对象就会被回收。
const removeReference = () => (container.key = null);
console.log(wm); // WeakMap { }
因为 WeakMap 中的键值对任何时候都可能被销毁,所以没必要提供迭代其键/值的能力。除此之外也没有 clear() 方法。因为无法迭代,所以也不可能在不知道对象引用的情况下从弱映射中取得值。即使代码可以访问 WeakMap 实例,也没办法看到其中的内容。如上所示是 node.js 环境下打印的 WeakMap 结果。
1、弱映射造就了在 JavaScript 中实现真正私有变量的一种新形式。前提很明确:私有变量会存储在弱键映射中,以对象实例为键,以私有成员的字典为值。
const User = (() => {
const userPrivateMsg = new WeakMap();
class User {
constructor(idNum, name, age) {
this.name = name;
userPrivateMsg.set(this, { idNum, age });
}
setAge(newAge) {
const privateMsg = userPrivateMsg.get(this);
privateMsg.age = newAge;
userPrivateMsg.set(this, privateMsg);
}
getAge() {
return userPrivateMsg.get(this).age;
}
}
return User;
})();
const tkop = new User('440825XXX', 'tkop', 18);
console.log(tkop.getAge());
tkop.setAge(20);
console.log(tkop.getAge());
可以看到由于闭包的私有变量模式,除了使用特权方法,在外部无法修改访问用户的私密信息。每个用户实例在弱映射中都会作为键关联私有的信息。一旦该用户实例被删除(外部不再存在对象引用),垃圾回收机制就会回收内存。
2、因为 WeakMap 实例不会妨碍垃圾回收,所以非常适合用来为 DOM 对象保存关联元素据。这样在页面执行 DOM 操作,例如节点从 DOM 树中被删除,在没有其他地方引用该对象的情况下,垃圾回收程序就可以立即释放其内存。而不会受其是否利用 WeakMap 保存有元素据影响。
// 假设这是一条评论(以下为简单示例,不要计较 dom 结构是否合理)
const domElement = document.getElementById('discuss0310');
// 假设使用的是 Map
// const m = new Map().set(domElement, { date: '2023/04/20', discussId: '0310' });
// 假设使用的是 WeakMap
const wm = new WeakMap().set(domElement, { date: '2023/04/20', discussId: '0310' });
// 将这个 dom 元素删除(删评),但由于 Map 中还保存着该对象引用,所以内存无法回收。
// 如果是使用 WeakMap 则能够释放内存。
const removeDiscuss = () => {
domElement.parentNode.removeChild(domElement);
};
ES6 新增的 Set 是一种新的集合类型,为这门语言带来集合数据结构。Set 在很多方面都像是加强的 Map,这是因为它们大多数 API 和行为都是共有的。个人将 Map 看作是 Object 的延申,而 Set 像是 Array 的延申。但 Array 又属于 Object 类型,所以它们相互关联不足为奇。
使用 new 关键字和 Set 构造函数可以创建一个空集合。创建同时可以传入一个可迭代对象用于初始化集合,该可迭代对象中的元素会被依次插入集合实例。与 Map 一样,集合会维护值插入时的顺序,所以后面在迭代集合实例时支持按照顺序迭代。Set 和 Map 的 API 对比。
Set | Map | 描述(是否都具有) |
---|---|---|
size | ✔ | |
add() | set() | 相同,均用来插入元素(键值对),不使用 set 可能是为了避开 Set 构造函数。 |
get() | X | |
clear() | ✔ | |
delete() | ✔ | |
has() | ✔ | |
@@species | ✔ | |
@@iterator | ✔ | |
forEach() | ✔ | |
entries() | ✔ | |
keys() | ✔ | |
values() | ✔ |
注意:集合的 entries() 与 [@@iterator]() 不是同一个引用。前者返回的迭代器,可以按照插入顺序产生包含两个元素的数组,这两个元素是集合中每个值得重复出现。后者和 values() 均是默认迭代器,迭代返回集合中的每个值。行为方面,集合元素比较也基于 SameValueZero 算法。可以是任何类型的数据作为集合元素,但是元素都是唯一的。
集合只是内置了上述的那些基本方法,但是无法满足开发者对集合类型的常用操作和使用。例如获取两个集合的并集,交集、差集等基本操作。因此需要自定义一些集合基本操作方法。
function isSuperset(set, subset) {
for (let elem of subset) {
if (!set.has(elem)) {
return false;
}
}
return true;
}
function union(setA, setB) {
let _union = new Set(setA);
for (let elem of setB) {
_union.add(elem);
}
return _union;
}
function intersection(setA, setB) {
let _intersection = new Set();
for (let elem of setB) {
if (setA.has(elem)) {
_intersection.add(elem);
}
}
return _intersection;
}
function symmetricDifference(setA, setB) {
let _difference = new Set(setA);
for (let elem of setB) {
if (_difference.has(elem)) {
_difference.delete(elem);
} else {
_difference.add(elem);
}
}
return _difference;
}
function difference(setA, setB) {
let _difference = new Set(setA);
for (let elem of setB) {
_difference.delete(elem);
}
return _difference;
}
//Examples
let setA = new Set([1, 2, 3, 4]),
setB = new Set([2, 3]),
setC = new Set([3, 4, 5, 6]);
isSuperset(setA, setB); // => true
union(setA, setC); // => Set [1, 2, 3, 4, 5, 6]
intersection(setA, setC); // => Set [3, 4]
symmetricDifference(setA, setC); // => Set [1, 2, 5, 6]
difference(setA, setC); // => Set [1, 2]
// 另外一些与数组有关的操作
let myArray = ["value1", "value2", "value3"];
// 用 Set 构造器将 Array 转换为 Set
let mySet = new Set(myArray);
mySet.has("value1"); // returns true
// 用...(展开操作符) 操作符将 Set 转换为 Array
console.log([...mySet]); // 与 myArray 完全一致
// 数组去重
const numbers = [2,3,4,4,2,3,3,4,4,5,5,6,6,7,5,32,3,4,5]
console.log([...new Set(numbers)])
// [2, 3, 4, 5, 6, 7, 32]
WeakSet 对象是一些对象值的集合。且其与 Set 类似,WeakSet 中的每个对象值都只能出现一次。在 WeakSet 的集合中,所有对象都是唯一的。它和 Set 对象的主要区别有:
弱集合与集合的关系就如同弱映射与映射的关系。弱集合只有 add()、delete() 和 has() 方法。这里只说一下 WeakSet 的一个应用场景,使用弱集合给对象打标签。
// 将来若删除DOM元素,但由于 Set 中还保存着它的引用,所以内存无法回收。
// const disabledElements = new Set();
// 使用 WeakSet 则不会妨碍垃圾回收。
const disabledElements = new WeakSet();
// 页面某个表单元素
const loginBtn = document.querySelector('#loginBtn');
// 通过加入对应的弱集合,给节点打上“禁用”的标签。
disabledElements.add(loginBtn);