【磨破嘴皮】Map & WeakMap 原理

39 阅读12分钟

Map

Map 是 JavaScript 中的一个内置对象,允许你存储键值对(key-value pairs)的集合。与对象相比,Map 具有更好的性能,特别是在频繁地添加和删除键值对时。Map 还保留了键值对的插入顺序,因此你可以按照插入顺序遍历键值对。

基本使用

constructor

创建一个Map

const myMap = new Map();

set

向Map中添加键值对,不存在将创建,存在将更新

myMap.set('value1', '66666')

js任何对象都能作为Map的键值,甚至undefined、null、NaN都行

// undefined
const map = new Map();
map.set(undefined, 'value1');
map.set(null, 'value2')
map.set(NaN, 'value3')
console.log(map.get(undefined)); // "value1"

console.log(map.get(null)); // value2

console.log(map.get(NaN)); // value3
for(let key of map.keys()) { console.log(key)} // undefined null NaN

map的键是有序的,而obj需要看在那个浏览器

map.keys()

get

获取一个键对应的值,不存在返回undefined

const vlaue1 = myMap.get('vlaue1') // 66666

delete

删除一个键值对, 删除成功返回true, 删除失败返回false

const status = myMap.delete('value1') // true
const status2 = myMap.delete('value2') // 删除一个不存在的键会返回false

forEach

遍历Map中的键值对

myMap.foeEach((value, key)=>{
    console.log(value, key)
})

clear

清空Map

myMap.clear()

size

获取Map的大小(存储了多少对键值对)

myMap.size() // 1

keys

获取所有的键 keys

返回的是一个可迭代对象

mayMap.keys()
for(const key of map.keys()) {
    console.log(key)
}

values

获取Map中所有的value

返回一个可迭代对象,包含 Map 中所有的值。

for (const value of map.values()) {
  console.log(value);
}

entries

返回一个可迭代对象,包含[key, value]

for (let entry of recipeMap) { // 与 recipeMap.entries() 相同
  alert(entry); // cucumber,500 (and so on)
}

实际场景

作为请求的缓存

作为axios发送请求的缓存,这里实现了一个包装器,传入需要缓存的请求方法,它返回一个新的函数,使用新函数发起请求就可以实现请求数据的缓存

function useCache(fn) {
  const cache = new Map();
  return async function(...args) {
    const url = args[0];
    if (cache.has(url)) {
      return cache.get(url);
    } else {
      const response = await fn(...args);
      const data = response.data;
      cache.set(url, data);
      return data;
    }
  };
}

// 使用
const request = useCache(getData)

request().then((data)=>{
    console.log(data)
})

实现沉底缓存

Map 是一个存储键值对的数据结构,它的大小取决于键和值的数量。因此,在使用 Map 时需要特别注意内存占用问题。如果要存储大量的键值对,可能会导致内存溢出的问题。可以考虑使用 Map 的限制大小的实现,一个缓存类,超过限制长度时将会自动删除最旧的数据,

class SinkCache {
  constructor(limit) {
    this.map = new Map();
    this.queue = [];
    this.limit = limit;
  }
  // 获取数据时,将当前key移动到队列末尾
  get(key) {
    const value = this.map.get(key);
    if (value) {
      const index = this.queue.indexOf(key);
      this.queue.splice(index, 1);
      this.queue.push(key);
      return value;
    }
  }
  // 设置数据时,除了修改数据状态外,还需检查数据是否超出长度限制,超出则从队列中
  // 获取最旧的数据,进行删除
  set(key, value) {
    if (this.map.has(key)) {
      const index = this.queue.indexOf(key);
      this.queue.splice(index, 1);
    } else if (this.queue.length >= this.limit) {
      const oldestKey = this.queue.shift();
      this.map.delete(oldestKey);
    }
    this.map.set(key, value);
    this.queue.push(key);
  }

  has(key) {
    return this.map.has(key);
  }

  delete(key) {
    const index = this.queue.indexOf(key);
    if (index !== -1) {
      this.queue.splice(index, 1);
    }
    return this.map.delete(key);
  }

  clear() {
    this.queue.length = 0;
    this.map.clear();
  }
}

const cache = new SinkCache(10);
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3');
cache.set('key4', 'value4');
cache.set('key5', 'value5');
cache.set('key6', 'value6');
cache.set('key7', 'value7');
cache.set('key8', 'value8');
cache.set('key9', 'value9');
cache.set('key10', 'value10');
cache.set('key11', 'value11');
console.log(cache.get('key1')); // undefined
console.log(cache.get('key11')); // 'value11'

Map原理与简单实现

Map实现使用hash table或者hash map, 这是map能够提供O1级复杂度,查询、插入、删除操作的原因。

这里就利用Object对象实现一个简单的Map,并说明几个Hash Table的重要概念

  • 负载因子(Load Factor): 用于衡量hash table的充满程度,他是hash table中已存储键值对数量/哈希表容量,这个数值越高,表示空间利用率越高,但容易出现键冲突

  • 键冲突(两个不同键映射到相同索引):一个表中存在两个相同的hash值,好的hash函数可以让数据均匀分布,减少冲突,因为js的hash函数就是好的那一种,所以不可能出现冲突,这里用Object举例说明Map冲突是什么

    • 在这里xiaowang\xiaoming 的length相同,所以他们的hash也是一样的,这样就产生了冲突

      • const obj = {}
        const hash = (key:string)=> return key.length
        obj[hash('xiaoming')] = '1'
        obj[hash('xiaowang')] = '2'
        
    • 冲突解决方案:

      • 链地址池法(Separate Chaining):冲突发生时,将具有相同索引的所有键值储存到一个链表中对应链式哈希表

      • 动态调整法(Open Addressing):当冲突发生时,根据一些规则(线性探测、二次探测或双散列),在数组中寻找下一个可用的位置,对应 开放地址哈希表(Open Addressing Hash Table)

        • 双散列:同时计算两hash,一个直接使用,另一个作为冲突发生时的托底
        • 二次探测:检测到hash冲突时,进入一个循环,递增i 直到index + i * i不存在表中时,再插入
        • 线性探测: 检测到hash冲突时,进入一个循环,递增i 直到index + i 不存在表中时,再插入
  • 动态调整:为了避免出现冲突,会控制负载因子在某个阈值,低于则缩减map大小,节省空间,高于则会扩容

下面是一个使用了链地址池法解决冲突的的简单实现,在(map.get)获取数据时,根据key遍历hash对应的数组

class SimpleHashTable {
  constructor() {
    this.capacity = 16; // 初始容量
    this.size = 0; // 当前大小
    this.storage = new Array(this.capacity); // 存储数组,偷个懒,不用链表
  }

  // 哈希函数
  hash(key) {
    return key.toString().length % this.capacity;
  }

  // 添加键值对并动态调整大小
 set(key, value) {
  // 检查负载因子,如果超过 0.75,则扩容
   if (this.size / this.capacity > 0.75) {
    this.resize(this.capacity * 2);
   }

   const index = this.hash(key);
   if (!this.storage[index]) {
     this.storage[index] = [];
   }
   this.storage[index].push([key, value]);
   this.size++;
 }

  // 获取键对应的值
  get(key) {
    const index = this.hash(key);
    if (!this.storage[index]) return undefined;
    for (const pair of this.storage[index]) {
      if (pair[0] === key) return pair[1];
    }
    return undefined;
  }
  delete(key) {
    const index = this.hash(key)
    if(this.stroage[index] && this.stroage[index][0] === key) {
        delete this.stroage[key]
        this.size--
        return true
    } else {
        return false
    }
  }
  // 调整哈希表大小
 resize(newCapacity) {
   const oldStorage = this.storage;
   this.storage = new Array(newCapacity);
   this.capacity = newCapacity;
   this.size = 0;

   for (const bucket of oldStorage) {
     if (bucket) {
       for (const [key, value] of bucket) {
         this.set(key, value);
       }
     }
   }
 }
}

Map vs Object

Object与Map都允许按键存取一个值,删除键,检测一个键是否绑定了这个值,并且没有其他内建的方式替代,在Map出现之前,我们一直都把Object当作Map使用

MapObject
意外的键Map 默认情况不包含任何键。只包含显式插入的键。一个 Object 有一个原型,原型链上的键名有可能和你自己在对象上的设置的键名产生冲突。可以使用Object.create(null)创建一个干净的Object
键的类型一个 Map 的键可以是任意值,包括函数、对象或任意基本类型。一个 Object 的键必须是一个 String 或是 Symbol。并会将键的类型转换为字符串类型
键的顺序Map 中的键是有序的。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键值。虽然 Object 的键目前是有序的,但并不总是这样,而且这个顺序是复杂的。因此,最好不要依赖属性的顺序。
大小Map 的键值对个数可以轻易地通过 size 属性获取。Object 的键值对个数只能手动计算。
迭代Map 是 可迭代的 的,所以可以直接被迭代。Object 没有实现 迭代协议,所以使用 JavaSctipt 的 for...of 表达式并不能直接迭代对象。需要自己实现迭代器
性能在频繁增删键值对的场景下表现更好。在频繁添加和删除键值对的场景下未作出优化。
序列化和解析没有元素的序列化和解析的支持。(但是你可以使用携带 replacer 参数的 JSON.stringify() 创建一个自己的对 Map 的序列化和解析支持。参见 Stack Overflow 上的提问:How do you JSON.stringify an ES6 Map?)原生的由 Object 到 JSON 的序列化支持,使用 JSON.stringify()。原生的由 JSON 到 Object 的解析支持,使用 JSON.parse()。
内存结构Object是一系列连续的属性,其中每一个属性都有一个对应的字符串类型名称与对应的值,js根据属性名称在对象内存结构中查找对应的值而 Map 的内存结构是基于哈希表或红黑树等数据结构实现的,其中每个键值对都有一个唯一的哈希值和对应的值,可以通过哈希值快速查找对应的值。

Object.formEntries

从Map创建一个Object对象

let obj = Object.fromEntries(map.entries());

Object.entries

从对象创建一个Map

let map = new Map(Object.entries(obj))

WeakMap

WeakMap是js弱引用实现的集合类型(Weak Collection)之一,还有WeakSet与WeakRef

它与Map的api基本一致,区别在于:

  • key必须是对象类型,值可以是任意类型
  • 对于键的引用是弱引用,当运行环境中,其他位置没有键的引用时,他会被GC回收
  • WeakMap的键是不可枚举的,所以他不能调用entries(),keys(),clear(),size(),原因是这些方法会调用ownKeys()尝试枚举所有的key

强引用与弱引用

下面这段代码中,a-c的引用就是弱引用,b-c的引用是强引用。

const a = {name: 'xiaowang'}
const b = {name: null}
const c = {name: 'xiaoming'}
b.name = c // 强引用
const weakmap = new WeakMap()
weakmap.set(a, c) // 弱引用
const d = {}
weakmap.set(a, d)

强引用

下图中b 对c的引用就是强引用,这时name的value直接指向Object c的内存地址,会被引用计数器+1,这种引用方式我们称之为强引用

弱引用

weakmap.get(a)调用时,js会先将object a 计算为hash code, 在weak collection中查找

  • 存在: 根据对应cell中存储的object c的地址,找到obejct c并返回
  • 不存在: 返回 undefined

弱引用在创建时,也会先将object a计算为一串hash code(整数类型),再从一个叫weak collection(hash table)中查找这串code是否存在

  • 不存在:建立一个新的Cell (键值对)ahash code作为键,b的地址作为值,并将cell储存到weak collection
  • 存在:将Cell的值更新为d的地址

暂时无法在飞书文档外展示此内容

引用计数与标记清除

在js中,垃圾回收是自动的,主要依靠两种算法

标记清除(Mark-Sweep)

他主要检测对象的可达性来判断对象是否活跃,他会定期执行以下步骤

  • 从根对象,(全局对象、当前执行上下文中的局部变量、参数)触发,标记所有能够访问到的对象
  • 然后遍历并标记来自他们的所有引用,所有被遍历到的对象都会被记住,以免重复遍历
  • 重复以上步骤,直到所有可达的引用都被访问到
  • 清除没有被标记的对象

它存在以下问题

  • 内存碎片化,空闲内存块是不连续的(比如你在重装系统时给想C盘分配更大的空间,会有一个碎片整理的过程),容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块

  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

    • First-fit,找到大于等于 size 的块立即返回
    • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
    • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

内存碎片化可以使用 标记整理 算法解决,他会在标记结束后,将活跃的对象向内存的一段移动,最后清理掉边界的内存

引用计数(Reference Counting)

最早的一种垃圾回收算法,他会检查对象是否被引用,如果没有引用指向此对象,它将被回收

它的策略是跟踪记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
  • 如果同一个值又被赋给另一个变量,那么引用数加 1
  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

存在的问题:

  • 无法处理循环引用

WeakMap使用场景

数据缓存

其实不止vue3,在load上,react中都有weakmap做缓存的例子,下面是Vue3中使用weakmap记录了所有的reactive对象,防止对象被重复代理的核心代码

// 存储 target 和 key 的依赖关系
const targetMap = new WeakMap();

// 记录依赖
function track(target, key) {
  // 获取当前的 effect 函数
  const effect = activeEffectStack[activeEffectStack.length - 1];
  if (effect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      depsMap = new Map();
      targetMap.set(target, depsMap);
    }
    let dep = depsMap.get(key);
    if (!dep) {
      dep = new Set();
      depsMap.set(key, dep);
    }
    dep.add(effect);
    // 为 effect 添加依赖
    effect.deps.push(dep);
  }
}

WeakMapPolyfill

因为在ES6之前的是没有weakmap,所以还是有一些框架和库(比如Lodash、babel)有这方面的实现的,就像之前看到的Weak collection 与 cell的关系一样,关键点在于,使用合适的算法将两个对象转为字面量,并以key:value的方式存储,以此建立弱引用关系

if (typeof WeakMap === "undefined") {
  (function() {
    var defineProperty = Object.defineProperty;

    var counter = Date.now() % 1e9;

    // 定义FinalizationRegistry,用于在键被垃圾回收时清理键值对
    var registry = new FinalizationRegistry(function(ref) {
      var entry = ref.entry;
      entry[0] = entry[1] = undefined;
    });

    // 定义WeakMap类
    var WeakMap = function() {
      this.name = "__st" + ((Math.random() * 1e9) >>> 0) + ((counter++) + "__");
    };

    WeakMap.prototype = {
      set: function(key, value) {
        // 使用WeakRef来创建对键和值的弱引用
        var keyRef = new WeakRef(key);
        var valueRef = new WeakRef(value);

        // 在registry中注册键引用,以便在键被垃圾回收时清理键值对
        registry.register(keyRef, { entry: [key, value], keyRef: keyRef, valueRef: valueRef });

        var entry = key[this.name];
        if (entry && entry[0].deref() === key)
          entry[1] = valueRef;
        else
          defineProperty(key, this.name, { value: [keyRef, valueRef], writable: true });
        return this;
      },
      get: function(key) {
        var entry;
        return (entry = key[this.name]) && entry[0].deref() === key ? entry[1].deref() : undefined;
      },
      delete: function(key) {
        var entry = key[this.name];
        if (!entry) return false;
        var hasValue = entry[0].deref() === key;
        // 在registry中注销键引用
        registry.unregister(entry.keyRef);
        entry[0] = entry[1] = undefined;
        return hasValue;
      },
      has: function(key) {
        var entry = key[this.name];
        if (!entry) return false;
        return entry[0].deref() === key;
      }
    };

    window.WeakMap = WeakMap;
  })();
}

遗憾的是,这个pollyfill只实现了对键的弱引用,在es6之前,是无法实现键值同时弱易用的。

WeakMap vs Map

WeakMap与Map,都是Js中的内置数据结构

相同点:

  1. 都可以用来存储键值对。
  2. 都可以使用任何JavaScript对象作为键,包括函数、数组、甚至其他对象。
  3. 都支持增加、删除和获取值的操作。

不同点:

  1. WeakMap使用弱引用,需要额外维护Weak Collection的键值对关系,所以他的性能会比Map略低
  2. WeakMap的键不能被枚举,而Map可以