2025 年 Object 和 Map 如何选择?

家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

1. 什么是 JS 的 Map

1.1 什么是 Map

Map 对象保存键值对(Key-Value Pairs),并且能够记住键的原始插入顺序,任何值,包括:对象或者原始值都可以作为键或值。

Map 是键值对的集合,Map 中的一个键只能出现一次,在 Map 集合中是独一无二的。Map 对象按键值对迭代,即一个 for...of 循环在每次迭代后会返回一个形式为 [key, value] 的数组。for..of 迭代按插入顺序进行,即键值对按 set() 方法首次插入到集合中的顺序进行迭代。

规范要求 Map 实现 “平均访问时间与集合中的元素数量呈次线性关系”。因此,可以在内部表示为哈希表(使用 O(1) 查找)、搜索树(使用 O(log(N)) 查找)或任何其他数据结构,只要复杂度小于 O(N)。

  • 哈希表:使用哈希函数来计算索引(也称为哈希码)到桶(bucket)或槽(slot)数组中,从中可以找到所需的值。
  • 搜索树:一种树数据结构,用于从集合中查找特定键。搜索树每个节点的键必须大于左侧子树中的任何键且小于右侧子树中的任何键。优点是其高效的搜索时间,因为树是相当平衡的,且两端叶子具有可比较的深度。 存在各种搜索树数据结构,其中一些还允许有效插入和删除元素,然后这些操作必须保持树平衡。

1.2 Map 的键比较算法

JavaScript 提供三种不同的值比较运算:

  • ==(宽松相等, IsLooselyEqual):== 执行类型转换,并且会按照 IEEE 754 标准对 NaN、-0 和 +0 进行特殊处理,因此 NaN != NaN,且 -0 == +0
  • ===(严格相等, IsStrictlyEqual):与 == 相同,包括:对 NaN、-0 和 +0 的特殊处理,但不进行类型转换。如果类型不同,则返回 false,例如:-0 === +0 返回 true,但是 NaN ===NaN 返回 false
  • Object.is(SameValue):既不进行类型转换,也不对 NaN、-0 和 +0 进行特殊处理。这使其和 === 在除了那些特殊数字值之外的情况具有相同的表现

Map 键的比较基于 SameValueZero 算法,类似于 SameValue ,但 +0 和 -0 被视为相等。SameValueZero 不作为 JavaScript API 公开,但可以使用自定义代码实现:

function sameValueZero(x, y) {
  if (typeof x === "number" && typeof y === "number") {
    // x and y are equal (may be -0 and 0) or they are both NaN
    return x === y || (x !== x && y !== y);
  }
  return x === y;
}
// sameValueZero(-0,+0) 返回 true
// sameValueZero(NaN,NaN) 返回 true

sameValueZero 与 === 的区别仅在于将 NaN 视为等效,而与 SameValue 的区别仅在于将 -0 视为等效于 0。这使得 sameValueZero 在搜索期间通常具有最明智的行为,尤其是在使用 NaN 时 。 Array.prototype.includes()、
TypedArray.prototype.includes() 以及 Map 和 Set 方法使用它来比较键相等性。

const keyString = "mystring";
const keyObject = {};
const keyFunc = function () {};

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

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

2.Object 和 Map 的比较

Object 和 Map 都允许按键存取一个值、删除键、检测一个键是否绑定了值。因此,过去开发者一直都把 Object 当成 Map 使用。不过 Map 和 Object 有一些重要的区别,因此在一些情况中使用 Map 可能会是更好的选择。

意外的原型属性搅局

Map 默认不包含任何键,只包含显式存入的键值对。而 Object 有原型,因此可能包含默认的键,如果不小心的话可能会与自己的键相冲突。

> var empty = {};  // empty map
> var key = 'toString';

> key in empty  // should be false
true
> empty[key]  // should be undefined
[Function: toString]

当然,开发者也可以通过使用 Object.create(null) 来绕过 Object 的不足,但很少这样做。

Map 的 key 支持更加丰富

Map 的键可以为任何值,包括:函数、对象或任何原始值。而 Object 的键必须为 String 或 Symbol。

let object1 = {
 name: "abc",
    age: 25
}
let object2 = {
 name: "abc",
    age: 25
}
let ourMap = new Map();
ourMap.set(Symbol.for(object1), "some value");
ourMap.set(Symbol.for(object2), "this value should be updated");
// Map 以 Symbol 为 key
const sym = Symbol('symbol_name');
const object = {[sym]: { foo: 'bar' }}
console.log(object[sym]);
// 下面是 Object 以 Symbol 为 key

Map 键的顺序更加清晰

Map 中的键以简单、直接的方式排序,即按照插入的顺序迭代条目、键和值。

然而,自 ES2015 以来,Object 的迭代顺序遵循一组特定的规则,但它并不(总是)遵循插入顺序。 简而言之,迭代顺序是字符串键的插入顺序和类似数字键的升序的组合:

// key order: 1, foo, bar
const obj = {"foo": "foo", "1": "1", "bar": "bar"}

因此,最好不要依赖 Object 属性的顺序。ECMAScript 2020 还定义了继承属性的顺序。但请注意,没有单一机制可以迭代对象的所有属性,而且各种机制包含不同的属性子集。

  • for-in :仅包含可枚举的字符串键属性
  • Object.keys: 仅包含可枚举的自有字符串键属性
  • Object.getOwnPropertyNames :包括自有的字符串键属性,即使不可枚举
  • Object.getOwnPropertySymbols: 仅对 Symbol 键属性执行相同的操作,等等

Map/Object 大小

Map 中的项目数量很容易从其 size 属性中获得。

const map1 = new Map();
map1.set('a', 1);
map1.set('b', 2);
map1.set('c', 3);
console.log(map1.size);

而确定 Object 中的项目数量通常更麻烦,效率也较低。一种常见的方法是通过获取 Object.keys() 返回的数组的长度。

Map/Object 可迭代

Map 是可迭代对象,所以可直接迭代。

Object 没有实现迭代协议,因此对象默认情况下不能直接通过 JavaScript 的 for...of 语句进行迭代。

const apples = {name: 'Apples'};
const bananas = {name: 'Bananas'};
const fruits = new Map();
fruits.set(apples, 500);
fruits.set(bananas, 300);
// List all entries
let text = "";
for (const x of fruits.entries()) {
  text += x;
}

值得注意的是:一个对象可以实现迭代协议,或者可以使用 Object.keys 或 Object.entries 来获取一个对象的可迭代对象。而 for...in 语句允许迭代对象的可枚举属性。

Map/Object 性能

Map 在涉及频繁添加和删除键值对的场景中表现更好,而 Object 未针对频繁添加和删除键值对进行优化。

序列化和解析

Map 没有对序列化或解析的原生支持,但开发者可以通过使用 JSON.stringify() 及其 replacer 参数和 JSON.parse() 及其 reviver 参数来为 Map 构建自己的序列化和解析支持。

function replacer(key, value) {
  if(value instanceof Map) {
    return {
      dataType: 'Map',
      value: Array.from(value.entries()),
      // or with spread: value: [...value]
    };
  } else {
    return value;
  }
}
function reviver(key, value) {
  if(typeof value === 'object' && value !== null) {
    if (value.dataType === 'Map') {
      return new Map(value.value);
    }
  }
  return value;
}
const originalValue = new Map([['a', 1]]);
const str = JSON.stringify(originalValue, replacer);
const newValue = JSON.parse(str, reviver);
console.log(originalValue, newValue);

而 Object 原生支持使用 JSON.stringify() 序列化 JSON,原生支持使用 JSON.parse() 解析 JSON 为 Object。

3. 如何正确使用 JavaScript 的 Map

不正确使用 Object 赋值设置 Map 属性

设置 Object 属性的方法同样适用于 Map 对象,但容易造成困扰。即,以下的代码能够正常运行但不推荐:

const wrongMap = new Map();
wrongMap["bla"] = "blaa";
wrongMap["bla2"] = "blaaa2";

console.log(wrongMap);
// 输出值 Map {bla: 'blaa', bla2: 'blaaa2'}

但这种设置属性的方式不会改变 Map 的数据结构,其使用的是通用对象的特性。'bla' 的值未被存储在 Map 中,无法被查询到。其他的对这一数据的操作也会失败:

wrongMap.has("bla");
// 输出 false
wrongMap.delete("bla");
// 输出 false
console.log(wrongMap);
// 输出 Map {bla: 'blaa', bla2: 'blaaa2'}

因此,正确的存储数据到 Map 中的方式是使用 set(key, value) 方法。

使用 NaN 作为 Map 的键

NaN 也可以作为键,虽然 NaN 与任何值甚至与自己都不相等(NaN !== NaN 返回 true),但是因为所有的 NaN 的值都是无法区分的,所以下面的例子成立:

const myMap = new Map();
myMap.set(NaN, "not a number");

myMap.get(NaN);
// 输出 "not a number"

const otherNaN = Number("foo");
myMap.get(otherNaN);
// 输出 "not a number"

正确使用 for...of 迭代 Map

Map 可以使用 for...of 循环来实现迭代:

const myMap = new Map();
myMap.set(0, "zero");
myMap.set(1, "one");

for (const [key, value] of myMap) {
  console.log(`${key} = ${value}`);
}
// 0 = zero
// 1 = one
for (const key of myMap.keys()) {
  console.log(key);
}
// 0
// 1
for (const value of myMap.values()) {
  console.log(value);
}
// zero
// one
for (const [key, value] of myMap.entries()) {
  console.log(`${key} = ${value}`);
}
// 0 = zero
// 1 = one

正确使用 forEach() 迭代 Map

Map 也可以通过 forEach() 方法迭代:

myMap.forEach((value, key) => {
  console.log(`${key} = ${value}`);
});
// 输出值如下
// 0 = zero
// 1 = one

Map 与数组对象的关系

const kvArray = [
  ["key1", "value1"],
  ["key2", "value2"],
];

const myMap = new Map(kvArray);
// Map 构造函数将二维数组转化为 Map

console.log(myMap.get("key1"));
// 输出 "value1"

// Array.from 将一个 Map 对象转换成一个二维的键值对数组
console.log(Array.from(myMap));
// 输出和 kvArray 相同的数组
// 或者使用 rest 运算符
console.log([...myMap]);
console.log(Array.from(myMap.keys()));
// 输出 ["key1", "key2"]

复制或合并 Map

Map 能像数组一样被复制:

const original = new Map([[1, "one"]]);

const clone = new Map(original);

console.log(clone.get(1));
// 输出 one
console.log(original === clone);
// 输出 false,这里是浅比较,不为同一个对象的引用

Map 对象间可以进行合并,但是会保持键的唯一性。

const first = new Map([
  [1, "one"],
  [2, "two"],
  [3, "three"],
]);

const second = new Map([
  [1, "uno"],
  [2, "dos"],
]);

// 合并 Map 对象如果有重复键值则会覆盖
// rest 语法本质上是将 Map 对象转换成数组。
const merged = new Map([...first, ...second]);
console.log(merged.get(1));
// 输出 uno
console.log(merged.get(2));
// 输出 dos
console.log(merged.get(3));
// 输出 three

Map 对象也能与数组合并:

const first = new Map([
  [1, "one"],
  [2, "two"],
  [3, "three"],
]);

const second = new Map([
  [1, "uno"],
  [2, "dos"],
]);

// Map 对象同数组进行合并时,如果有重复的键值,则后面的也会覆盖
const merged = new Map([...first, ...second, [1, "eins"]]);
console.log(merged.get(1));
// 输出 eins
console.log(merged.get(2));
// 输出 dos
console.log(merged.get(3));
// 输出 three

参考资料

https://stackoverflow.com/questions/29085197/how-do-you-json-stringify-an-es6-map

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness#same-value-zero_equality

https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order

https://www.youtube.com/watch?v=-HjvUAP2Zvg

原文链接:,转发请注明来源!