为什么我推荐你用Map作哈希?深入浅出ES6之Map对象

前言

最近,在面试一个求职者的时候,我问了她一个这样的问题:"前端开发中,用对象作哈希和用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关键字的效果。

最后再演示一下getset

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.keysObject.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只支持支持SymbolString,非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 以后你出去面试,谁要是问你MapSet的区别,直接把这个结论给他拍在脸上,让对方无话可说,哈哈哈。

正是因为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替换成WeakMapWeakMap只允许键的类型是引用类型。

因为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而不再使用对象作哈希啦。

对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

相关推荐
zqx_722 分钟前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己39 分钟前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称1 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色1 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2342 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河2 小时前
CSS总结
前端·css
NiNg_1_2342 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript
读心悦2 小时前
如何在 Axios 中封装事件中心EventEmitter
javascript·http
BigYe程普2 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
神之王楠2 小时前
如何通过js加载css和html
javascript·css·html