js/ts 键值对相关的Record、Map, 以及"'相关易误解的map遍历'与'常识的forEach遍历'"的区别与详解
本文已同步至同名 微信公众平台 以及同名 掘金平台 。 由于本站不做SEO, 故可通过前往平台阅读以达到推荐此文的目的。
ts中键值对使用案例
Record
以下是TypeScript官网对于键值对Record这一实用类型的说明, 源链接在参考中给出。
Record<Keys, Type>
[1]
构造一个对象类型,其属性键为keys,属性值为type。此实用程序可用于将一个类型的属性映射到另一个类型。
Example:
1 | interface CatInfo { |
最后, 个人对Record的使用做个补充:
- Record构造的类型为基础的对象类型(Object) =
{}
, 并不是键值对类型(下文的Map对象类型才是), 不用混淆。 - Record使用时, 需要在泛型框内传递两个实际类型做参数, 分别代表Object对象中的 key 和 value。
- key要求必须是联合类型的子集, value可以是任何类型。(如果使用时仍看不明白编辑器给的提示, 可阅读解释于示例, 助你解决这一问题)
- 通过这两个类型参数, 最终实现了将一个类型的属性映射到另一个类型。
Map对象
Map 是 ES6 中引入的一种新的数据结构,可以参考 ES6 Map 与 Set。
简单来说, Map 对象保存键值对。任何值(对象或者原始值) 都可以作为一个键或一个值。[2]
1 | let myMap = new Map([ |
而且Map还提供一系列相关的Api:
map.clear()
– 移除 Map 对象的所有键/值对 。map.set()
– 设置键值对,返回该 Map 对象。map.get()
– 返回键对应的值,如果不存在,则返回 undefined。map.has()
– 返回一个布尔值,用于判断 Map 中是否包含键对应的值。map.delete()
– 删除 Map 中的元素,删除成功返回 true,失败返回 false。map.size
– 返回 Map 对象键/值对的数量。map.keys()
- 返回一个 Iterator 对象, 包含了 Map 对象中每个元素的键 。map.values()
– 返回一个新的Iterator对象,包含了Map对象中每个元素的值 。
Record 与 Map对象 的应用场景
本身也没怎么实践过这门语言, 也就刚刚简单分别尝试了下这两者, 先就当前理解说下自己的个人看法吧!
个人通过简单的尝试后, 认为:
- 首先要明确的是, Record构造出的是Object基础对象类型, 而Map构造出的是Map键值对类型, 但由于js的缘故, 使得他们很容易被搞混(比如仅用基础类型(如number)去构造他们时)。
- Record用于定义一些已经明确范围的东西, 后续是只读使用的场景, 不建议当键值对使用, 主要用于定义复杂度较高的基础对象类型时映射联合类型与其他类型的联系时使用。 Record不管是定义还是使用, 都比较简洁方便。
容易误解的地方:
Record的对象虽然也可以(借助js特性来)增加和删除数据, 但毕竟不是专门用来干这个的; 而且Object对象本身没有size概念,是无法直接遍历的(只能借助for…in…来遍历, 本来就不是让当键值对来用的)。总之, 非要使用在未明确类型范围的场景下, 长远来看, 一定会使代码的维护负担加重。
- Map对象用于需要暂存一些动态或是不确定数据范围的场景时使用, 因为体提供了set、delete、set、以及遍历等一众用于动态存取数据结构的Api, 维护成本会大大降低。 不过对于不需要过多维护的小型的个人项目来说定义和使用的不便性就是其使用层面的唯一缺点了。
- 总之, 个人感觉, 如果单纯是个人的小型项目, 且数据范围相对明确无太大波动, Record对于资源的占用会小于Map对象(数据量动态变化大时还是用Map对象), 但功能会稍微弱一些, 不过还是适用于小工程下很多场景的, 使用起来也较为简便。 因此,对于这种场景下, 非要用Record这种Object对象 + js特性来代替map键值对使用也无可后非(利用js特性来满足Object对象的增删改查及遍历, 可参考解释与示例中的尝试过程)。
解释与示例
尝试过程如下:
首先是对比了简单的定义和使用,图中注释说的很明白:
`运行结果`
然后尝试了对以定义初始化的Record与Map做一些曾删改、读取成员数量以及遍历操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43const b: Record<number,number> = {1:33,2:44} // 定义时简单
const c: Map<number,number> = new Map([[1,35],[2,45]]) // 定义时繁琐
console.log("-----------------------------------------"); // 分割线
console.log(b[1]); // 使用时简单, 直接输入下标就能取值
console.log(b[2]);
console.log(b[3]); // 对于不存在的 , 返回 undefined
console.log(c.get(1)); // 使用时繁琐, 无法通过直接c[1]这样输入下标取值, 只能通过get()函数
console.log(c.get(2));
console.log(c.get(3)); // 对于不存在的 , 同样是返回 undefined
console.log("-----------------------------------------"); // 分割线
// 增加,和修改
b[3] = 22 // Record 是没有set函数的, 不过我发现这样子也能加数据进去
c.set(3,25) // Map对象, 可用set函数向数据结构中添加键值对
b[2] = 122 // 修改
c.set(2,125) // 修改
console.log(b[3]); // 添加成功
console.log(c.get(3)); //添加成功
console.log(b[2]); // 修改成功
console.log(c.get(2)); //修改成功
console.log("-----------------------------------------"); // 分割线
// 删除
delete(b[1]) // Record 是没有delete函数的, 不过我发现这样子也能打到删除数据的效果
c.delete(1)
console.log(b[1]); // 对于不存在的 , 返回 undefined (因为已被删除)
console.log(b[2]);
console.log(b[3]);
console.log(c.get(1)); // 对于不存在的 , 返回 undefined (因为已被删除)
console.log(c.get(2));
console.log(c.get(3));
console.log("-----------------------------------------"); // 分割线
// 查看成员数量及遍历操作
console.log(`b对象:`,b) // 打印b对象, 可发现b对象中没有size概念, 因此后续无法遍历
console.log(`c对象:`, c.size, c) // 打印c.size和c对象, 可发现c对象中有size概念, 故可方便将其遍历, 并且ts也提供遍历API
c.forEach(e=>{ // c对象可以遍历
console.log(e)
})运行结果
我们可以发现Map键值对对象的属性是可以直接遍历的,而Record构造的b为Object基础对象并没有遍历api, 因此, 我们需要使用for...in..这种日常遍历对象的操作来实现遍历,那么我们在刚才代码末尾使用for...in...实现对象的遍历,添加如下代码
1
2
3for (let key in b) {
console.log(key, b[key],"遍历Object对象b")
}运行结果
此时, 我们回到Record的官方示例, 并尝试通过遍历这个对象的示例, 来读懂Record的源码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40interface CatInfo {
age: number;
breed: string;
}
type CatName = "miffy" | "boris" | "mordred";
const cats: Record<CatName, CatInfo> = {
miffy: { age: 10, breed: "Persian" },
boris: { age: 5, breed: "Maine Coon" },
mordred: { age: 16, breed: "British Shorthair" },
};
for (let key in cats)
{
console.log(key, "--仅打印了key--");
// console.log(cats[key]); // 此时这样写不行, 在ts中会由于类型原因报错无法获取
}
console.log("==================");
// 使用 keyof 来获取一个明确的由 Record 中所有key类型组成的 联合类型, 来解决ts中的报错问题。 (其实在此处就想当于CatName)
let aaa: keyof Record<CatName, CatInfo>;
for ( aaa in cats)
{
console.log(aaa, "--keyof--");
console.log(cats[aaa]); // 此时可以正常获取, 因为我们已经通过keyof从对象(Object)中获得了一个对象中所有key类型组成的联合类型
}
console.log("==================");
// 直接用CatName也是一样的(因为我们使用Record构造Object对象前,已经明确了类型范围); 不过大多数使用他人的代码的场景下, 其是否认定义了明确的类型范围是不确定的, 我们往往也懒得通过去逐行阅读去找到这个定义, 往往临时会重新定义一个与之兼容的相同类型 , 因此我们优先在前面使用了keyof做说明, 这也是我们对联合类型的常见做法
let bbb:CatName
for (bbb in cats)
{
console.log(bbb, "--CatName--");
console.log(cats[bbb]); // 此时可以正常获取
}运行结果
看了以上例子的运行结果, 想必你也对Record解决的问题有了一定认识, 那么我们继续看如果不用Record的话,相同的代码在ts中需要怎么写, 可以和Record作一个对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35interface CatInfo {
age: number;
breed: string;
}
type CatName = "miffy" | "boris" | "mordred";
// 因此 , Record的源码肯定离不开keyof, 也可以说Record就是简化了某些特定场景下keyof的用法
type Easy = {
// [K in keyof Record<CatName, CatInfo>]: CatInfo // keyof 用于获取 对象类型中的所有键类型 组成的 联合类型
// [K in keyof CatName]: CatInfo // 这个就是错误写法了, 因为对象类型是一个联合类型"类型1|类型2|类型3", 并非键值对象类型{key1:value; key2:value;} , 所以 keyof就是多余的, 这样写是无法正常遍历CatName的
[K in CatName]: CatInfo // CatName本身就是一个联合类型, 并非无法遍历的对象类型 ,因此可以直接正常遍历
}
const cats_1: Easy = {
miffy: { age: 10, breed: "Persian" },
boris: { age: 5, breed: "Maine Coon" },
mordred: { age: 16, breed: "British Shorthair" },
};
// 因此简化后, keyof在此场景下的用法如下, 对比Record官方列子定义cats时使用的Record, 不管
// 是可读性,还是易用性, 都落于下风 , 代码如下方注释(用于直观对比Recore官方例子对cats的定义)
/*
const cats_1: { [K in CatName]: CatInfo } = {
miffy: { age: 10, breed: "Persian" },
boris: { age: 5, breed: "Maine Coon" },
mordred: { age: 16, breed: "British Shorthair" },
};
*/
let key: CatName
for (key in cats_1)
{
console.log(key,cats_1[key]);
}运行结果
我们成功使用原始(Object)对象类型'{}'定义出了于Record相同的类型, 那么接下来我们一起看一下Record的源码,就浅显易懂了
源码通过泛型编程使api可以适用于不同类型参数的场景下; 同时通过类型继承机制,成功约束了api的参数类型
如源码中 , 类型 'T'是没有限制类型的,可以是任何类型; 而类型 'K' 继承于 'keyof any' 且仅继承于它,并无多余实现, 因此 K 就等价于 'keyof any' , 而keyof的结果只能是联合类型, 因此 'K' 只能是联合类型的子集, 'K' 的类型范围成功得到了约束
比如当你想传一个对象类型(Object), 编译器就会因为其不是联合类型子集而报错误
单词map还出现在ts的哪些地方?
看了以上案例, 是不是发现又一次被自身的先入为主给坑害了,哈哈, 弱小和无知可从来不是障碍, 某些时候你了解的越多, 看其他领域的代码反而容易误解(也可以说你了解的还是不够多), 不要管是否有意义, 很多时候, 只有承认无知才能了解接触到更多。人都是从新手慢慢成长的。
那么, 那么ts中map这个单词还出现在哪些地方呢?
先说点题外话, 有些未接触过其他语言的小伙伴们可能会不知所问 -> 为什么要关联到ts中的map呢? 在这里统一解释一下:
因为 map 这个词, 不论是在C++ stl库中 ,或是 go语言中, 都是代表键值对这一概念来使用的。(顺便科普下, python与c#的键值对概念是字典, python中 dict() 对象也可直接用
{}
表示, c#中使用Dictionary<key,value>来初始化键值对; 至于java,由于本人才疏学浅没接触过,无法科普。)
答案是: 遍历。 比如对一个数组遍历时, 可以使用map。 这在其他地方貌似是没有出现过的,所以单独拿出来提示一下大家, 否则没接触过前端的你, 也可能像我一样, 突然间看一个前端的代码, 直接被整蒙了哈哈。
如以下代码示例:
执行结果为
基于以上示例, 我们可以看到对于遍历, 在ts中不止可以使用常识下的’forEach’, 同样可以使用’map’, 那么他们又有哪些区别呢?
遍历Api - map和ForEach的区别
本小节参考于[3], 在原文基础上, 少量对其实验中个人认为不严谨的地方作出了修改, 并给出了自己的使用总结。
解释
forEach()和map()都是处理数组的高阶函数。
也同样都接收三个参数:
- value:必选,当前元素的值
- index:可选,当前元素的下标
- arr:可选,当前遍历的数组对象
那么,forEach和map都有相同三个参数,他们有什么区别呢?
相同处:
forEach 和 map都相当于封装好的单层for循环,三个值都相同。不同处:
- forEach()方法没有返回值,而map()方法有返回值;
- forEach遍历通常都是直接引入当前遍历数组的内存地址,通常用于不需要额外返回数组的场景, 不过也是可以向map那样复制数组的(过程繁琐些罢了);(具体可参考后续示例)
- map遍历的后的数组通常都是生成一个新的数组,在使用时是可以做到: 新的数组的值发生变化,当前遍历的数组值不会变化的;(具体可参考后续示例)
总结一下:
- 这里为什么都说遍历后通常是引入当前遍历数组的内存地址和生成一个新的数组,因为按forEach和map创作思想,forEach遍历基本引入遍历数组内存地址,但不会返回一个数组、map遍历不止引入遍历数组的内存地址,还会生成一个新的数组。
- forEach虽然没有返回的新数组,不过我们也可以通过提前初始化好一个新数组来承接forEach遍历时的每个值来达到相同目的。(可参考后续的示例)
- 不论是forEach还是map的遍历使用, 都可以通过返回的值是否是直接把当前遍历数组的每个元素的内存地址给了另外一个数组,来确定最终本质是否是复制了一个完全独立的新数组。(可参考后续示例)
- 既然在应用层面, map比forEach更强大, 那么对资源消耗不敏感的项目中, 是可以无脑梭哈map的。
示例
示例一
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22let arr = [
{
title: '雪碧',
price: 2.5,
},
{
title: '可乐',
price: 2.5,
}
]
let a = arr.forEach((item, index) => {
return item
})
let b = arr.map((item, index) => {
return item
})
console.log(arr) //打印arr数组
console.log(a) //undefined , 因为forEach方法是没有返回值的
console.log(b) //打印b数组, 但由于操作返回值时未手动定义新的数据变量, 因此这里本质上打印的还是arr同一内存地址的数组运行结果
示例二
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33let arr = [{ title: '雪碧', price: 2.5 }, { title: '可乐', price: 2.5 }]
let list = [{ title: '雪碧', price: 2.5 }, { title: '可乐', price: 2.5 }]
let a:any = []; // 用于映射arr , 由于forEach并没有返回值, 如果我们要向达到和map相同的效果, 可用提前定义并初始化一个a来满足需求
// let b:any = []; // 用于映射list , 由于我们用b来接收map的返回值, 所以无须在此初始化
arr.forEach((item, index) => {
console.log("forEach,item:",item,"index",index);
a[index] = item; // 此时虽然a的整体对象是一个新地址, 但内部使用的 item ,依旧是旧地址, 故后续表现上依旧会和 arr同步
})
let b = list.map((item, index) => { // b直接在此声明并接收返回值即可, map的返回值是一个新的独立地址的[],与list的地址无关(就像手动定义并初始化了一个与arr无关的a那样)。
console.log("map,item:",item,"index",index);
return item // 此时虽然返回值b的整体对象是一个新地址, 但内部使用的 item ,依旧是旧地址, 故后续表现上依旧会和 list同步
})
console.log("a:",a)
console.log("b:",b)
console.log("现在我们修改a和b,中的数据,并观察arr和list的变化")
a[0].price = 3;
b[0].price = 3;
console.log("a:",a);
console.log("b:",b);
console.log("arr:",arr)
console.log("list:",list)
console.log("arr[0]:",arr[0]);
console.log("由结果可知, 当我们修改a和b中的数据时, arr与list中的数据也随之改变, 因此可确定他们使用的是同一块内存")运行结果
示例三
我们对示例二作出改进, 在给新数组值的时候, 我们重新生成新的值元素, 然后看看最终效果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39let arr = [{ title: '雪碧', price: 2.5 }, { title: '可乐', price: 2.5 }]
let list = [{ title: '雪碧', price: 2.5 }, { title: '可乐', price: 2.5 }]
let a:any = []; // 用于映射arr
arr.forEach((item, index) => {
console.log("forEach,item:",item,"index",index);
a[index] = { // 在给新数组值的时候, 我们重新生成新的值元素
title:item.title,
price:item.price
}
})
let b = list.map((item, index) => {
console.log("map,item:",item,"index",index);
return { // 在给新数组值的时候, 我们重新生成新的值元素
title:item.title,
price:item.price
}
})
console.log("a:",a)
console.log("b:",b)
console.log("现在我们修改a和b,中的数据,并观察arr和list的变化")
a[0].price = 3;
b[0].price = 3;
console.log("a:",a);
console.log("b:",b);
console.log("arr:",arr)
console.log("list:",list)
console.log("arr[0]:",arr[0]);
console.log("由结果可知, 当我们修改a和b中的数据时, arr与list中的数据并没有跟着改变, 因此可确定我们通过遍历复制出了独立的数组")运行结果
参考
[1] https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeystype
[2] 菜鸟教程