前言
最近,在面试一个求职者的时候,我问了她一个这样的问题:"前端开发中,用对象作哈希和用Map作哈希有什么区别?"
她的回答是"Map得到的键的顺序是确定的,而Object的键的顺序是不能确定的",她的回答无疑是对的,但是她的回答并不是我想要的回答,所以在这篇文章为大家分享一下对象作哈希和Map作哈希的区别,并结合这些区别再发散一些有实际价值的知识点。
对象作哈希
Object的属性描述符
对于这个知识点,想必大家也是非常非常熟悉了,毕竟各位都是精通Vue框架的大佬,照顾到一些初级同学,还是花一些篇幅来讲一下它。
ts
interface PropertyDescriptor {
configurable?: boolean;
enumerable?: boolean;
value?: any;
writable?: boolean;
get?(): any;
set?(v: any): void;
}
对象的属性描述符有六个字段:
configurable
,配置对象的这个属性是否可以通过delete
符号删除;enumerable
就是配置对象的这个属性是否可以被for-in
遍历;value
,就是配置对象的这个属性值了;writable
,就是配置对象的这个属性是否可以写;get
,属性取值器,通过它可以改写获取属性值的默认行为;set
,属性设置器,通过它可以改写湖区属性值的默认行为。
接着为大家展示一下效果:
js
const o = {}
Object.defineProperty(o, 'test', {
configurable: false,
value: 111
})
当我们把configurable
配置成false
了之后,这个属性无法通过delete删除了,如下图:
js
const o = {}
Object.defineProperty(o, 'test', {
enumerable: false,
value: 111
})
当我们把enumerable
设置成false
之后,无法for-in
遍历了。
js
const o = {}
Object.defineProperty(o, 'test', {
writable: false,
value: 111
})
o.test = 222;
console.log(o);
当我们把writable
设置成false
之后,可以模拟出const
关键字的效果。
最后再演示一下get
和set
:
js
const o = {
_test: null
}
Object.defineProperty(o, 'test', {
get() {
return this._test;
},
set(val) {
if(typeof val !== 'number') {
return;
}
this._test = val;
}
})
o.test = 222;
o.test = 'xxx';
console.log(o);
当我们直接调用对象赋值语句,比如:
js
const o = {}
o.test = 111;
我们来打印一下test这个键的对象描述符。 (我为了让自己形成使用推荐的API的编程习惯,所以用的是更推荐的Reflect.getOwnPropertyDescriptor
,用Object.getOwnPropertyDescriptor
也可以,只不过ES6推出Reflect
API之后就不推荐了,因为从语义上讲这属于语言层面的行为,用"反射"描述更好。)
获取对象的键
我们还是从一道面试题开始说起。
请解析下列代码的输出:
js
// example 1
var a={}, b='123', c=123;
a[b]='b';
a[c]='c';
console.log(a[b]);
---------------------
// example 2
var a={}, b=Symbol('123'), c=Symbol('123');
a[b]='b';
a[c]='c';
console.log(a[b]);
---------------------
// example 3
var a={}, b={key:'123'}, c={key:'456'};
a[b]='b';
a[c]='c';
console.log(a[b]);
上面的代码,我不给解答,如果有不懂的同学可以在评论区提出来。
获取对象的键的方法有好几种,比如for-in
。
js
const p = {
demo: 1
}
const s = Symbol(111);
const o = Object.create(p);
o[s] = 3333;
o['2x'] = 'xxx'
o.test = 222;
o['22x'] = 'xxxxxxx'
for(const key in o) {
console.log(key)
}
对象的键可以是Symbol
,也可以是String
,如果不是String
会自动调用对象的Symbol.toPrimitive
方法转为String
。
我们可以来做个实验,看看是不是这个道理
js
const o = {};
const p = { [Symbol.toPrimitive]() { return 'hello world' } }
o[p] = 222
console.log(o);
题外话说完了,还是回到for-in
,从刚才的例子我们可以看到,明显我们可以假设对象的键的顺序是无序(或者说不确定的),可以看到,for-in
还会把原型上的属性一起遍历出来,同时它无法获取到对象上Symbol
类型的键。
对于不遍历原型上的key,我们在遍历的时候,可以加上hasOwnProperty
进行判断,或者直接使用Object.keys
,Object.entries
。
js
const p = { p1: 'p1' }
const o = Object.create(p)
Object.assign(o, {
'k1': 1,
'k2': 2,
})
for(const key in o) {
console.log(key)
}
const keys = Object.keys(o);
console.log(keys);
对于处理不到Symbol
类型的问题也有办法解决,可以使用Object.getOwnPropertySymbols
。
js
const k1 = Symbol(1);
const o = {
[k1]: 1111
}
Object.getOwnPropertySymbols(o);
但是,对于对象的Key的顺序无法确定的问题,这个就无法解决了。
实际的开发中,假设确实要用使用对象作哈希的话,可以使用如下方式,避免处理到原型上的属性,这样我们就可以不用去判断hasOwnProperty
了。
js
// 这个空集符号,在MacOS上可以使用optins+o键打出来
const ø = Object.create(null)
至此,我们可以得出不使用对象作哈希的3个理由:
- 对象的Key只支持支持
Symbol
和String
,非String
类型自动调用Symbol.toPrivitive
方法进行转换。 - 对象的Key无法得到确定的顺序
- 对象的Key遍历较为麻烦,需要注意原型和
Symbol
类型的处理,没有一个原子化的API处理。
Map作哈希
Map
是专门为哈希场景设计的一个结构,以下内容是阮一峰老师的网络博客解释的为什么没有给对象部署默认的Iterator
接口的解释:
对象(Object)之所以没有默认部署 Iterator 接口,是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。本质上,遍历器是一种线性处理,对于任何非线性的数据结构,部署遍历器接口,就等于部署一种线性转换。不过,严格地说,对象部署遍历器接口并不是很必要,因为这时对象实际上被当作 Map 结构使用,ES5 没有 Map 结构,而 ES6 原生提供了。
使用Map结构的优势
在上文,我们提过了,对象无法处理引用类型的键,这是一个致命的伤。
为什么说这是致命的呢,比如我想给一个函数设置一些元数据,那就只能给函数增加一些key了,这是无法让人接受的。这就相当于改写了我们本来的函数,直接形成了对业务的侵入。
使用Map就避免了这个问题,比如我想给我的一个函数设置一些元数据:
js
const map = new Map();
function handleClick() {
console.log('click')
}
map.set(handleClick, {
test: 1
})
map.get(handleClick)
这个能力在一些框架中广泛使用,比如在NestJS中:
Map的API
Map.prototype.clear
,清理掉Map
对象上记录的所有键值映射。Map.prototype.delete
,删除某个Key为目标参数的键值映射。Map.prototype.entries
,获取到Map
对象上的所有键值映射组成的迭代器,迭代时,值是一个数组,arr[0]是Key,arr[1]是值。Map.prototype.set
,将键值为目标参数的记录设置到Map
对象上。Map.prototype.has
,判断当前Map
对象上是否存在键为目标值的键值映射Map.prototype.get
,获取当前Map
对象一个键为目标值的值,若不存在,返回undefined
,存在则返回目标值。Map.prototype.keys
,返回当前Map
对象上记录的键组成的迭代器Map.prototype.values
,返回当前Map
对象上记录的值组成的迭代器Map.prototype.size
,返回当前Map
对象上记录的键值对的个数
是使用Map
对象时,需要有几个注意点:
Map
对象的键可以是任意类型。Map
对象的那些获取记录值集合的返回值均不是数组,而是迭代器。Map
对象的默认迭代器指向的是Map.prototype.entries
,即[Symbol.iterator]跟Map.prototype.entries
是一个。JSON.stringify
不会序列化Map对象上引用的键值映射,因此它会被序列化成一个普通对象。Map
值的比较规则是===
,但是NaN
会被Map
视为是同一个Key
,-0
和+0
也是一个Key
。
以下代码为大家演示上述的注意点:
js
const map = new Map();
map.set(1, 10);
map.set(2, 20);
const a = map.entries();
const b = [...map.entries()];
console.log(a, b)
js
const map = new Map();
map.set(1, 10);
map.set(2, 20);
for(const [key, value] of map) {
console.log(key, value)
}
js
const map = new Map();
map.set(1, 10);
map.set(2, 20);
for(const [key, value] of map.entries()) {
console.log(key, value)
}
js
const map = new Map();
map.set(1, 10);
map.set(2, 20);
JSON.stringify(map);
js
const map = new Map();
map.set({}, 1)
map.set({}, 2)
map.set(NaN, 10)
map.set(NaN, 20)
map.set(+0, 100)
map.set(-0, 1000)
console.log(map.keys())
另外,Map
对象的构造器可以接受一个可迭代对象,作为它的初始值,不过这个基本上不常用。 比如:
js
const obj = [[1, 'demo1'], ['2', 'demo2']];
const map = new Map(obj);
console.log(map);
Map与Set
我相信各位同学对数组去重这道面试题已经非常熟悉了吧。
那我给大家上点难度,你们该如何应对呢?
js
const arr = [{}, {}, {}, {}]
const arr1 = [...new Set(arr)]
// ==================================
const arr2 = [NaN, 0, 0, {}, NaN]
const arr3 = [...new Set(arr2)]
// ==================================
const arr4 = [1, 2, 3, 3, 4, 4, 5]
const arr5 = [...new Set(arr4)]
请问上面的代码,哪些场景能完成去重,哪些场景不能完成去重?what or why?
如果我现在告诉你,Set
是一个特殊的Map
你们该如何应对呢?哈哈哈。
请一定要记住这个结论,Set
可以看做是一个Key-Value
相同的Map
。 以后你出去面试,谁要是问你Map
和Set
的区别,直接把这个结论给他拍在脸上,让对方无话可说,哈哈哈。
正是因为Set
是一个特殊的Map
,因此Set
的有些API的实现就跟Map
不一样,我在此就不赘述了,大家可以直接参考MDN。
那么,我们用代码来证明上面总结的结论:
js
const set = new Set();
const obj = {};
set.add(obj);
const keys = [...set.keys()]
const values = [...set.values()]
console.log(keys[0] === values[0], keys[0] === obj)
所以,现在我们把之前的3个case的去重改写一下,大家就明白为什么有的能去重,有的不能去重了。
js
function deDuplicate(arr) {
const map = new Map();
arr.forEach(v => {
if(!map.has(v)) {
map.set(v, v)
}
})
return [...map.keys()]
}
在第一个case中,因为对象字面量用的===
判断,肯定是不相等的,所以不能去重。在第二个case中,因为Map
在处理键的时候,用的是同值判断,NaN
视为一个,+0
和-0
都视为0
,所以能去重,第三个case同理。
Map与WeakMap
在前文我们说过,Map
的键可以是任意类型的,当我们在存储引用类型的键值映射的时候就需要考虑一些问题了。
因为JS的垃圾回收机制有一种方式是引用计数法,当我们用Map添加一个引用类型的键值映射时,势必就会造成这个引用类型的引用次数增加,那么当Map
对象没被销毁的时候,这个引用类型就无法被回收了,从而造成非预期的内存泄露。
解决这个办法也很简单,就是将原本使用Map
替换成WeakMap
,WeakMap
只允许键的类型是引用类型。
因为WeakMap
不会增加引用类型的引用计数(这是JS引擎的实现),所以它的能力就有限制了。
在Vue3框架的源码中,广泛应用WeakMap
,有兴趣的同学可以参考一下。
它相比原来的Map
对象就没有遍历键值映射的能力了。
Map键有顺序的意义
因为Map
对象的键值是有顺序的,我们可以先通过获取一个已有的键值映射,然后删除旧的键值映射,增加新的键值映射,从而实现刷新的行为。
这个场景,给大家举个LRUCache
的例子。
关于什么是LRUCache
,有兴趣的同学可以参考我早期的文章。我们好像在哪儿见过?------从LRUCache到Vue内置组件KeepAlive
如果我们自己去维护这个顺序,那就得用双向链表处理,代码复杂:
ts
/**
* @param {number} capacity
*/
var LRUCache = function (capacity) {
if (capacity <= 0) {
console.error("the LRUCache capacity must bigger than zero");
}
this.capacity = capacity;
this.size = 0;
/**
* @type { Map<any, DoubleLinkedListNode> }
*/
this.mapping = new Map();
/**
* @type { DoubleLinkedListNode | null }
*/
this.head = null;
/**
* @type { DoubleLinkedListNode | null }
*/
this.tail = null;
};
/**
* 刷新链表节点
* @param {DoubleLinkedListNode} node
* @returns
*/
LRUCache.prototype.refresh = function (node) {
if (!node) {
console.warn("failed to refresh cache node");
return;
}
let prevNode = node.prev;
let nextNode = node.next;
// 如果不存在前驱节点,说明当前节点就是最近使用过的节点,无需刷新
if (!prevNode) {
// this.head = node;
return;
}
// 如果不存在后继节点,说明当前节点就是最后一个节点,直接提到最前面去
if (!nextNode) {
prevNode.next = null;
this.tail = prevNode;
node.next = this.head;
this.head.prev = node;
this.head = node;
}
// 如果同时存在前驱和后继节点
if (prevNode && nextNode) {
// 把原来的两个节点接到一起
prevNode.next = nextNode;
nextNode.prev = prevNode;
// 然后把当前这个节点提到最前面去
node.next = this.head;
this.head.prev = node;
node.prev = null;
this.head = node;
}
};
/**
* @param {any} key
* @return {number}
*/
LRUCache.prototype.get = function (key) {
let node = this.mapping.get(key);
if (!node) {
return -1;
}
// 刷新节点
this.refresh(node);
return node.val;
};
/**
* @param {any} key
* @param {number} value
* @return {void}
*/
LRUCache.prototype.put = function (key, value) {
let oldNode = this.mapping.get(key);
// 旧节点不存在
if (!oldNode) {
const newNode = this.createNode(key, value);
// 设置新值
this.mapping.set(key, newNode);
if (this.size === 0) {
this.head = newNode;
this.tail = newNode;
} else {
newNode.next = this.head;
this.head.prev = newNode;
this.head = newNode;
}
this.size++;
if (this.size > this.capacity) {
let oldKey = this.tail.key;
this.mapping.delete(oldKey);
// 解开最后一个节点
let preTail = this.tail.prev;
preTail.next = null;
this.tail.prev = null;
this.tail = preTail;
this.size--;
}
} else {
oldNode.val = value;
this.refresh(oldNode);
}
};
/**
* 创建一个链表节点
* @param {number} val
* @returns {Node}
*/
LRUCache.prototype.createNode = function (key, val) {
return {
prev: null,
next: null,
val,
key,
};
};
如果直接使用Map
自带的顺序:
ts
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) {
return -1;
}
const value = this.cache.get(key);
// 刷新键,将其移动到最近使用的位置
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
put(key, value) {
// 如果键已存在,先删除旧的键值对
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 添加新的键值对
this.cache.set(key, value);
// 如果超出容量,删除最老的项目
if (this.cache.size > this.capacity) {
// Map.prototype.keys().next().value 返回第一个插入的键
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
}
}
可以看到,代码简单了不少。
不过,需要注意的是,并不是说这种写法的性能更高 ,因为维护Map
的顺序肯定是有代价的,在这个例子中,我主要向大家举例的是Key的顺序的实际应用。
Map的广泛实践
在TS的元编程有一个比较重要的库,叫做reflect-metadata
。有兴趣的同学可以看一下它的源码,它使用了Map
对象记录元数据。
另外,在这个库中,它实现了一个Map
的polyfill,这就是我向大家阐述那些知识点的来源。
rbuckton/reflect-metadata: Prototype for a Metadata Reflection API for ECMAScript (github.com)
而正是因为这些能力,才有了简单易用的NestJS
,对于这些框架和库,对我们开发自己的框架有非常重要的参考意义。
结语
本文通过阐述以对象作哈希存在的问题,并向大家展示了Map
对象的一些基本用法,以及Map
对象Key的一些特性、Map
对象和Set
对象的异同点,并且阐述了什么时候用Map
对象,什么时候该用WeakMap
对象,相信学完这篇文章之后能够助力于你搞明白很多之前面试被问到的问题。
在以后的开发中,请一定要用Map
而不再使用对象作哈希啦。
对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。
如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。