同名附件为什么被 Map 覆盖?重复键与一对多索引

原数组里有三条附件记录,其中两条都叫"报价.pdf"。把文件名直接作为 Map 的键之后,索引只剩两项。文件并没有从原数组消失,丢掉的是"一个名称对应多个附件"这层关系。

以米米商聊为背景,已整理的手机端资料包含文件发送。本文借附件元信息建立独立的 JavaScript 示例,不代表产品采用这些字段、索引或实现,也不推断其原生 App、桌面客户端及服务端。文中只有合成元信息,没有读取、上传或修改用户文件。

1. 先复现:重复名称变成同一个 Map 键

javascript 复制代码
const files = [
  { id: "f1", name: "报价.pdf" },
  { id: "f2", name: "会议.txt" },
  { id: "f3", name: "报价.pdf" }
];
const byName = new Map(files.map(file => [file.name, file]));
console.log(byName.size);                  // 2
console.log(byName.get("报价.pdf").id);     // "f3"
console.log([...byName.keys()]);           // ["报价.pdf", "会议.txt"]

MDN 对 Map.set() 的说明明确区分新增键和更新已有键:键已经存在时,会更新对应值。构造 Map 时逐项写入相同键,也不会为每次出现额外保留一条记录。

因此,问题不是 Map 随机漏数据,而是代码选择了"一键一个值"的结构,却又期望它保存同名附件的全部记录。f3 是最后遍历到的同名记录,并不因此自动成为"最新版本"或"应该保留的文件"。

上面还有一个容易忽略的结果:"报价.pdf"仍在第一个键的位置。已有键更新值,与新增键插入顺序不是一回事;相应行为可对照 ECMAScript 的 Map.set 算法。不能根据索引位置推断文件更新时间。

2. 唯一查找和按名称分组,回答不同问题

先决定索引要回答什么,再选键和值。按 ID 查找回答"我要哪一条附件";按名称分组回答"这个名称下有哪些附件"。后者不能只保存一个附件对象。

结构 键的含义 相同键再次出现时的策略
Map<name, file> 一个名称只留一个值 默认后写覆盖;仅在业务确实允许时采用
Map<id, file> 一个标识定位一条记录 本文要求 ID 唯一,重复就拒绝构建
Map<name, file[]> 一个名称对应一个附件列表 同名放入同一组,保留全部记录

同名与重复 ID 也不能混为一谈。两个不同 ID 的同名附件,在本文中都是有效记录;两个相同 ID 的条目,则需要另有去重或版本协议。这个示例选择直接拒绝重复 ID,避免在索引层悄悄作出合并决定。

3. 先校验元信息,再构建严格的 ID 索引

下面约定输入是普通记录数组,每条记录有非空、首尾无空白的字符串 ID,以及非空字符串名称。名称按原值保留,不替调用方自动裁剪、改大小写或规范化字符。它不是文件内容或完整文件名安全校验器。

javascript 复制代码
function checkedFiles(files) {
  if (!Array.isArray(files)) {
    throw new TypeError("Expected a file record array");
  }
  for (const file of files) {
    if (file === null || typeof file !== "object" || Array.isArray(file)
        || typeof file.id !== "string" || file.id === "" || file.id.trim() !== file.id
        || typeof file.name !== "string" || file.name === "") {
      throw new TypeError("Invalid file metadata");
    }
  }
  return files;
}

function indexFilesById(files) {
  const index = new Map();
  for (const file of checkedFiles(files)) {
    if (index.has(file.id)) {
      throw new Error("Duplicate file id");
    }
    index.set(file.id, file);
  }
  return index;
}

这里明确检查 has(),而不是先写入再猜测是否覆盖。重复 ID 即使两条对象内容相同,也按本文契约拒绝,不偷偷把"这可能是重试"当作已经确认的事实。

索引在函数内部新建,完整成功后才返回。如果调用方希望保留旧索引,就在新索引成功返回之后再替换原引用;不要先清空旧索引,再把一个可能校验失败的新批次往里面塞。

代码只读取输入,不改写数组或记录;Map 的值仍引用原记录对象,并不是深复制。建立索引后若又原地修改记录的 id,键与对象字段可能不再一致。接入时应保持身份字段约定,数据发生相关变化时重建索引。

4. 需要保留同名项时,把值改成数组

分组函数先复用严格的 ID 索引,保证输入没有违反唯一身份约定;再按名称把记录放进不同数组。这样两个索引对重复 ID 采用相同策略,不会一处报错、另一处却无声接受。

javascript 复制代码
function groupFilesByName(files) {
  const byId = indexFilesById(files);
  const groups = new Map();
  for (const file of byId.values()) {
    if (!groups.has(file.name)) {
      groups.set(file.name, []);
    }
    groups.get(file.name).push(file);
  }
  return groups;
}

对开头的合成记录,indexFilesById(files).size 为 3;名称分组只有两个键,但"报价.pdf"的组包含 f1 和 f3,另一个组包含 f2。分组键数量变少,并不意味着附件数量应该变少。

Map 文档描述了插入顺序及各类遍历方法。本文按原记录的遇见顺序构建索引,分组键保持名称首次出现的顺序,组内记录也保持遇见顺序;没有按文件名、时间或版本排序。

为保持两种输出契约一致,这份实现临时建立了 ID 索引,再建立分组。它没有声称这是最低内存或最快方案;实际规模较大时,可以在保持相同输入约束的前提下评估别的构建方式。没有基准测试,就不把简单样例写成性能提升结论。

5. 别让索引过程替业务决定重复记录的含义

一条重复 ID 记录,可能来自分页重叠、重试,也可能携带较新的字段。仅凭"最后遍历到",无法知道哪种含义成立。需要保留首条、覆盖末条、按版本合并还是直接拒绝,应由数据协议明确决定。

本文选择拒绝重复 ID,是为了让异常在建立查找表时暴露;它不实现消息去重、版本合并或服务端约束。若实际接口允许同 ID 多版本,应该先完成有依据的版本处理,再交给要求唯一 ID 的索引阶段。

名称比较也要有明确边界。本例中 A.pdf 与 a.pdf 是不同组,首尾空格和规范等价但原始序列不同的名称也不会被自动合并。这不等于各操作系统都这样识别文件名,只是本文使用原始字符串键的契约。

显示时可以按名称组织候选项,但最终选择哪一条附件,仍应依据相应记录的标识和上下文。名称相同不证明内容相同;索引命中也不证明访问者有权下载,权限和文件有效性需要由真实业务检查。

6. 验证数量守恒,也验证不该接受的输入

独立验证从本文三段 JavaScript 原样提取代码,用本地 Node.js 和合成元信息运行。先复现名称单值索引覆盖,再验证严格 ID 索引和名称分组的预期。

检查场景 本文预期
不同 ID、相同名称 ID 索引保留两项,名称组保留两条
相同 ID、不同名称或相同对象再次出现 两种构建函数都拒绝
非数组、空记录、错误字段类型 拒绝,不悄悄转换
空数组 两种索引都为空
输入对象与数组被冻结 仍可构建,输入不被改写
相同输入重新构建 内容顺序一致,各组数组为新容器
新批次校验失败 调用方此前持有的旧索引不受修改
大量不同 ID 共享少量名称 组内总记录数等于原始条数

其中批量样例使用 1,000 个不同 ID 和 20 个重复名称,检查每个 ID 都保留一次、全部分组的条数合计仍为 1,000。这是在检查关系是否完整,不是做吞吐、内存或实际文件系统测试。

本轮没有读取真实附件、调用产品接口或验收客户端列表。需要接入页面时,还应继续核对输入来源、列表渲染、选择对象与请求回传是否使用了相同的身份约定。

Map 会按它的键规则工作。能否保留正确的业务关系,取决于我们是否先说清楚:什么必须唯一,什么允许重名,以及重复出现时究竟应该怎么处理。

本文为独立教学示例,部分内容由 AI 辅助整理,代码与发布内容经独立核对。

相关推荐
echome8882 小时前
Python 异常处理:try、else、finally 的正确搭配
开发语言·前端·python·异常处理·编程技巧
时间的情敌2 小时前
OpenLayers 实战|Vue3 + OpenLayers 完整工程案例(加载天地图、要素、弹窗、绘制、WFS)
前端·openlayers
陈随易2 小时前
下载神器!Motrix 停更三年后重写回归
前端·后端·程序员
前端的日常2 小时前
一个人+AI做情侣食谱小程序,0 费用的服务器
前端·后端
计算机魔术师3 小时前
谷歌搜索广告踩刹车,AI广告成为救命稻草
前端
总要学点什么3 小时前
第81篇:数据可视化-PM2.5分布——让圆点的胖瘦替每座城市报出浓度
前端·信息可视化·echarts·cesium·空间分析
IT_陈寒3 小时前
Vue的v-for里用了个临时变量,页面直接挂了
前端·人工智能·后端
hunter_0763 小时前
一次 iOS 真机签名 409 的完整排查:证书、设备、描述文件为什么总是"对不上"
前端
枣把儿3 小时前
开源!公众号排版+一键发布工具懒人版
前端