前端开发··2 阅读·预计 5 分钟

Map 与对象字面量的性能对决:V8 隐藏类、单态缓存与键值容器选型的工程化真相

几乎所有前端都在用「对象当字典」:

const cache = {};
cache[userInput] = Date.now();

Map 似乎一直背负着「新语法糖」「不性能」的刻板印象。真相是,两者的性能差距会随键的稳定性、增删频率与容量发生剧烈反转。本文不站队,只拆底层。

一、对象查找的快,是「隐藏类 + 单态缓存」给的

V8 对对象的属性访问做了极致的缓存:当对象形状(Shape)稳定时,连续多次访问同一形状的对象会形成单态(monomorphic)内联缓存,编译成近乎一条寻址指令。

function read(obj: Record<string, number>) {
  return obj.count; // 形状稳定 → 单态缓存命中,极快
}

但一旦你把对象当字典用,动态塞键就会摧毁这份红利:

const dict = {};
// 运行时才知道键,且键五花八门
for (const k of dynamicKeys) {
  dict[k] = hash(k); // 每个新键都在改变隐藏类形状
}

后果是:

  • 每个不同的键都让对象从「快模式」退化为字典模式(dictionary mode)
  • 读取同一个属性时,缓存从单态 → 多态(polymorphic)→ 巨态(megamorphic),退化到哈希查找。

对象字面量只适合「键名在编译期已知、形状固定」的场景,例如配置项、DTO、带命名字段的记录。

二、Map 的快,是「天生哈希 + 形状无关」给的

Map 从设计上就是键值容器:它内部用哈希桶存储,无论键多少、键是否动态,访问路径始终是一条——不存在隐藏类偏离问题。

const cache = new Map<string, number>();
for (const k of dynamicKeys) {
  cache.set(k, hash(k)); // 形状不变,查询路径稳定
}

因此,在「键是运行时生成的、键集合大且不稳定」的场景下,Map 会反超对象,且差距随容量扩大而拉大。

三、一个 10 行基准,看性能反转

const N = 1_000_000;
const keys = Array.from({ length: N }, (_, i) => `key_${i}`);

// 场景 A:固定形状、整数下标访问
function objectFixed() {
  const arr = [];
  for (let i = 0; i < N; i++) arr.push({ id: i });
  let sum = 0;
  for (let i = 0; i < N; i++) sum += arr[i].id;
  return sum;
}

// 场景 B:动态字符串键、高频写入
function objectDynamic() {
  const o: Record<string, number> = {};
  for (let i = 0; i < N; i++) o[keys[i]] = i;
  let sum = 0;
  for (let i = 0; i < N; i++) sum += o[keys[i]];
  return sum;
}

function mapDynamic() {
  const m = new Map<string, number>();
  for (let i = 0; i < N; i++) m.set(keys[i], i);
  let sum = 0;
  for (let i = 0; i < N; i++) sum += m.get(keys[i])!;
  return sum;
}

方向结论清晰:

  • 场景 A(固定形状整数索引):对象快,隐藏类稳定、单态缓存满速命中;
  • 场景 B(动态键 + 大容量)Map 快,且随 N 增大优势放大——对象已跌落字典模式,Map 仍走稳定哈希路径。

四、正反例:别再用对象当任意键的缓存

反例(字典模式陷阱):

const userCache = {};
function remember(id: string, v: number) {
  userCache[id] = v; // id 来自用户输入,形状不断变化
}

正例:

const userCache = new Map<string, number>();
function remember(id: string, v: number) {
  userCache.set(id, v); // 无论 id 怎么变,访问路径恒定
}

另一个被忽视的语义红利:Map 的键可以是任意类型,且区分 1'1',而对象键会被强制转成字符串:

const o: Record<string, number> = {};
o[1] = 'a' as any; // 键变成 '1'
const m = new Map<number, string>();
m.set(1, 'a'); m.get(1); // 严格按 1 找,不与 '1' 混淆

五、选型清单(直接照抄)

场景选择理由
键名编译期已知、形状固定对象单态缓存满速
键来自运行时、集合大且动态Map形状无关,哈希路径稳定
需要任意类型键(对象/数字严格区分)Map语义正确,无字符串化
需要迭代顺序或 .sizeMap内建有序迭代与 O(1) 长度
需要频繁增删大量键Map避免字典模式退化
序列化到 JSON / 跨系统传递对象Map 需手动转换

结语

「对象永远比 Map 快」是一个只在上世纪和固定形状里成立的假设。区分标准不是「新旧」,而是键的稳定度与容器职责:当键名编译期未知、集合大且动态时,Map 用恒定的哈希路径碾压对象在字典模式下的退化查找。下次再写 const cache = {} 前,先问一句:这里的键,是字段,还是数据?

0 评论

评论区

登录 后参与评论