
const left = { id: 7 };
const sameShape = { id: 7 };
const map = new Map([[left, "stored"]]);
console.log(left === sameShape); // false
console.log(map.get(sameShape)); // undefined
console.log(map.get(left)); // stored
对象看起来相同,Map 却没有命中。问题不在字段比较,也不是 Map 忘了保存值。对象作为键时,比的是对象身份;两个分别创建的对象,即使属性和值相同,也不是同一个键。
Map 查询对象键时比较什么?
实测脚本 tools/verify-map-object-identity.mjs 在本机 Node 运行,输出原样留在 tools/verify-map-object-identity.log。新建两个 { id: 7 } 后,left === sameShape 为 false。以 left 建立的条目,map.get(sameShape) 返回 undefined,而 map.get(left) 返回 stored。
这个结果看起来像 Map 在做引用比较。更准确地说,对象键按对象身份匹配;内容相同不会替代身份相同。普通对象用作键时,属性名或 toString() 都不会自动参与匹配。Map 也不会递归比较字段。
若把第二个对象再放入同一个 Map,条目数从 1 变成 2。它们是两条独立记录,不是对原条目的覆盖。调试时只打印对象内容,常会让两把键看起来没有差别;打印 map.size 或直接验证键身份,更容易看到分叉。
left === sameShape: false
map.get(sameShape): undefined
map.get(left): stored
map.has(sameShape): false
size after same-shape object key: 2
keys are same reference: false
round-trip key lookup: undefined
round-trip values equal: true
JSON 往返后为什么查不到原来的条目?
最容易遇到的地方是缓存。数据从状态管理层传出、经过消息通道,或用 JSON 保存再读回后,原对象已经变成另一个对象。即便 JSON.stringify 得到的文本一样,新旧对象依然不能互相查 Map。
脚本把 { id: 7 } 做 JSON 往返,再用新对象查询原来的 Map:查询仍为 undefined,但两个对象的序列化文本相同。JSON 只能重建可表达的数据结构,不负责延续运行时对象的身份。这里的两个字段值相等,不意味着身份相等;后续若把这份数据再次解析,得到的仍是一份新对象。
这里我会优先选稳定的业务键,例如数据库 id 或规范化后的字符串。前提是这个值确实唯一,而且构造规则一致。把整个对象直接当键,只适用于读写两端持有同一个对象实例的情况;跨越序列化边界后,通常需要回到稳定标识符查找。
哪些场景适合用对象作键?
对象键并非不该用。只要键对象由同一处持有,并且调用方拿到的就是那份引用,Map 可以把对象关联到额外状态,而不必改动对象本身。以 DOM 节点或内部任务对象建立临时关系,常常符合这个条件。
例如一个解析阶段先生成任务对象,后续多个函数都接收并传递这同一个对象,就能用它在 Map 里取出任务状态。相反,若每次从接口响应重新组装一个看起来相同的任务对象,查询就不是同一个键。把键对象的创建和传递方式也纳入设计,才能判断这种用法是否稳妥。若使用业务 id,还要确认唯一范围;例如编号只在单个租户内唯一,就不能不加区分地当全局键。可以把租户标识也纳入键,或先按租户分组再查编号。
需要按字段内容查询时,先把"相同"的规则说清楚。若只有一个唯一 id,可直接用它作键;若多个字段共同决定等价关系,就得定义稳定的编码或显式索引。直接 JSON.stringify 充当通用对象键,依赖属性排列和可序列化范围,也可能混淆不同数据,不能当作默认做法。
判断是否该用对象键,可以从调用路径看:如果创建键与查询键能保证复用同一实例,Map 的身份语义正合适;如果查询端只拿到另一份同内容数据,按 id 或明确的内容索引会更可靠。先确认这条边界,缓存未命中就不会被误当成 Map 的异常。