22.JS高级-ES6之Symbol类型与Set、Map数据结构

该系列文章连载于公众号coderwhy和掘金XiaoYu2002中

  • 对该系列知识感兴趣和想要一起交流的可以添加wx:XiaoYu2002-AI,拉你进群参与共学计划,一起成长进步
  • 课程对照进度:JavaScript高级系列111-116集(coderwhy)
  • 后续JavaScript高级知识技术会持续更新,如果喜欢我们的文章,欢迎关注、点赞、转发、评论,大家的支持是我们最大的动力

脉络探索

  • 在JavaScript的无尽探索之旅中,ES6(也称为ES2015)无疑是一座充满宝藏的岛屿。它带来了一系列令人兴奋的新特性,这些特性不仅改变了我们编写代码的方式,还极大地提升了代码的性能和可读性。但在这个岛屿上,有哪些隐藏的宝石等待着我们去发现呢?

  • 你是否好奇为什么在对象属性名的战场上,Symbol能够独树一帜,提供独一无二的标识?或者你是否曾想过,除了数组,还有没有其他方式可以存储不重复的元素?如果你对这些问题感到好奇,那么你已经站在了探索的起点

  • 本文将带你深入ES6的世界,探索Symbol的神秘力量,揭开Set和Map数据结构的面纱,以及窥探WeakSet和WeakMap背后的弱引用之谜。我们将一起发现,这些特性是如何在JavaScript的生态系统中,为我们的代码带来革命性的变化

一、Symbol基础使用

  • Symbol是什么呢?Symbol是ES6中新增的一个基本数据类型(原始数据类型),翻译为符号
    • 它用于创建一个独一无二的标识符
    • 主要用途是作为对象属性的键,而这些属性是独一无二的,可以防止属性名的冲突
  • 独一无二就是该基础数据类型最核心的要素(也是仅有的目的),为什么会需要该要素就是为什么需要Symbol的本质问题
    • 在ES6之前,对象的属性名都是字符串形式,那么很容易造成属性名的冲突
    • 如原来有一个对象,我们希望在其中添加一个新的属性和值 ,但是我们在不确定它原来内部有什么内容的情况下,很容易造成冲突,从而覆盖掉它内部的某个属性
    • 比如我们前面在讲apply、call、bind实现时,我们有给其中添加一个fn属性,那么如果它内部原来已经有了fn属性了呢?
    • 比如开发中我们使用混入或者展开语法结合,那么这个过程如果出现了同名的属性,必然有一个会被覆盖掉
js 复制代码
const obj1 = { foo: 'bar', x: 42 };
const obj2 = { foo: 'baz', y: 13 };
const mergedObj = { ...obj1, ...obj2 };//foo同名,后者覆盖前者
  • Symbol就是为了解决上面的问题,用来生成一个独一无二的值
    • Symbol值是通过Symbol函数 来生成的,生成后可以作为属性名
    • 也就是在ES6中,对象的属性名可以使用字符串 ,也可以使用Symbol值,Symbol不与字符串划等号
  • Symbol即使多次创建值,它们也是不同的:Symbol函数执行后每次创建出来的值都是独一无二的
  • 我们也可以在创建Symbol值的时候传入一个描述description :在MDN中,对其释义是可用于调试但不是访问 symbol 本身
js 复制代码
let sym1 = Symbol('description');//括号内是对应的描述标记
let sym2 = Symbol('description');

console.log(sym1 === sym2); // 输出:false
console.log(sym1 == sym2);//false
console.log(typeof sym1);   // 输出:'symbol'
  • 不管是相等(==)还是严格相等(===),得出来的结果都是false
    • 哪怕在控制台输出结果,两者答案都是Symbol(description),也依旧不相等
    • 因为首先Symbol括号内的内容是仅作为描述,在作用上类似于代码中的注释,实际影响接近于0,所以独立性并不依赖于描述
  • 从内存分配角度来说,每个 Symbol 值都指向一个独立的内存地址,这意味着即使两个 Symbol 看起来相似(比如都没有描述),它们也是完全不同的,这是比较容易发现的一点
    • 在该基础上,Symbol本身都具备一个唯一标识,每次调用 Symbol() 时,由 JavaScript 引擎生成。这个标识是隐蔽的,开发者无法访问
    • 虽然标识无法访问,但描述是可以的,通过对应的实例属性进行获取
    • 这个实例属性是在ES10加入的(也是Symbol唯一的实例属性),在此之前,虽然 Symbol 初始化时可以提供描述字符串,但没有直接的标准方法来访问这个描述,需要借助其他手段(如转换为字符串等)间接地查看这些描述
js 复制代码
//获取对应的描述
let sym1 = Symbol('小余');//括号内是对应的描述标记
console.log(sym1.description);//小余
  • 在MDN的描述中,就记录了对应的历程,在description实例属性出来之前,会采用例如Symbol.prototype.toString()实例方法间接处理
    • 在这里同时需要注意一点,Object原型链上是默认存在toString方法的,而Symbol对象拥有自己的 toString 方法,因而遮蔽了原型链上Object.prototype.toString方法,依旧是一个优先度的问题,优先考虑自身的情况,在去考虑通用的内容

图22-1 MDN文档对Symbol的描述

1.1 Symbol值作为key

  • 对象的key也被称为属性名,只是称呼上的不同,但意思相同,在ES6之前,key只有一种情况,那就是字符串,在ES6之后,多出了Symbol这一选项
    • 直到目前为止,key都只有字符串与Symbol两种选择
    • 在key中使用Symbol,需要使用动态属性名写法[]进行扩起来,因为默认写法只有一种情况就是字符串
    • 而方括号允许使用任何表达式作为键名,包括了 Symbol,从而确保 Symbol 值被正确解析和访问,并且Symbol不会被动态属性名转化为字符串
js 复制代码
const s1 = Symbol()
const s2 = Symbol()
//基础写法
const obj = {
  name:"xiaoyu",
  age:20,
  [s1]:"aaa",
  [s2]:"bbb"
}
  • 而在原有对象基础上去新增内容,要如何做到与Symbol相结合?这需要使用到方括号
    • 正常的新增属性中,我们使用.操作符访问属性,这种情况下,属性名是硬编码且不变的,适合属性名已知的情况
    • 而使用方括号 [] 访问属性允许动态确定属性名,属性名可以是变量,也可以是任意表达式的结果
  • 所以两者严格意义上来说,是静态动态 的区别,Symbol恰好是动态的情况之一
    • 每个通过 Symbol() 函数创建的标识符都是全局唯一的。这种唯一性让 Symbol 无法被预先知道,因此不能像普通字符串键那样静态地通过点运算符来访问
    • 在这里可以注意到我的措辞是'访问',而非'创建'。这是因为该方式本质上就是一种访问行为,只是当没有访问到对应属性名,且该行为是赋值情况时,会进行默认的创建属性名操作,这种做法与变量赋值时,不存在对应变量会进行隐式声明很像
    • 不管是.操作符还是动态访问[],都具备极高的运算优先度,在进行运行时,优先处理
js 复制代码
const s3 = Symbol()
//正常写法
obj.xxx = 'ccc'
//与Symbol相结合
obj[s3] = 'ccc'
  • 对象的新增内容,除了基础的新增之外,还可以通过defineProperty方式进一步的精度细调对应的属性描述符,这也是一种好的方式,不过一般情况下很少做到这个程度
js 复制代码
const s4 = Symbol()
Object.defineProperty(obj,s4,{
  //属性描述付
  value:'dddd',
  enumerable:true
  //... 属性描述符
})
  • 同时需要注意,使用Symbol作为key的属性名的话,在遍历/Object.keys等情况中,是获取不到这些Symbol值的
    • 使用getOwnPropertyNames方法只能获取字符串形式的属性名
    • 对于Symbol类型的属性名,有相似的方法getOwnPropertySymbols去进行获取
js 复制代码
console.log(Object.keys(obj));//获取不到['name', 'age']
console.log(Object.getOwnPropertyNames(obj));//[ 'name', 'age' ]
console.log(Object.getOwnPropertySymbols(obj));//[Symbol(), Symbol()]
  • 获取对应的属性名的情况,我们大多数是以此为跳板去获取值
    • getOwnPropertySymbols方法,获取的是一个数组形式的Symbol属性名
    • 因此我们可以对Symbol数组进行循环,获取每一个详细的key,然后从对象中取出对应的值出来,再去进行一个详细处理
js 复制代码
const keys = Object.getOwnPropertySymbols(obj)
for(const key of keys){
  console.log(obj[key]);
}

1.2 Symbol方法

  • Symbol的方法分为实例方法和静态方法
    • 实例方法:toString、valueOf与**[Symbol.toPrimitive]()** 方法,前两个大家比较熟悉,而后面这个则用于将Symbol对象转为symbol值
js 复制代码
const sym = Symbol("example");
sym === sym[Symbol.toPrimitive](); // true
  • 我们主要讲静态方法,主要分为Symbol.forSymbol.keyFor两个方法
    • 对应的语法是Symbol.for(key)Symbol.keyFor(sym)
    • 需要填入的参数分别是什么意思?
  • 想要确立这个问题,我们就需要说明一个知识点前置,那就是什么是全局符号注册表,在MDN中,称呼为Symbol注册表,for通常代表循环遍历的含义,在这里遍历内容指的就是遍历该注册表,从而进行查询对应内容
    • 而for方法和keyfor方法,所不同的区别在于前者是查询Symbol本身,后者查询的是Symbol的注释,也就是description内容
    • 因此这个Symbol注册表也被分为两个部分,分别存储两种字段名以及所对应的内容,如表22-1

表22-1 Symbol注册表的两部分

字段名 字段值
[[key]] 一个字符串,用来标识每个 symbol
[[symbol]] 存储的 symbol 值
  • 两个方法都是以Symbol注册表为根基的,从而造成该方式与普通Symbol是不同用法的形式
    • 在正常的使用中,我们需要先填入内容到该表中,才能进行查询获取
    • 比如我们使用keyFor方法,就是获取对应的描述(但在这里准确的说法是获取对应的key,称为描述只是为了更方便理解),且这个表不需要我们手动管理,而是由JS引擎进行自动维护
    • 因此在有值的情况下返回值,没值的情况下,默认由JS引擎赋予默认值undefined
js 复制代码
// 创建一个全局 Symbol
var globalSym = Symbol.for("foo");
Symbol.keyFor(globalSym); // "foo"

var localSym = Symbol();
Symbol.keyFor(localSym); // undefined,localSym是普通使用Symobl创建,而非放入Symbol注册表中
  • 在这里,for方法将Symbol本身以及对应的描述字符串存入Symbol注册表中,所以此时的Symbol.keyFor方法就可以从注册表中获取到对应的描述字符串
    • 这里有一点需要注意,准确的说,不管是for方法还是keyFor方法,都是查询的方法
    • 而我用词在这里为存入,这跟对应的规则有关系,在发送存入关系之前,会进行查找,如果没有查找到才会进行存入,如果有查找到则进行返回
    • 这种做法,其实和隐式声明赋值变量很像,直接对一个不存在的变量进行赋值,JS引擎会先查找该变量是否存在,如果不存在则隐式创建该变量并存如对应的值,隐式创建的变量都是全局的,因此我认为for方法跟这种方式很相似
js 复制代码
//正常声明赋值
let age
age = 18
//隐式声明赋值变量
age2 = 35
  • 因此,我们在MDN文档中看对应的描述就很清晰了:Symbol.for(key) 方法会根据给定的键 key,来从运行时的 symbol 注册表中找到对应的 symbol,如果找到了,则返回它,否则,新建一个与该键关联的 symbol,并放入全局 symbol 注册表中
    • 其中的返回由给定的 key 找到的 symbol,否则就是返回新创建的 symbol
    • 而我们知道,两个新创建的Symbol哪怕所有内容都是一样的,也是独一无二,两不相同的
    • 因此,我们可以利用该特性进行一个严格相等判断,去验证我们的看法
js 复制代码
Symbol.for("foo"); // 创建一个 symbol 并放入 symbol 注册表中,键为 "foo"
Symbol.for("foo"); // 从 symbol 注册表中读取键为"foo"的 symbol

Symbol.for("bar") === Symbol.for("bar"); // true,证明了上面说的
  • for方法和keyFor方法的出现,主要是为了解决全局状态管理的问题,在复杂的应用或多个合作组件间需要共享某些全局状态或标识时,能够得以实现
    • 这个概念在后续学习Vue的Pinia或者React的Redux时,会有更深刻的体会
js 复制代码
// 在模块 A 中
const symA = Symbol.for('app.symbol');

// 在模块 B 中
const symB = Symbol.for('app.symbol');

console.log(symA === symB); // true,即使在不同模块,得到的是相同的 Symbol 实例

// 获取该 Symbol 的全局键名
const key = Symbol.keyFor(symA);
console.log(key); // 'app.symbol',允许从 Symbol 实例反向获取键名
  • Symbol对应的学习就到一段落了,但这其实并不意味着Symbol内容的结束,我们主要说明了Symbol两种不同的使用方式,在此之外,还有13个静态属性,这些方面就不进行展开
    • 主要有几个原因,首先这些方法有些涉及到可迭代概念,有的涉及到异步概念,有的涉及到正则表达式概念
    • 而这些内容都是我们还未学习的,可以等到后面掌握了之后,再回过头来进行回顾

二、数据结构Set

2.1 Set的基本使用

  • 在ES6之前,我们存储数据的结构主要有两种:数组、对象
    • 数据结构可以简单理解为存放数据的一种方式,由数据结构能够延伸出各种算法,但这点不在我们的讨论范围内
    • JS对象的底层是一个HashMap,因此我们也能够将其看作是一个数据结构
    • 在ES6中新增了另外两种数据结构:Set、Map,以及它们的另外形式WeakSet、WeakMap
  • Set是一个新增的数据结构,可以用来保存数据,类似于数组,但是和数组的区别是元素不能重复
    • Set 对象允许我们存储任何类型(无论是原始值还是对象引用)的唯一值,也就是我们说的不能重复
    • 创建Set我们需要通过Set构造函数(暂时没有字面量创建的方式)
  • 我们可以发现Set中存放的元素是不会重复 的,因此Set有一个非常常用的功能就是给数组去重
js 复制代码
// 应用场景:数组的去重
const numbers = [2, 3, 4, 4, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 5, 32, 3, 4, 5];

// 以前的去重写法
const uniqueNumbers = [];
for (const number of numbers) {
  if (!uniqueNumbers.includes(number)) {
    uniqueNumbers.push(number);
  }
}
console.log(uniqueNumbers); // [2, 3, 4, 5, 6, 7, 32]

// 现在的去重写法
// 使用 Set 来去除重复元素,得到的是 Set 类型的结果
const numberSet = new Set(numbers); // Set(7) { 2, 3, 4, 5, 6, 7, 32 }
console.log(numberSet);

// 将 Set 转化为数组类型
const uniqueNumbers1 = Array.from(numberSet);
console.log(uniqueNumbers1); // [2, 3, 4, 5, 6, 7, 32]

// 另一种将 Set 转换为数组的方法
const uniqueNumbers2 = [...numberSet];
console.log(uniqueNumbers2); // [2, 3, 4, 5, 6, 7, 32]
  • 这里可以看到,Set的使用方式是通过new的,意味着这是一个特殊对象,我们可以称为Set对象

    • 我们使用了展开语法,这是因为Set本身是类似数组,但不是数组的特殊对象
    • 这意味着他们的结构上,虽然比较相似,但也有一定的差异化,所以需要进行一定的转化,使用展开语法会比Array.from方法更简洁明了一些,但作用是一样的
  • 我们刚才使用Set是直接往内部添加了一个初始值数组

    • 但我们不可能永远只用这些内容,一个合格的数据结构,必不可免的会涉及到对数据的操作,比如经典的增删改查
    • 使用Set身上的add实例方法可以做到添加数据,在这里简单数据不能重复是比较好理解的
  • 但复杂数据,例如对象,要如何判别这不是同一个重复对象呢?如果两个一样的空对象,要不要进行去重?

    • 这就需要回顾我们曾经学习对象的概念,对象作为复杂数据结构,本身在栈空间是以内存地址的形式存在,两个空对象从内容表达形式来说是一样的,但指向的是两块不同的内存区域
    • 所以如果使用add方法添加两个空对象,Set是不会进行去重的,因为从内存角度来说,他们是不一样的,但要是添加两次一样的对象(例如下方的obj对象),就只有第一次会生效了,第二次添加就会被去重掉了
js 复制代码
//创建set
const set = new Set()
console.log(set);
//往set中添加内容
set.add(10)
set.add(20)
set.add(30)
set.add(30)//会自动去重,30只会保留一个
const info = {name:"小余"}
const obj = {name:"coderwhy"}
set.add(info)//可以添加多个对象,但是不能是同一个对象
set.add(obj)
set.add(obj)//添加obj对象只会生效一次
set.add({})//添加成功
set.add({})//添加成功
console.log(set);//Set(7) { 10, 20, 30, { name: '小余' }, { name: 'coderwhy' }, {}, {} }

2.2 Set的常见方法

  • Set身上只有一个实例属性Size,从释义角度来说是大小
    • 在这里指该集合中(唯一的)元素的个数,和数组的length很像
  • 而常见的方法,指的就是对数据操作最频繁的那些方法,分别有以下几个:
    • add增、delete删、has查、clear清除所有
    • 在这里可以看到好像没有改这个方式,但这个其实是可以依靠forEach方法实现对应操作

表22-2 数据结构Set的常见方法

方法 描述 返回值
add(value) 向 Set 添加一个新元素 Set 对象本身,允许链式调用
delete(value) 从 Set 中删除指定的元素 Boolean,删除成功返回 true,否则返回 false
has(value) 检查 Set 是否包含指定的元素 Boolean,如果存在则返回 true,否则返回 false
clear() 移除 Set 中的所有元素 无返回值
forEach(callback, [, thisArg]) 遍历 Set 的每个元素并执行回调函数 无返回值,回调函数接收三个参数:元素值、元素键(与值相同)、目标Set对象。thisArg 可选,执行回调时使用的 this
js 复制代码
const numbers = [2, 3, 4, 4, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 5, 32, 3, 4, 5];
const set = new Set(numbers)
//常见属性:获取数组大小(个数)
console.log(set.size);//7
//常见方法:增、删、判断存在(查)、清空、遍历这5种常见功能
//增
set.add("666")
console.log(set);//Set(8) { 2, 3, 4, 5, 6, 7, 32, '666' }
//删
set.delete(2)
console.log(set);//Set(7) { 3, 4, 5, 6, 7, 32, '666' }
//判断存在
console.log(set.has("666"));//true
//forEach遍历
set.forEach(item => console.log(item))//3 4 5 6 7 32 '666'
//清空
set.clear()
console.log(set);//Set(0) {size: 0}
  • 那对于遍历来说,就能够实现我们的改动操作(删除和新增的结合),可以操作的空间自由度就很大,但一般情况下,我们很少有动机去进行类似的改动操作
js 复制代码
if (item === '666') {
    mySet.delete(item); // 删除元素'666'
    mySet.add('小余');  // 添加新元素'小余'
  }
  • 整体来看,Set的基础方法使用,较为简单,也好理解
    • Set的出现是JS在需求和性能上的追求体现,在MDN中有这么描述:规范要求集合的实现是"对合集中的元素的平均访问时间与集合中元素的数量呈次线性关系"。因此,它可以在内部表示为哈希表(查找的时间复杂度为 O(1))、搜索树(查找的时间复杂度为 O(log(N)))或任何其他的时间复杂度低于 O(N) 的数据结构
    • 换句话说,正常情况下检查一个元素是否存在于一个大数组中通常需要遍历整个数组(线性),这是一个时间复杂度为O(n)的操作。而Set内部结构(通常实现为哈希表)做到查找操作的平均时间复杂度接近O(1),从而在频繁检查元素存在性的场景下性能大大提高
  • 从使用角度来说,Set做到了更加简洁高效,但对数据的处理会是优先度更高的事情,因为数据操作的效率直接影响到应用的响应速度和处理能力,而这也是引入新的数据类型的主要原因

三、WeakSet的使用

  • 和Set类似的另外一个数据结构称之为WeakSet,也是内部元素不能重复的数据结构
  • 那么和Set有什么区别呢?
    • 区别一:WeakSet中只能存放对象类型,不能存放基本数据类型
    • 区别二:WeakSet对元素的引用是弱引用 (WeakSet的前缀Weak由来),弱引用 指的是对对象的引用不足以阻止该对象被垃圾回收机制回收。在WeakSet中,对象的存在不会影响到它们的生命周期;如果没有其他引用存在,这些对象仍然可以被GC回收
  • 用我们之前所掌握的说法,可以这样形容:WeakSet 中的值一定是可被垃圾回收的值
  • 而WeakSet的创建方式和Set是一样的,使用new创建
js 复制代码
const weakSet = new WeakSet()

3.1 WeakSet常见方法

  • 和Set的十六个实例方法相比,WeakSet只有三个实例方法,分别是add、delete、has这三个方法,逻辑作用和Set一致,在对内容的去重角度也和Set保持一致,在这个方面的话,我们不过多赘述
    • add(value):添加某个元素,返回WeakSet对象本身
    • delete(value):从WeakSet中删除和这个值相等的元素,返回boolean类型
    • has(value):判断WeakSet中是否存在某个元素,返回boolean类型
js 复制代码
const ws = new WeakSet();
const foo = {};
const bar = {};
//add 添加方法
ws.add(foo);
ws.add(bar);
// has判断方法
ws.has(foo); // true
ws.has(bar); // true
// delete 删除方法
ws.delete(foo); // 从 set 中删除 foo 对象
ws.has(foo); // false,foo 对象已经被删除了
ws.has(bar); // true,bar 依然存在
  • 当然在WeakSet中,我们只能存放对象类型,如果存放数字(对象类型以外的其他类型),就会报错
    • 在编辑器中,也会在使用WeakSet时,给出对应的语法提示:add(value: object): WeakSet<object>
js 复制代码
const ws = new WeakSet();
ws.add(10)//TypeError: Invalid value used in weak set
  • WeakSet只能包含对象的原因与其设计为存储弱引用的特性有密切联系,在JS中,垃圾回收机制负责自动释放不再需要的内存,这通常是通过跟踪对象的引用数来实现的,在之前有说明过这一点

  • 在该基础上,我们就能够进一步说明弱引用与对象的关系

    • 原始数据类型(如数值、字符串、布尔值)直接存储在栈上,或者作为不可变值直接管理。它们的生命周期与垃圾回收的概念不同,因为它们通常在定义时就已确定
    • 对象,包括数组和函数,是引用类型,存储在堆上。它们的引用是可以变的,且生命周期受垃圾回收的影响,这从而让它们天然适合用弱引用来管理
  • 在这种基础上,如果WeakSet允许包含原始数据类型,那么其行为将与常规的Set没有区别,因为原始类型不涉及堆内存管理和垃圾回收,这样就会失去WeakSet的主要功能和优势------即不影响其成员的生命周期

    • 因此WeakSet的主要用途是存储对象的集合,而不影响对象的生命周期。这对于管理那些可能会自由进入和离开内存的对象很有帮助,例如缓存或其他临时数据
    • 在技术实现层面,WeakSet内部可能使用了哈希表来存储对象的弱引用。这种实现允许即时删除那些已经被垃圾回收的对象,确保WeakSet不会保留无用的引用
  • 由于WeakSet中的数据项是不可枚举的,这意味JS引擎无需跟踪具体的元素,只需要关心那些仍然活跃的对象,从而实现自动清理的同时还不影响程序的内存管理效率

js 复制代码
//WeakSet的用法
//和set的区别1:只能存放对象类型
const weakSet = new WeakSet()
// weakSet.add(123)//无效会报错,
const obj = {
  name:"小余"
}
weakSet.add(obj)

//和set的区别2:对对象的引用都是弱引用,GC要进行回收的时候,就会当弱引用的指向不存在

3.2 弱引用与强引用

  • 在上面的学习中,我们对WeakSet有一个较为明确的理解
    • 可以看出,想要理解WeakSet的使用,最直接的方式就是掌握弱引用意味什么?对于这点,我们有简要说明,但对于掌握该要素还不足够
    • 首先既然有弱引用,就必然有与之相对的强引用,我们要通过弱引用的对立面来辅助理解
  • 强引用被称为Strong Reference,弱引用被称为Weak Reference,在日常使用中,正常的引用就属于强引用
    • 我们可以更精细化的去描述,强引用是指一个对象被另一个对象或变量直接引用,只要这种引用关系存在,垃圾回收器就不会回收被引用的对象。这意味对象会持续保留在内存中,直到所有引用它的变量都不再指向它
    • 变量、对象属性、数组元素等几乎所有的日常使用都是通过强引用进行的,并且需要牢记什么是引用类型
  • 而主要的强引用使用常见有如下几种:
    1. 变量引用:在函数或全局作用域中定义的变量
    2. 属性引用:对象属性指向另一个对象,形成了一个引用关系
    3. 数组元素:数组中的元素可以是对象,数组对这些对象的引用也是强引用
    4. 函数闭包:函数可以通过闭包引用外部作用域中的变量或对象,这些也都是强引用

图22-2 强引用说明

  • 在上图中就是一个非常常见的强引用关系,其实在我们不特地去追求的情况下,日常使用皆为强引用,而这些强引用即是垃圾回收的评判指标
    • 与之相反,弱引用的指向,则会被垃圾回收所忽略
    • 在这种情况下,我们如果有一个内容指向于0x200内存地址的弱引用,那么该弱引用并不能影响垃圾回收,也就是在我们使用的过程中,如果没有其他强引用继续指向于0x200,则该内存地址会被强行回收,相当于弱引用使用的数据已经"过期了",这也是弱引用的使用常见大多数为临时数据
    • 但也可以换一种角度思考,我们这个弱引用必须建立在有其他地方依赖使用下,那这种情况也可以进行使用

图22-3 弱引用以及引用不存在说明

js 复制代码
let set = new Set()
let weakSet = new WeakSet()
let obj = {
  name:"小余"
}
// 建立强引用
set.add(obj)
// 建立弱引用
weakSet.add(obj)
  • 在JS当中,日常默认的引用都为强引用是有对应原因的,首先垃圾回收机制依赖于强引用,从而实现简化内存管理,我们不需要(也不能)手动管理内存,提供了一个自动的、可预测的方式来确保数据的生命周期与程序的需求相匹配
    • 且减少意外数据丢失,通过强引用,只要还有引用指向一个对象,该对象就不会被垃圾回收,从而防止了数据在还需要时被意外回收的问题,但弱引用的应用场景就没有这个需求
    • JS引擎中,弱引用通常通过某种形式的哈希表实现。每个对象都由一个或多个散列桶来持有,而这些散列桶本身并不足以保证对象的持久性

3.3 WeakSet的应用

  • 需要注意的是WeakSet不能遍历
    • 因为WeakSet只是对对象的弱引用,如果我们遍历获取到其中的元素,那么有可能造成对象不能正常的销毁
    • JS引擎不会阻止这些WeakSet中的对象在程序运行期间被回收。如果WeakSet被允许遍历,那么在遍历过程中,集合内部的元素可能会突然消失(被垃圾回收),这将导致遍历过程中出现错误或不一致的行为
    • WeakSet可遍历同时会导致它的存在会间接地影响对象的生命周期,这与弱引用的设计初衷相违背。遍历可能意味着在某种程度上"触碰"到这些对象,而这种触碰本身可能会成为阻止垃圾回收的临时强引用
    • 且从逻辑的角度去思考,遍历的前提是能够有内容能够进行遍历,但对于一个弱引用数据(临时数据)来说,我们无法保证在某一时刻具体持有哪些对象
  • 从使用角度来说,遍历的目的是对遍历数据进行二次的操作,对于不稳定的数据来说,这是不切实际的做法。很像是对已经确定会离开的人员下本金去投资,注定会是一场空的
    • 从这个角度去看待,存储到WeakSet中的对象也是没办法获取的,我们没办法从不确定的数据中取出确定的内容,因为这个数据随时可能会被垃圾回收,而从已经被回收的数据中获取确定的内容很可能会是undefined.xxx,这是会报错的
  • 那么这个东西有什么用呢?
    • WeakSet的应用场景是很少的,事实上这个问题并不好回答,我们来使用一个Stack Overflow上的答案
    • 在使用类方法时,我们常使用的是第一种传统调用方法,但调用的方式有很多种,像第二第三种方式的调用看似差不多,但其中的this已经发生了改变
js 复制代码
class Person{
  running(){
    console.log("欢迎学习JS高级系列知识")
  }
}
//第一种传统调用方法
const p1 = new Person()
p1.running()//欢迎学习JS高级系列知识

//第二种调用方式,其实就只是将调用的过程变成了赋值,调用。本质没有任何区别
const runFn = p1.running
runFn()//欢迎学习JS高级系列知识

//在第二种的基础上继续的进阶用法
const obj = {run:runFn}
obj.run()//欢迎学习JS高级系列知识

//总结:但是我们通过类来写的话,我们更希望是使用对象来进行调用,也就是第一种传统的方式,而不是二三种的方式,那如何提示用户不要使用第二三种方式呢,这就用到我们所学的这些内容知识了
  • 我们如果对this有需求(权限判定的一种),不允许使用第二第三种方式调用,那此时就能够使用WeakSet来进行处理
    • 这种用法一般只会用在特别严谨的框架或者非常严谨的写法中,在JS这种动态类型,不追求强类型的语言来说,就很少会去这样使用
    • 我们每次调用类创建实例时,this都会因new调用而显示绑定,此时我们使用WeakSet在构造器中存储当前锚定的this,在调用Person类方法时,就可以使用WeakSet的has方法来进行一层判定,判定this是否和构造器中的this相同,防止调用时的篡改
    • 这种方式是十分有效的,因为在const p = new Person()阶段就已经锚定了对应的this,且new调用的this优先度本身就是最高的,不会受到其他因素的篡改影响,因此在后续调用方法时,可以有效的通过WeakSet判断,防止类似call的显示调用去影响this的改变(发现想要改变就不执行方法,而是直接返回错误提示)
js 复制代码
//改变添加的部分
const pWeakSet = new WeakSet()//使用WeakSet

class Person{
  constructor(){
    pWeakSet.add(this)//将调用者添加到WeakSet中
  }
  running(){
    if(!pWeakSet.has(this)){//进行判断WeakSet中是否有我们调用的这个结构
      console.log("Type error:你的类型错误");//抛出异常也行
      return
    }
    console.log("正常调用 this无误")
  }
}

const p = new Person()
p.running()//正常调用 this无误
p.running.call({name:"小余"})//Type error:你的类型错误
  • 那么为什么非要使用WeakSet来实现这种操作,如果只是单纯去存储正确的this,使用简单的变量去存储不行吗?不也能够做到这种效果?WeakSet在其中起到了什么作用?
    • 当使用简单的变量或常规的 Set 存储实例引用时,这些引用是强引用,意味只要这些引用存在,所引用的对象就不会被垃圾回收,即使已经不再需要也是如此
    • 我们之前有说过,这些方法在调用时会进行创建,当调用结束后就会进行销毁,并不是长期都需要这个this,而是在创建时需要进行判断。使用WeakSet来进行判断时,就很适合调用时跟随创建,当方法销毁后,不存在强引用之后,pWeakSet也会跟着被垃圾回收。弱引用是用在该地方,避免长期持有造成不必要的引用
    • 且如果使用全局变量或其他常规存储方式,可能会更容易在类的外部被访问或修改,从而破坏封装性,WeakSet由于其自身的特性(不支持遍历、不提供对存储元素的直接访问与修改等),在某种程度上隐藏了其内容,这增强了封装,防止外部代码错误或恶意操作实例

四、数据结构Map

  • 与Set所对应的,还有另外一个新增的数据结构Map,用于存储映射关系,也就是键值对
  • 但是我们可能会想,在之前我们可以使用对象来存储映射关系,他们有什么区别呢?
    • 事实上我们对象存储映射关系只能用字符串(以及ES6新增了Symbol)作为属性名(key),有严格的要求
js 复制代码
//正常情况
{key:value}//key:字符串/Symbol(两种数据类型) , value:任意类型
  • 某些情况下我们可能希望通过其他类型作为key,比如对象作为key,但这是不可以的,这个时候键值对会自动将对象转成字符串来作为key
    • 我们使用obj1与obj2作为info的两个key,再赋予对应的value,在这里我们发现了打印出来的结果只有后者一个key-value关系值,这是怎么回事?为什么只存在一个?另外一个去哪里了?
    • 首先对象作为key确实转为了字符串,返回的格式是 "[object Type]",其中 Type 是对象的类型,而打印出来的结果是[object Object]则是转为的字符串固定是object,表示结果来自一个对象的 toString() 方法,类型是Object,区别在于大O小o的区别。被转化的过程是通过toString()方法进行的
js 复制代码
//对象作为key会被转化为字符串
const obj1= {name:"小余"}
const obj2= {name:"coderwhy"}

const info = {
  [obj1]:'aaa',
  [obj2]:'bbb',
}

console.log(info);//{ '[object Object]': 'bbb' }
  • 因此我们能够确定,对象转为字符串,跟对象内部的数据没有关系,两个普通对象,转为字符串都为object,且类型都是Object,这是相同的字符串,所以它们实际上在作为键时引用的是同一个键,导致了后者覆盖前者
js 复制代码
const obj1= {name:"小余"}
const obj2= {name:"coderwhy"}
const obj1String = obj1.toString();
const obj2String = obj2.toString();

console.log(obj1String,obj2String);//[object Object] [object Object]
console.log(obj1String === obj2String);//true

表22-3 对象类型以及对应返回值说明

对象类型 返回值
普通对象 [object Object]
日期对象 [object Date]
数组 [object Array]

4.1 Map的基本使用

  • 而Map就是允许我们使用对象类型来作为key
    • Map使用set与get两个方法分别来进行设置值和获取值
    • set设置值所需参数就是键值对,也就是set(key, value),其中的key准确的说是可以使用任何JS类型(任何原始值或者任何类型的JS对象),我们使用对象类型只是为了方便举例
js 复制代码
const map = new Map()

const obj1= {name:"小余"}
const obj2= {name:"coderwhy"}

map.set(obj1,'aaa')
map.set(obj2,'bbb')
console.log(map);//Map(2) { { name: '小余' } => 'aaa', { name: 'coderwhy' } => 'bbb' }
  • 除了直接往Map中使用set添加内容之外,还可以为Map设置初始值,这和Set很像,但Map的初始值是有要求的
    • 每个内层数组必须恰好有两个元素,第一个元素作为键,第二个元素作为值。如果格式不正确,比如元素不是成对的,那么在运行时会抛出错误
js 复制代码
const map = Map([[key,value],[key2,value2],[key3,value3]])
  • 这会让我们联想到为什么需要这样的格式?Map为什么要这样进行设计?
    • 该初始化格式(类似于数组的键值对结构作为初始值),从创建和理解角度来说都是直观的
    • 而且存储的内容本身就是映射类型的结构,这种设计可以直接从一组键值对快速构建,提高了初始化的便利性和效率
  • 每个键值对数组直接转换为 Map 的一个条目(数组到Map),我们可以很方便地从其他数据结构(如二维数组或来自其他 Map 的条目)转换数据,这主要涉及到Map初始化的设计理念
    • 从二维数组到 Map :如果我们有一个二维数组,直接传递它给 Map 构造函数会非常高效。Map 内部实现可以直接遍历数组,将每对元素插入到 Map 中,而不需要额外的转换操作
    • 从另一个 Map 到新 Map :当我们将一个现有的 Map 传递给 Map 构造函数时,Map 会直接复制所有键值对。这种设计做到了让数据的复制变得非常高效,因为 Map 可以直接在底层结构上进行操作,而不需要额外的遍历或转换
  • 因此这种设计使 Map 能够灵活地适应不同的数据源和使用场景
js 复制代码
const originalMap = new Map([
  ['key1', 'value1'],
  ['key2', 'value2']
]);
//旧Map到新Map的转换
const newMap = new Map(originalMap);

4.2 Map的常见方法

  • Map的实例方法没有Set那么多,但也有10个左右,我们主要说明常见的部分,而在方法之外还有一个实例属性,与Set是相同的size(大小)属性,功能一致
    • Map、Set都是JS新增的数据结构,正如以前所说,编程的精髓在于数据的处理,而对数据结构来说,数据的处理更是核心,所以常见的核心方法一定是和数据处理有关
    • 在本章节中,Map所介绍的方法有以下几个:set、get、has、delete、clear、forEach,是不是很眼熟,这和Set所具备的常见方法很相似,重合度很高,这是因为他们的目的都是数据处理,所以在做法上具备共同之处,理解处理数据的常见方式,对于这类型的API就很容易得心应手,换个地方使用,最多也就换个名称但作用类似

表22-4 数据结构Map的常见方法

方法 描述 返回值
set(key, value) 在Map中添加key、value 返回整个Map对象
get(key) 根据key获取Map中的value 返回对应的value
has(key) 判断是否包括某一个key 返回Boolean类型
delete(key) 根据key删除一个键值对 返回Boolean类型
clear() 清空所有的元素 无返回值
forEach(callback[, thisArg]) 通过forEach遍历Map,执行callback 无返回值
  • 和Set最主要的区别在于获取和填入数据的不同,在Set中只有add添加方法与Map中的set所对应,而Set数据结构作为类数组类型,其实并不适合精准的获取数据(以索引为key,不具备实际含义),所以并没有像Map数据结构的get方法,这一点完全是由于内在结构所决定的
    • 其余的常见方法的使用都是相似的,容易上手,所以我们就放在一起说明一下
js 复制代码
  const info = {name:"小余"}
const info2 = {name:"why"}

//Map映射类型
const map = new Map()
//set方法,设置内容
map.set(info,"XiaoYu")
map.set(info2,"coderwhy")

//Map的常见属性
console.log(map.size);//2

//get方法,获取刚刚设置的内容
console.log(map.get(info));//XiaoYu

//forEach方法,遍历map中设置的内容
map.forEach((item,key) => {
    console.log(item,key,"forEach水印");//XiaoYu { name: '小余' } forEach水印 coderwhy { name: 'why' } forEach水印
});

//for of遍历迭代,使用这种方式可以拿到里面的具体内容,更加细化
//for([key,value] of map)
for(item of map){
  console.log(item,"for of水印");
  //遍历1:[ { name: '小余' }, 'XiaoYu' ] for of水印
  //遍历2:[ { name: 'why' }, 'coderwhy' ] for of水印
  [key,value] = item
  console.log(key,value);
  //遍历1:{ name: '小余' } XiaoYu
  //遍历2:{ name: 'why' } coderwhy
}

//delete方法,删除内容
map.delete(info2)
console.log(map);//Map(1) { { name: '小余' } => 'XiaoYu' },info2内容删除成功

//has判断
console.log(map.has(info2));//false,刚刚删除掉了,所以是false

//clear清空内容
map.clear()
console.log(map);//Map(0) {size: 0}
  • 同时可以看到Map也可以通过for of 进行遍历,这也是因为Map实现了 迭代器协议 ,从底层来看,这意味着Map 对象有一个 Symbol.iterator 属性,该属性是一个方法,返回一个迭代器对象。这个迭代器对象可以按照插入顺序一次返回 Map 中的键值对,从而实现遍历效果
    • 使用for of与forEach最关键的区别在于,for of更加细化(保持住遍历的key,value结构),是ES6后专门用来遍历所有实现了迭代器协议的数据结构的,而且在语法上支持解构,从而做到在循环中直接提取键和值变得非常简单和直观
    • 后续我们会专门学习迭代器相关的知识

图22-4 Map的可迭代协议说明

五、WeakMap的使用

  • Set与WeakSet对应,Map与WeakMap对应,在这其中自有共通之处,WeakMap也是以键值对的形式存在
  • 那么和Map有什么区别呢?
    • 区别一:WeakMap的key只能使用对象,不接受其他的类型作为key
    • 区别二:WeakMap的key对对象的引用是弱引用,如果没有其他引用引用这个对象,那么GC可以回收该对象,在这点上和WeakSet很相似,也是命名前缀同为Weak的原因
js 复制代码
const weakMap = new WeakMap()
//无效值用作弱映射键:Invalid value used as weak map key
weakMap.set(1,"abc")
weakMap.set("aaa","cba")
  • 为什么WeakMap的key(键)只能够是对象,这是基于什么原因去考虑的?
    • 想要思考这个问题,就需要清楚知道弱引用到底意味着说明,好在我们在学习WeakSet那里已经铺垫过了
    • 弱引用首先他是一个引用,而引用类型有Object 类型、Array 类型、Date 类型、RegExp 类型、Function 类型等,在这一层面上就先排除掉简单的值类型(原始类型),因为值类型不具备对象的"可回收性"特征,它们不是通过引用来管理, JS 的执行环境中通常被优化为直接存储,不适合作为弱引用的目标
    • 而WeakMap的弱引用和WeakSet相似,其设计目标是相契合的:允许其键所引用的对象在不再被其他部分引用时自然消亡,有时候也会被称为非持久化的引用
    • 这里的只能够是对象指的是对象类型,而对象类型几乎包括所有通过 new 关键字创建的类型,如 ObjectArrayFunctionDateRegExp 等,以及更特殊的构造函数创建的对象
  • 以上是从实现的角度去思考,而键必须是对象的规定不仅仅因为技术实现上的需要,也是实际需求的体现
    • 在实际应用中,使用对象作为键的场景更多是出于需要给对象附加额外信息或缓存某些与对象相关的数据,而这种需求与 WeakMap 的特性完美契合。例如,开发者可能希望为 DOM 元素附加数据,而不希望这些数据的存在阻碍 DOM 元素的回收
    • 在内存管理中,不仅符合 JS 引擎对内存管理的实现机制,也满足了开发实践中对关联元数据到对象而不引起额外内存泄漏风险的需求

5.1 WeakMap常见方法

  • WeakMap常见的方法是基于Map已有的部分继续简洁化
    • 主要为:set、get、has、delete这四个,使用方式相似,因此我们使用表格进行总结并简要使用概况就行,不进一步去说明
js 复制代码
// 创建一个 WeakMap 对象
const weakMap = new WeakMap();

// 创建一个对象用作键
const objKey = { name: 'coderwhy' };

// 使用 set 方法添加键值对
weakMap.set(objKey, '了解真相 才会获得真正的自由');
// 输出:WeakMap中设置了一个键值对

// 使用 get 方法获取键的值
console.log(weakMap.get(objKey));  // 输出:了解真相 才会获得真正的自由
// 输出:根据对象键获取的值

// 使用 has 方法检查键是否存在
console.log(weakMap.has(objKey));  // 输出:true
// 输出:WeakMap 是否包含特定的键

// 使用 delete 方法删除一个键值对
console.log(weakMap.delete(objKey));  // 输出:true
// 输出:键是否被成功删除

// 再次检查键是否存在
console.log(weakMap.has(objKey));  // 输出:false
// 输出:删除键后,再次确认键不存在

表22-5 数据结构WeakMap的常见方法

方法 描述 返回值
set(key, value) WeakMap 中添加键值对,其中键必须是对象。返回 WeakMap 实例本身,以便于链式调用 WeakMap 对象
get(key) 根据给定的键(必须是对象)获取对应的值 若键存在则返回对应的值,否则返回 undefined
has(key) 判断 WeakMap 是否包含给定的键 返回 Boolean 类型,指示键是否存在
delete(key) 根据给定的键从 WeakMap 中删除对应的键值对 返回 Boolean 类型,指示键是否被成功删除

5.2 WeakMap的应用

  • WeakMap的键是不可枚举的,也就意味不能遍历,这与垃圾回收的需求相符合,因为枚举键实际上需要保持对键的引用,这与WeakMap的设计初衷相违背。如果键可以是原始值,那么实现这种非枚举性质将更加复杂
    • 因此在我们前面WeakMap常见方法中,没有forEach方法,也不支持通过for of的方式进行遍历
    • 那么我们的WeakMap有什么作用呢?(有用在例如Vue3的响应式原理部分,这部分就属于超纲范围,可以考虑在学习Vue时,再去深入研究)
  • 比如下方的一个依赖收集机制,使用到了Map、Set与WeakMap
    • WeakMap非常适合于响应式系统中,在 Vue 3 中,组件和响应式对象可能会被销毁或不再使用,使用 WeakMap 确保这些对象的依赖能够被垃圾收集器清理
    • 然后使用 Map 存储属性依赖,使用 Set 存储依赖项
    • 对于每一个响应式对象,每个属性可能有多个副作用函数(effect)依赖于它。使用一个普通的 Map 存储这些依赖关系,允许快速访问和更新特定属性的依赖,例如在响应式系统中,当一个属性被访问时,需要将当前活动的副作用函数(如果有的话)添加到这个属性的依赖列表中。当属性被修改时,需要运行这些副作用函数来响应这个改变
    • Set 用于存储依赖同一属性的所有副作用函数,保证副作用函数的唯一性,避免重复执行相同的副作用,在属性值发生变化时,通过遍历 Set 中存储的副作用函数,可以有效地执行所有相关的副作用,从而更新 UI 或执行其他响应式操作
js 复制代码
// 创建一个 WeakMap,用于存储每个目标对象与其依赖项 Map 的映射
const targetMap = new WeakMap();

function getDep(target, key) {
  // 1、根据对象(target)从 targetMap 中取出对应的 Map 对象(depsMap)
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);  
  }

  // 2、从 depsMap 中取出具体的 dep 对象,这个对象用于存储依赖该属性的 effect 函数
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();  
    depsMap.set(key, dep);
  }
  return dep;
}

六、ES6小总结

  • ES6其他知识点说明(未学习部分):
    • **事实上ES6(ES2015)是一次非常大的版本更新,所以里面重要的特性非常多:**除了前面讲到的特性外还有很多其他特性
    • Proxy、Reflect,我们会在后续专门进行学习,并且会利用Proxy、Reflect来讲解Vue3的响应式原理
    • Promise,用于处理异步的解决方案,后续会详细学习,并且会学习如何手写Promise
    • **ES Module模块化开发:**从ES6开发,JavaScript可以进行原生的模块化开发,这部分内容会在工程化部分学习,包括其他模块化方案:CommonJS、AMD、CMD等方案

后续预告

  • 在结束ES6之后,JS每年都会更新一些特性,到目前为止已经更新到ES15,而在下一章节中,我们会先解决ES7-ES12的主要内容,各种各样提升工作效率的编程小技巧,即将向我们展开
    • 其中重要的模块我们会抽离为单独模块进行讲解,例如Promise
相关推荐
寻找09之夏1 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
非著名架构师1 小时前
js混淆的方式方法
开发语言·javascript·ecmascript
多多米10052 小时前
初学Vue(2)
前端·javascript·vue.js
敏编程2 小时前
网页前端开发之Javascript入门篇(5/9):函数
开发语言·javascript
柏箱2 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑2 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz8562 小时前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序
看到请催我学习2 小时前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript
blaizeer3 小时前
深入理解 CSS 浮动(Float):详尽指南
前端·css
编程老船长3 小时前
网页设计基础 第一讲:软件分类介绍、工具选择与课程概览
前端