JavaScript Map 对象键:字段完全一样,为什么 get 还是找不到
重新构造一个对象,并没有拿到原来的键
把任务对象放进 Map,稍后根据接口返回的编号重新创建一个同样的对象,再用它读取结果,可能得到 undefined。检查两边字段时看不出区别,问题却在比较标准:对象作为 Map 的键时,匹配取决于对象身份,而不是逐字段比较内容是否相等。
这对于给某个具体对象附加运行状态很有用,但并不自动适合跨请求查找。接口再次返回的对象、展开语法生成的副本,以及反序列化得到的新对象,即使字段相同,通常也是不同身份。选择键之前,应先确定想跟踪的是这次实例,还是某个业务实体。
用断言区分同一引用和同样内容
下面是可以直接用 Node 执行的完整脚本,本文在二十四版本验证。样例先创建两个编号相同的对象,把其中一个作为键,再分别查询原引用、另一个对象和复制对象。之后修改原对象字段,观察 Map 是否仍能使用这个引用找到之前的值。
第二部分改用经过验证的字符串编号作为键,并分别测试值为 undefined、缺失键、NaN 和正负零。这些情况放在一起,是为了说明键身份、键值比较和查询结果存在性属于不同层面,不能仅凭某次 get 返回什么就推断全部状态。
AI概念配图,非真实界面
const assert = require('node:assert/strict');
const first = {id: 7};
const second = {id: 7};
const states = new Map([[first, 'ready']]);
assert.equal(states.get(first), 'ready');
assert.equal(states.has(second), false);
assert.equal(states.has({...first}), false);
first.id = 8;
assert.equal(states.get(first), 'ready');
assert.equal(states.has({id: 8}), false);
console.log('same reference found; equal-looking objects missing');
function taskKey(task) {
if (typeof task.id !== 'string' || !/^task-[0-9]+$/.test(task.id)) {
throw new TypeError('expected a task-N string id');
}
return task.id;
}
const byId = new Map();
byId.set(taskKey({id: 'task-7'}), 'ready');
assert.equal(byId.get(taskKey({id: 'task-7'})), 'ready');
assert.throws(() => taskKey({id: 7}), TypeError);
const typed = new Map([[7, 'number'], ['7', 'string']]);
assert.equal(typed.size, 2);
assert.equal(typed.get('7'), 'string');
console.log('validated business id works across object instances');
byId.set('task-8', undefined);
assert.equal(byId.get('task-8'), undefined);
assert.equal(byId.get('task-9'), undefined);
assert.equal(byId.has('task-8'), true);
assert.equal(byId.has('task-9'), false);
const special = new Map([[NaN, 'first'], [-0, 'zero']]);
special.set(Number('invalid'), 'updated');
assert.equal(special.get(NaN), 'updated');
assert.equal(special.get(+0), 'zero');
assert.equal(special.size, 2);
console.log('presence, key types, NaN and signed zero checks passed');修改对象字段,不会把它换成另一把键
原对象的编号从七改成八后,仍然能找到登记的状态,因为对象身份没有变化。新建一个编号为八的对象却依然找不到。这对实例级缓存非常自然,但也意味着 Map 不会随着对象字段变化,自动重新按照某个字段建立索引。
如果需要通过任务编号查找,就应直接使用编号作为键,而不是给编号外面再包一层对象。示例的两个独立业务对象共享同一个字符串编号,所以能命中同一条记录。编号规则要在入口确定,例如是否允许前导零、是否区分大小写,以及哪些字符构成有效标识。
稳定键也必须保持类型和含义稳定
数字七与字符串七是不同的键,Map 不会替你做宽松类型转换。如果一个接口输出数字,另一个接口输出字符串,就应在数据边界统一协议,并检测无法转换或可能丢失信息的输入。不要为了命中缓存而随意把所有值都拼成字符串,那可能让不同含义意外合并。
同样,把整个对象做 JSON 序列化也不是通用的结构相等方案。字段顺序、缺失值、特殊值和嵌套数据都需要你额外定义语义。对于已有明确身份编号的实体,直接使用经过验证的编号更容易解释;对于复合键,则应设计不会产生歧义的组成规则。
Map 的 get 在键不存在时返回 undefined,但存在的键也可以保存 undefined。代码因此用 has 判断存在性,再读取值。把这两步分开,才能区分“尚未缓存”和“已经缓存但结果为空”的状态,避免无谓重算或把正常空结果当成失败。
别把 Map 的比较规则等同于所有相等运算
Map 使用 SameValueZero 语义,NaN 可以命中另一个 NaN 键,正零和负零也属于同一个键。脚本用 size 验证重复设置只是更新原条目。这些数值边界与对象按身份比较并不矛盾,它们共同构成 Map 的键匹配规则,而不是逐字段深度相等。
验收缓存或索引逻辑时,至少放入原引用、同内容新对象、被修改的原对象、类型不同的编号和存在但值为空的条目。若 Map 的生命周期很长,还应规定何时删除不再需要的条目。先让身份协议清楚,再讨论性能,通常比追查偶发的缓存未命中更省力。


