JS数据类型整理(Set Map WeakSet WeakMap Proxy Reflect)

191 阅读11分钟

什么是集合

在学习以下数据结构之前,我们先温习一下集合的概念。

集合(英语:set)简称,是一个基本的数学模型,指具有某种特定性质的事物的总体。若x是集合A的元素,记作 x ∈ A。集合A也可以用 A = { x, a, b} 表示。 集合具有以下特性:

  • 无序性:一个集合中,每个元素的地位都是相同的,元素之间是无序的。集合上可以定义序关系,定义了序关系后,元素之间就可以按照序关系排序。但就集合本身的特性而言,元素之间没有必然的序。(参见序理论

  • 互异性:一个集合中,任何两个元素都认为是不相同的,即每个元素只能出现一次。有时需要对同一元素出现多次的情形进行刻画,可以使用多重集,其中的元素允许出现多次。

  • 确定性:给定一个集合,任给一个元素,该元素或者属于或者不属于该集合,二者必居其一,不允许有模棱两可的情况出现。

Set & WeakSet

什么是Set
  • Set 也是 ECMAScript 6 规范中引入的一种数据结构,是一种叫做集合(是由一堆无序的、相关联的,且不重复的内存结构)的数据结构。
  • Set 就像一个数组,但是仅包含唯一项。Set对象是值的集合,可以按照插入的顺序迭代它的元素。 Set中的元素只会出现一次,即 Set 中的元素是唯一的。
  • 存储的值可以是原始值或者是对象引用。
  • 与array相比,操作性能做了优化。
使用场景
  1. Set & Array
// 1.Set 与 Array 互转
let mySet = new Set([1, "some text", {"a": 1, "b": 2}, {"a": 1, "b": 2}]);
let myArr = Array.from(mySet); // [1, "some text", {"a": 1, "b": 2}, {"a": 1, "b": 2}]

// 2.数组去重
const mySet2 = new Set([1, 2, 3, 4, 3]);
mySet2.size;               // 4
[...mySet2];               // [1,2,3,4]
  1. 在vue3中的应用

在vue3响应系统中,有响应式数据和副作用函数,如果在副作用函数中读取了某个响应式数据的属性,在响应式数据属性改变的时候,我们期望副作用函数重新执行,这就是响应系统最核心的功能。那我们如何在响应式数据与副作用函数之间建立联系呢?

image.png

  • 当读取操作发生时,将副作用函数收集到“桶”中;
  • 当设置操作发生时,从“桶”中取出副作用函数并执行。

副作用被收集到“桶”中这个操作使用的就是Set数据结构,副作用函数可能有多个,全部收集到同一个集合中,并利用数据特性,在多次读取时,可以实现去重

什么是WeakSet
  • WeakSet 对象是一些对象值的集合。且其与 Set 类似,WeakSet 中的每个对象值都只能出现一次。
  • 在 WeakSet 的集合中,所有对象都是唯一的。

它和 Set 对象的主要区别有:

  • WeakSet 只能是对象的集合,而不能像 Set 那样,可以是任何类型的任意值。
  • WeakSet 持弱引用:集合中对象的引用为引用。如果没有其他的对 WeakSet 中对象的引用,那么这些对象会被当成垃圾回收掉。
使用场景
  1. 作为额外的存储空间,用于判断“是/否”的事实。

比如有一个 messages 数组,在不改变messages数据的情况下,标记消息是否已读。当消息从messages中被删除时,与之相关的标记消息也应该一起消失。

let messages = [
  {text: "Hello", from: "John"},
  {text: "How goes?", from: "John"},
  {text: "See you soon", from: "Alice"}
];

let readMessages = new WeakSet();

// 两个消息已读
readMessages.add(messages[0]);
readMessages.add(messages[1]);
// readMessages 包含两个元素

// ……让我们再读一遍第一条消息!
readMessages.add(messages[0]);
// readMessages 仍然有两个不重复的元素

// 回答:message[0] 已读?
alert("Read message 0: " + readMessages.has(messages[0])); // true

messages.shift();
// 现在 readMessages 有一个元素(技术上来讲,内存可能稍后才会被清理)
  1. 检测循环引用
// 对 传入的 subject 对象 内部存储的所有内容执行回调
function execRecursively(fn, subject, _refs = new WeakSet()) {
  // 避免无限递归
  if (_refs.has(subject)) {
    return;
  }

  fn(subject);
  if (typeof subject === "object") {
    _refs.add(subject);
    for (const key in subject) {
      execRecursively(fn, subject[key], _refs);
    }
  }
}

const foo = {
  foo: "Foo",
  bar: {
    bar: "Bar",
  },
};

foo.bar.baz = foo; // 循环引用!
execRecursively((obj) => console.log(obj), foo);

Map & WeakMap

什么是Map
  • Map 对象是键值对的集合。Map 中的一个键只能出现一次;它在 Map 的集合中是独一无二的。
  • Map 对象按键值对迭代——一个 for...of 循环在每次迭代后会返回一个形式为 [key,value] 的数组。迭代按插入顺序进行,即键值对按 set() 方法首次插入到集合中的顺序(也就是说,当调用 set() 时,map 中没有具有相同值的键)进行迭代。
使用场景
  1. Map & Object

相同:Object 和 Map 类似的是,它们都允许你按键存取一个值、删除键、检测一个键是否绑定了值。因此(并且也没有其他内建的替代方式了)过去我们一直都把对象当成 Map 使用。

差异:

差异点ObjectMap
意外的键Object有原型链,可能存在拿到不属于本身的键值对Map只有插入的键
键的类型Object键根据不同的迭代方法返回的键是不固定的(for-in 仅包含了以字符串为键的属性;Object.keys 仅包含了对象自身的、可枚举的、以字符串为键的属性;Object.getOwnPropertyNames 包含了所有以字符串为键的属性,即使是不可枚举的;Object.getOwnPropertySymbols 与前者类似,但其包含的是以 Symbol 为键的属性,等等。)Map的键按插入顺序排列
sizeObject 的键值对个数无法直接拿到Map的可以通过size属性轻易拿到
迭代Object 没有实现 迭代协议,所以使用 JavaSctipt 的 for...of 表达式并不能直接迭代对象Map 是 可迭代的 的,所以可以直接被迭代。
性能Object在频繁添加和删除键值对的场景下未作出优化Map在频繁增删键值对的场景下表现更好
序列化和解析原生的由 Object 到 JSON 的序列化支持,使用 JSON.stringify()。原生的由 JSON 到 Object 的解析支持,使用 JSON.parse()Map没有元素的序列化和解析的支持。(但是你可以使用携带 replacer 参数的 JSON.stringify() 创建一个自己的对 Map 的序列化和解析支持。参见 Stack Overflow 上的提问:How do you JSON.stringify an ES6 Map?
const myMap = new Map();

const keyString = 'a string';
const keyObj = {};
const keyFunc = function() {};

// 添加键
myMap.set(keyString, "和键'a string'关联的值");
myMap.set(keyObj, "和键 keyObj 关联的值");
myMap.set(keyFunc, "和键 keyFunc 关联的值");

console.log(myMap.size); // 3

// 读取值
console.log(myMap.get(keyString)); // "和键'a string'关联的值"
console.log(myMap.get(keyObj)); // "和键 keyObj 关联的值"
console.log(myMap.get(keyFunc)); // "和键 keyFunc 关联的值"

console.log(myMap.get('a string')); // "和键'a string'关联的值",因为 keyString === 'a string'
console.log(myMap.get({})); // undefined,因为 keyObj !== {}
console.log(myMap.get(function() {})); // undefined,因为 keyFunc !== function () {}

// 替代Object类数组的结构,让Object回归对象,用于描述更具体的事物
const components = {componentA:'componentA',componentB:'componentB'}

const componentMap = new Map([['componentA','componentA'],['componentB':'componentB']])

  1. 在vue3中的应用

在之前的响应系统中,收集了对象属性的副作用函数列表,为了和改变的属性对应起来,而不是对象的任何一个属性改变都执行所有的副作用函数,使用了Map数据结构,针对每个对象属性设置唯一的key,每个key对应一个Set结构的副作用函数集合。

image.png 这里的target是指响应式数据对象。

什么是WeakMap
  • WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。
  • 其键必须是对象,而值可以是任意的。
  • WeakMap 的 key 是不可枚举的
使用场景
  1. 额外数据的存储,可以存储任意数据

假如我们正在处理一个“属于”另一个代码的一个对象,也可能是第三方库,并想存储一些与之相关的数据,那么这些数据就应该与这个对象共存亡 —— 这时候 WeakMap 正是我们所需要的利器。例如实例的私有属性、与存在用户相关的数据。

我们将这些数据放到 WeakMap 中,并使用该对象作为这些数据的键,那么当该对象被垃圾回收机制回收后,这些数据也会被自动清除。

// john是一个对象
let john = { name: "John" };
let weakMap = new WeakMap();
weakMap.set(john,"secret documents");
// 如果 john 消失,secret documents 将会被自动清除
  1. 在vue3中的应用

在vue3响应系统中,使用WeakMap存储所有需要响应的对象target,这样当target没有任何引用了,说明用户侧不再需要它了,存储的与target相关依赖集合也就跟着一起释放了,不需要额外处理,防止内存溢出。 image.png 以下是测试MapWeakMap内存使用的代码

// map.js
// 使用 node --expose-gc map.js 执行,--expose-gc允许手动执行垃圾回收
// 计算内存使用大小
function usageSize () {
  // 获取 Node.js 进程的内存使用情况(以字节为单位)
  const used = process.memoryUsage().heapUsed;
  return Math.round((used / 1024 / 1024) * 100) / 100 + "M";
}
// 手动执行垃圾回收 garbage collector
global.gc();
console.log(usageSize()); // ≈ 1.85M

let arr = new Array(10 * 1024 * 1024);
const map = new Map();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 83.19M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 83.2M
// weakmap.js
// 使用 node --expose-gc weakmap.js 执行,--expose-gc允许手动执行垃圾回收
function usageSize () {
  const used = process.memoryUsage().heapUsed;
  return Math.round((used / 1024 / 1024) * 100) / 100 + "M";
}

global.gc();
console.log(usageSize()); // ≈ 3.19M

let arr = new Array(10 * 1024 * 1024);
const map = new WeakMap();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 83.2M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 3.2M

参考:WeakMap and WeakSet(弱映射和弱集合)

Proxy

什么是Proxy
  • Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
使用场景
  1. 数据绑定与观察者模式

实现数据绑定与观察者模式,我们也可以使用 ES5 新增的Object.defineProperty()方法,Vue2 就是使用该技术实现的,而 Vue3 为什么换用 Proxy 需要来实现呢? 所以现在我们先来对比一下这两种实现方法。

Object.defineProperty

优点:操作原数据

缺点:监听的范围有限,不能检测数组和对象的变化,具体表现为

  • 对于对象:Vue 无法检测 property 的添加或移除
  • 对于数组:无法监听以下改变
  1. 当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
  2. 当你修改数组的长度时,例如:vm.items.length = newLength
// 对象
var o = {
  a: 7,
  get b() {
    return this.a + 1;
  },
  set c(x) {
    this.a = x / 2
  }
};

console.log(o.a); // 7
console.log(o.b); // 8
o.c = 50;
console.log(o.a); // 25

Proxy:

优点:可拦截对象的基本语义行为,包括对象的 property 的添加或移除,数组通过索引设置或修改数组长度等。

缺点:需要进行对象代理,操作新对象。

const handler = {
    get: function(obj, prop) {
        return prop in obj ? obj[prop] : 37;
    }
    // 还可代理set,has,delete等一系列对象属性读取或设置的捕捉器
    ...
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b);      // 1, undefined
console.log('c' in p, p.c); // false, 37

  1. 在vue3的应用

在vue3响应系统中,需要监听数据的变化,当读取操作发生时,将副作用函数收集到“桶”中,当设置操作发生时,从“桶”中取出副作用函数并执行。在vue3中使用Proxy代理进行响应式的数据,规避了Object.defineProperty某些情况无法监听的缺陷。

Reflect

什么是Reflect

Reflect  是一个全局对象,其下有许多方法,任何在 Proxy 的拦截器中能够找到的方法,都能够在 Reflect 中找到同名函数。

存在的意义:

  • 可以知道执行结果
  • 不会因为报错而中断正常的代码逻辑执行。
  • Proxy get/set()方法需要的返回值正是Reflect的get/set方法的返回值,并且Reflect可以设置第三个参数receiver,指定调用方,具有不可替代性,可以天然配合使用,比直接对象赋值/获取值要更方便和准确。
使用场景
  1. 指定调用方
const obj={foo:1}
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 输出的是 2 而不是 1
  1. vue3中的应用
// 改造前,用来监听数据获取和设置
 const obj = { 
         foo: 1, 
         get bar(){
             return this.foo
         },
         set bar(value){
             return this.foo = value
         }
     }
 const p = new Proxy(obj, {
     get(target, key) {
         track(target, key)
         // 注意,这里我们没有使用 Reflect.get 完成读取
         return target[key]
     },
     set(target, key, newVal) {
         // 这里同样没有使用 Reflect.set 完成设置
         target[key] = newVal
         trigger(target, key)
     }
 })

在直接读取和设置foo属性时,是没什么问题的,但是在使用访问器属性getter与setter的情况下,结果可能不符合预期,例如访问bar属性时,getter访问器读取的是foo的值,那么在使用的时候,foo改变的情况下,读取bar属性的副作用函数也应该一起触发。实际情况却是foo改变时,读取bar的副作用函数未触发。

    function effect(){
        console.log(proxy.bar)
    }

    p.foo++

这是因为在proxy代理get时,返回的是target[key],target指向obj原始对象,所以等价于读取obj.bar,obj不是proxy代理对象,所以副作用函数没有被收集,也就触发不了。

    function effect(){
        //等价于
        console.log(obj.bar)
    }
    
    p.foo++

因此,需要将this指向proxy代理对象,实现依赖收集。

//改造后
const obj = { 
            foo: 1,
            get bar(){
                return this.foo
            },
            set bar(value){
                return this.foo = value
            } 
        }
const p = new Proxy(obj, {
    get(target, key, receiver) {
        track(target, key)
        // 使用 Reflect.get 返回读取到的属性值
        console.log('target',target)
        return Reflect.get(target, key, receiver)
    },
    set(target, key, newVal, receiver) {
        // 使用 Reflect.set 完成设置
        Reflect.set(target, key, newVal, receiver)
        trigger(target, key)
    }
})