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 | 语义正确,无字符串化 |
需要迭代顺序或 .size | Map | 内建有序迭代与 O(1) 长度 |
| 需要频繁增删大量键 | Map | 避免字典模式退化 |
| 序列化到 JSON / 跨系统传递 | 对象 | Map 需手动转换 |
结语
「对象永远比 Map 快」是一个只在上世纪和固定形状里成立的假设。区分标准不是「新旧」,而是键的稳定度与容器职责:当键名编译期未知、集合大且动态时,Map 用恒定的哈希路径碾压对象在字典模式下的退化查找。下次再写 const cache = {} 前,先问一句:这里的键,是字段,还是数据?
0 评论
评论区
登录 后参与评论